Studio: stop the overlay rail going click-through while it is scrolling by danielhanchen · Pull Request #9150 · unslothai/unsloth · GitHub
Skip to content

Studio: stop the overlay rail going click-through while it is scrolling - #9150

Merged
danielhanchen merged 3 commits into
mainfrom
fix-mainred-banner-rail
Aug 18, 2026
Merged

Studio: stop the overlay rail going click-through while it is scrolling#9150
danielhanchen merged 3 commits into
mainfrom
fix-mainred-banner-rail

Conversation

@danielhanchen

Copy link
Copy Markdown
Member

Chat UI Tests (banner) has been red on main since #9132, on the WebKit leg of
"Update banner layout, other engines", at 921x534 on New chat:

FAIL the card keeps its full width whatever the scrollbar does
     cardWidth=430 want=448 railGutter=0 scrolls=True
FAIL the rail takes pointer input exactly when it scrolls
     scrolls=True pointerEvents=none

Both assertions are from #8367 and both are right. The second one describes a
state a user can actually be in, and the shard's own screenshot shows it: the
rail is capped, the llama.cpp card is sliced off at the fold, the Unsloth update
card is entirely above it, and the rail is pointer-events: none, so there is no
scrollbar and no drag that brings it back. The suite's reachability checks pass
because they call scrollIntoView, which a reader has no way to do.

What #9132 changed is not the product, it is the state the suite finds. The
banner scripts used to run on the machine and the Studio home the chat and extra
shards had already been through; they now get their own runner and a pristine
~/.unsloth/studio, since boot-studio-api-only.sh wipes only auth. The last
green artifact is dark-themed with "Chat C" and "Chat B" in Recents; the first
red one is default-themed with "No chats yet". Same commit range, different
starting state, so the rail is capped now where it was not before. #9132 is
correct; it exposed this rather than caused it, and the same failure reproduces
on unrelated PR branches.

The defect is that overflowing is a prediction. floorRoom > maxHeight asks
whether the cards WOULD fit if they collapsed to the floor that was measured for
them under a max-height of 0. When they stop short of that floor the box
overflows a cap the prediction says it fits inside, and the rail is left
click-through while it scrolls, which is the failing pair exactly.

So the reading is kept and the actual state is or-ed into it, taken off the node
in the same synchronous block that already lifts and restores the cap, with the
real cap back on and the layout just flushed. It is re-read on every pass and
never remembered, which is what the derived value was there to protect against:
the observers watch the rail and every descendant, so a placement that grows to
fit clears the flag on the same pass that applied it. Being an ||, it can only
add the case the prediction misses and can never take pointer input away from a
rail that already had it.

Verified on this box, WebKit and Chromium, against a build actually served from
this tree (unsloth studio --frontend; the venv's installed copy is what gets
served otherwise, which is worth knowing before trusting a local Studio run):

  • Broken deliberately, overflowing forced false: the full Chromium suite is
    1433 checks, 1 failed, and the one is
    320x480 at 20px: the rail takes pointer input exactly when it scrolls scrolls=True pointerEvents=none.
  • Fixed: 1433 checks, 0 failed. Firefox and WebKit spot: 221, 0 failed each.
  • Unchanged where the prediction was already right. Sweeping WebKit down the
    height axis at 921 wide, the rail and the flag agree at every step both
    before and after: no scroll and none at 534 through 364, scroll and auto
    from 360 down, card 448 throughout.

The width half is not fixed here, because I could not reproduce it and will not
guess at it. 448 - 430 is 18px of scrollbar, but offsetWidth - clientWidth
reports 0 in the same breath, so on that runner the bar takes width out of the
content box without showing up in the gutter the assertion reads. This box never
reproduces it: WebKit here keeps the card at 448 whether the rail scrolls or not.
scrollbar-gutter: stable is the obvious candidate and is deliberately not taken
on a guess, since it reserves the gutter permanently and would shift the cards
left by the scrollbar width on Chromium, where the card is already correct.

