From f88a41ae5fb62ae39d5d1d33fd5e21490e1ea60e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:57:13 +0800 Subject: [PATCH 1/4] fix(ui): stabilize scroll chrome at boundaries --- src/components/ClinicalDashboard.tsx | 46 +++---- .../global-search-shell.tsx | 25 ++-- .../master-search-header.tsx | 7 -- .../clinical-dashboard/use-hide-on-scroll.ts | 98 ++++++++++++--- tests/ui-smoke.spec.ts | 54 ++++++++ tests/use-hide-on-scroll.test.ts | 115 +++++++++++++++++- 6 files changed, 281 insertions(+), 64 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index a3be64c25..ba74c002d 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -440,7 +440,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); @@ -2784,7 +2783,10 @@ 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), + }); }); }; @@ -3305,7 +3307,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..8e5ac9612 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,11 @@ 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), + }); } const mainRefCallback = (node: HTMLDivElement | null) => { @@ -448,7 +451,10 @@ 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), + }); }; main.addEventListener("scroll", onScrollCapture, { capture: true, passive: true }); @@ -564,7 +570,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 +587,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 40a7be517..33ed7cd9b 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 37a8586ea..6457f8156 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -16,26 +16,74 @@ const topRevealOffset = 8; // Minimum per-event delta (px) before we treat movement as intentional, to // avoid jitter from momentum settling and fractional scroll positions. const minimumDelta = 4; +// Require sustained motion before changing chrome state. This filters +// fractional momentum reversals without making a deliberate reveal feel slow. +const hideIntentDistance = 24; +const revealIntentDistance = 12; + +type ScrollDirection = "down" | "up" | null; + +export interface ScrollMetrics { + offset: number; + maxOffset?: number; +} /** Pure scroll-direction evaluation used by the hook; exported for unit tests. */ -export function computeScrollHideUpdate(params: { offset: number; lastOffset: number; currentlyHidden: boolean }): { +export function computeScrollHideUpdate(params: { + offset: number; + lastOffset: number; + maxOffset?: number; + currentlyHidden: boolean; + direction?: ScrollDirection; + directionTravel?: number; +}): { hidden: boolean; lastOffset: number; + direction: ScrollDirection; + directionTravel: number; } { - const { offset, lastOffset, currentlyHidden } = params; + const { offset, lastOffset, maxOffset, currentlyHidden, direction = null, directionTravel = 0 } = params; // Ignore iOS rubber-band overscroll at the top. - if (offset < 0) return { hidden: currentlyHidden, lastOffset }; - const delta = offset - lastOffset; + if (offset < 0) return { hidden: currentlyHidden, lastOffset, direction, directionTravel }; if (offset <= topRevealOffset) { - return { hidden: false, lastOffset: offset }; + 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 }; + return { hidden: currentlyHidden, lastOffset, direction, directionTravel }; } - if (delta > 0) { - return { hidden: offset > hideActivationOffset, lastOffset: offset }; + + const nextDirection: Exclude = delta > 0 ? "down" : "up"; + const nextDirectionTravel = nextDirection === direction ? directionTravel + Math.abs(delta) : Math.abs(delta); + let hidden = currentlyHidden; + + if (!currentlyHidden && nextDirection === "down" && offset > hideActivationOffset) { + const travelPastActivation = Math.min(nextDirectionTravel, offset - hideActivationOffset); + hidden = travelPastActivation >= hideIntentDistance; + } else if (currentlyHidden && nextDirection === "up" && nextDirectionTravel >= revealIntentDistance) { + hidden = false; } - return { hidden: false, lastOffset: offset }; + + return { + hidden, + lastOffset: offset, + direction: nextDirection, + directionTravel: nextDirectionTravel, + }; } function subscribeToPhoneMedia(onChange: () => void) { @@ -67,10 +115,13 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa const [hidden, setHidden] = useState(false); const hiddenRef = useRef(false); const lastOffsetRef = useRef(0); + const directionRef = useRef(null); + const directionTravelRef = useRef(0); const active = usePhoneScrollHideActive(disabled, allowAllBreakpoints); const reportScroll = useCallback( - (offset: number) => { + (report: number | ScrollMetrics) => { + const { offset, maxOffset } = typeof report === "number" ? { offset: report, maxOffset: undefined } : report; if (!active || offset < 0) return; const lastOffset = lastOffsetRef.current; const delta = offset - lastOffset; @@ -78,10 +129,15 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa const update = computeScrollHideUpdate({ offset, lastOffset, + maxOffset, currentlyHidden: hiddenRef.current, + direction: directionRef.current, + directionTravel: directionTravelRef.current, }); lastOffsetRef.current = update.lastOffset; hiddenRef.current = update.hidden; + directionRef.current = update.direction; + directionTravelRef.current = update.directionTravel; setHidden(update.hidden); }, [active], @@ -91,6 +147,8 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa if (active) return undefined; hiddenRef.current = false; lastOffsetRef.current = 0; + directionRef.current = null; + directionTravelRef.current = 0; const frame = window.requestAnimationFrame(() => setHidden(false)); return () => window.cancelAnimationFrame(frame); }, [active]); @@ -104,6 +162,8 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa useEffect(() => { hiddenRef.current = false; lastOffsetRef.current = 0; + directionRef.current = null; + directionTravelRef.current = 0; const frame = window.requestAnimationFrame(() => setHidden(false)); return () => window.cancelAnimationFrame(frame); }, [allowAllBreakpoints]); @@ -155,14 +215,24 @@ 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), + }; + } + const scrollingElement = document.scrollingElement ?? document.documentElement; + return { + offset: window.scrollY, + maxOffset: Math.max(0, scrollingElement.scrollHeight - window.innerHeight), + }; }; const evaluate = () => { frame = 0; - reportScroll(readOffset()); + reportScroll(readMetrics()); }; const onScroll = () => { @@ -180,7 +250,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 167fe917f..a157c93e9 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); }); @@ -3250,6 +3275,35 @@ test.describe("Clinical KB UI smoke coverage", () => { }), ) .toBe(0); + + // 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 682e622ec..1bd42fe30 100644 --- a/tests/use-hide-on-scroll.test.ts +++ b/tests/use-hide-on-scroll.test.ts @@ -7,17 +7,45 @@ describe("computeScrollHideUpdate", () => { expect(computeScrollHideUpdate({ offset: 0, lastOffset: 0, currentlyHidden: true })).toEqual({ hidden: false, lastOffset: 0, + direction: null, + directionTravel: 0, }); expect(computeScrollHideUpdate({ offset: 8, lastOffset: 20, currentlyHidden: true })).toEqual({ hidden: false, lastOffset: 8, + direction: null, + directionTravel: 0, }); }); - it("hides after scrolling down past the activation offset", () => { - expect(computeScrollHideUpdate({ offset: 80, lastOffset: 10, currentlyHidden: false })).toEqual({ + it("waits for deliberate travel beyond the activation offset before hiding", () => { + const beforeThreshold = computeScrollHideUpdate({ + offset: 92, + lastOffset: 60, + currentlyHidden: false, + direction: "down", + directionTravel: 60, + }); + expect(beforeThreshold).toEqual({ + hidden: false, + lastOffset: 92, + direction: "down", + directionTravel: 92, + }); + + expect( + computeScrollHideUpdate({ + offset: 100, + lastOffset: beforeThreshold.lastOffset, + currentlyHidden: beforeThreshold.hidden, + direction: beforeThreshold.direction, + directionTravel: beforeThreshold.directionTravel, + }), + ).toEqual({ hidden: true, - lastOffset: 80, + lastOffset: 100, + direction: "down", + directionTravel: 100, }); }); @@ -25,13 +53,34 @@ describe("computeScrollHideUpdate", () => { expect(computeScrollHideUpdate({ offset: 40, lastOffset: 10, currentlyHidden: false })).toEqual({ hidden: false, lastOffset: 40, + direction: "down", + directionTravel: 30, }); }); - it("reveals again on deliberate scroll up", () => { - expect(computeScrollHideUpdate({ offset: 120, lastOffset: 180, currentlyHidden: true })).toEqual({ + it("reveals after a short but deliberate upward travel", () => { + const firstUpwardMove = computeScrollHideUpdate({ + offset: 174, + lastOffset: 180, + currentlyHidden: true, + direction: "down", + directionTravel: 80, + }); + expect(firstUpwardMove.hidden).toBe(true); + + expect( + computeScrollHideUpdate({ + offset: 166, + lastOffset: firstUpwardMove.lastOffset, + currentlyHidden: firstUpwardMove.hidden, + direction: firstUpwardMove.direction, + directionTravel: firstUpwardMove.directionTravel, + }), + ).toEqual({ hidden: false, - lastOffset: 120, + lastOffset: 166, + direction: "up", + directionTravel: 14, }); }); @@ -39,6 +88,8 @@ describe("computeScrollHideUpdate", () => { expect(computeScrollHideUpdate({ offset: -12, lastOffset: 4, currentlyHidden: true })).toEqual({ hidden: true, lastOffset: 4, + direction: null, + directionTravel: 0, }); }); @@ -46,6 +97,58 @@ describe("computeScrollHideUpdate", () => { expect(computeScrollHideUpdate({ offset: 82, lastOffset: 80, currentlyHidden: false })).toEqual({ hidden: false, lastOffset: 80, + direction: null, + directionTravel: 0, + }); + }); + + it("resets intent when the user changes direction", () => { + expect( + computeScrollHideUpdate({ + offset: 76, + lastOffset: 80, + currentlyHidden: false, + direction: "down", + directionTravel: 80, + }), + ).toEqual({ + hidden: false, + lastOffset: 76, + direction: "up", + directionTravel: 4, + }); + }); + + 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, }); }); }); From 4f1a0375a17a57e9e911c7eaee0d49853a450014 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:59:49 +0800 Subject: [PATCH 2/4] docs: record scroll stability review --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 576d4914f..743cba1f1 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -560,3 +560,4 @@ 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. | From 35e74ddbd61bacc5b34f06efbd58091f092665fd Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:01:55 +0800 Subject: [PATCH 3/4] fix(ui): rebase scroll intent across containers --- src/components/ClinicalDashboard.tsx | 1 + .../global-search-shell.tsx | 2 ++ .../clinical-dashboard/use-hide-on-scroll.ts | 36 +++++++++++++++++-- tests/ui-smoke.spec.ts | 18 ++++++++++ tests/use-hide-on-scroll.test.ts | 18 ++++++++++ 5 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 7aa2b94fd..931335a1a 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -2833,6 +2833,7 @@ export function ClinicalDashboard({ reportPhoneScrollHideRef.current({ offset: main.scrollTop, maxOffset: Math.max(0, main.scrollHeight - main.clientHeight), + source: main, }); }); }; diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 8e5ac9612..a13d9efda 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -433,6 +433,7 @@ function GlobalStandaloneSearchShellClient({ phoneScrollHide.reportScroll({ offset: target.scrollTop, maxOffset: Math.max(0, target.scrollHeight - target.clientHeight), + source: target, }); } @@ -454,6 +455,7 @@ function GlobalStandaloneSearchShellClient({ reportPhoneScrollHideRef.current({ offset: target.scrollTop, maxOffset: Math.max(0, target.scrollHeight - target.clientHeight), + source: target, }); }; diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index 6457f8156..aff05307a 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -26,6 +26,7 @@ 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. */ @@ -33,6 +34,7 @@ export function computeScrollHideUpdate(params: { offset: number; lastOffset: number; maxOffset?: number; + sourceChanged?: boolean; currentlyHidden: boolean; direction?: ScrollDirection; directionTravel?: number; @@ -42,9 +44,22 @@ export function computeScrollHideUpdate(params: { direction: ScrollDirection; directionTravel: number; } { - const { offset, lastOffset, maxOffset, 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 }; + // 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 }; } @@ -117,19 +132,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( (report: number | ScrollMetrics) => { - const { offset, maxOffset } = typeof report === "number" ? { offset: report, maxOffset: undefined } : report; + 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, @@ -149,6 +173,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]); @@ -164,6 +190,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]); @@ -221,12 +249,14 @@ export function useHideOnScroll({ 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, }; }; diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index e018d277a..31cb5931d 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -3280,6 +3280,24 @@ 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. diff --git a/tests/use-hide-on-scroll.test.ts b/tests/use-hide-on-scroll.test.ts index 1bd42fe30..a25d84b16 100644 --- a/tests/use-hide-on-scroll.test.ts +++ b/tests/use-hide-on-scroll.test.ts @@ -119,6 +119,24 @@ describe("computeScrollHideUpdate", () => { }); }); + 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, From b03313edcd8861670cf10613aee4b7c089ed62e6 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:02:17 +0800 Subject: [PATCH 4/4] docs: record scroll source review follow-up --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index ef0d04fc9..48f77207d 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -562,3 +562,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 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-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. |