diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 0e6e9c62e..4b839f05e 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -507,6 +507,7 @@ export function ClinicalDashboard({ // overlay); other modes keep the phone-only collapse, so the reporter only // widens past the phone media gate while in answer mode. const phoneScrollHide = useScrollHideReporter(false, searchMode === "answer"); + const [bottomComposerHidden, setBottomComposerHidden] = useState(false); const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll); reportPhoneScrollHideRef.current = phoneScrollHide.reportScroll; const [modeSearchSubmitted, setModeSearchSubmitted] = useState(() => @@ -3249,6 +3250,20 @@ export function ClinicalDashboard({ const compactMobileBottomSearch = hasMobileBottomSearch && modeSearchSubmitted; const differentialsCompareAddonActive = searchMode === "differentials" && modeSearchSubmitted && Boolean(query.trim()); + const visibleMobileComposerReserve = + searchMode === "answer" + ? answerFollowUpSuggestions.length > 0 + ? "calc(7.5rem + var(--safe-area-bottom))" + : "calc(5.25rem + var(--safe-area-bottom))" + : differentialsCompareAddonActive + ? "calc(8.75rem + var(--safe-area-bottom))" + : compactMobileBottomSearch + ? "calc(5rem + var(--safe-area-bottom))" + : "calc(5.25rem + var(--safe-area-bottom))"; + // Browser safe areas protect the visible interactive dock. Once it has + // scrolled away, reserving Safari's translucent toolbar inset would leave a + // large blank band instead of allowing ordinary content to paint beneath it. + const mobileComposerReserve = bottomComposerHidden ? "0.75rem" : visibleMobileComposerReserve; const renderDegradedNotice = () => ( @@ -3529,6 +3545,7 @@ export function ClinicalDashboard({ ? { strategy: "overlay", allBreakpoints: true, scrollHidden: phoneScrollHide.hidden } : { strategy: "collapse", scrollHidden: phoneScrollHide.hidden } } + onBottomComposerHiddenChange={setBottomComposerHidden} />
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" + : // Keep the phone scrollport edge-to-edge and reserve the visible + // dock inside its scrollable content. Padding can collapse when the + // dock hides without exposing the app-shell background; the + // bottom-clamp guard in use-hide-on-scroll prevents false reveals. + "max-sm:pb-[var(--mobile-composer-reserve)] sm:mb-24" : hasMobileBottomSearch - ? 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" + ? // 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" + : "max-sm:pb-[var(--mobile-composer-reserve)] sm:mb-0" : "mb-0", )} > diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index f925c140a..928d261b1 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -2681,7 +2681,16 @@ export function DocumentViewer({ ) : null} -
+
{downloadError ? ( {downloadError} diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index e903fe69a..ca61aba77 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -284,21 +284,21 @@ function GlobalStandaloneSearchShellClient({ : isStandaloneModeHome ? "2rem" : searchMode === "answer" - ? "calc(9rem + env(safe-area-inset-bottom))" + ? "calc(9rem + var(--safe-area-bottom))" : differentialsCompareAddonActive - ? "calc(8.75rem + env(safe-area-inset-bottom))" + ? "calc(8.75rem + var(--safe-area-bottom))" : useCompactBottomSearch - ? "calc(5.5rem + env(safe-area-inset-bottom))" - : "calc(9rem + env(safe-area-inset-bottom))"; + ? "calc(5.5rem + var(--safe-area-bottom))" + : "calc(9rem + var(--safe-area-bottom))"; // Release the large bottom reserve only when the phone bottom composer is // actually hidden (MasterSearchHeader's bottomComposerHidden). Header-only // scroll-hide, pinned compare addons, open menus/sheets, and composer focus // keep the full reserve so content does not slide under a still-visible dock. - // When the dock does hide, keep only a small safe-area breathing room so - // content — not the background — fills the revealed edge-to-edge viewport. - const mobileComposerReserve = bottomComposerHidden - ? "max(0.75rem, env(safe-area-inset-bottom))" - : visibleMobileComposerReserve; + // Safari's bottom safe-area inset includes its translucent browser toolbar. + // Reusing that inset after the app composer hides recreates a toolbar-sized + // blank band, so the hidden state intentionally keeps only a small content + // pad. Interactive composer chrome still receives the full inset above. + const mobileComposerReserve = bottomComposerHidden ? "0.75rem" : visibleMobileComposerReserve; useEffect(() => { // Re-derive the mode and query from the URL, but only when the search string diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 7837d9c3c..434c8e8d3 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1206,7 +1206,7 @@ export function MasterSearchHeader({ // Keep that dock pinned while the header can still collapse independently. const shouldHideBottomOnScroll = Boolean(hideOnScroll && usesPhoneFooterDock && !mobileBottomSearchAddonSlotId); // Phone submitted non-answer result docks reserve pill-only scroll - // clearance (ClinicalDashboard
margins / global-search-shell + // clearance (ClinicalDashboard / global-search-shell
padding via // mobileComposerReserve), so an extra notice line would push the fixed // dock over the last result. Those flows already showed the notice on // their entry composer; answer docks keep it (their reserves were sized diff --git a/tests/clinical-dashboard-merge-artifacts.test.ts b/tests/clinical-dashboard-merge-artifacts.test.ts index 90cf1df20..af01f9f3c 100644 --- a/tests/clinical-dashboard-merge-artifacts.test.ts +++ b/tests/clinical-dashboard-merge-artifacts.test.ts @@ -29,6 +29,7 @@ const globalSearchShellSource = readFileSync( "utf8", ); const clinicalDashboardSource = readFileSync(resolve(process.cwd(), "src/components/ClinicalDashboard.tsx"), "utf8"); +const documentViewerSource = readFileSync(resolve(process.cwd(), "src/components/DocumentViewer.tsx"), "utf8"); const globalStylesSource = readFileSync(resolve(process.cwd(), "src/app/globals.css"), "utf8"); type FoundDeclaration = { source: string }; @@ -86,11 +87,19 @@ describe("ClinicalDashboard merge-artifact guards", () => { expect(globalStylesSource).not.toMatch(/^\s*-webkit-backdrop-filter\s*:/m); }); - it("reserves phone space for the fixed mode-home composer", () => { + it("releases the Safari toolbar reserve only after phone composers hide", () => { expect(globalSearchShellSource).toContain("const visibleMobileComposerReserve = !shouldShowSearchComposer"); expect(globalSearchShellSource).toContain("const mobileComposerReserve = bottomComposerHidden"); + expect(globalSearchShellSource).toContain('bottomComposerHidden ? "0.75rem"'); + expect(globalSearchShellSource).not.toContain("max(0.75rem, env(safe-area-inset-bottom))"); expect(globalSearchShellSource).not.toContain("const mobileComposerReserve = !reservesFloatingComposer"); expect(globalSearchShellSource).not.toContain("const mobileComposerReserve = phoneScrollHide.hidden"); + expect(clinicalDashboardSource).toContain('bottomComposerHidden ? "0.75rem"'); + expect(clinicalDashboardSource).toContain('"max-sm:pb-[var(--mobile-composer-reserve)] sm:mb-24"'); + expect(documentViewerSource).toContain('data-testid="document-viewer-content"'); + expect(documentViewerSource).toContain('"max-sm:pb-3"'); + expect(documentViewerSource).toContain('"max-sm:pb-[calc(9rem+var(--safe-area-bottom))]"'); + expect(globalStylesSource).toMatch(/--safe-area-bottom\s*:/); }); it("does not reintroduce the obsolete output-mode copy helper", () => { diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index d06284c2f..a25bd90b4 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -667,6 +667,13 @@ async function openScopeControl(page: Page) { .catch(() => undefined); const composer = page.locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible').first(); + const bottomDock = page.locator("form.answer-footer-search-dock"); + if (await bottomDock.isVisible().catch(() => false)) { + // Prior sheet/scroll interactions can leave the phone dock translated off-screen. + // Restore it before opening scope so the click lands in the viewport. + await scrollPrimarySurface(page, 0); + await expect(bottomDock).not.toHaveAttribute("data-scroll-hidden", "true"); + } await composer.click(); const scopeOption = page.getByRole("option", { name: /Scope sources/i }); @@ -1914,10 +1921,20 @@ test.describe("Clinical KB UI smoke coverage", () => { // Wait for streaming to finish (deterministic) so the geometry below reads the // final, settled layout — replaces a fixed 400ms sleep. await expect(page.getByTestId("answer-streaming")).toHaveCount(0); - // Start from the top so the assertions describe the resting, top-aligned view. - await page.locator("main#main-content").evaluate((el) => { - el.scrollTop = 0; + // Apply the Safari toolbar simulation after answer navigation has settled; + // the submit flow may update the URL and replace earlier document styles. + await page.evaluate(() => { + document.documentElement.style.setProperty("--safe-area-bottom", "112px"); }); + const main = page.locator("main#main-content"); + const bottomDock = page.locator("form.answer-footer-search-dock"); + // Start from the top so the assertions describe the resting, top-aligned + // view and the hide reporter has observed the restored position. + await scrollPrimarySurface(page, 0); + await expect(bottomDock).not.toHaveAttribute("data-scroll-hidden", "true"); + await expect + .poll(async () => main.evaluate((el) => Number.parseFloat(window.getComputedStyle(el).paddingBottom))) + .toBeGreaterThan(200); const geo = await page.evaluate(() => { const main = document.querySelector("main#main-content"); @@ -1927,6 +1944,9 @@ test.describe("Clinical KB UI smoke coverage", () => { scrollHeight: main?.scrollHeight ?? 0, clientHeight: main?.clientHeight ?? 0, mainBottom: main ? Math.round(main.getBoundingClientRect().bottom) : 0, + mainMarginBottom: main ? Number.parseFloat(window.getComputedStyle(main).marginBottom) : -1, + mainPaddingBottom: main ? Number.parseFloat(window.getComputedStyle(main).paddingBottom) : 0, + viewportHeight: window.innerHeight, headerBottom: header ? Math.round(header.getBoundingClientRect().bottom) : 0, surfaceTop: surface ? Math.round(surface.getBoundingClientRect().top) : 0, }; @@ -1934,39 +1954,51 @@ test.describe("Clinical KB UI smoke coverage", () => { // A long answer overflows and scrolls, still top-aligned under the header. expect(geo.scrollHeight).toBeGreaterThan(geo.clientHeight + 40); expect(geo.surfaceTop - geo.headerBottom).toBeLessThanOrEqual(160); - // The
reserve keeps the opaque composer input fully below the scroll - // viewport, so answer content clears the composer instead of being hidden - // behind it. (The dock's blur scrim intentionally fades content above the - // input, so we anchor on the input itself, not the scrim's bounding box.) + // The scrollport itself remains edge-to-edge. Its content padding—not an + // outer margin—keeps the answer endpoint clear of the visible composer and + // Safari toolbar. const composerInputTop = await visibleQuestionInput(page).evaluate((el) => 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"); + expect(geo.mainMarginBottom).toBe(0); + expect(Math.abs(geo.mainBottom - geo.viewportHeight)).toBeLessThanOrEqual(1); + expect(geo.mainPaddingBottom).toBeGreaterThan(112); + expect(geo.mainPaddingBottom + 4).toBeGreaterThanOrEqual(geo.mainBottom - composerInputTop); + + // Once the fixed dock is actually hidden, release both the composer and + // Safari toolbar reserve. The scrollport dimensions stay stable while its + // bottom padding contracts; the bottom-clamp guard must keep the dock from + // immediately reappearing as a false upward gesture. Do not compare total + // scrollHeight here because universal matches can finish streaming while + // this test moves the scrollport. const scrollGeometryBeforeHide = await main.evaluate((el) => ({ + clientHeight: el.clientHeight, + paddingBottom: Number.parseFloat(window.getComputedStyle(el).paddingBottom), + })); + // WebKit retains focus on the submitted composer more aggressively than + // Chromium. Move focus to the scroll surface to model the user dismissing + // the composer before scrolling; focused composer chrome must stay visible. + await expect(async () => { + await main.focus(); + await scrollPrimarySurface(page, 0); + await expect(bottomDock).not.toHaveAttribute("data-scroll-hidden", "true", { timeout: 1_000 }); + for (const offset of [120, 240, 360]) { + await scrollPrimarySurface(page, offset); + } + await expect(bottomDock).toHaveAttribute("data-scroll-hidden", "true", { timeout: 1_000 }); + }).toPass({ timeout: 15_000 }); + await expect + .poll(async () => main.evaluate((el) => Number.parseFloat(window.getComputedStyle(el).paddingBottom))) + .toBeLessThanOrEqual(13); + const scrollGeometryAfterHide = await main.evaluate((el) => ({ clientHeight: el.clientHeight, scrollHeight: el.scrollHeight, + paddingBottom: Number.parseFloat(window.getComputedStyle(el).paddingBottom), })); - for (const offset of [120, 240, 360]) { - await main.evaluate((el, top) => { - el.scrollTop = top; - }, offset); - } + expect(scrollGeometryBeforeHide.paddingBottom).toBeGreaterThan(200); + expect(scrollGeometryAfterHide.clientHeight).toBe(scrollGeometryBeforeHide.clientHeight); + expect(scrollGeometryAfterHide.scrollHeight).toBeGreaterThan(scrollGeometryAfterHide.clientHeight); 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); }); @@ -3496,6 +3528,15 @@ test.describe("Clinical KB UI smoke coverage", () => { // must restore it before the explicit hide-on-scroll checks below. await scrollPrimarySurface(page, 0); await expect(composer).not.toHaveAttribute("data-scroll-hidden", "true"); + await page.evaluate(() => { + document.documentElement.style.setProperty("--safe-area-bottom", "112px"); + }); + const viewerContent = page.getByTestId("document-viewer-content"); + await expect + .poll(async () => + viewerContent.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom)), + ) + .toBeGreaterThan(250); const main = page.locator("#main-content"); await waitForReactEventHandler(main, "onScroll"); @@ -3507,21 +3548,42 @@ test.describe("Clinical KB UI smoke coverage", () => { (main ?? window.document.body).appendChild(spacer); }); - // Hide on deliberate scroll down past the activation offset. - for (const offset of [40, 80, 120, 160, 200]) { - await scrollPrimarySurface(page, offset); - } - await expect(composer).toHaveAttribute("data-scroll-hidden", "true"); + // Hide on deliberate scroll down past the activation offset. The chunk + // deep-link effect can finish late in Chromium and move the scrollport once + // more, so treat reset + deliberate movement as one retriable action. + await expect(async () => { + await scrollPrimarySurface(page, 0); + await expect(composer).not.toHaveAttribute("data-scroll-hidden", "true", { timeout: 1_000 }); + for (const offset of [40, 80, 120, 160, 200]) { + await scrollPrimarySurface(page, offset); + } + await expect(composer).toHaveAttribute("data-scroll-hidden", "true", { timeout: 1_000 }); + }).toPass({ timeout: 15_000 }); + await expect + .poll(async () => + viewerContent.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom)), + ) + .toBeLessThanOrEqual(13); // Reappear on scroll up. await scrollPrimarySurface(page, 60); await expect(composer).not.toHaveAttribute("data-scroll-hidden", "true"); + await expect + .poll(async () => + viewerContent.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom)), + ) + .toBeGreaterThan(250); // Keyboard focus inside the composer reveals it while hidden. 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"); + await expect + .poll(async () => + viewerContent.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom)), + ) + .toBeGreaterThan(250); }); test("document questions use the shared answer stream with progress and cleaned bold formatting", async ({ diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 35585484f..c2791757d 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1021,6 +1021,26 @@ test.describe("Clinical KB tools launcher", () => { const dock = page.locator("form.answer-footer-search-dock"); await expect(dock).toBeVisible(); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); + const main = page.locator("#main-content"); + // Safari reports its translucent bottom toolbar through the safe-area + // inset. Make that region deliberately large so this catches the exact + // toolbar-sized blank band seen on an iPhone, even in Chromium CI. + await page.evaluate(() => { + document.documentElement.style.setProperty("--safe-area-bottom", "112px"); + }); + await expect + .poll(async () => main.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom))) + .toBeGreaterThan(112); + const visibleMainGeometry = await main.evaluate((node) => { + const style = window.getComputedStyle(node); + return { + bottom: Math.round(node.getBoundingClientRect().bottom), + marginBottom: Number.parseFloat(style.marginBottom), + viewportHeight: window.innerHeight, + }; + }); + expect(visibleMainGeometry.marginBottom).toBe(0); + expect(Math.abs(visibleMainGeometry.bottom - visibleMainGeometry.viewportHeight)).toBeLessThanOrEqual(1); const transition = await dock.evaluate((node) => { const style = window.getComputedStyle(node); const durationMs = Math.max( @@ -1070,12 +1090,28 @@ test.describe("Clinical KB tools launcher", () => { await expect .poll(async () => dock.evaluate((node) => window.getComputedStyle(node).transform !== "none")) .toBe(true); + await expect + .poll(async () => main.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom))) + .toBeLessThanOrEqual(13); + const hiddenMainGeometry = await main.evaluate((node) => { + const style = window.getComputedStyle(node); + return { + bottom: Math.round(node.getBoundingClientRect().bottom), + marginBottom: Number.parseFloat(style.marginBottom), + viewportHeight: window.innerHeight, + }; + }); + expect(hiddenMainGeometry.marginBottom).toBe(0); + expect(Math.abs(hiddenMainGeometry.bottom - hiddenMainGeometry.viewportHeight)).toBeLessThanOrEqual(1); await scrollPrimarySurface(page, 60); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); await expect .poll(async () => dock.evaluate((node) => window.getComputedStyle(node).transform === "none")) .toBe(true); + await expect + .poll(async () => main.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom))) + .toBeGreaterThan(112); }); test("mode toggle keeps forms separate from services", async ({ page }) => {