Context
Found in manual testing of apps/workstation on dev (2026-10-22). @tobiu reproduced it, and his screenshots of the three states follow in a comment.
- In the shipped layout, the pane with the Runtime Health Envelope tab holds 12 tabs. At its default width only the active header fits, and the rest sit behind the
⋯ overflow action.
- Click into the pane body. Focus reveals the focus-gated header actions. Then press maximize.
- The pane fills the workspace, but its header shows 2 tabs, and most of the strip stays empty.
- Activate the second tab. The header re-partitions, and 8 tabs show.
Steps 1–4 are observed. The stale partition also reproduces headless (Chromium 1440×900, 8bb3574d0f, whose src/ matches dev): 1.5 s after maximize, the header still shows 2 of the 12 tabs (#19984's probe). The mechanism below is read from source, and the e2e criterion measures it.
The Problem
The overflow partition is runtime projection state. It is recomputed only when one of its triggers fires: an owner resize, an activation, or a tab-set change. Maximize does fire resize, but the pass it triggers measures the strip in the middle of the maximize motion:
Maximize#applyPresentation() writes the workspace rect through wrapperStyle. The node's layout box grows, and the resize DOM event fires.
playFlip() then runs DockFlip.play({geometryOnly: true}). That installs an inverse translate(…) scale(sx, sy), so the node visually starts from its old rect, and then transitions the transform to identity.
Overflow#project() reads the strip extent through getDomRect(), which is getBoundingClientRect(), the visual box. A pass that lands inside that window reads a transform-scaled extent, close to the pre-maximize width, and partitions for it.
- A transform settling never changes the layout box, so no further
resize fires. Nothing re-projects until another trigger arrives, which is the activation in step 4.
Inferred and still to be confirmed by the e2e:
- Restore: the mirror case. The pass measures at an inverse scale above 1, so the shrunken pane could keep more headers than fit.
- Other dock operations: every DockFlip-animated operation that resizes a tabs node opens the same window.
The Architectural Reality
src/tab/plugin/Overflow.mjs: onResize() calls project(). project() step 2 measures owner.getDomRect([owner, ...actions, control]) and derives extent from those visual rects. The plugin has no motion awareness. The dock routes the overflow's own start/idle into MotionSignal (LayoutAdapter, overflowProjectionStart / overflowProjectionIdle), but nothing routes a settled motion back into a re-projection.
src/dashboard/dock/plugin/Maximize.mjs: applyPresentation() sets wrapperStyle, awaits promiseUpdate(), then calls playFlip().
src/main/addon/DockFlip.mjs: play() inverts with translate() scale() and resolves after the transition.
- Design authority: the JSDoc of
Neo.main.DomAccess#getLayoutRect: "Layout consumers that persist sizes (grid column generation, buffered mounting math) must read the LAYOUT box instead, which transforms never affect." Its examples name the DockFlip inverse-transform window. The grid moved to Component#getLayoutRect() in #16375 (grid/Container.mjs, grid/header/Toolbar.mjs), and so did DockSplitter. The tab Overflow plugin still reads visual rects, and its @summary collapses tabs only "when headers exceed the available main-axis extent". A stale partition hides tabs that fit.
The Fix
Make the overflow partition transform-immune. In Overflow#project(), read the natural widths (step 1) and the strip extent (step 2) through Component#getLayoutRect() instead of getDomRect(). The resize pass that maximize already triggers then partitions against the final layout box, wherever it lands in the motion. The same change covers restore and every other DockFlip window. That is why the reader is the right place to fix this, not each motion owner.
Trap: getLayoutRect() x/y values are offset-parent-relative. The coordinate arm (actionStart − ownerStart) is only valid when both nodes share an offset parent. Otherwise use the size arm (ownerSize − actionSize) alone, which is already the ceiling.
Acceptance Criteria
Out of Scope
- Revealing the focus-gated header actions on hover: #19983.
- Overflow-control visuals and the packing algorithm (
Overflow.computeOverflow). The decision is correct; its input is wrong.
Avoided Traps
- Re-projecting from
Maximize after playFlip() settles. This fixes one motion owner and leaves restore and every other DockFlip consumer with the same misread.
- Debouncing
onResize past the motion. This adds a timing guess, and the transform window has no fixed length when frames are starved.
Related
- #16375: the grid's transform-immune measurement, the precedent.
- #19655: the hosted Workstation NL tier the new spec joins.
- #17928: the dock maximize affordance.
- #19984: the same maximized pane, where the overflow menu closes as it opens (a different cause).
Live latest-open sweep: checked latest 20 open issues at 2026-10-22T09:01:52Z; no equivalent found.
A2A claim sweep: last 15 messages; no overlapping lane-claim.
MC sweep: "tab header overflow not updated after maximize…", 6 results, no prior decision (#14771 / #16424 / #17928 are adjacent, not the same).
Own-assignment sweep: 6 open, none overlapping.
Origin Session ID: 70e59a84-efd6-4e7a-9262-c5478bd314ff
Retrieval Hint: "tab overflow stale after dock maximize, DockFlip inverse transform, getDomRect vs getLayoutRect"
Context
Found in manual testing of
apps/workstationondev(2026-10-22). @tobiu reproduced it, and his screenshots of the three states follow in a comment.⋯overflow action.Steps 1–4 are observed. The stale partition also reproduces headless (Chromium 1440×900,
8bb3574d0f, whosesrc/matchesdev): 1.5 s after maximize, the header still shows 2 of the 12 tabs (#19984's probe). The mechanism below is read from source, and the e2e criterion measures it.The Problem
The overflow partition is runtime projection state. It is recomputed only when one of its triggers fires: an owner
resize, an activation, or a tab-set change. Maximize does fireresize, but the pass it triggers measures the strip in the middle of the maximize motion:Maximize#applyPresentation()writes the workspace rect throughwrapperStyle. The node's layout box grows, and theresizeDOM event fires.playFlip()then runsDockFlip.play({geometryOnly: true}). That installs an inversetranslate(…) scale(sx, sy), so the node visually starts from its old rect, and then transitions the transform to identity.Overflow#project()reads the strip extent throughgetDomRect(), which isgetBoundingClientRect(), the visual box. A pass that lands inside that window reads a transform-scaled extent, close to the pre-maximize width, and partitions for it.resizefires. Nothing re-projects until another trigger arrives, which is the activation in step 4.Inferred and still to be confirmed by the e2e:
The Architectural Reality
src/tab/plugin/Overflow.mjs:onResize()callsproject().project()step 2 measuresowner.getDomRect([owner, ...actions, control])and derivesextentfrom those visual rects. The plugin has no motion awareness. The dock routes the overflow's own start/idle intoMotionSignal(LayoutAdapter,overflowProjectionStart/overflowProjectionIdle), but nothing routes a settled motion back into a re-projection.src/dashboard/dock/plugin/Maximize.mjs:applyPresentation()setswrapperStyle, awaitspromiseUpdate(), then callsplayFlip().src/main/addon/DockFlip.mjs:play()inverts withtranslate() scale()and resolves after the transition.Neo.main.DomAccess#getLayoutRect: "Layout consumers that persist sizes (grid column generation, buffered mounting math) must read the LAYOUT box instead, which transforms never affect." Its examples name the DockFlip inverse-transform window. The grid moved toComponent#getLayoutRect()in #16375 (grid/Container.mjs,grid/header/Toolbar.mjs), and so didDockSplitter. The tab Overflow plugin still reads visual rects, and its@summarycollapses tabs only "when headers exceed the available main-axis extent". A stale partition hides tabs that fit.The Fix
Make the overflow partition transform-immune. In
Overflow#project(), read the natural widths (step 1) and the strip extent (step 2) throughComponent#getLayoutRect()instead ofgetDomRect(). Theresizepass that maximize already triggers then partitions against the final layout box, wherever it lands in the motion. The same change covers restore and every other DockFlip window. That is why the reader is the right place to fix this, not each motion owner.Trap:
getLayoutRect()x/y values are offset-parent-relative. The coordinate arm (actionStart − ownerStart) is only valid when both nodes share an offset parent. Otherwise use the size arm (ownerSize − actionSize) alone, which is already the ceiling.Acceptance Criteria
Overflow#project()measures withgetLayoutRect(), never with visual rects.test/playwright/e2e/workstation/WorkstationMaximizeTabOverflowNL.spec.mjs, drives the real animated maximize and then restore on the shipped layout's 12-tab pane. After each settles, it compares the visible-header set with the set a forced re-projection produces. It must be red ondev, where the base run shows the stale partition rather than just a precondition, and green with the fix. It joins the hosted Workstation NL tier (#19655).WorkstationTabOverflowCapNL,WorkstationFocusedTabOverflowNLandWorkstationNL("maximize uses measured workspace geometry…") stay green.Out of Scope
Overflow.computeOverflow). The decision is correct; its input is wrong.Avoided Traps
MaximizeafterplayFlip()settles. This fixes one motion owner and leaves restore and every other DockFlip consumer with the same misread.onResizepast the motion. This adds a timing guess, and the transform window has no fixed length when frames are starved.Related
Live latest-open sweep: checked latest 20 open issues at 2026-10-22T09:01:52Z; no equivalent found. A2A claim sweep: last 15 messages; no overlapping lane-claim. MC sweep: "tab header overflow not updated after maximize…", 6 results, no prior decision (#14771 / #16424 / #17928 are adjacent, not the same). Own-assignment sweep: 6 open, none overlapping.
Origin Session ID: 70e59a84-efd6-4e7a-9262-c5478bd314ff Retrieval Hint: "tab overflow stale after dock maximize, DockFlip inverse transform, getDomRect vs getLayoutRect"