From a434883d96e970bfd2913353a942a160fceaf82c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 07:22:11 +0000 Subject: [PATCH 1/2] feat(ui): compact bottom search dock on phones everywhere except the home hero On phones (<640px) every search surface now uses the compact single-row bottom pill (the submitted-results dock style): standalone and dashboard mode homes stop portaling the composer into the hero below sm and dock it at the bottom edge instead, and the answer conversation dock drops to the compact treatment whenever its follow-up chip row is absent. The home page ("How can I help?") is the only phone surface that keeps the in-flow hero pill, and it is now also the only phone surface showing the APP-5 "Do not enter patient-identifiable information" notice; tablet/desktop composers keep the notice everywhere as before. - MasterSearchHeader: new heroComposerBreakpoint prop ("all" | "sm-up") gates the hero portal media query and re-enables the phone dock for sm-up hero hosts; phone privacy notice scoped to the home hero; answer dock joins the compact styling when no follow-up row renders - Hosts: GlobalSearchShell and ClinicalDashboard always pass the compact dock variant, reserve dock clearance on phone mode homes, and keep the answer home on the "all" hero breakpoint - mobile-composer-reserve: dock reserves unified (shellDock/dashboardDock, 5.5rem/5rem + safe-area); answer reserves trimmed to the notice-free dock, follow-up row reserve unchanged - Hide-on-scroll, edge-to-edge scrim, safe-area handling, and the hidden 0.75rem pad are unchanged and covered by the updated Playwright specs Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SF3LtSFni9tbBCkBxGazkr --- src/app/globals.css | 3 +- src/components/ClinicalDashboard.tsx | 30 ++-- .../global-search-shell.tsx | 15 +- .../master-search-header.tsx | 79 +++++++---- .../mobile-composer-reserve.ts | 28 ++-- tests/mobile-composer-reserve.test.ts | 54 ++++++- tests/ui-smoke.spec.ts | 4 +- tests/ui-tools.spec.ts | 132 +++++++++++------- 8 files changed, 226 insertions(+), 119 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index e3f4ac2cf..274108920 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1847,7 +1847,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 1b2faa658..247844925 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 6c5e19318..6a1bc2d9c 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. @@ -931,15 +939,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; @@ -991,7 +1004,7 @@ export function MasterSearchHeader({ setDesktopHomeComposerFallback(false); setDesktopHomeComposerHost(null); }; - }, [desktopHomeComposerSlotId]); + }, [desktopHomeComposerSlotId, heroComposerBreakpoint]); const dismissModeMenu = useCallback(() => setModeMenuOpen(false), []); function dismissScope(reason: "outside" | "escape") { @@ -1309,7 +1322,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. @@ -1321,16 +1333,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"; @@ -1383,10 +1405,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 77e153bbd..2fb33d9bd 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2952,7 +2952,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 e8de54044..c14e68720 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: "Find a service", 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); } }); @@ -815,7 +852,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 }, { @@ -1878,7 +1915,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)).toBeGreaterThan(80); await scrollport.evaluate((element) => element.scrollTo({ top: element.scrollHeight, behavior: "instant" })); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); @@ -1914,7 +1952,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)).toBeGreaterThan(80); expect(clearance!.footerBottom, JSON.stringify(clearance)).toBeLessThanOrEqual(clearance!.dockTop - 8); }); From e0092be62c4fe4a02d2ef2919658db6ade45b9c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 07:56:21 +0000 Subject: [PATCH 2/2] refactor(ui): derive answer dock reserves from dock constants; robust reserve assertions Address CodeRabbit review: share one constant per shell/dashboard compact reserve so the answer/dock pairs cannot silently diverge, and assert the dock reserve with >= so a zero safe-area inset landing exactly on the 5rem/5.5rem floor cannot flake the service-detail clearance spec. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SF3LtSFni9tbBCkBxGazkr --- .../clinical-dashboard/mobile-composer-reserve.ts | 15 ++++++++++----- tests/ui-tools.spec.ts | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/components/clinical-dashboard/mobile-composer-reserve.ts b/src/components/clinical-dashboard/mobile-composer-reserve.ts index db76b6d3b..40bdc30d2 100644 --- a/src/components/clinical-dashboard/mobile-composer-reserve.ts +++ b/src/components/clinical-dashboard/mobile-composer-reserve.ts @@ -19,13 +19,18 @@ export const mobileComposerIdleReserve = "2rem"; export const mobileComposerDifferentialsCompareReserve = "calc(12.5rem + var(--safe-area-bottom))"; // Every phone dock is the compact single-row pill (mode homes and result views -// alike); only the answer dock with a follow-up chip row is taller. +// alike); only the answer dock with a follow-up chip row is taller. The answer +// values are derived from the dock constants so the pairs cannot silently +// diverge — master-search-header's compact styling assumes they stay equal. +const shellCompactSingleRowReserve = "calc(5.5rem + var(--safe-area-bottom))"; +const dashboardCompactSingleRowReserve = "calc(5rem + var(--safe-area-bottom))"; + export const mobileComposerVisibleReserve = { - shellAnswer: "calc(5.5rem + var(--safe-area-bottom))", - shellDock: "calc(5.5rem + var(--safe-area-bottom))", + shellAnswer: shellCompactSingleRowReserve, + shellDock: shellCompactSingleRowReserve, dashboardAnswerWithFollowUps: "calc(7.5rem + var(--safe-area-bottom))", - dashboardAnswer: "calc(5rem + var(--safe-area-bottom))", - dashboardDock: "calc(5rem + var(--safe-area-bottom))", + dashboardAnswer: dashboardCompactSingleRowReserve, + dashboardDock: dashboardCompactSingleRowReserve, differentialsCompare: mobileComposerDifferentialsCompareReserve, } as const; diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index c14e68720..db2210c0f 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1916,7 +1916,7 @@ test.describe("Clinical KB service detail page", () => { await dockInput.focus(); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); // The compact dock reserve is 5.5rem (88px) plus any safe-area inset. - await expect.poll(async () => readMobileComposerReservePx(scrollport)).toBeGreaterThan(80); + 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"); @@ -1952,7 +1952,7 @@ test.describe("Clinical KB service detail page", () => { }); expect(clearance, JSON.stringify(clearance)).not.toBeNull(); - expect(clearance!.reservePx, JSON.stringify(clearance)).toBeGreaterThan(80); + expect(clearance!.reservePx, JSON.stringify(clearance)).toBeGreaterThanOrEqual(80); expect(clearance!.footerBottom, JSON.stringify(clearance)).toBeLessThanOrEqual(clearance!.dockTop - 8); });