Instead both failing assertions now print the measurements that would settle it:
the card's transform and border box, its computed and max width, innerWidth and
documentElement.clientWidth, the rail's offset, client and content widths, its
scroll and client heights, its cap, and the children's heights. Nothing is
relaxed; the same two checks fail on the same conditions, with enough attached to
say which of the two mechanisms it is. That output is what the next run of this
shard is for.

`Chat UI Tests (banner)` has been red on main since #9132, on the WebKit leg of
"Update banner layout, other engines", at 921x534 on New chat:

    FAIL the card keeps its full width whatever the scrollbar does
         cardWidth=430 want=448 railGutter=0 scrolls=True
    FAIL the rail takes pointer input exactly when it scrolls
         scrolls=True pointerEvents=none

Both assertions are from #8367 and both are right. The second one describes a
state a user can actually be in, and the shard's own screenshot shows it: the
rail is capped, the llama.cpp card is sliced off at the fold, the Unsloth update
card is entirely above it, and the rail is `pointer-events: none`, so there is no
scrollbar and no drag that brings it back. The suite's reachability checks pass
because they call scrollIntoView, which a reader has no way to do.

What #9132 changed is not the product, it is the state the suite finds. The
banner scripts used to run on the machine and the Studio home the chat and extra
shards had already been through; they now get their own runner and a pristine
`~/.unsloth/studio`, since boot-studio-api-only.sh wipes only `auth`. The last
green artifact is dark-themed with "Chat C" and "Chat B" in Recents; the first
red one is default-themed with "No chats yet". Same commit range, different
starting state, so the rail is capped now where it was not before. #9132 is
correct; it exposed this rather than caused it, and the same failure reproduces
on unrelated PR branches.

The defect is that `overflowing` is a prediction. `floorRoom > maxHeight` asks
whether the cards WOULD fit if they collapsed to the floor that was measured for
them under a max-height of 0. When they stop short of that floor the box
overflows a cap the prediction says it fits inside, and the rail is left
click-through while it scrolls, which is the failing pair exactly.

So the reading is kept and the actual state is or-ed into it, taken off the node
in the same synchronous block that already lifts and restores the cap, with the
real cap back on and the layout just flushed. It is re-read on every pass and
never remembered, which is what the derived value was there to protect against:
the observers watch the rail and every descendant, so a placement that grows to
fit clears the flag on the same pass that applied it. Being an `||`, it can only
add the case the prediction misses and can never take pointer input away from a
rail that already had it.

Verified on this box, WebKit and Chromium, against a build actually served from
this tree (`unsloth studio --frontend`; the venv's installed copy is what gets
served otherwise, which is worth knowing before trusting a local Studio run):

  - Broken deliberately, `overflowing` forced false: the full Chromium suite is
    1433 checks, 1 failed, and the one is
    `320x480 at 20px: the rail takes pointer input exactly when it scrolls
     scrolls=True pointerEvents=none`.
  - Fixed: 1433 checks, 0 failed. Firefox and WebKit spot: 221, 0 failed each.
  - Unchanged where the prediction was already right. Sweeping WebKit down the
    height axis at 921 wide, the rail and the flag agree at every step both
    before and after: no scroll and `none` at 534 through 364, scroll and `auto`
    from 360 down, card 448 throughout.

The width half is not fixed here, because I could not reproduce it and will not
guess at it. 448 - 430 is 18px of scrollbar, but `offsetWidth - clientWidth`
reports 0 in the same breath, so on that runner the bar takes width out of the
content box without showing up in the gutter the assertion reads. This box never
reproduces it: WebKit here keeps the card at 448 whether the rail scrolls or not.
`scrollbar-gutter: stable` is the obvious candidate and is deliberately not taken
on a guess, since it reserves the gutter permanently and would shift the cards
left by the scrollbar width on Chromium, where the card is already correct.

Instead both failing assertions now print the measurements that would settle it:
the card's transform and border box, its computed and max width, innerWidth and
documentElement.clientWidth, the rail's offset, client and content widths, its
scroll and client heights, its cap, and the children's heights. Nothing is
relaxed; the same two checks fail on the same conditions, with enough attached to
say which of the two mechanisms it is. That output is what the next run of this
shard is for.
@danielhanchen

