From b5e61626b072b53ee09c42207691fe844e6dae29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 07:12:37 +0000 Subject: [PATCH 1/3] fix(ui): stop scroll bounce when collapsing chrome pins at the bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone hide-on-scroll chrome (header/composer) collapses in-flow, so hiding it grows the scroll container and shrinks its max scroll offset. At the bottom the browser clamps scrollTop to that shrinking maximum, emitting a phantom "up" reading each frame of the collapse animation. The previous bottom-clamp guard only held the hidden state when the prior offset sat more than `minimumDelta` above the new maximum — a predicate the browser's per-frame clamping makes unreliable — so on the frames it missed the phantom "up" revealed the chrome, which grew the viewport back and started a hide/reveal bounce (the choppiness and bounce felt when scrolling to the bottom). Generalise the guard to the robust invariant: while the chrome is hidden and the offset stays pinned to the bottom edge, any upward reading is layout feedback, not a scroll — hold the hidden state and rebase intent. Only a genuine upward scroll (one that pulls the offset clear of the bottom) reveals. This holds across the whole multi-frame collapse without depending on the previous offset, so the bottom stays stable and all content remains reachable. Add deterministic unit tests for the multi-frame collapse clamp and the small-shrink phantom the old predicate let slip through. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01E4Fomoa1oeD6nqf8QNNytj --- .../clinical-dashboard/use-hide-on-scroll.ts | 24 +++++-- tests/use-hide-on-scroll.test.ts | 64 +++++++++++++++++++ 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index e781cffea..d423048bd 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -22,6 +22,10 @@ const minimumDelta = 4; // chrome at a direction change. const hideIntentDistance = 24; const revealIntentDistance = 12; +// How close to the bottom edge (px) counts as "pinned to the bottom". When the +// offset is this near the maximum, an upward reading is the viewport growing +// under a collapsing header rather than a real scroll, so it must not reveal. +const bottomClampTolerance = 1; type ScrollDirection = "down" | "up" | null; export interface ScrollMetrics { @@ -65,15 +69,23 @@ export function computeScrollHideUpdate(params: { return { hidden: false, lastOffset: offset, direction: null, directionTravel: 0 }; } - // Collapsing in-flow chrome grows the scroll viewport. At the bottom the - // browser clamps scrollTop to the new maximum and emits an apparent upward - // scroll even though the user is still moving down. Keep the chrome hidden - // and rebase intent so that layout feedback cannot start a hide/show loop. + // Collapsing in-flow chrome grows the scroll viewport: as the header hands its + // height back to the content, the browser clamps scrollTop to the new, smaller + // maximum and emits an apparent upward scroll even though the user is moving + // down or holding at the bottom. A collapse animates over several frames, so + // this clamp repeats frame after frame; if any frame's phantom "up" reveals + // the chrome, the viewport shrinks again and a hide/reveal scroll-bounce + // begins. While the chrome is hidden and the offset stays pinned to the bottom + // edge, treat every upward reading as layout feedback — hold the hidden state + // and rebase intent so only a genuine upward scroll (one that pulls the offset + // clear of the bottom) can reveal. This intentionally does not depend on the + // previous offset's relationship to the maximum, which the browser's per-frame + // clamping makes unreliable during the collapse. if ( currentlyHidden && maxOffset !== undefined && - lastOffset > maxOffset + minimumDelta && - Math.abs(offset - maxOffset) <= 1 + offset < lastOffset && + offset >= maxOffset - bottomClampTolerance ) { return { hidden: true, lastOffset: offset, direction: null, directionTravel: 0 }; } diff --git a/tests/use-hide-on-scroll.test.ts b/tests/use-hide-on-scroll.test.ts index a25d84b16..7bf993a6c 100644 --- a/tests/use-hide-on-scroll.test.ts +++ b/tests/use-hide-on-scroll.test.ts @@ -137,6 +137,70 @@ describe("computeScrollHideUpdate", () => { }); }); + it("holds the chrome hidden across a multi-frame collapse clamp at the bottom", () => { + // As the collapsing header hands its height back to the scroll container, + // maxOffset shrinks frame by frame and the browser clamps scrollTop to it, + // so each reading looks like a small upward scroll while the offset stays + // pinned to the (moving) bottom edge. Include a frame whose shrink is only + // `minimumDelta` — the case the previous `lastOffset > maxOffset + delta` + // guard let slip through and reveal the chrome, starting the bounce. + const clampFrames = [1000, 996, 988, 980, 974, 970, 968, 940, 928]; + let state = { + hidden: true, + lastOffset: clampFrames[0], + direction: "down" as "down" | "up" | null, + directionTravel: 160, + }; + for (const offset of clampFrames.slice(1)) { + state = computeScrollHideUpdate({ + offset, + lastOffset: state.lastOffset, + // offset is pinned to the shrinking bottom edge each frame. + maxOffset: offset, + currentlyHidden: state.hidden, + direction: state.direction, + directionTravel: state.directionTravel, + }); + expect(state.hidden).toBe(true); + expect(state.lastOffset).toBe(offset); + } + + // Once the collapse settles, a genuine upward scroll that pulls the offset + // clear of the bottom still reveals the chrome. + const revealed = computeScrollHideUpdate({ + offset: 900, + lastOffset: state.lastOffset, + maxOffset: 928, + currentlyHidden: state.hidden, + direction: state.direction, + directionTravel: state.directionTravel, + }); + expect(revealed.hidden).toBe(false); + expect(revealed.direction).toBe("up"); + }); + + it("does not reveal on a small phantom clamp when the offset stays pinned to the bottom", () => { + // Single frame: a 4px upward clamp while glued to the bottom edge. The old + // guard required the previous offset to sit more than `minimumDelta` above + // the new maximum, which fails here, so the phantom read reached the reveal + // path. It must be treated as layout feedback instead. + expect( + computeScrollHideUpdate({ + offset: 968, + lastOffset: 972, + maxOffset: 968, + currentlyHidden: true, + direction: "down", + directionTravel: 120, + }), + ).toEqual({ + hidden: true, + lastOffset: 968, + direction: null, + directionTravel: 0, + }); + }); + it("ignores an upward clamp caused by the viewport growing at the bottom", () => { const clamped = computeScrollHideUpdate({ offset: 900, From 1a31a5ce8b1bb09c505a569be0e6a412d7dec69b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 07:15:39 +0000 Subject: [PATCH 2/3] style: satisfy prettier in use-hide-on-scroll guard Collapse the bottom-clamp guard condition onto one line to match the repository's Prettier print width; the CI format:check gate flagged it. No logic change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01E4Fomoa1oeD6nqf8QNNytj --- src/components/clinical-dashboard/use-hide-on-scroll.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index d423048bd..58929c252 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -81,12 +81,7 @@ export function computeScrollHideUpdate(params: { // clear of the bottom) can reveal. This intentionally does not depend on the // previous offset's relationship to the maximum, which the browser's per-frame // clamping makes unreliable during the collapse. - if ( - currentlyHidden && - maxOffset !== undefined && - offset < lastOffset && - offset >= maxOffset - bottomClampTolerance - ) { + if (currentlyHidden && maxOffset !== undefined && offset < lastOffset && offset >= maxOffset - bottomClampTolerance) { return { hidden: true, lastOffset: offset, direction: null, directionTravel: 0 }; } From f9f65ecac422c2ebf2c37c002ae377002a7b5de9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 07:45:28 +0000 Subject: [PATCH 3/3] test(ui): lock in one-sided bottom-clamp tolerance for overscroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review suggested making the bottom-edge tolerance symmetric (`|offset - maxOffset| <= tol`). That would regress iOS bottom rubber-band: during overscroll the browser reports a scrollTop past the maximum, and as the content springs back the reading moves up — still the bottom edge, not a scroll away from it. A symmetric window would fall through and reveal the chrome mid-rubber-band, reintroducing the flicker this guard removes. The one-sided test mirrors the existing `offset < 0` guard that holds state through top overscroll. Document the intent on the guard and add a regression test covering the bottom-overscroll spring-back so the asymmetry is not "fixed" later. No behavior change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01E4Fomoa1oeD6nqf8QNNytj --- .../clinical-dashboard/use-hide-on-scroll.ts | 8 +++++++ tests/use-hide-on-scroll.test.ts | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index 58929c252..d706a8caa 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -81,6 +81,14 @@ export function computeScrollHideUpdate(params: { // clear of the bottom) can reveal. This intentionally does not depend on the // previous offset's relationship to the maximum, which the browser's per-frame // clamping makes unreliable during the collapse. + // + // The bottom test is deliberately one-sided (`offset >= maxOffset - tol`, not + // `|offset - maxOffset| <= tol`): iOS rubber-band overscroll at the bottom can + // report a scrollTop *past* the maximum, and while the content springs back + // the reading moves up. That is still the bottom edge, not a scroll away from + // it, so it must hold hidden too — mirroring the `offset < 0` guard that holds + // state through top overscroll. A symmetric window would instead reveal the + // chrome mid-rubber-band, reintroducing the flicker this guard removes. if (currentlyHidden && maxOffset !== undefined && offset < lastOffset && offset >= maxOffset - bottomClampTolerance) { return { hidden: true, lastOffset: offset, direction: null, directionTravel: 0 }; } diff --git a/tests/use-hide-on-scroll.test.ts b/tests/use-hide-on-scroll.test.ts index 7bf993a6c..e494253d7 100644 --- a/tests/use-hide-on-scroll.test.ts +++ b/tests/use-hide-on-scroll.test.ts @@ -201,6 +201,28 @@ describe("computeScrollHideUpdate", () => { }); }); + it("holds the chrome hidden while a bottom rubber-band overscroll springs back", () => { + // iOS reports a scrollTop past the maximum during bottom overscroll; as the + // content springs back the reading moves up but is still the bottom edge. + // A symmetric `|offset - maxOffset| <= tolerance` window would fall through + // here and reveal the chrome mid-rubber-band — the one-sided test holds it. + expect( + computeScrollHideUpdate({ + offset: 930, + lastOffset: 962, + maxOffset: 900, + currentlyHidden: true, + direction: "down", + directionTravel: 120, + }), + ).toEqual({ + hidden: true, + lastOffset: 930, + direction: null, + directionTravel: 0, + }); + }); + it("ignores an upward clamp caused by the viewport growing at the bottom", () => { const clamped = computeScrollHideUpdate({ offset: 900,