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