Copy link
Copy Markdown
Member Author

The DOM reading was inserted between the flush of the restored cap and the
line that puts the eased transition back, which is the one place the store
must keep adjacent: update-banner-flex-priority asserts on it, and the
assertion went red on both the Linux and the Windows frontend unit jobs.

The reading does not need to sit there. The cap change is already committed
under the suppression by the flush above it, and transition is not itself a
transitionable property, so no reflow the read forces can hand that cap to an
animation. Moving it below the restore keeps the invariant and the reading.
The width half of the banner failure is the card's enter animation, not a
scrollbar. The diagnostics added earlier in this branch settled it on the
WebKit leg: transform matrix(0.96, 0, 0, 0.96, 0, 12), which is exactly the
initial state in components/web/update-banner.tsx, with borderBox 448,
cssWidth 448px, railContentW 448 and railGutter 0. 448 * 0.96 is 430.08, and
430 is what the assertion read. Nothing had taken any width; the card had
simply not been painted at full size yet.

So the assertion was measuring the painted box for a question about layout.
getBoundingClientRect includes transforms and offsetWidth does not, so the
reading moves to offsetWidth, which a scrollbar taking width out of the
rail's content box still shrinks. The painted width is still reported next to
it, since the gap between the two is the diagnosis.

Checked on chromium, firefox and webkit that this is red exactly when it
should be: with the transform applied and no width lost, the old reading
fails and the new one passes; with 18px actually taken out of the rail's
content box the new one fails on all three, with or without the transform
also applied; on a settled healthy box both pass.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: b308f32ea8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@danielhanchen
danielhanchen merged commit 571a99b into main Aug 18, 2026
34 of 36 checks passed
@danielhanchen
danielhanchen deleted the fix-mainred-banner-rail branch August 18, 2026 08:41
@danielhanchen

Copy link
Copy Markdown
Member Author

shimmyshimmer added a commit that referenced this pull request Aug 23, 2026
…ter contract

Two fixes to the Playwright suite, both mine to begin with.

The corner check measured `fromRight` off the rail's border box. The rail
carries the shadow gutter, `-mx-3 px-3`, so its border box sits 4px from the
viewport edge while the cards it pads sit at 16. It reported 4 and would have
failed at every inspected viewport. Measured at 320x480 and at 921x534, one of
the two the pass runs: rail 4, every in-flow card 16. It now takes the right
edge from the cards, filtered to the ones in flow, since a dragged loaded
models card is `fixed` somewhere else. The bottom still comes off the rail:
no vertical margin or padding, and its border box does not move when the cards
scroll. Checked at both scroll extremes.

The other is a contract this branch already invalidated. The suite asserted the
rail takes pointer input exactly when it scrolls, which was #9150 and needed the
JS that also placed it. The rail is click-through in every state now, so that
check failed whenever the rail scrolled. Verified at 320x480 with the rail
actually scrolling, scrollHeight 501 against clientHeight 448: pointer-events
none, and a click on the gutter lands on the thread viewport behind it. Both
assertions are now unconditional.
shimmyshimmer added a commit that referenced this pull request Aug 25, 2026
)

* Studio: put the notification rail back in its bottom-right corner

The rail was anchored with `fixed bottom-4 right-4` until #8082, which added
the loaded models indicator and, with it, a frame store the rail read: the
Live monitor, the API monitor panel and the chat composer publish their boxes,
and `useStackGeometry` wrote `bottom` and `maxHeight` as inline styles that
dodged them.

Every input to that placement moves on its own. The composer grows with its
input and goes from centred to docked with no resize event, a download row
arrives, release notes expand, a banner is dismissed. Each change re-ran the
measurement, and when the room fell short the rail lifted over the obstacle at
`viewportHeight - frame.top + STACK_GAP`. On the welcome chat that is the cards
in the middle of the window; against a maximised monitor the lift clamped at
MIN_STACK_ROOM and parked them at the top. It reads as random because the
trigger is measurement timing.

