diff --git a/src/app/globals.css b/src/app/globals.css index 3c7dc5264..241f1bf60 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1800,7 +1800,8 @@ summary::-webkit-details-marker { padding-bottom: max(0.5rem, var(--safe-area-bottom)); } - .answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge.document-mobile-search-compact { + .answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge.document-mobile-search-compact, + .answer-footer-search-dock.dashboard-composer-edge.answer-footer-search-edge.document-mobile-search-compact { padding-bottom: max(0.45rem, var(--safe-area-bottom)); } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 4e9e6963d..cceecf086 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3242,10 +3242,6 @@ export function ClinicalDashboard({ const compactMobileModeHome = centeredModeHome || ((searchMode === "services" || searchMode === "forms") && !modeSearchSubmitted && !query.trim() && !loading); - // Submitted (non-answer) searches are result views, not mode homes: on phones - // the bottom composer drops its chip row and hugs the screen edge so results - // keep maximum vertical space. Mode homes keep the default chip-row layout. - const compactMobileBottomSearch = hasMobileBottomSearch && modeSearchSubmitted; const differentialsCompareAddonActive = searchMode === "differentials" && modeSearchSubmitted && Boolean(query.trim()); // Hidden dock pad must stay at 0.75rem — Safari toolbar safe-area recreates a blank band. @@ -3255,7 +3251,6 @@ export function ClinicalDashboard({ searchMode, hasAnswerFollowUps: answerFollowUpSuggestions.length > 0, differentialsCompareAddonActive, - compactMobileBottomSearch, }), ); const renderDegradedNotice = () => ( @@ -3527,11 +3522,17 @@ export function ClinicalDashboard({ composerFollowUpSuggestionsDisabled={loading} composerPlaceholder={searchMode === "answer" && latestAnswerQuery ? "Ask a follow-up..." : undefined} mobileSearchPlacement={hasMobileBottomSearch ? "bottom" : "default"} - mobileBottomSearchVariant={compactMobileBottomSearch ? "compact" : "default"} + // Every phone dock is the compact single-row pill so content keeps + // maximum screen space (mode homes and result views alike). + mobileBottomSearchVariant="compact" mobileBottomSearchAddonSlotId={ differentialsCompareAddonActive ? differentialsMobileCompareAddonSlotId : undefined } desktopHomeComposerSlotId={desktopHomeComposerSlotId} + // Only the answer home ("How can I help?") keeps the in-flow hero + // pill + privacy notice on phones; every other mode home docks the + // compact pill to the bottom edge below sm. + heroComposerBreakpoint={showAnswerHome ? "all" : "sm-up"} // Answer view: the header overlays the scrolling
at every width // (main reserves matching top padding) so content frosts under the // glass bar, and it slides away/returns with scroll direction. Other @@ -3575,11 +3576,10 @@ export function ClinicalDashboard({ // bottom-clamp guard in use-hide-on-scroll prevents false reveals. "max-sm:pb-[var(--mobile-composer-reserve)] sm:mb-24" : hasMobileBottomSearch - ? // 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" + ? // Phones dock the compact composer on every non-answer view + // (mode homes included), so they always reserve dock + // clearance; sm+ keeps the in-flow hero/sticky composers. + "max-sm:pb-[var(--mobile-composer-reserve)] sm:mb-0" : "mb-0", )} > @@ -3635,11 +3635,11 @@ export function ClinicalDashboard({ // keep the original generous padding. "pb-4 sm:pb-36 lg:pb-40" : hasMobileBottomSearch - ? compactMobileModeHome + ? // The
reserve clears the compact dock on phones, so + // content keeps only a small pad of its own. + compactMobileModeHome ? "pb-4 sm:pb-10 lg:pb-12" - : compactMobileBottomSearch || showDesktopHomeComposer - ? "pb-8 sm:pb-10 lg:pb-12" - : "pb-32 sm:pb-10 lg:pb-12" + : "pb-8 sm:pb-10 lg:pb-12" : "pb-8 sm:pb-10 lg:pb-12", )} > diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index ad26d0dda..49f2101f4 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -345,8 +345,8 @@ function GlobalStandaloneSearchShellClient({ !isDifferentialPresentationWorkflow && (!isInfoPage || isToolDetailWithFooterSearch(pathname)); const reservesFloatingComposer = shouldShowSearchComposer && !isStandaloneModeHome; - // Standalone mode homes portal the composer into the hero (in-flow at every - // width), so phones need no bottom-dock clearance there. Document viewer + // Standalone mode homes keep the hero composer from sm up but dock the + // compact pill on phones, so they reserve dock clearance too. Document viewer // routes own their own floating composer, so the shell keeps only a small pad // and lets DocumentViewer manage visible-dock clearance. // Release the large bottom reserve only when the phone bottom composer is @@ -365,7 +365,6 @@ function GlobalStandaloneSearchShellClient({ isStandaloneModeHome, searchMode, differentialsCompareAddonActive, - useCompactBottomSearch, }), ); @@ -714,16 +713,18 @@ function GlobalStandaloneSearchShellClient({ onCrossModeSearch={crossModeSearch} headerVariant={isDifferentialPresentationWorkflow ? "workflow" : "default"} mobileSearchPlacement="bottom" - // Submitted searches that stay in the shell (services results) are - // result views: compact the phone bottom composer so results keep - // maximum screen space. Mode homes keep the chip-row layout. - mobileBottomSearchVariant={useCompactBottomSearch ? "compact" : "default"} + // Every phone dock is the compact single-row pill so content keeps + // maximum screen space (mode homes and result views alike). + mobileBottomSearchVariant="compact" mobileBottomSearchAddonSlotId={ differentialsCompareAddonActive ? differentialsMobileCompareAddonSlotId : undefined } desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"} searchComposerVisible={shouldShowSearchComposer} desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} + // Standalone mode homes keep the hero pill from sm up; phones get + // the compact bottom dock (only the answer home hero owns phones). + heroComposerBreakpoint="sm-up" // 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 }} diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index f08500c27..02f34f26f 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -78,6 +78,7 @@ const phoneSearchLayoutMediaQuery = "(max-width: 639px)"; const scopeSheetMediaQuery = "(max-width: 1023px)"; const desktopHomeComposerMediaQuery = "(min-width: 1024px)"; const modeHomeComposerMediaQuery = "(min-width: 0px)"; +const modeHomeComposerSmUpMediaQuery = "(min-width: 640px)"; function splitFilterText(value: string) { return value @@ -180,6 +181,7 @@ export function MasterSearchHeader({ desktopSearchPlacement = "default", searchComposerVisible = true, desktopHomeComposerSlotId, + heroComposerBreakpoint = "all", mobileBottomSearchAddonSlotId, mobileLeadingAction = "menu", onMobileBack, @@ -229,16 +231,20 @@ export function MasterSearchHeader({ composerFollowUpSuggestionsDisabled?: boolean; headerVariant?: "default" | "workflow"; mobileSearchPlacement?: "default" | "bottom"; - /** "compact" drops the phone footer chip row and hugs the bottom edge — - * used by search/result views so results keep maximum screen space. - * Mode homes keep the default chip-row layout. */ + /** "compact" drops the phone footer chip row and hugs the bottom edge so + * content keeps maximum screen space. Every phone dock uses it now; the + * "default" value remains for hosts that need the taller legacy dock. */ mobileBottomSearchVariant?: "default" | "compact"; desktopSearchPlacement?: "default" | "hero"; searchComposerVisible?: boolean; - /** Mode-home slot the composer portals into at every viewport width, so the - * search pill sits in the middle of the hero on phones as well as desktop - * instead of docking to the bottom edge. */ + /** Mode-home slot the composer portals into so the search pill sits in the + * middle of the hero instead of docking to the bottom edge. Which widths the + * hero owns is controlled by `heroComposerBreakpoint`. */ desktopHomeComposerSlotId?: string; + /** Widths where the mode-home hero slot hosts the composer. "all" keeps the + * hero pill on phones too (the answer home); "sm-up" reserves the hero for + * sm+ widths and hands phones the compact bottom dock instead. */ + heroComposerBreakpoint?: "all" | "sm-up"; /** Mobile/tablet slot rendered above the search pill for page-specific composer addons. */ mobileBottomSearchAddonSlotId?: string; mobileLeadingAction?: "menu" | "back"; @@ -343,12 +349,14 @@ export function MasterSearchHeader({ const scrollHidden = hideOnScroll?.scrollHidden !== undefined ? hideOnScroll.scrollHidden : internalScrollHidden; const headerChromeHidden = scrollHidden && !modeMenuOpen && !actionMenuOpen && !scopeOpen && !scopeSheetOpen && !headerChromeFocused; - // Mode homes portal the composer into the hero slot at every width, so the - // phone bottom dock only exists when no hero slot is provided. + // Mode homes portal the composer into the hero slot. With "all" the hero owns + // every width (the answer home keeps its in-flow pill on phones); "sm-up" + // hero hosts hand phones the bottom dock instead. + const heroComposerOwnsPhones = Boolean(desktopHomeComposerSlotId) && heroComposerBreakpoint === "all"; const phoneBottomSearchDockActive = usesPhoneSearchLayout && searchComposerVisible && - !desktopHomeComposerSlotId && + !heroComposerOwnsPhones && (isAnswerFooterComposer || mobileSearchPlacement === "bottom"); // Compare addon chrome lives inside the phone dock; hide/reveal with it so // the search pill and Compare selected bar reclaim space together. @@ -942,15 +950,20 @@ export function MasterSearchHeader({ // into it made React reconcile the portal against a container that another // part of the tree had already removed, throwing a null-parentNode error. // Because the host is stable, React's portal container never disappears. - // The slot is used at every viewport width — phones included — so mode - // homes keep the composer in the middle of the hero instead of docking it - // to the bottom edge. + // heroComposerBreakpoint decides which widths use the slot: "all" keeps the + // composer in the middle of the hero on phones too (the answer home), while + // "sm-up" leaves phones to the compact bottom dock and reserves the hero + // for sm+ widths. const host = document.createElement("div"); // Layout-transparent so the composer lays out as a direct child of the slot. host.style.display = "contents"; const mediaQuery = window.matchMedia( - desktopHomeComposerSlotId ? modeHomeComposerMediaQuery : desktopHomeComposerMediaQuery, + desktopHomeComposerSlotId + ? heroComposerBreakpoint === "sm-up" + ? modeHomeComposerSmUpMediaQuery + : modeHomeComposerMediaQuery + : desktopHomeComposerMediaQuery, ); let retryTimeout: number | null = null; @@ -1002,7 +1015,7 @@ export function MasterSearchHeader({ setDesktopHomeComposerFallback(false); setDesktopHomeComposerHost(null); }; - }, [desktopHomeComposerSlotId]); + }, [desktopHomeComposerSlotId, heroComposerBreakpoint]); const dismissModeMenu = useCallback(() => setModeMenuOpen(false), []); function dismissScope(reason: "outside" | "escape") { @@ -1320,7 +1333,6 @@ export function MasterSearchHeader({ const isDesktopHomeComposer = placement === "desktop-home"; const usesAnswerFooterStyle = isAnswerFooterComposer && !isDesktopHomeComposer; const usesMobileBottomStyle = isMobileBottomComposer && !isDesktopHomeComposer; - const usesCompactMobileBottomStyle = usesMobileBottomStyle && mobileBottomSearchVariant === "compact"; const usesBottomComposerPlacement = usesAnswerFooterStyle || (usesMobileBottomStyle && usesPhoneSearchLayout); // Sticky-top result composers (tablet+) share the footer chip layout so the // pill + chip row looks identical across homes, results, and the answer dock. @@ -1332,16 +1344,26 @@ export function MasterSearchHeader({ const ModeIdentityIcon = appModeIcons[searchMode]; const hasScopeFooterChip = searchMode === "answer" || searchMode === "documents" || searchMode === "forms"; const usesPhoneFooterDock = usesBottomComposerPlacement && usesPhoneSearchLayout; + const showsAnswerFollowUpRow = Boolean( + usesPhoneFooterDock && + searchMode === "answer" && + composerFollowUpSuggestions?.length && + onPickComposerFollowUpSuggestion, + ); + // Every phone dock is the compact single-row pill; the answer dock only + // keeps the taller default treatment while its follow-up chip row renders + // above the pill (the compact scrim would be too short for it). + const usesCompactMobileBottomStyle = + (usesMobileBottomStyle && mobileBottomSearchVariant === "compact") || + (usesAnswerFooterStyle && usesPhoneFooterDock && !showsAnswerFollowUpRow); // Differentials compare addon is dock chrome (search pill + Compare bar). // Hide/reveal the whole dock together; do not pin for the addon slot. const shouldHideBottomOnScroll = Boolean(hideOnScroll && usesPhoneFooterDock); - // Phone submitted non-answer result docks reserve pill-only scroll - // 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 - // for the old taller notice-above-pill stack). - const showsComposerPrivacyNotice = searchMode === "answer" || !usesPhoneFooterDock; + // Phones show the APP-5 notice only on the home hero (the answer mode + // home's in-flow composer); every phone bottom dock is a compact + // result/entry pill without it, so content keeps maximum screen space. + // Tablet/desktop composers keep the site-wide notice everywhere. + const showsComposerPrivacyNotice = usesPhoneSearchLayout ? isDesktopHomeComposer : true; const commandSurfacePlacement = usesBottomComposerPlacement ? "bottom-dock" : "inline"; @@ -1394,10 +1416,7 @@ export function MasterSearchHeader({ className="differentials-mobile-search-addon relative z-10 w-full empty:hidden" /> ) : null} - {usesPhoneFooterDock && - searchMode === "answer" && - composerFollowUpSuggestions?.length && - onPickComposerFollowUpSuggestion ? ( + {showsAnswerFollowUpRow && composerFollowUpSuggestions?.length && onPickComposerFollowUpSuggestion ? ( - {/* Single site-wide APP-5 privacy line: every composer variant (home - hero, answer dock, sticky search) renders exactly one compact - notice below the pill; no other surface may duplicate it. Phone - non-answer result docks skip it — see showsComposerPrivacyNotice. */} + {/* Single site-wide APP-5 privacy line: every tablet/desktop composer + variant renders exactly one compact notice below the pill; no other + surface may duplicate it. Phones show it only on the home hero — + see showsComposerPrivacyNotice. */} {showsComposerPrivacyNotice ? ( { isStandaloneModeHome: false, searchMode: "documents", differentialsCompareAddonActive: false, - useCompactBottomSearch: false, }), ).toBe(mobileComposerHiddenReserve); }); + it("reserves compact dock clearance on standalone mode homes (phone dock, hero from sm up)", () => { + expect( + resolveShellVisibleMobileComposerReserve({ + shouldShowSearchComposer: true, + documentViewerOwnedRoute: false, + isStandaloneModeHome: true, + searchMode: "services", + differentialsCompareAddonActive: false, + }), + ).toBe(mobileComposerVisibleReserve.shellDock); + }); + + it("uses the compact dock reserve for every non-answer dashboard dock (mode homes included)", () => { + for (const searchMode of ["documents", "services", "forms", "tools", "favourites"]) { + expect( + resolveDashboardVisibleMobileComposerReserve({ + searchMode, + hasAnswerFollowUps: false, + differentialsCompareAddonActive: false, + }), + ).toBe(mobileComposerVisibleReserve.dashboardDock); + } + }); + + it("keeps the answer dock reserve compact, growing only for the follow-up chip row", () => { + expect( + resolveDashboardVisibleMobileComposerReserve({ + searchMode: "answer", + hasAnswerFollowUps: false, + differentialsCompareAddonActive: false, + }), + ).toBe(mobileComposerVisibleReserve.dashboardAnswer); + expect( + resolveDashboardVisibleMobileComposerReserve({ + searchMode: "answer", + hasAnswerFollowUps: true, + differentialsCompareAddonActive: false, + }), + ).toBe(mobileComposerVisibleReserve.dashboardAnswerWithFollowUps); + expect(mobileComposerVisibleReserve.dashboardAnswer).toContain("var(--safe-area-bottom)"); + expect(mobileComposerVisibleReserve.dashboardAnswerWithFollowUps).toContain("var(--safe-area-bottom)"); + }); + it("keeps differentials compare clearance shared across hosts", () => { expect(mobileComposerVisibleReserve.differentialsCompare).toBe(mobileComposerDifferentialsCompareReserve); expect(mobileComposerDifferentialsCompareReserve).toContain("12.5rem"); @@ -48,7 +90,15 @@ describe("mobile composer reserve contract", () => { searchMode: "differentials", hasAnswerFollowUps: false, differentialsCompareAddonActive: true, - compactMobileBottomSearch: true, + }), + ).toBe(mobileComposerDifferentialsCompareReserve); + expect( + resolveShellVisibleMobileComposerReserve({ + shouldShowSearchComposer: true, + documentViewerOwnedRoute: false, + isStandaloneModeHome: false, + searchMode: "differentials", + differentialsCompareAddonActive: true, }), ).toBe(mobileComposerDifferentialsCompareReserve); }); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 5125ae293..8dd6d723a 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2950,7 +2950,9 @@ test.describe("Clinical KB UI smoke coverage", () => { expect(startHereBox).not.toBeNull(); expect(documentsHeadingBox).not.toBeNull(); expect((documentsHeadingBox?.y ?? 0) + (documentsHeadingBox?.height ?? 0)).toBeLessThan(searchInputBox?.y ?? 0); - expect(searchInputBox?.y ?? 0).toBeLessThan(startHereBox?.y ?? 0); + // Phones dock the compact composer at the bottom edge, below the hero content. + expect(searchInputBox?.y ?? 0).toBeGreaterThan(startHereBox?.y ?? 0); + await expect(page.locator('form.answer-footer-search-dock[data-footer-variant="compact"]')).toHaveCount(1); const recentDocumentsButton = page.getByRole("button", { name: /Recent documents/i }).first(); const browseLibraryButton = page.getByRole("button", { name: /Browse library/i }).first(); const sourcePdfButton = page.getByRole("button", { name: /Open a source PDF/i }).first(); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index ab00979b5..7812a36b8 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -382,7 +382,12 @@ test.describe("Clinical KB tools launcher", () => { } await expect(page.getByLabel("Mode Tools")).toBeVisible(); await expect(visibleGlobalSearchInput(page)).toHaveCount(1); - await expect(page.getByTestId("tools-home").getByTestId("global-search-input")).toBeVisible(); + if (viewport.name === "mobile") { + // Phones dock the compact shared search at the bottom edge. + await expect(page.locator("form.answer-footer-search-dock").getByTestId("global-search-input")).toBeVisible(); + } else { + await expect(page.getByTestId("tools-home").getByTestId("global-search-input")).toBeVisible(); + } await expect(page.getByTestId("tools-local-search-input")).toHaveCount(0); await expectNoPageHorizontalOverflow(page); }); @@ -553,49 +558,82 @@ test.describe("Clinical KB tools launcher", () => { await expectNoPageHorizontalOverflow(page); }); - test("mode home routes center the shared search on mobile", async ({ page }) => { + test("phone keeps the answer home search centered in the hero with the privacy notice", async ({ page }) => { + await mockAnswerDashboardApi(page); + await page.setViewportSize({ width: 390, height: 820 }); + + await gotoLauncher(page, "/?mode=answer"); + await expect(page.getByTestId("answer-empty-state")).toBeVisible(); + await expect(visibleGlobalSearchInput(page)).toHaveCount(1, { timeout: 15_000 }); + + const heroSearch = page.getByTestId("answer-empty-state").getByTestId("global-search-input"); + await expect(heroSearch).toBeVisible(); + + const searchBox = await heroSearch.boundingBox(); + const headingBox = await page.getByRole("heading", { level: 2, name: "How can I help?" }).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); + // The home centres its 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, "answer-empty-state"); + expect(metrics).not.toBeNull(); + expect(metrics?.position).not.toBe("fixed"); + expect(metrics?.formWidth ?? 0).toBeLessThanOrEqual(390 - 16); + expect(metrics?.pillClassName).toContain("answer-footer-search-pill"); + expect(metrics?.homeCenterX).not.toBeNull(); + expect(Math.abs((metrics?.formCenterX ?? 0) - (metrics?.homeCenterX ?? 0))).toBeLessThanOrEqual(24); + await expect(page.locator(".answer-footer-search-chip:visible")).toHaveCount(0); + // The home hero is the only phone surface with the APP-5 privacy notice. + await expect(page.getByTestId("answer-composer-privacy-warning")).toBeVisible(); + await expectNoPageHorizontalOverflow(page); + }); + + test("phone mode homes dock the compact shared search at the bottom edge", async ({ page }) => { await mockAnswerDashboardApi(page); await page.setViewportSize({ width: 390, height: 820 }); for (const home of [ - { path: "/?mode=answer", testId: "answer-empty-state", heading: "How can I help?", headingLevel: 2 }, - { path: "/services", testId: "services-home", heading: "Services", headingLevel: 1 }, - { path: "/forms", testId: "forms-home", heading: "Forms", headingLevel: 1 }, - { path: "/differentials", testId: "differentials-home", heading: "Differentials", headingLevel: 1 }, - { path: "/favourites", testId: "favourites-hub", heading: "Favourites command library", headingLevel: 1 }, - { path: "/tools", testId: "tools-home", heading: "Tools", headingLevel: 1 }, + { path: "/services", testId: "services-home" }, + { path: "/forms", testId: "forms-home" }, + { path: "/differentials", testId: "differentials-home" }, + { path: "/favourites", testId: "favourites-hub" }, + { path: "/tools", testId: "tools-home" }, ] as const) { await gotoLauncher(page, home.path); await expect(page.getByTestId(home.testId)).toBeVisible(); await expect(visibleGlobalSearchInput(page)).toHaveCount(1, { timeout: 15_000 }); - const heroSearch = page.getByTestId(home.testId).getByTestId("global-search-input"); - await expect(heroSearch).toBeVisible(); - - const searchBox = await heroSearch.boundingBox(); - 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 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"); - expect(metrics?.formWidth ?? 0).toBeLessThanOrEqual(390 - 16); + // Phones dock the composer as the compact single-row pill; the hero slot + // stays empty below sm (it hosts the pill again from the sm breakpoint). + const dock = page.locator('form.answer-footer-search-dock[data-footer-variant="compact"]'); + await expect(dock, home.path).toHaveCount(1); + await expect(dock.getByTestId("global-search-input")).toBeVisible(); + await expect(page.locator(".mode-home-composer-slot").getByTestId("global-search-input")).toHaveCount(0); + + const metrics = await globalSearchComposerMetrics(page); + expect(metrics, home.path).not.toBeNull(); + expect(metrics?.position).toBe("fixed"); + expect(metrics?.formBottom ?? 0).toBeGreaterThanOrEqual(820 - 48); + expect(metrics?.formWidth ?? 0).toBeLessThanOrEqual(390); expect(metrics?.pillClassName).toContain("answer-footer-search-pill"); - expect(metrics?.homeCenterX).not.toBeNull(); - expect(Math.abs((metrics?.formCenterX ?? 0) - (metrics?.homeCenterX ?? 0))).toBeLessThanOrEqual(24); + // Compact docks drop the chip row and the privacy notice on phones. await expect(page.locator(".answer-footer-search-chip:visible")).toHaveCount(0); + await expect(page.getByTestId("answer-composer-privacy-warning")).toHaveCount(0); + + // Phone dock composers must not cover the page with the universal sheet. + const dockInput = dock.getByTestId("global-search-input"); + await dockInput.click(); + await dockInput.press("ArrowDown"); + await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(0); + await expect(page.getByRole("listbox")).toHaveCount(0); await expectNoPageHorizontalOverflow(page); } }); @@ -623,9 +661,14 @@ test.describe("Clinical KB tools launcher", () => { await expect(page.getByTestId(home.testId)).toBeVisible(); // The composer must never vanish: exactly one visible search input. await expect(visibleGlobalSearchInput(page)).toHaveCount(1, { timeout: 15_000 }); - // Hero-centred design: the input lives inside the mode-home hero at - // every width, phones included. - await expect(page.getByTestId(home.testId).getByTestId("global-search-input")).toBeVisible(); + if (viewport.name === "phone" && home.path !== "/?mode=answer") { + // Phones dock the standalone mode-home composer at the bottom edge. + await expect(page.locator("form.answer-footer-search-dock").getByTestId("global-search-input")).toBeVisible(); + } else { + // The hero owns the composer: the answer home at every width, and + // every mode home from the sm breakpoint up. + await expect(page.getByTestId(home.testId).getByTestId("global-search-input")).toBeVisible(); + } }); } } @@ -715,8 +758,8 @@ test.describe("Clinical KB tools launcher", () => { await expectNoPageHorizontalOverflow(page); }); - test("phone mode homes keep the shared search in the hero, not the bottom dock", async ({ page }) => { - await page.setViewportSize({ width: 390, height: 820 }); + test("tablet mode homes keep the shared search in the hero, not the bottom dock", async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); for (const home of ["/services", "/forms", "/differentials", "/tools"]) { await gotoLauncher(page, home); const heroInput = page.locator(".mode-home-composer-slot").getByTestId("global-search-input"); @@ -730,12 +773,6 @@ test.describe("Clinical KB tools launcher", () => { }); expect(geometry.top).toBeGreaterThan(0); expect(geometry.bottom).toBeLessThan(geometry.viewportHeight - 40); - - // Phone hero composers must not cover the page with the universal sheet. - await heroInput.click(); - await heroInput.press("ArrowDown"); - await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(0); - await expect(page.getByRole("listbox")).toHaveCount(0); await expectNoPageHorizontalOverflow(page); } }); @@ -819,7 +856,7 @@ test.describe("Clinical KB tools launcher", () => { ] as const) { for (const route of [ { path: "/services?q=13YARN&focus=1&run=1", modeButton: "Mode Services", compactBottomSearch: true }, - { path: "/services/13yarn", modeButton: "Mode Services", compactBottomSearch: false }, + { path: "/services/13yarn", modeButton: "Mode Services", compactBottomSearch: true }, { path: "/forms?q=transport&focus=1&run=1", modeButton: "Mode Forms", compactBottomSearch: true }, { path: "/favourites?q=lithium&focus=1&run=1", modeButton: "Mode Favourites", compactBottomSearch: true }, { @@ -1882,7 +1919,8 @@ test.describe("Clinical KB service detail page", () => { // while we measure end-of-page clearance under a still-visible composer. await dockInput.focus(); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); - await expect.poll(async () => readMobileComposerReservePx(scrollport)).toBeGreaterThan(120); + // The compact dock reserve is 5.5rem (88px) plus any safe-area inset. + await expect.poll(async () => readMobileComposerReservePx(scrollport)).toBeGreaterThanOrEqual(80); await scrollport.evaluate((element) => element.scrollTo({ top: element.scrollHeight, behavior: "instant" })); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); @@ -1918,7 +1956,7 @@ test.describe("Clinical KB service detail page", () => { }); expect(clearance, JSON.stringify(clearance)).not.toBeNull(); - expect(clearance!.reservePx, JSON.stringify(clearance)).toBeGreaterThan(120); + expect(clearance!.reservePx, JSON.stringify(clearance)).toBeGreaterThanOrEqual(80); expect(clearance!.footerBottom, JSON.stringify(clearance)).toBeLessThanOrEqual(clearance!.dockTop - 8); });