diff --git a/docs/branch-review-records/1798e0af4644bdccaa00cfc3bd7fedeb56a5a019e93f3966580d3d41434e2a47.record.md b/docs/branch-review-records/1798e0af4644bdccaa00cfc3bd7fedeb56a5a019e93f3966580d3d41434e2a47.record.md new file mode 100644 index 0000000000..88903cd3a0 --- /dev/null +++ b/docs/branch-review-records/1798e0af4644bdccaa00cfc3bd7fedeb56a5a019e93f3966580d3d41434e2a47.record.md @@ -0,0 +1 @@ +| 2026-08-16 | PR-1993 | 0df502749540c4e4de789bfad12837726b1944d8 | PR #1993 Tools phone footer search review | Fixed: preserved the tappable standalone-home suggestion ticker while retaining the Tools phone shared-footer placement. | Static phone-home truth table passed; exact one-line fix diff verified; prior exact-head CI isolated to the audit assertion; distinct manual adversarial pass found no additional high-confidence defects. | diff --git a/docs/branch-review-records/f0bc08ea7f5c5c924273de2608597dddb1e7c8c43197488836bb70a79c6d366b.record.md b/docs/branch-review-records/f0bc08ea7f5c5c924273de2608597dddb1e7c8c43197488836bb70a79c6d366b.record.md new file mode 100644 index 0000000000..688e3f4f9d --- /dev/null +++ b/docs/branch-review-records/f0bc08ea7f5c5c924273de2608597dddb1e7c8c43197488836bb70a79c6d366b.record.md @@ -0,0 +1 @@ +| 2026-08-16 | codex/tools-universal-footer-search | 58821ca016b39933eaa9bf2a756672a71171923f | PR #1993 Tools phone footer ownership against main 0b95d063b44712ce409d9fbfe5bf8e706b10ccaf | Confirmed and fixed dashboard Tools phone composer ownership, aligned stale responsive contracts, removed the temporary repair workflow, and kept the branch current with main. | Prettier; ESLint on changed files; TypeScript; targeted Vitest; immutable ledger validation | diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index df06a54db6..f28f5e564a 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -7,7 +7,7 @@ This repo uses one shared search experience across the global shell, dashboard r | Page state | Composer placement | Reserve owner | | ---------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | Answer home / standalone mode homes | In-flow hero composer on phones and larger breakpoints | Page content; no fixed phone dock reserve | -| Tools directory (`/tools`, no submitted query) | In-flow composer above the all-tools results directory | Page content; no fixed phone dock reserve | +| Tools directory (`/tools`, no submitted query) | Compact bottom dock on phones; in-flow hero composer from `sm` | Shell dock reserve on phones; page content from `sm` | | Submitted/search-result views | Compact bottom dock on phones; in normal page flow on tablets and desktops | Shell/dashboard `--mobile-composer-reserve` on phones; page content on desktop | | Answer result view | Overlaid glass header plus answer composer dock | Dashboard `#main-content` top/bottom reserves | | Document detail/source routes | `DocumentViewer` floating composer | `DocumentViewer` content padding | diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 223d050489..3d31ed7949 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3057,11 +3057,10 @@ export function ClinicalDashboard({ const desktopHomeComposerSlotId = showDesktopHomeComposer ? modeHomeDesktopComposerSlotId : undefined; const desktopResultComposerSlotId = !desktopHomeComposerSlotId && searchMode !== "answer" ? desktopPageComposerSlotId : undefined; - // Any mounted mode home (answer, documents, prescribing, differentials, tools, - // favourites) keeps the in-flow hero pill on phones ("all") per the - // page-ownership contract. Only result/submitted views fall back to "sm-up" - // so phones get the compact bottom dock. - const heroComposerBreakpoint = showDesktopHomeComposer ? "all" : "sm-up"; + // Most mounted mode homes keep the in-flow hero pill on phones. Tools is the + // deliberate exception: its content-rich directory uses the shared compact + // footer on phones while retaining the hero composer from sm upward. + const heroComposerBreakpoint = showDesktopHomeComposer && activeModeResultKind !== "tools" ? "all" : "sm-up"; const heroOwnsPhoneComposer = Boolean(desktopHomeComposerSlotId) && heroComposerBreakpoint === "all"; const hasMobileBottomSearch = searchMode !== "answer" && !heroOwnsPhoneComposer; // Favourites and Tools are content-rich hubs: they share the centred hero but diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 1e37cb3c4e..ab549142ab 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -108,6 +108,8 @@ type GlobalSearchShellProps = { initialMode?: AppModeId; availableModeIds?: readonly AppModeId[]; desktopSearchPlacement?: "default" | "hero"; + /** Override the phone placement for a standalone mode home's shared composer. */ + mobileHomeComposerPlacement?: "hero" | "footer"; /** Hide the shared search composer on routes that provide their own search surface. */ searchComposerVisible?: boolean; /** Keep the global header/search while allowing a route to use the full desktop canvas. */ @@ -311,6 +313,7 @@ function GlobalStandaloneSearchShellBody({ initialMode = "answer", availableModeIds, desktopSearchPlacement = "default", + mobileHomeComposerPlacement = "hero", searchComposerVisible = true, hideDesktopSidebar = false, chromeVisible = true, @@ -444,9 +447,14 @@ function GlobalStandaloneSearchShellBody({ searchComposerVisible && !isDifferentialPresentationWorkflow && (!isInfoPage || isToolDetailWithFooterSearch(pathname)); + const heroOwnsPhoneComposer = isStandaloneModeHome && mobileHomeComposerPlacement === "hero"; + // This flag controls sm+ padding, where every standalone home (including + // Tools) keeps its composer in flow. Phone clearance is resolved separately + // from heroOwnsPhoneComposer below. const reservesFloatingComposer = shouldShowSearchComposer && !isStandaloneModeHome; - // Standalone mode homes keep the in-flow hero pill at every width (no phone - // dock reserve). Document viewer routes own their own floating composer, so + // Most standalone mode homes keep the in-flow hero pill at every width. Tools + // deliberately uses the shared footer on phones. Document viewer routes own + // their own floating composer, so // the shell keeps only a small pad and lets DocumentViewer manage clearance. // Release the large bottom reserve only when the phone bottom composer is // actually hidden (MasterSearchHeader's bottomComposerHidden). Header-only @@ -461,7 +469,7 @@ function GlobalStandaloneSearchShellBody({ resolveShellVisibleMobileComposerReserve({ shouldShowSearchComposer, pageOwnedComposerRoute: isPageOwnedComposerRoute(pathname), - isStandaloneModeHome, + heroOwnsPhoneComposer, searchMode, differentialsCompareAddonActive, }), @@ -892,11 +900,10 @@ function GlobalStandaloneSearchShellBody({ desktopPageComposerSlotId={ shouldShowSearchComposer && !isStandaloneModeHome ? desktopPageComposerSlotId : undefined } - // Standalone mode homes keep the in-flow hero pill at every width, - // phones included — the composer sits in the middle of the hero and - // scrolls with the content, matching the answer home rather than - // docking to the bottom edge. - heroComposerBreakpoint="all" + // Most standalone homes keep the in-flow hero pill at every width. + // Tools keeps that placement from sm up but uses the same global + // footer dock as submitted views on phones. + heroComposerBreakpoint={mobileHomeComposerPlacement === "footer" ? "sm-up" : "all"} // Phones: #main-content owns vertical scroll, so hide-on-scroll // collapses the top bar to hand space back to content. // Tablet and desktop portal search into normal page flow. The outer @@ -936,7 +943,7 @@ function GlobalStandaloneSearchShellBody({ data-chrome-transitioning={chromeTransitioning ? "true" : undefined} data-phone-scroll-owner={activeScrollOwner} data-phone-footer-owner={ - isStandaloneModeHome + heroOwnsPhoneComposer ? "hero" : isPageOwnedComposerRoute(pathname) ? "page" diff --git a/src/components/clinical-dashboard/mobile-composer-reserve.ts b/src/components/clinical-dashboard/mobile-composer-reserve.ts index 326655f798..8c1bb6aa2e 100644 --- a/src/components/clinical-dashboard/mobile-composer-reserve.ts +++ b/src/components/clinical-dashboard/mobile-composer-reserve.ts @@ -113,7 +113,8 @@ export function resolveShellVisibleMobileComposerReserve(input: { /** @deprecated Prefer pageOwnedComposerRoute */ documentViewerOwnedRoute?: boolean; pageOwnedComposerRoute?: boolean; - isStandaloneModeHome: boolean; + /** The standalone hero owns the phone composer instead of the shared footer dock. */ + heroOwnsPhoneComposer: boolean; searchMode: string; differentialsCompareAddonActive: boolean; patientDetailsAddonActive?: boolean; @@ -124,11 +125,11 @@ export function resolveShellVisibleMobileComposerReserve(input: { const pageOwned = input.pageOwnedComposerRoute ?? input.documentViewerOwnedRoute ?? false; return pageOwned ? mobileComposerHiddenReserve : mobileComposerIdleReserve; } - // Standalone mode homes keep the in-flow hero pill at every width (phones - // included), so the composer sits in the content flow rather than docking to - // the bottom edge. Reserve only the idle content pad — matching the answer - // home hero — so no empty band opens below the pill. - if (input.isStandaloneModeHome) return mobileComposerIdleReserve; + // An all-width hero composer sits in content flow rather than docking to the + // bottom edge. Reserve only the idle content pad so no empty band opens below + // the pill. Standalone homes whose phone placement is the shared footer use + // the normal shell dock reserve below. + if (input.heroOwnsPhoneComposer) return mobileComposerIdleReserve; if (input.searchMode === "answer") return mobileComposerVisibleReserve.shellAnswer; if (input.differentialsCompareAddonActive) return mobileComposerVisibleReserve.differentialsCompare; if (input.patientDetailsAddonActive) return mobileComposerVisibleReserve.patientDetails; diff --git a/src/lib/search-shell-props.ts b/src/lib/search-shell-props.ts index 3abf405a01..7c2d162546 100644 --- a/src/lib/search-shell-props.ts +++ b/src/lib/search-shell-props.ts @@ -4,6 +4,7 @@ export type SearchShellPathProps = { initialMode: AppModeId; availableModeIds?: AppModeId[]; desktopSearchPlacement?: "default" | "hero"; + mobileHomeComposerPlacement?: "hero" | "footer"; searchComposerVisible?: boolean; mobileChromeVisible?: boolean; }; @@ -67,7 +68,11 @@ export function searchShellPropsForPathname(pathname: string): SearchShellPathPr } if (pathname.startsWith("/tools")) { - return { initialMode: "tools", desktopSearchPlacement: "hero" }; + return { + initialMode: "tools", + desktopSearchPlacement: "hero", + mobileHomeComposerPlacement: "footer", + }; } if (pathname.startsWith("/calculators")) { diff --git a/tests/mobile-composer-reserve.test.ts b/tests/mobile-composer-reserve.test.ts index 91d5ecd7b1..deb59201cf 100644 --- a/tests/mobile-composer-reserve.test.ts +++ b/tests/mobile-composer-reserve.test.ts @@ -43,7 +43,7 @@ describe("mobile composer reserve contract", () => { resolveShellVisibleMobileComposerReserve({ shouldShowSearchComposer: false, documentViewerOwnedRoute: true, - isStandaloneModeHome: false, + heroOwnsPhoneComposer: false, searchMode: "documents", differentialsCompareAddonActive: false, }), @@ -55,13 +55,25 @@ describe("mobile composer reserve contract", () => { resolveShellVisibleMobileComposerReserve({ shouldShowSearchComposer: true, documentViewerOwnedRoute: false, - isStandaloneModeHome: true, + heroOwnsPhoneComposer: true, searchMode: "services", differentialsCompareAddonActive: false, }), ).toBe(mobileComposerIdleReserve); }); + it("uses the shared compact dock reserve when a standalone home delegates phones to the footer", () => { + expect( + resolveShellVisibleMobileComposerReserve({ + shouldShowSearchComposer: true, + documentViewerOwnedRoute: false, + heroOwnsPhoneComposer: false, + searchMode: "tools", + differentialsCompareAddonActive: false, + }), + ).toBe(mobileComposerVisibleReserve.shellDock); + }); + it("uses the compact dock reserve for non-answer dashboard docks when the hero does not own phones", () => { for (const searchMode of ["documents", "services", "forms", "tools", "favourites"]) { expect( @@ -101,16 +113,19 @@ describe("mobile composer reserve contract", () => { ).toBe(mobileComposerIdleReserve); }); - it("derives hero phone ownership from the mounted hero slot; any mode home uses all-widths breakpoint", () => { - // Any mounted mode home (answer, documents, prescribing, tools, favourites) - // needs "all" (phones keep the in-flow hero pill) per the page-ownership - // contract. Only result/submitted views use "sm-up" so phones get the compact - // bottom dock. desktopHomeComposerSlotId is undefined on result views, so - // heroOwnsPhoneComposer stays false there regardless of the breakpoint value. + it("derives hero phone ownership from the mounted hero slot while Tools delegates phones to the footer", () => { + // Most mounted mode homes keep the in-flow hero pill on phones. Tools is the + // deliberate exception: its content-rich directory delegates phones to the + // compact footer while retaining the hero from sm upward. Result/submitted + // views also use "sm-up"; desktopHomeComposerSlotId is undefined there, so + // heroOwnsPhoneComposer stays false regardless of the breakpoint value. const dashboard = source("src/components/ClinicalDashboard.tsx"); const header = source("src/components/clinical-dashboard/master-search-header.tsx"); expect(dashboard).toContain('(activeModeResultKind === "favourites" && favouritesAccessible)'); - expect(dashboard).toContain('const heroComposerBreakpoint = showDesktopHomeComposer ? "all" : "sm-up";'); + expect(dashboard).toMatch( + /const heroComposerBreakpoint =\s*showDesktopHomeComposer && activeModeResultKind !== "tools" \? "all" : "sm-up";/, + ); + expect(dashboard).not.toContain('const heroComposerBreakpoint = showDesktopHomeComposer ? "all" : "sm-up";'); expect(dashboard).toContain( 'const heroOwnsPhoneComposer = Boolean(desktopHomeComposerSlotId) && heroComposerBreakpoint === "all";', ); @@ -188,7 +203,7 @@ describe("mobile composer reserve contract", () => { resolveShellVisibleMobileComposerReserve({ shouldShowSearchComposer: true, documentViewerOwnedRoute: false, - isStandaloneModeHome: false, + heroOwnsPhoneComposer: false, searchMode: "differentials", differentialsCompareAddonActive: true, }), @@ -213,7 +228,7 @@ describe("mobile composer reserve contract", () => { resolveShellVisibleMobileComposerReserve({ shouldShowSearchComposer: false, pageOwnedComposerRoute: true, - isStandaloneModeHome: false, + heroOwnsPhoneComposer: false, searchMode: "tools", differentialsCompareAddonActive: false, }), diff --git a/tests/phone-dock-addon-contract.test.ts b/tests/phone-dock-addon-contract.test.ts index 6f45f7a8cb..f776c0f43b 100644 --- a/tests/phone-dock-addon-contract.test.ts +++ b/tests/phone-dock-addon-contract.test.ts @@ -107,7 +107,7 @@ describe("reserve resolvers honour the patient-details addon", () => { expect( resolveShellVisibleMobileComposerReserve({ shouldShowSearchComposer: true, - isStandaloneModeHome: false, + heroOwnsPhoneComposer: false, searchMode: "prescribing", differentialsCompareAddonActive: false, patientDetailsAddonActive: true, @@ -119,7 +119,7 @@ describe("reserve resolvers honour the patient-details addon", () => { expect( resolveShellVisibleMobileComposerReserve({ shouldShowSearchComposer: true, - isStandaloneModeHome: false, + heroOwnsPhoneComposer: false, searchMode: "differentials", differentialsCompareAddonActive: true, patientDetailsAddonActive: true, @@ -133,7 +133,7 @@ describe("reserve resolvers honour the patient-details addon", () => { expect( resolveShellVisibleMobileComposerReserve({ shouldShowSearchComposer: true, - isStandaloneModeHome: true, + heroOwnsPhoneComposer: true, searchMode: "prescribing", differentialsCompareAddonActive: false, patientDetailsAddonActive: true, diff --git a/tests/search-shell-props.test.ts b/tests/search-shell-props.test.ts index 08299a4bd8..b39b076779 100644 --- a/tests/search-shell-props.test.ts +++ b/tests/search-shell-props.test.ts @@ -28,6 +28,17 @@ describe("searchShellPropsForPathname", () => { }); }); + it("keeps tools in the hero from tablet up and uses the shared phone footer", () => { + const expectedToolsShell = { + initialMode: "tools", + desktopSearchPlacement: "hero", + mobileHomeComposerPlacement: "footer", + } as const; + + expect(searchShellPropsForPathname("/tools")).toEqual(expectedToolsShell); + expect(searchShellPropsForPathname("/tools/interaction-checker")).toEqual(expectedToolsShell); + }); + it("maps therapy and home fallbacks", () => { expect(searchShellPropsForPathname("/therapy-compass/search")).toEqual({ initialMode: "therapy-compass", diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 46f8d08075..86ce537561 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -444,7 +444,13 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(page.getByTestId("tools-search-results-page")).toBeVisible(); await expect(page.getByRole("heading", { level: 1, name: "All tools" })).toBeVisible(); - await expect(page.getByTestId("tools-results-home-composer").getByTestId("global-search-input")).toBeVisible(); + if (viewport.name === "phone") { + await expect(page.getByTestId("tools-results-home-composer").getByTestId("global-search-input")).toHaveCount(0); + await expect(page.locator("form.answer-footer-search-dock")).toBeVisible(); + } else { + await expect(page.getByTestId("tools-results-home-composer").getByTestId("global-search-input")).toBeVisible(); + await expect(page.locator("form.answer-footer-search-dock")).toHaveCount(0); + } await expectNoPageHorizontalOverflow(page); }); } @@ -488,9 +494,10 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(page.getByLabel("Mode Tools")).toBeVisible(); await expect(visibleGlobalSearchInput(page)).toHaveCount(1); if (viewport.name === "mobile") { - // Phones keep the compact shared search in the tools-home hero slot. - await expect(page.getByTestId("tools-home").getByTestId("global-search-input")).toBeVisible(); - await expect(page.locator("form.answer-footer-search-dock")).toHaveCount(0); + // Tools delegates its phone composer to the same shared global footer + // used by submitted views; tablet and desktop retain the hero slot. + await expect(page.getByTestId("tools-home").getByTestId("global-search-input")).toHaveCount(0); + await expect(page.locator("form.answer-footer-search-dock")).toBeVisible(); } else { await expect(page.getByTestId("tools-home").getByTestId("global-search-input")).toBeVisible(); } @@ -601,7 +608,13 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(page.getByRole("heading", { level: 1, name: "All tools" })).toBeVisible(); await expect(page.getByRole("region", { name: "Tool results" })).toBeVisible(); await expect(page.getByRole("heading", { level: 2, name: "Clinical KB Search" }).first()).toBeVisible(); - await expect(page.getByTestId("tools-results-home-composer").getByTestId("global-search-input")).toBeVisible(); + if (width < 640) { + await expect(page.getByTestId("tools-results-home-composer").getByTestId("global-search-input")).toHaveCount(0); + await expect(page.locator("form.answer-footer-search-dock")).toBeVisible(); + } else { + await expect(page.getByTestId("tools-results-home-composer").getByTestId("global-search-input")).toBeVisible(); + await expect(page.locator("form.answer-footer-search-dock")).toHaveCount(0); + } await expectNoPageHorizontalOverflow(page); } @@ -985,7 +998,6 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { { path: "/differentials", testId: "differentials-home" }, { path: "/factsheets", testId: "factsheets-home-main" }, { path: "/favourites", testId: "favourites-hub" }, - { path: "/tools", testId: "tools-search-results-page" }, ] as const) { await gotoLauncher(page, home.path); const homeSurface = page.getByTestId(home.testId);