diff --git a/src/app/globals.css b/src/app/globals.css index 3bc755aef..e7962a80b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -708,12 +708,13 @@ summary::-webkit-details-marker { transparent 0%, color-mix(in srgb, var(--background) 8%, transparent) 38%, color-mix(in srgb, var(--background) 18%, transparent) 68%, - color-mix(in srgb, var(--background) 32%, transparent) 100% + color-mix(in srgb, var(--background) 32%, transparent) 88%, + color-mix(in srgb, var(--background) 42%, transparent) 100% ); backdrop-filter: blur(2px) saturate(130%); -webkit-backdrop-filter: blur(2px) saturate(130%); - mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 45%) 28%, black 60%, black 100%); - -webkit-mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 45%) 28%, black 60%, black 100%); + mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 45%) 28%, black 55%, black 100%); + -webkit-mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 45%) 28%, black 55%, black 100%); } .answer-footer-search-dock .answer-footer-search-backdrop::before, @@ -741,8 +742,8 @@ summary::-webkit-details-marker { .answer-footer-search-dock .answer-footer-search-backdrop::after { backdrop-filter: blur(22px) saturate(140%); -webkit-backdrop-filter: blur(22px) saturate(140%); - mask-image: linear-gradient(180deg, transparent 0%, transparent 55%, black 72%, black 100%); - -webkit-mask-image: linear-gradient(180deg, transparent 0%, transparent 55%, black 72%, black 100%); + mask-image: linear-gradient(180deg, transparent 0%, transparent 50%, black 68%, black 100%); + -webkit-mask-image: linear-gradient(180deg, transparent 0%, transparent 50%, black 68%, black 100%); } @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { @@ -1566,17 +1567,22 @@ summary::-webkit-details-marker { } @media (max-width: 639px) { - .dashboard-composer-edge.answer-footer-search-edge { + .edge-glass-header { + padding-left: max(0px, var(--safe-area-left)); + padding-right: max(0px, var(--safe-area-right)); + } + + .dashboard-composer-edge.answer-footer-search-edge:not(.answer-footer-search-dock) { width: min(calc(100vw - 8px - var(--safe-area-left) - var(--safe-area-right)), 400px); } - .document-mobile-search-edge { + .document-mobile-search-edge:not(.answer-footer-search-dock) { left: max(0.375rem, var(--safe-area-left)); right: max(0.375rem, var(--safe-area-right)); bottom: max(0.5rem, calc(var(--safe-area-bottom) + 0.375rem)); } - .document-mobile-search-edge.answer-footer-search-edge { + .document-mobile-search-edge.answer-footer-search-edge:not(.answer-footer-search-dock) { left: 50%; right: auto; bottom: max(0.45rem, calc(var(--safe-area-bottom) + 0.35rem)); @@ -1586,7 +1592,9 @@ summary::-webkit-details-marker { /* Compact search/result views: no chip row below the pill, so the pill itself hugs the bottom edge and the scrim shrinks to match. */ - .document-mobile-search-edge.answer-footer-search-edge.document-mobile-search-compact { + .document-mobile-search-edge.answer-footer-search-edge.document-mobile-search-compact:not( + .answer-footer-search-dock + ) { bottom: max(0.4rem, calc(var(--safe-area-bottom) + 0.3rem)); } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 49f12d906..202341bbb 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3197,6 +3197,7 @@ export function ClinicalDashboard({
) to avoid a needless scrollbar. @@ -3224,7 +3225,12 @@ export function ClinicalDashboard({
(null); + useEffect(() => { + let cancelled = false; + const sync = () => { + if (cancelled) return; + const main = window.document.getElementById("main-content"); + setShellScrollContainer((current) => (current === main ? current : main)); + }; + const frame = window.requestAnimationFrame(sync); + const observer = new MutationObserver(sync); + observer.observe(window.document.body, { childList: true, subtree: true }); + return () => { + cancelled = true; + window.cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, []); + const scrollHidden = useHideOnScroll(shellScrollContainer ? { scrollContainer: shellScrollContainer } : {}); const composerScrollHidden = scrollHidden && !mobileActionsOpen && !composerChromeFocused; const [useNativePdfViewer, setUseNativePdfViewer] = useState(() => getInitialPdfViewerMode().useNativePdfViewer); const [hasExplicitPdfViewerMode, setHasExplicitPdfViewerMode] = useState( diff --git a/src/components/clinical-dashboard/favourites-home-page.tsx b/src/components/clinical-dashboard/favourites-home-page.tsx index 1d23df348..fcbf7beb4 100644 --- a/src/components/clinical-dashboard/favourites-home-page.tsx +++ b/src/components/clinical-dashboard/favourites-home-page.tsx @@ -17,17 +17,19 @@ export function FavouritesHomePage({ query = "" }: FavouritesHomePageProps) { const filterQuery = queryOverride?.source === query ? queryOverride.value : query; return ( -
- { - setQueryOverride({ source: query, value: "" }); - router.push(appModeHomeHref("favourites", { focus: true })); - }} - onAddFavourite={() => undefined} - desktopComposerSlotId={modeHomeDesktopComposerSlotId} - headingLevel={1} - /> +
+
+ { + setQueryOverride({ source: query, value: "" }); + router.push(appModeHomeHref("favourites", { focus: true })); + }} + onAddFavourite={() => undefined} + desktopComposerSlotId={modeHomeDesktopComposerSlotId} + headingLevel={1} + /> +
); } diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index eb88de475..7b4f5c5fb 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -1,7 +1,16 @@ "use client"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { Suspense, type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { + Suspense, + type CSSProperties, + type ReactNode, + type UIEvent, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { ClinicalDashboard } from "@/components/clinical-dashboard"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; @@ -15,6 +24,7 @@ import { } from "@/components/clinical-dashboard/ClinicalSidebar"; import { GuideDialog } from "@/components/clinical-dashboard/dashboard-shell"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; +import { useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; @@ -95,6 +105,12 @@ function GlobalMockupSearchShellClient({ const pathname = usePathname(); const searchParams = useSearchParams(); const inputRef = useRef(null); + const [mainElement, setMainElement] = useState(null); + const phoneScrollHide = useScrollHideReporter(); + const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll); + useEffect(() => { + reportPhoneScrollHideRef.current = phoneScrollHide.reportScroll; + }, [phoneScrollHide.reportScroll]); const visibleShellModes = useMemo(() => { const modes = visibleAppModeDefinitions(); if (!availableModeIds?.length) return modes; @@ -168,6 +184,13 @@ function GlobalMockupSearchShellClient({ const effectiveSidebarCollapsed = isDifferentialPresentationWorkflow ? true : sidebarCollapsed; const effectiveSidebarWidth = shouldShowDesktopSidebar ? (effectiveSidebarCollapsed ? "5.25rem" : "20rem") : "0px"; const shouldShowSearchComposer = searchComposerVisible && !isDifferentialPresentationWorkflow; + const mobileComposerReserve = !shouldShowSearchComposer + ? "2rem" + : searchMode === "answer" + ? "calc(9rem + env(safe-area-inset-bottom))" + : useCompactBottomSearch + ? "calc(5.5rem + env(safe-area-inset-bottom))" + : "calc(9rem + env(safe-area-inset-bottom))"; useEffect(() => { // Re-derive the mode and query from the URL, but only when the search string @@ -294,9 +317,35 @@ function GlobalMockupSearchShellClient({ navigateToMode(mode, { query: crossQuery, focus: true, run: true }); } + function handleMainScroll(event: UIEvent) { + phoneScrollHide.reportScroll(event.currentTarget.scrollTop); + } + + const mainRefCallback = (node: HTMLDivElement | null) => { + setMainElement(node); + }; + const isMedicationDetailRoute = /^\/medications\/[^/]+$/.test(pathname); const shouldRenderClinicalDashboard = !isMedicationDetailRoute && (isHomeRoute || shouldRenderDashboardSearch); + // Page canvases can become nested scrollers when `overflow-x-hidden` pairs with + // a flex height cap (overflow-y becomes auto per CSS). Capture descendant scroll + // so the phone dock/header still hide while users scroll results. + useEffect(() => { + const main = mainElement; + if (!main) return undefined; + + const onScrollCapture = (event: Event) => { + const target = event.target; + if (!(target instanceof HTMLElement) || !main.contains(target)) return; + if (target.scrollHeight <= target.clientHeight + 1) return; + reportPhoneScrollHideRef.current(target.scrollTop); + }; + + main.addEventListener("scroll", onScrollCapture, { capture: true, passive: true }); + return () => main.removeEventListener("scroll", onScrollCapture, { capture: true }); + }, [mainElement, shouldRenderClinicalDashboard, chromeVisible]); + if (shouldRenderClinicalDashboard) { return ( @@ -358,12 +408,8 @@ function GlobalMockupSearchShellClient({
) : null} -
- {/* max-sm:contents lets the header's own `sticky top-0` engage against - the document scroll on phones (a plain wrapper div otherwise caps - its sticking range at its own height), which the phone - hide-on-scroll overlay relies on. */} -
+
+
@@ -423,38 +469,40 @@ function GlobalMockupSearchShellClient({
- } - > - setCommandScopes((current) => current.filter((scope) => scope !== scopeId)), - onClearScopes: () => setCommandScopes([]), - }} +
+ } > - {children} - - + + setCommandScopes((current) => current.filter((scope) => scope !== scopeId)), + onClearScopes: () => setCommandScopes([]), + }} + > + {children} + + +
diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index a332242d9..ad654a61e 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1092,8 +1092,14 @@ export function MasterSearchHeader({ ? "floating-composer-edge dashboard-composer-edge z-40 mx-auto max-w-3xl max-sm:fixed max-sm:bottom-0 sm:sticky sm:top-[calc(4.75rem+env(safe-area-inset-top))] sm:z-20 lg:fixed lg:bottom-0 lg:top-auto lg:max-w-4xl" : usesMobileBottomStyle ? cn( - "document-mobile-search-edge universal-top-search-edge fixed z-40 mx-auto max-w-3xl sm:z-20 sm:w-full sm:px-4 sm:py-3 lg:max-w-4xl", - isHeroDesktopComposer ? "sm:hidden" : "sm:sticky sm:top-[calc(4.75rem+env(safe-area-inset-top))]", + usesPhoneFooterDock + ? "document-mobile-search-edge universal-top-search-edge fixed z-40 w-full" + : cn( + "document-mobile-search-edge universal-top-search-edge fixed z-40 mx-auto max-w-3xl sm:z-20 sm:w-full sm:px-4 sm:py-3 lg:max-w-4xl", + isHeroDesktopComposer + ? "sm:hidden" + : "sm:sticky sm:top-[calc(4.75rem+env(safe-area-inset-top))]", + ), ) : "universal-top-search-edge sticky top-[calc(4.75rem+env(safe-area-inset-top))] z-20 mx-auto box-border w-full px-3 py-3 sm:px-4", usesBottomComposerPlacement && "answer-footer-search-edge", diff --git a/src/components/mode-home-template.tsx b/src/components/mode-home-template.tsx index 86fec7c75..9645f0cdd 100644 --- a/src/components/mode-home-template.tsx +++ b/src/components/mode-home-template.tsx @@ -75,7 +75,7 @@ export function ModeHomeHero({ return (
@@ -224,20 +219,23 @@ export function ModeHomeTemplate({
{desktopComposerSlotId ? ( -
+
) : null} {actions?.length ? (
{actions.map((action, index) => { const ActionIcon = action.icon; @@ -292,7 +290,7 @@ export function ModeHomeTemplate({ {pills?.length ? (
{pillsTitle || pillsAction ? (
@@ -333,7 +331,7 @@ export function ModeHomeTemplate({
) : null} - {footer ?
{footer}
: null} + {footer ?
{footer}
: null}
); } diff --git a/tests/playwright-scroll.ts b/tests/playwright-scroll.ts new file mode 100644 index 000000000..999ff6643 --- /dev/null +++ b/tests/playwright-scroll.ts @@ -0,0 +1,16 @@ +import type { Page } from "playwright/test"; + +/** Scroll the app's primary phone surface (#main-content when present, else the document). */ +export async function scrollPrimarySurface(page: Page, top: number) { + await page.evaluate((scrollTop) => { + const main = document.getElementById("main-content"); + if (main) { + main.scrollTo({ top: scrollTop, behavior: "auto" }); + // WebKit doesn't reliably emit a native scroll event for programmatic scrollTo. + main.dispatchEvent(new Event("scroll")); + return; + } + window.scrollTo({ top: scrollTop, behavior: "auto" }); + window.dispatchEvent(new Event("scroll")); + }, top); +} diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index b50b1834e..1c6ad274f 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1,5 +1,6 @@ import type { Route } from "playwright-core"; import { expect, test, type Locator, type Page } from "playwright/test"; +import { scrollPrimarySurface } from "./playwright-scroll"; import { answerThreadStorageKey } from "../src/lib/answer-thread-storage"; import { demoAnswer, demoDocuments, getDemoDocument, getDemoDocumentPayload } from "../src/lib/demo-data"; import { deriveGovernanceFromSections } from "../src/lib/medication-records"; @@ -2267,16 +2268,26 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(composer).toBeVisible(); await expect(composer).not.toHaveAttribute("data-scroll-hidden", "true"); + await page.evaluate(() => { + const main = window.document.getElementById("main-content"); + const spacer = window.document.createElement("div"); + spacer.setAttribute("data-testid", "composer-hide-scroll-spacer"); + spacer.style.height = "2000px"; + (main ?? window.document.body).appendChild(spacer); + }); + // Hide on deliberate scroll down past the activation offset. - await page.evaluate(() => window.scrollTo({ top: 120, behavior: "auto" })); + for (const offset of [40, 80, 120, 160, 200]) { + await scrollPrimarySurface(page, offset); + } await expect(composer).toHaveAttribute("data-scroll-hidden", "true"); // Reappear on scroll up. - await page.evaluate(() => window.scrollTo({ top: 60, behavior: "auto" })); + await scrollPrimarySurface(page, 60); await expect(composer).not.toHaveAttribute("data-scroll-hidden", "true"); // Keyboard focus inside the composer reveals it while hidden. - await page.evaluate(() => window.scrollTo({ top: 240, behavior: "auto" })); + await scrollPrimarySurface(page, 240); await expect(composer).toHaveAttribute("data-scroll-hidden", "true"); await composer.locator("input").focus(); await expect(composer).not.toHaveAttribute("data-scroll-hidden", "true"); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 981a8410f..e5fe26807 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -421,7 +421,7 @@ test.describe("Clinical KB tools launcher", () => { ] as const) { await gotoLauncher(page, home.path); await expect(page.getByTestId(home.testId)).toBeVisible(); - await expect(visibleGlobalSearchInput(page)).toHaveCount(1); + await expect(visibleGlobalSearchInput(page)).toHaveCount(1, { timeout: 15_000 }); const heroSearch = page.getByTestId(home.testId).getByTestId("global-search-input"); await expect(heroSearch).toBeVisible(); @@ -430,13 +430,18 @@ test.describe("Clinical KB tools launcher", () => { const headingBox = await page .getByRole("heading", { level: home.headingLevel, name: home.heading }) .boundingBox(); + const mainBox = await page.locator("#main-content").boundingBox(); expect(searchBox).not.toBeNull(); expect(headingBox).not.toBeNull(); + expect(mainBox).not.toBeNull(); expect((headingBox?.y ?? 0) + (headingBox?.height ?? 0)).toBeLessThan(searchBox?.y ?? 0); - // Short homes centre their hero+search block mid-screen on phones, so the - // search midpoint should land in a centred band rather than hug an edge. - expect((searchBox?.y ?? 0) + (searchBox?.height ?? 0) / 2).toBeLessThan(820 * 0.72); - expect((searchBox?.y ?? 0) + (searchBox?.height ?? 0) / 2).toBeGreaterThan(820 * 0.2); + // Short homes centre their hero+search block in the scrollable main pane on + // phones (below the sticky header), not necessarily the full viewport. + const searchMidpoint = (searchBox?.y ?? 0) + (searchBox?.height ?? 0) / 2; + const mainTop = mainBox?.y ?? 0; + const mainHeight = mainBox?.height ?? 820; + expect(searchMidpoint).toBeLessThan(mainTop + mainHeight * 0.72); + expect(searchMidpoint).toBeGreaterThan(mainTop + mainHeight * 0.08); const metrics = await globalSearchComposerMetrics(page, home.testId); expect(metrics).not.toBeNull(); expect(metrics?.position).not.toBe("fixed"); @@ -762,21 +767,42 @@ test.describe("Clinical KB tools launcher", () => { await expect(dock).toBeVisible(); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); + // focus=1 leaves the composer focused; hide-on-scroll stays off while it has focus. + const input = visibleGlobalSearchInput(page).first(); + await input.focus(); + await page.keyboard.press("Escape"); + await input.blur(); + await expect(dock).not.toHaveAttribute("data-command-open", "true"); + + // Inject a spacer to ensure the container is scrollable even with minimal search results await page.evaluate(() => { - window.scrollTo({ top: 120, behavior: "auto" }); - // WebKit doesn't reliably emit a native scroll event for a programmatic scrollTo, so the - // hide-on-scroll listener never ran and data-scroll-hidden stayed unset (release-browser-matrix - // WebKit flake). Dispatch one explicitly; harmless on Chromium/Firefox (the rAF guard dedupes). - window.dispatchEvent(new Event("scroll")); + const container = document.getElementById("main-content"); + if (container) { + const spacer = document.createElement("div"); + spacer.id = "test-scroll-spacer"; + spacer.style.height = "2000px"; + spacer.style.minHeight = "2000px"; + spacer.style.display = "block"; + container.appendChild(spacer); + } }); + + const scroller = page.locator("#main-content"); + // Step scroll down so the shell's scroll reporter sees deliberate movement. + for (const offset of [40, 80, 120, 160, 200]) { + await scroller.evaluate((node, top) => { + node.scrollTop = top; + node.dispatchEvent(new Event("scroll", { bubbles: true })); + }, offset); + } await expect(dock).toHaveAttribute("data-scroll-hidden", "true"); await expect .poll(async () => dock.evaluate((node) => window.getComputedStyle(node).transform !== "none")) .toBe(true); - await page.evaluate(() => { - window.scrollTo({ top: 60, behavior: "auto" }); - window.dispatchEvent(new Event("scroll")); + await scroller.evaluate((node) => { + node.scrollTop = 60; + node.dispatchEvent(new Event("scroll", { bubbles: true })); }); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); await expect