diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index c107206c7..129b7dd07 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -560,5 +560,7 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-15 | codex/outstanding-work-cleanup | 0c56f27a37af88a073d2bb695d2cf4c05067ff4f + working-tree diff | repo-wide outstanding-work reconciliation and cleanup | No high-confidence P0/P1 remained in the locally executable scope. Fixed the stale architecture index for DSM/specifier routes, pruned redundant Knip configuration, removed the unused `SignedImage` default export, and reconciled maintained docs so completed/superseded plans no longer present as active work. Retained explicitly blocked work: provider-gated operator actions, the live-eval shadow reindex harness, deep-memory section-ownership design, and RAG follow-ups that require live evidence or product/security decisions. Full Knip findings were triaged rather than mass-deleted; unresolved-import scan is clean. | Offline `npm run verify:cheap` passed with 2,417 tests/1 skipped; docs links 806, script refs 264, codebase index 35/35; Knip unresolved scan clean; typecheck clean; focused Vitest 15/15; env-name parity clean; `git diff --check`. Provider-backed Supabase/OpenAI/GitHub/Railway, dependency audit, browser, build, drift, and release gates not run. | | 2026-07-15 | codex/design-polish-pass | 0c56f27a37af88a073d2bb695d2cf4c05067ff4f + reviewed working diff | full live design, responsive, UX, accessibility, design-system, routing, performance, lint, testing, documentation, and release-readiness review | No P0/P1 reproduced. Fixed the P2 duplicate phone scroll surface in the shared standalone shell and added a regression test; fixed mode-menu Tab dismissal; explicitly hid decorative dynamic icons; restricted press scaling to motion-safe environments; moved sheet backdrops and forced-colour glass/backdrop behavior onto design tokens. External target fidelity remains blocked because no independent Figma/mockup source was supplied. | Live 30-route desktop + 21-route phone sweep; targeted 320/390/639/768/1440/1920 proofs; `npm run verify:cheap` (2417 passed/1 skipped); `npm run verify:ui` (175/175); focused keyboard 1/1; accessibility media/axe 5/5; scoped Prettier, ESLint, TypeScript, type-scale, and icon-scale passed. Provider-backed checks not run. | | 2026-07-15 | codex/documents-closed-default | 49f63791bced2b1764a11ab723aea94b45b026b6 | documents viewer disclosure defaults and related defect hunt | Fixed the inconsistent default-open document viewer sections by making indexed text, high-yield summary, tables/diagrams, and indexing details a native mutually exclusive closed disclosure group. The section navigation opens its requested disclosure and deep-linked evidence still reveals its target. The hunt also removed the explicitly open nested table-review queue, preserved printable summary content through the browser print lifecycle, and added cold-server readiness guards to the affected viewer tests. No other high-confidence default-open defect remains in the live Documents scope. | `npm run verify:cheap`; TypeScript; focused ESLint/Prettier; clean-worktree mocked Chromium coverage for deep-linked evidence, structured summary, closed/mutually-exclusive disclosures, navigation opening, and print state restore; `git diff --check`. Turbopack could not run through the local external `node_modules` junction, so clean browser verification used Next's supported Webpack dev mode. No Supabase/OpenAI/live-provider checks run. | +| 2026-07-17 | codex/scroll-geometry-stability-20260717 | f88a41ae5fb62ae39d5d1d33fd5e21490e1ea60e | phone scroll geometry and boundary stability review | Confirmed two coupled P2 interaction defects: hiding the fixed bottom composer removed its reserved space and changed the scroll viewport, while collapsing the in-flow header at the bottom produced a browser-driven scrollTop clamp that was misread as an upward gesture. The dock now keeps stable clearance, hide/reveal uses directional hysteresis, and geometry clamps are rebased without revealing chrome. No other high-confidence defect remains in the changed scope. | Focused Vitest 8/8; TypeScript; scoped ESLint; Prettier; `git diff --check`. `npm run verify:cheap` reached the full Vitest phase but was stopped after making no progress under concurrent local Vitest workloads. Turbopack rejected the external node_modules junction and Webpack did not reach the identity endpoint, so exact-head hosted UI and required CI remain the merge gates. No Supabase/OpenAI/live-provider checks run. | | 2026-07-17 | codex/header-footer-scroll-timing-20260717 | 8298bfdcb40c207dbac1128e83c07b4aba782e32 | header and bottom-composer scroll timing, motion, responsive behavior, and merge readiness | Added deliberate hide/reveal travel thresholds with direction-reset handling, aligned header and composer easing/durations, and preserved reduced-motion and breakpoint behavior. No remaining high-confidence P0-P2 defect was found in the scoped diff or focused live behavior. | Focused Vitest 7/7; targeted Chromium UI 5/5; scoped ESLint; Prettier; full TypeScript; full lint; `git diff --check`. `verify:cheap` reached the aggregate Vitest phase, where two repository graph scans exceeded their 30-second test timeout under local disk contention; isolated assertions passed until the same timeout. No Supabase/OpenAI/live-provider checks run. | | 2026-07-15 | HEAD / main snapshot (detached review worktree) | 0c56f27a37af88a073d2bb695d2cf4c05067ff4f | comprehensive repository review | Changes requested: two P1 defects (high-risk clinical claim support can accept a different trigger condition on lexical overlap; document-mode URL auto-run loops on navigation and leaves search loading indefinitely), one P2 supply-chain guard gap (the action-pin checker accepts mutable major tags and ignores SHA-pinned major versions), and one P3 orientation-doc gap (DSM and legacy specifier routes are absent from the codebase index). No P0 found. | Node 24/npm 11 and `npm ls --depth=0`; format, runtime, lint, TypeScript, sitemap/brand/icon/type-scale, docs links/scripts, CI/action/Codex guards; coverage 259 files passed/1 skipped and 2,417 tests passed/1 skipped; offline RAG 36 fixtures plus 282 tests; production build/client-secret scan; deployment boot smoke; critical Chromium 8/10 with two reproducible document-search failures; accessibility 5/5; viewport/focus checks through 1920x1080. Provider-backed governance, quality, drift, tenancy, hosted CI, and the cross-browser matrix were blocked or skipped by policy/targeted failures. | +| 2026-07-17 | PR #704 / codex/scroll-geometry-stability-20260717 | 35e74ddbd61bacc5b34f06efbd58091f092665fd | nested scroll-source review follow-up | Confirmed the outside-diff CodeRabbit finding: the standalone shell shared one intent history across main and descendant scroll containers, so a switch from a deep main offset to a near-zero nested offset could falsely reveal chrome. Scroll metrics now identify their source, source changes rebase direction and travel while preserving visibility, and unit/UI regressions cover the switch. No unresolved actionable review finding remains. | Focused Vitest 9/9; TypeScript; scoped ESLint; Prettier; `git diff --check`. Exact-head hosted CI and UI remain required after push. No Supabase/OpenAI/live-provider checks run. | diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index d8e286ad9..931335a1a 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -442,7 +442,6 @@ export function ClinicalDashboard({ mainRef.current = node; setMainScrollRoot(node); }, []); - const [bottomSearchScrollHidden, setBottomSearchScrollHidden] = useState(false); const composerInputRef = useRef(null); const scrollFrameRef = useRef(null); const navSyncLockRef = useRef(null); @@ -2831,7 +2830,11 @@ export function ClinicalDashboard({ if (frame) return; frame = window.requestAnimationFrame(() => { frame = 0; - reportPhoneScrollHideRef.current(main.scrollTop); + reportPhoneScrollHideRef.current({ + offset: main.scrollTop, + maxOffset: Math.max(0, main.scrollHeight - main.clientHeight), + source: main, + }); }); }; @@ -3352,7 +3355,6 @@ export function ClinicalDashboard({ ? { strategy: "overlay", allBreakpoints: true, scrollHidden: phoneScrollHide.hidden } : { strategy: "collapse", scrollHidden: phoneScrollHide.hidden } } - onBottomComposerScrollHiddenChange={setBottomSearchScrollHidden} />
reserves room for it. When that dock hides on - // scroll, reclaim the reserved strip too — otherwise the near-black - // shell background shows through as an empty band. (sm+ is inert: - // bottomSearchScrollHidden only ever goes true on phones.) The - // reserve hugs the real dock height (follow-up scroll row + composer - // pill ≈ 6rem measured); the old 18rem reserve just painted extra - // shell background as a black band above the dock. - bottomSearchScrollHidden - ? "mb-0 sm:mb-24" - : answerFollowUpSuggestions.length > 0 - ? "mb-[calc(7.5rem+env(safe-area-inset-bottom))] sm:mb-24" - : "mb-[calc(5.25rem+env(safe-area-inset-bottom))] sm:mb-24" + // bottom, so
reserves room for it. Keep that geometry stable + // while the dock translates off-screen: changing the flex item's + // margin alters its client height and clamps scrollTop near the + // bottom, feeding a false upward movement into hide-on-scroll. + answerFollowUpSuggestions.length > 0 + ? "mb-[calc(7.5rem+env(safe-area-inset-bottom))] sm:mb-24" + : "mb-[calc(5.25rem+env(safe-area-inset-bottom))] sm:mb-24" : hasMobileBottomSearch - ? bottomSearchScrollHidden - ? "mb-0 sm:mb-0" - : compactMobileBottomSearch - ? differentialsCompareAddonActive - ? "mb-[calc(8.75rem+env(safe-area-inset-bottom))] sm:mb-0" - : "mb-[calc(5rem+env(safe-area-inset-bottom))] sm:mb-0" - : // Mode homes keep the composer in the hero (in-flow at every - // width), so phones need no bottom-dock clearance on them. - compactMobileModeHome || showDesktopHomeComposer - ? "mb-0" - : "mb-[calc(5.25rem+env(safe-area-inset-bottom))] sm:mb-0" + ? compactMobileBottomSearch + ? differentialsCompareAddonActive + ? "mb-[calc(8.75rem+env(safe-area-inset-bottom))] sm:mb-0" + : "mb-[calc(5rem+env(safe-area-inset-bottom))] sm:mb-0" + : // Mode homes keep the composer in the hero (in-flow at every + // width), so phones need no bottom-dock clearance on them. + compactMobileModeHome || showDesktopHomeComposer + ? "mb-0" + : "mb-[calc(5.25rem+env(safe-area-inset-bottom))] sm:mb-0" : "mb-0", )} > diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 7c798693b..a13d9efda 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -233,7 +233,6 @@ function GlobalStandaloneSearchShellClient({ }), [query, searchMode, commandScopes, removeCommandScope, clearCommandScopes], ); - const [bottomSearchScrollHidden, setBottomSearchScrollHidden] = useState(false); const { theme, toggleTheme } = useTheme(); const auth = useAuthSession(); const sidebarIdentity = useMemo(() => deriveSidebarIdentity(auth.session?.user.email), [auth.session?.user.email]); @@ -430,7 +429,12 @@ function GlobalStandaloneSearchShellClient({ } function handleMainScroll(event: UIEvent) { - phoneScrollHide.reportScroll(event.currentTarget.scrollTop); + const target = event.currentTarget; + phoneScrollHide.reportScroll({ + offset: target.scrollTop, + maxOffset: Math.max(0, target.scrollHeight - target.clientHeight), + source: target, + }); } const mainRefCallback = (node: HTMLDivElement | null) => { @@ -448,7 +452,11 @@ function GlobalStandaloneSearchShellClient({ const target = event.target; if (!(target instanceof HTMLElement) || !main.contains(target)) return; if (target.scrollHeight <= target.clientHeight + 1) return; - reportPhoneScrollHideRef.current(target.scrollTop); + reportPhoneScrollHideRef.current({ + offset: target.scrollTop, + maxOffset: Math.max(0, target.scrollHeight - target.clientHeight), + source: target, + }); }; main.addEventListener("scroll", onScrollCapture, { capture: true, passive: true }); @@ -564,7 +572,6 @@ function GlobalStandaloneSearchShellClient({ // Phone-only: #main-content owns vertical scroll, so hide-on-scroll // collapses the header/composer to hand space back to content. hideOnScroll={{ strategy: "collapse", scrollHidden: phoneScrollHide.hidden }} - onBottomComposerScrollHiddenChange={setBottomSearchScrollHidden} queryInputAutoFocus={searchParams.get("focus") === "1"} /> @@ -582,13 +589,11 @@ function GlobalStandaloneSearchShellClient({ "min-w-0 focus:outline-none max-sm:flex max-sm:min-h-0 max-sm:flex-1 max-sm:flex-col max-sm:overflow-x-hidden max-sm:overflow-y-auto max-sm:overscroll-contain max-sm:[-webkit-overflow-scrolling:touch] sm:min-h-[calc(100dvh-4rem)] sm:overflow-x-clip", !reservesFloatingComposer ? "max-sm:pb-[var(--mobile-composer-reserve)] sm:pb-8" - : bottomSearchScrollHidden - ? "max-sm:pb-8 sm:pb-8" - : searchMode === "answer" - ? "max-sm:pb-[var(--mobile-composer-reserve)] sm:pb-[calc(9rem+env(safe-area-inset-bottom))]" - : useCompactBottomSearch - ? "max-sm:pb-[var(--mobile-composer-reserve)] sm:pb-8" - : "max-sm:pb-[var(--mobile-composer-reserve)] sm:pb-[calc(9rem+env(safe-area-inset-bottom))] sm:pb-8", + : searchMode === "answer" + ? "max-sm:pb-[var(--mobile-composer-reserve)] sm:pb-[calc(9rem+env(safe-area-inset-bottom))]" + : useCompactBottomSearch + ? "max-sm:pb-[var(--mobile-composer-reserve)] sm:pb-8" + : "max-sm:pb-[var(--mobile-composer-reserve)] sm:pb-[calc(9rem+env(safe-area-inset-bottom))] sm:pb-8", )} >
diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 6c9102785..04ef4a475 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -183,7 +183,6 @@ export function MasterSearchHeader({ mobileLeadingAction = "menu", onMobileBack, hideOnScroll, - onBottomComposerScrollHiddenChange, }: { documents: ClinicalDocument[]; documentTotal?: number; @@ -257,8 +256,6 @@ export function MasterSearchHeader({ /** Parent-owned hidden state for hosts that report scroll via React `onScroll`. */ scrollHidden?: boolean; }; - /** Fired when the phone bottom search dock enters or leaves the scroll-hidden state. */ - onBottomComposerScrollHiddenChange?: (hidden: boolean) => void; }) { const visibleAppModeOptions = defaultVisibleAppModeOptions; const trimmedQuery = query.trim(); @@ -333,10 +330,6 @@ export function MasterSearchHeader({ !scopeSheetOpen && !composerChromeFocused; - useEffect(() => { - onBottomComposerScrollHiddenChange?.(bottomComposerHidden); - }, [bottomComposerHidden, onBottomComposerScrollHiddenChange]); - useEffect(() => { if (!loading || !commandDropdownOpen) return undefined; const frame = window.requestAnimationFrame(() => setCommandDropdownOpen(false)); diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index 750721013..e781cffea 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -24,11 +24,18 @@ const hideIntentDistance = 24; const revealIntentDistance = 12; type ScrollDirection = "down" | "up" | null; +export interface ScrollMetrics { + offset: number; + maxOffset?: number; + source?: EventTarget; +} /** Pure scroll-direction evaluation used by the hook; exported for unit tests. */ export function computeScrollHideUpdate(params: { offset: number; lastOffset: number; + maxOffset?: number; + sourceChanged?: boolean; currentlyHidden: boolean; direction?: ScrollDirection; directionTravel?: number; @@ -38,13 +45,40 @@ export function computeScrollHideUpdate(params: { direction: ScrollDirection; directionTravel: number; } { - const { offset, lastOffset, currentlyHidden, direction = null, directionTravel = 0 } = params; + const { + offset, + lastOffset, + maxOffset, + sourceChanged = false, + currentlyHidden, + direction = null, + directionTravel = 0, + } = params; // Ignore iOS rubber-band overscroll at the top. if (offset < 0) return { hidden: currentlyHidden, lastOffset, direction, directionTravel }; - const delta = offset - lastOffset; + // Offsets from different scroll containers are not comparable. Preserve the + // current chrome state and establish a fresh intent baseline for this source. + if (sourceChanged) { + return { hidden: currentlyHidden, lastOffset: offset, direction: null, directionTravel: 0 }; + } if (offset <= topRevealOffset) { 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. + if ( + currentlyHidden && + maxOffset !== undefined && + lastOffset > maxOffset + minimumDelta && + Math.abs(offset - maxOffset) <= 1 + ) { + return { hidden: true, lastOffset: offset, direction: null, directionTravel: 0 }; + } + + const delta = offset - lastOffset; if (Math.abs(delta) < minimumDelta) { return { hidden: currentlyHidden, lastOffset, direction, directionTravel }; } @@ -101,17 +135,28 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa const lastOffsetRef = useRef(0); const directionRef = useRef(null); const directionTravelRef = useRef(0); + const scrollSourceRef = useRef(null); + const hasScrollSourceRef = useRef(false); const active = usePhoneScrollHideActive(disabled, allowAllBreakpoints); const reportScroll = useCallback( - (offset: number) => { + (report: number | ScrollMetrics) => { + const { offset, maxOffset, source } = + typeof report === "number" ? { offset: report, maxOffset: undefined, source: undefined } : report; if (!active || offset < 0) return; const lastOffset = lastOffsetRef.current; const delta = offset - lastOffset; - if (Math.abs(delta) < minimumDelta && offset > topRevealOffset) return; + const sourceChanged = source !== undefined && hasScrollSourceRef.current && scrollSourceRef.current !== source; + if (source !== undefined) { + scrollSourceRef.current = source; + hasScrollSourceRef.current = true; + } + if (!sourceChanged && Math.abs(delta) < minimumDelta && offset > topRevealOffset) return; const update = computeScrollHideUpdate({ offset, lastOffset, + maxOffset, + sourceChanged, currentlyHidden: hiddenRef.current, direction: directionRef.current, directionTravel: directionTravelRef.current, @@ -131,6 +176,8 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa lastOffsetRef.current = 0; directionRef.current = null; directionTravelRef.current = 0; + scrollSourceRef.current = null; + hasScrollSourceRef.current = false; const frame = window.requestAnimationFrame(() => setHidden(false)); return () => window.cancelAnimationFrame(frame); }, [active]); @@ -146,6 +193,8 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa lastOffsetRef.current = 0; directionRef.current = null; directionTravelRef.current = 0; + scrollSourceRef.current = null; + hasScrollSourceRef.current = false; const frame = window.requestAnimationFrame(() => setHidden(false)); return () => window.cancelAnimationFrame(frame); }, [allowAllBreakpoints]); @@ -197,14 +246,26 @@ export function useHideOnScroll({ const resolveContainer = () => scrollContainer ?? containerRef?.current ?? null; - const readOffset = () => { + const readMetrics = (): ScrollMetrics => { const container = resolveContainer(); - return container ? container.scrollTop : window.scrollY; + if (container) { + return { + offset: container.scrollTop, + maxOffset: Math.max(0, container.scrollHeight - container.clientHeight), + source: container, + }; + } + const scrollingElement = document.scrollingElement ?? document.documentElement; + return { + offset: window.scrollY, + maxOffset: Math.max(0, scrollingElement.scrollHeight - window.innerHeight), + source: window, + }; }; const evaluate = () => { frame = 0; - reportScroll(readOffset()); + reportScroll(readMetrics()); }; const onScroll = () => { @@ -222,7 +283,7 @@ export function useHideOnScroll({ attachedTarget?.removeEventListener("scroll", onScroll); attachedTarget = target; target.addEventListener("scroll", onScroll, { passive: true }); - reportScroll(readOffset()); + reportScroll(readMetrics()); return true; }; diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 768d24ff5..31cb5931d 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1943,6 +1943,31 @@ test.describe("Clinical KB UI smoke coverage", () => { Math.round(el.getBoundingClientRect().top), ); expect(composerInputTop).toBeGreaterThanOrEqual(geo.mainBottom - 4); + + // Hiding the fixed dock must not change the scrollable geometry. If its + // clearance is removed at the same time, reaching the bottom clamps + // scrollTop upward; hide-on-scroll reads that forced movement as an upward + // gesture and repeatedly restores/hides the dock, producing visible jitter. + const main = page.locator("main#main-content"); + const bottomDock = page.locator("form.answer-footer-search-dock"); + const scrollGeometryBeforeHide = await main.evaluate((el) => ({ + clientHeight: el.clientHeight, + scrollHeight: el.scrollHeight, + })); + for (const offset of [120, 240, 360]) { + await main.evaluate((el, top) => { + el.scrollTop = top; + }, offset); + } + await expect(bottomDock).toHaveAttribute("data-scroll-hidden", "true"); + await expect + .poll(async () => + main.evaluate((el) => ({ + clientHeight: el.clientHeight, + scrollHeight: el.scrollHeight, + })), + ) + .toEqual(scrollGeometryBeforeHide); await expectNoPageHorizontalOverflow(page); }); @@ -3254,6 +3279,53 @@ test.describe("Clinical KB UI smoke coverage", () => { }), ) .toBe(0); + + // A descendant may become the active scroller. Its near-zero offset must + // establish a new baseline rather than looking like a large upward gesture + // relative to the deeply scrolled main container. + await main.evaluate(async (node) => { + const nested = document.createElement("div"); + nested.dataset.testid = "nested-scroll-intent-source"; + nested.style.height = "40px"; + nested.style.overflowY = "auto"; + const content = document.createElement("div"); + content.style.height = "200px"; + nested.appendChild(content); + node.appendChild(nested); + nested.scrollTop = 4; + nested.dispatchEvent(new Event("scroll")); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + }); + await expect(collapseHost).toHaveAttribute("data-scroll-hidden", "true"); + + // At the bottom, collapsing the in-flow header grows the scroll viewport + // and clamps scrollTop to the new maximum. That geometry-driven event is + // not an upward user gesture and must not immediately reveal the header. + await main.evaluate((node) => { + node.scrollTop = 0; + }); + await expect(collapseHost).not.toHaveAttribute("data-scroll-hidden", "true"); + const visibleGeometry = await main.evaluate((node) => ({ + clientHeight: node.clientHeight, + maxOffset: node.scrollHeight - node.clientHeight, + })); + await main.evaluate((node, top) => { + node.scrollTop = top; + }, visibleGeometry.maxOffset); + await expect(collapseHost).toHaveAttribute("data-scroll-hidden", "true"); + await expect.poll(async () => collapseHost.getAttribute("data-scroll-hidden"), { timeout: 1_000 }).toBe("true"); + const collapsedGeometry = await main.evaluate((node) => ({ + clientHeight: node.clientHeight, + maxOffset: node.scrollHeight - node.clientHeight, + scrollTop: node.scrollTop, + })); + expect(collapsedGeometry.clientHeight).toBeGreaterThan(visibleGeometry.clientHeight); + expect(Math.abs(collapsedGeometry.scrollTop - collapsedGeometry.maxOffset)).toBeLessThanOrEqual(1); + + await main.evaluate((node) => { + node.scrollTop = Math.max(0, node.scrollTop - 24); + }); + await expect(collapseHost).not.toHaveAttribute("data-scroll-hidden", "true"); }); test("document viewer bottom composer hides while scrolling down on phones", async ({ page }) => { diff --git a/tests/use-hide-on-scroll.test.ts b/tests/use-hide-on-scroll.test.ts index 7b25a9de4..a25d84b16 100644 --- a/tests/use-hide-on-scroll.test.ts +++ b/tests/use-hide-on-scroll.test.ts @@ -118,4 +118,55 @@ describe("computeScrollHideUpdate", () => { directionTravel: 4, }); }); + + it("rebases intent when scroll events switch containers", () => { + expect( + computeScrollHideUpdate({ + offset: 4, + lastOffset: 500, + sourceChanged: true, + currentlyHidden: true, + direction: "down", + directionTravel: 300, + }), + ).toEqual({ + hidden: true, + lastOffset: 4, + direction: null, + directionTravel: 0, + }); + }); + + it("ignores an upward clamp caused by the viewport growing at the bottom", () => { + const clamped = computeScrollHideUpdate({ + offset: 900, + lastOffset: 972, + maxOffset: 900, + currentlyHidden: true, + direction: "down", + directionTravel: 140, + }); + expect(clamped).toEqual({ + hidden: true, + lastOffset: 900, + direction: null, + directionTravel: 0, + }); + + expect( + computeScrollHideUpdate({ + offset: 884, + lastOffset: clamped.lastOffset, + maxOffset: 900, + currentlyHidden: clamped.hidden, + direction: clamped.direction, + directionTravel: clamped.directionTravel, + }), + ).toEqual({ + hidden: false, + lastOffset: 884, + direction: "up", + directionTravel: 16, + }); + }); });