diff --git a/next.config.ts b/next.config.ts index f732fd9dd..78045f45a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -10,6 +10,10 @@ const projectRoot = path.dirname(fileURLToPath(import.meta.url)); const securityHeaders = buildSecurityHeaders(resolveRuntimeFlags()); const nextConfig: NextConfig = { + // Playwright and some local tooling hit the dev server via 127.0.0.1; without + // this, Next blocks HMR/client hydration from that host and phone scroll-hide + // never wires up its listeners. + allowedDevOrigins: ["127.0.0.1"], devIndicators: false, experimental: { cpus: 1, diff --git a/scripts/playwright-base-url.ts b/scripts/playwright-base-url.ts index e879dab20..143556905 100644 --- a/scripts/playwright-base-url.ts +++ b/scripts/playwright-base-url.ts @@ -74,7 +74,11 @@ function tryVerifiedLocalProjectUrl(baseUrl: string) { function findExistingLocalProjectUrl() { const stablePort = stableProjectPort(projectRoot); - return tryVerifiedLocalProjectUrl(`http://127.0.0.1:${stablePort}`); + // Prefer localhost so dev HMR matches ensure-local-server's printed URL. + return ( + tryVerifiedLocalProjectUrl(`http://localhost:${stablePort}`) ?? + tryVerifiedLocalProjectUrl(`http://127.0.0.1:${stablePort}`) + ); } export function getPlaywrightBaseUrl() { diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index c365ae008..49f12d906 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -23,7 +23,7 @@ import { WifiOff, Wrench, } from "lucide-react"; -import { type CSSProperties, type UIEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; @@ -670,7 +670,14 @@ export function ClinicalDashboard({ const router = useRouter(); const searchParams = useSearchParams(); const mainRef = useRef(null); + const [mainScrollRoot, setMainScrollRoot] = useState(null); + const assignMainRef = useCallback((node: HTMLElement | null) => { + mainRef.current = node; + setMainScrollRoot(node); + }, []); const phoneScrollHide = useScrollHideReporter(); + const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll); + reportPhoneScrollHideRef.current = phoneScrollHide.reportScroll; const [bottomSearchScrollHidden, setBottomSearchScrollHidden] = useState(false); const composerInputRef = useRef(null); const scrollFrameRef = useRef(null); @@ -2655,11 +2662,31 @@ export function ClinicalDashboard({ }); } - function handleMainScroll(event: UIEvent) { + function handleMainScroll() { scheduleActiveSectionSync(); - phoneScrollHide.reportScroll(event.currentTarget.scrollTop); } + useEffect(() => { + const main = mainScrollRoot; + if (!main) return undefined; + + let frame = 0; + const onScroll = () => { + if (frame) return; + frame = window.requestAnimationFrame(() => { + frame = 0; + reportPhoneScrollHideRef.current(main.scrollTop); + }); + }; + + onScroll(); + main.addEventListener("scroll", onScroll, { passive: true }); + return () => { + main.removeEventListener("scroll", onScroll); + if (frame) window.cancelAnimationFrame(frame); + }; + }, [mainScrollRoot]); + async function copyText(action: string, text: string) { let copied = false; try { @@ -3122,13 +3149,13 @@ export function ClinicalDashboard({ heroComposerFromTablet={Boolean(desktopHomeComposerSlotId)} // Phone-only: the header sits above the internally scrolling
, // so hiding must collapse its layout space to hand it to content. - hideOnScroll={{ strategy: "collapse", scrollHidden: phoneScrollHide.hidden, containerRef: mainRef }} + hideOnScroll={{ strategy: "collapse", scrollHidden: phoneScrollHide.hidden }} onBottomComposerScrollHiddenChange={setBottomSearchScrollHidden} />
; - scrollContainer?: HTMLElement | null; /** Parent-owned hidden state for hosts that report scroll via React `onScroll`. */ scrollHidden?: boolean; }; @@ -295,12 +293,9 @@ export function MasterSearchHeader({ const [headerChromeFocused, setHeaderChromeFocused] = useState(false); const [composerChromeFocused, setComposerChromeFocused] = useState(false); const internalScrollHidden = useHideOnScroll({ - containerRef: hideOnScroll?.containerRef, - scrollContainer: hideOnScroll?.scrollContainer, - disabled: !hideOnScroll, + disabled: !hideOnScroll || hideOnScroll.scrollHidden !== undefined, }); - const scrollHidden = - hideOnScroll?.scrollHidden !== undefined ? hideOnScroll.scrollHidden || internalScrollHidden : internalScrollHidden; + const scrollHidden = hideOnScroll?.scrollHidden !== undefined ? hideOnScroll.scrollHidden : internalScrollHidden; const headerChromeHidden = scrollHidden && !modeMenuOpen && !actionMenuOpen && !scopeOpen && !scopeSheetOpen && !headerChromeFocused; const phoneBottomSearchDockActive = @@ -1560,6 +1555,8 @@ export function MasterSearchHeader({ // never carries a transform, and everything is inert from sm up. return (
{ await expectNoPageHorizontalOverflow(page); }); + test("phone universal header fully hides while scrolling dashboard main on phones", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await gotoApp(page, "/?mode=answer"); + + const header = page.locator("header.universal-header"); + const collapseHost = page.getByTestId("universal-header-collapse"); + await expect(header).toBeVisible(); + await expect(collapseHost).not.toHaveAttribute("data-scroll-hidden", "true"); + await expect.poll(async () => header.evaluate((node) => window.getComputedStyle(node).position)).toBe("relative"); + + const main = page.locator("main#main-content"); + await main.evaluate((node) => { + const spacer = document.createElement("div"); + spacer.setAttribute("data-testid", "header-hide-scroll-spacer"); + spacer.style.height = "2000px"; + node.appendChild(spacer); + }); + // Step scroll down so the dashboard main listener sees deliberate movement. + for (const offset of [40, 80, 120, 160, 200]) { + await main.evaluate((node, top) => { + node.scrollTop = top; + }, offset); + } + + await expect(collapseHost).toHaveAttribute("data-scroll-hidden", "true"); + await expect + .poll(async () => + header.evaluate((node) => { + const rect = node.getBoundingClientRect(); + return Math.max(0, rect.bottom) - Math.max(0, rect.top); + }), + ) + .toBe(0); + }); + test("document viewer bottom composer hides while scrolling down on phones", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockDemoApi(page);