Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -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. |
Original file line numberDiff line numberDiff line change
@@ -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 |
2 changes: 1 addition & 1 deletion docs/search-chrome-behaviour.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |
Expand Down
9 changes: 4 additions & 5 deletions src/components/ClinicalDashboard.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
25 changes: 16 additions & 9 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. */
Expand DownExpand Up@@ -311,6 +313,7 @@ function GlobalStandaloneSearchShellBody({
initialMode = "answer",
availableModeIds,
desktopSearchPlacement = "default",
mobileHomeComposerPlacement = "hero",
searchComposerVisible = true,
hideDesktopSidebar = false,
chromeVisible = true,
Expand DownExpand Up@@ -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
Expand All@@ -461,7 +469,7 @@ function GlobalStandaloneSearchShellBody({
resolveShellVisibleMobileComposerReserve({
shouldShowSearchComposer,
pageOwnedComposerRoute: isPageOwnedComposerRoute(pathname),
isStandaloneModeHome,
heroOwnsPhoneComposer,
searchMode,
differentialsCompareAddonActive,
}),
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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"
Expand Down
13 changes: 7 additions & 6 deletions src/components/clinical-dashboard/mobile-composer-reserve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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;
Expand Down
7 changes: 6 additions & 1 deletion src/lib/search-shell-props.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ export type SearchShellPathProps = {
initialMode: AppModeId;
availableModeIds?: AppModeId[];
desktopSearchPlacement?: "default" | "hero";
mobileHomeComposerPlacement?: "hero" | "footer";
searchComposerVisible?: boolean;
mobileChromeVisible?: boolean;
};
Expand DownExpand Up@@ -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")) {
Expand Down
37 changes: 26 additions & 11 deletions tests/mobile-composer-reserve.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ describe("mobile composer reserve contract", () => {
resolveShellVisibleMobileComposerReserve({
shouldShowSearchComposer: false,
documentViewerOwnedRoute: true,
isStandaloneModeHome: false,
heroOwnsPhoneComposer: false,
searchMode: "documents",
differentialsCompareAddonActive: false,
}),
Expand All@@ -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(
Expand DownExpand Up@@ -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";',
);
Expand DownExpand Up@@ -188,7 +203,7 @@ describe("mobile composer reserve contract", () => {
resolveShellVisibleMobileComposerReserve({
shouldShowSearchComposer: true,
documentViewerOwnedRoute: false,
isStandaloneModeHome: false,
heroOwnsPhoneComposer: false,
searchMode: "differentials",
differentialsCompareAddonActive: true,
}),
Expand All@@ -213,7 +228,7 @@ describe("mobile composer reserve contract", () => {
resolveShellVisibleMobileComposerReserve({
shouldShowSearchComposer: false,
pageOwnedComposerRoute: true,
isStandaloneModeHome: false,
heroOwnsPhoneComposer: false,
searchMode: "tools",
differentialsCompareAddonActive: false,
}),
Expand Down
6 changes: 3 additions & 3 deletions tests/phone-dock-addon-contract.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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,
Expand All@@ -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,
Expand Down
11 changes: 11 additions & 0 deletions tests/search-shell-props.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
24 changes: 18 additions & 6 deletions tests/ui-tools.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
});
}
Expand DownExpand Up@@ -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();
}
Expand DownExpand Up@@ -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);
}

Expand DownExpand Up@@ -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);
Expand Down
Loading