The store also had to probe its own output, dropping `max-height` to read
`scrollHeight` and restoring it with transitions suppressed, because the
placement was feeding on the box it had just sized. It went from 184 lines to
701 over nine follow-up PRs, all of them editing the same arithmetic.

So this restores the anchor rather than adding another special case. The rail
is `fixed bottom-4 right-4` with the CSS cap again, on the named layer rather
than the old hard-coded z-index. The frame store keeps its map of boxes, which
api-monitor/panel-placement still needs to open the API panel clear of the
monitor and the composer, and loses the dodge arithmetic, the measurement loop
and the `coverable` licence. The composer still publishes for that panel's
sake. The indicator itself is untouched.

Known trade-off, and the pre-#8082 behaviour: in a short window a card can sit
over the composer's Send button rather than the rail dodging it. The indicator
is off by default, draggable and dismissible.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: scroll the rail at its cap instead of spilling cards off screen

Review catch, and it reproduces. The cap alone does not contain anything: the
cards that cannot shrink lay out past the rail's bottom edge, which is pinned
16px off the floor, so the overflow goes under the bottom of the window with
no way to bring it back.

At 320x480 with --ui-font-scale 1.25, three cards up: the llama.cpp banner is
in flow with its bottom at 497 against a 480px viewport, and the card after it
is squeezed to 0px at 505. scrollHeight 501 against clientHeight 448, and
overflow-y visible. The Update button is below the fold for good.

Earlier sizes missed it because the download panel is the one min-h-0 card in
the rail and absorbed the squeeze on its own. Raising the type size puts the
two banner floors over the cap and the slack runs out.

So the rail scrolls again, in CSS only. No measurement, no placement: the
offset and the cap stay where they were. The gutter comes back with it, since
overflow-y forces overflow-x and the card shadows clip without it, and stays
horizontal so the bottom card keeps sitting on bottom-4.

The rail stays click-through. Verified that the fold is still reachable:
elementFromPoint over a card returns a node inside it, so the card is the hit
target despite the rail being pointer-events-none, and the rail is that node's
nearest scrollable ancestor, which is the chain a wheel scrolls. Focus works
too: the Update button measured at bottom 481 in a 480px viewport, and
focusing it scrolled the rail to 17 and brought it to 464.

* Studio: measure the rail's corner off a card, and drop the stale pointer contract

Two fixes to the Playwright suite, both mine to begin with.

The corner check measured `fromRight` off the rail's border box. The rail
carries the shadow gutter, `-mx-3 px-3`, so its border box sits 4px from the
viewport edge while the cards it pads sit at 16. It reported 4 and would have
failed at every inspected viewport. Measured at 320x480 and at 921x534, one of
the two the pass runs: rail 4, every in-flow card 16. It now takes the right
edge from the cards, filtered to the ones in flow, since a dragged loaded
models card is `fixed` somewhere else. The bottom still comes off the rail:
no vertical margin or padding, and its border box does not move when the cards
scroll. Checked at both scroll extremes.

The other is a contract this branch already invalidated. The suite asserted the
rail takes pointer input exactly when it scrolls, which was #9150 and needed the
JS that also placed it. The rail is click-through in every state now, so that
check failed whenever the rail scrolled. Verified at 320x480 with the rail
actually scrolling, scrollHeight 501 against clientHeight 448: pointer-events
none, and a click on the gutter lands on the thread viewport behind it. Both
assertions are now unconditional.

* CI: drop the install and the artifact dir the deleted rail harness owned

The vite install in the banner shard exists for playwright_overlay_rail.py and
nothing else, so with the harness gone it is dead. Nothing left in
studio-ui-smoke.yml touches npm, npx, node_modules or studio/frontend, so on a
frontend-dist cache hit it would run a clean `npm ci` for no consumer.

logs/playwright-overlay-rail goes with it: it was only ever written by the two
steps this branch removed.

Job-level setup-node stays. It predates the harness (#9060) and the installer
needs node to build the frontend on a cache miss.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant