From 0ad1cfc42d42c6dde23321e79a7e8efe557d5c87 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:41:41 +0800 Subject: [PATCH 1/3] fix: keep differential compare action tappable --- .../global-search-shell.tsx | 15 ++- .../master-search-header.tsx | 6 +- tests/ui-tools.spec.ts | 109 +++++++++++++++++- 3 files changed, 124 insertions(+), 6 deletions(-) diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 9be51e3af..a08259790 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -45,7 +45,7 @@ import { } from "@/lib/app-modes"; import { isLocalNoAuthMode } from "@/lib/client-env"; import { documentsSearchHref } from "@/lib/document-flow-routes"; -import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; +import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { readSearchNavigationContext, type SearchNavigationOptions } from "@/lib/search-navigation-context"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { useAuthSession } from "@/lib/supabase/client"; @@ -241,6 +241,8 @@ function GlobalStandaloneSearchShellClient({ const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search"); const isDocumentCommandSearchView = pathname === "/documents/search" && requestedQuery.length > 0; const useCompactBottomSearch = hasSubmittedModeSearch || isDocumentCommandSearchView; + const differentialsCompareAddonActive = + pathname === "/differentials" && searchMode === "differentials" && hasSubmittedModeSearch; // Registry and local decision-support modes own their submitted-search views on their // standalone routes; the shell must not swap them to the dashboard. On the // home route the dashboard always renders, so these exclusions only apply @@ -283,9 +285,11 @@ function GlobalStandaloneSearchShellClient({ ? "2rem" : searchMode === "answer" ? "calc(9rem + env(safe-area-inset-bottom))" - : useCompactBottomSearch - ? "calc(5.5rem + env(safe-area-inset-bottom))" - : "calc(9rem + env(safe-area-inset-bottom))"; + : differentialsCompareAddonActive + ? "calc(8.75rem + env(safe-area-inset-bottom))" + : useCompactBottomSearch + ? "calc(5.5rem + env(safe-area-inset-bottom))" + : "calc(9rem + env(safe-area-inset-bottom))"; useEffect(() => { // Re-derive the mode and query from the URL, but only when the search string @@ -551,6 +555,9 @@ function GlobalStandaloneSearchShellClient({ // result views: compact the phone bottom composer so results keep // maximum screen space. Mode homes keep the chip-row layout. mobileBottomSearchVariant={useCompactBottomSearch ? "compact" : "default"} + mobileBottomSearchAddonSlotId={ + differentialsCompareAddonActive ? differentialsMobileCompareAddonSlotId : undefined + } desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"} searchComposerVisible={shouldShowSearchComposer} desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 8f9537929..52f4ad1ef 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1188,7 +1188,11 @@ export function MasterSearchHeader({ const ModeIdentityIcon = appModeIcons[searchMode]; const hasScopeFooterChip = searchMode === "answer" || searchMode === "documents" || searchMode === "forms"; const usesPhoneFooterDock = usesBottomComposerPlacement && usesPhoneSearchLayout; - const shouldHideBottomOnScroll = Boolean(hideOnScroll && usesPhoneFooterDock); + // A differential comparison is a persistent batch action: hiding its host + // dock on downward scroll makes the CTA slide under mobile browser chrome + // and disables pointer events just when users finish reviewing the list. + // Keep that dock pinned while the header can still collapse independently. + const shouldHideBottomOnScroll = Boolean(hideOnScroll && usesPhoneFooterDock && !mobileBottomSearchAddonSlotId); // Phone submitted non-answer result docks reserve pill-only scroll // clearance (ClinicalDashboard
margins / global-search-shell // mobileComposerReserve), so an extra notice line would push the fixed diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 663288a5d..71f861c5b 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Locator, type Page } from "playwright/test"; import type { Route } from "playwright-core"; -import { acuteConfusionPresentationWorkflow } from "../src/lib/differentials"; +import { acuteConfusionPresentationWorkflow, differentialRecords } from "../src/lib/differentials"; import { demoAnswer, demoDocuments } from "../src/lib/demo-data"; import { formRecords } from "../src/lib/forms"; import { loadMedicationSnapshot } from "../src/lib/medication-snapshot"; @@ -138,6 +138,41 @@ async function mockAnswerDashboardApi(page: Page) { }); } +async function mockDifferentialCatalogApi(page: Page) { + await page.route(/\/api\/differentials(?:\?.*)?$/, async (route) => { + const url = new URL(route.request().url()); + const query = url.searchParams.get("q")?.trim() ?? ""; + const kind = url.searchParams.get("kind") ?? "diagnosis"; + + if (kind === "presentation") { + await route.fulfill({ + json: { + matches: [ + { + workflow: acuteConfusionPresentationWorkflow, + score: 1, + reasons: [`Matched ${query}`], + }, + ], + demoMode: true, + }, + }); + return; + } + + await route.fulfill({ + json: { + matches: differentialRecords.slice(0, 20).map((record, index) => ({ + record, + score: 1 - index / 10, + reasons: [`Matched ${query}`], + })), + demoMode: true, + }, + }); + }); +} + async function commandSurfaceOpensAbovePill(page: Page) { const input = visibleGlobalSearchInput(page).first(); await expect(input).toBeVisible(); @@ -1332,6 +1367,78 @@ test.describe("Clinical KB tools launcher", () => { await expectNoPageHorizontalOverflow(page); }); + test("mobile differential compare action stays tappable while results scroll", async ({ page }) => { + await mockAnswerDashboardApi(page); + await mockDifferentialCatalogApi(page); + await page.route(/\/api\/search(?:\?.*)?$/, async (route) => { + await route.fulfill({ + json: { + results: [], + visualEvidence: [], + relatedDocuments: [], + documentMatches: [ + { + document_id: "11111111-1111-4111-8111-111111111111", + title: "Acute confusion differential guide", + file_name: "acute-confusion-differentials.pdf", + labels: [], + summarySnippet: "Reviewed acute confusion differential guidance.", + bestPages: [1], + bestChunkIds: ["chunk-acute-confusion"], + imageCount: 0, + tableCount: 0, + matchReason: "Matched indexed passage", + score: 0.93, + }, + ], + relevance: { verdict: "strong", score: 0.93, directSourceCount: 1, weakSourceCount: 0 }, + smartPanel: {}, + telemetry: { query_class: "differential_compare", retrieval_strategy: "text_fast_path" }, + scope: { queryMode: "compare_guidance" }, + sourceGovernanceWarnings: [], + demoMode: true, + }, + }); + }); + await page.setViewportSize({ width: 390, height: 844 }); + await gotoLauncher(page, "/differentials"); + + const input = page.locator('input[placeholder="Ask or search a presentation"]:visible').first(); + const submit = page.locator('button[aria-label="Search differential presentations"]:visible'); + await input.fill("acute confusion"); + await expect(submit).toBeEnabled(); + await submit.click(); + + const compareAction = page.getByTestId("differentials-compare-selected-mobile"); + const dock = page.locator("form.answer-footer-search-dock"); + const scrollport = page.getByTestId("differentials-search-results"); + await expect(scrollport).toBeVisible(); + await expect(page.locator("#differentials-mobile-compare-addon-slot")).toHaveCount(1); + await expect(compareAction).toBeVisible(); + await expect(compareAction).toContainText("Compare selected"); + + await scrollport.evaluate((element) => element.scrollTo({ top: 700, behavior: "instant" })); + await expect.poll(() => scrollport.evaluate((element) => element.scrollTop)).toBeGreaterThan(100); + await page.waitForTimeout(300); + + await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); + const compareGeometry = await compareAction.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const centreX = rect.left + rect.width / 2; + const centreY = rect.top + rect.height / 2; + const hit = document.elementFromPoint(centreX, centreY); + return { + top: rect.top, + bottom: rect.bottom, + viewportHeight: window.innerHeight, + receivesPointer: hit === element || element.contains(hit), + }; + }); + expect(compareGeometry.top).toBeGreaterThanOrEqual(0); + expect(compareGeometry.bottom).toBeLessThanOrEqual(compareGeometry.viewportHeight); + expect(compareGeometry.receivesPointer).toBe(true); + }); + test("diagnosis detail actions stay tappable and tabs stay single-line", async ({ page }) => { await page.setViewportSize({ width: 1024, height: 800 }); await gotoLauncher(page, "/differentials/diagnoses/delirium"); From 8c159b0a87bb3a3746c87f7f338961881a263669 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:16:24 +0800 Subject: [PATCH 2/3] fix: preserve pinned dock clearance --- .../master-search-header.tsx | 4 +++- tests/ui-tools.spec.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 52f4ad1ef..058e04817 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -321,7 +321,9 @@ export function MasterSearchHeader({ searchComposerVisible && !desktopHomeComposerSlotId && (isAnswerFooterComposer || mobileSearchPlacement === "bottom"); - const bottomComposerScrollHiddenActive = Boolean(hideOnScroll && phoneBottomSearchDockActive); + const bottomComposerScrollHiddenActive = Boolean( + hideOnScroll && phoneBottomSearchDockActive && !mobileBottomSearchAddonSlotId, + ); const bottomComposerHidden = bottomComposerScrollHiddenActive && scrollHidden && diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 71f861c5b..5294335eb 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1412,6 +1412,7 @@ test.describe("Clinical KB tools launcher", () => { const compareAction = page.getByTestId("differentials-compare-selected-mobile"); const dock = page.locator("form.answer-footer-search-dock"); const scrollport = page.getByTestId("differentials-search-results"); + const mainContent = page.locator("#main-content"); await expect(scrollport).toBeVisible(); await expect(page.locator("#differentials-mobile-compare-addon-slot")).toHaveCount(1); await expect(compareAction).toBeVisible(); @@ -1422,6 +1423,23 @@ test.describe("Clinical KB tools launcher", () => { await page.waitForTimeout(300); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); + const mainPaddingBottom = await mainContent.evaluate((element) => + Number.parseFloat(window.getComputedStyle(element).paddingBottom), + ); + const dockHeight = await dock.evaluate((element) => element.getBoundingClientRect().height); + expect(mainPaddingBottom).toBeGreaterThanOrEqual(dockHeight); + + await mainContent.evaluate((element) => element.scrollTo({ top: element.scrollHeight, behavior: "instant" })); + const lastResultBottom = await page + .getByTestId("differential-status-badge") + .last() + .evaluate( + (element) => + element.closest("article")?.getBoundingClientRect().bottom ?? element.getBoundingClientRect().bottom, + ); + const dockTop = await dock.evaluate((element) => element.getBoundingClientRect().top); + expect(lastResultBottom).toBeLessThanOrEqual(dockTop); + const compareGeometry = await compareAction.evaluate((element) => { const rect = element.getBoundingClientRect(); const centreX = rect.left + rect.width / 2; From ea0c88fd821b8870ddfc28102c3444d934a801c7 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:09:43 +0800 Subject: [PATCH 3/3] fix: expose differential compare on tablets --- src/components/clinical-dashboard/differentials-home.tsx | 8 ++++---- .../clinical-dashboard/master-search-header.tsx | 4 ++-- src/lib/mode-home-composer.ts | 2 +- tests/ui-tools.spec.ts | 7 +++++++ 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index 018b94cf5..3b888af2a 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -125,10 +125,10 @@ function routeWithQuery(path: string, query: string, selectedIds?: Set) } /** - * Phone-only floating compare action. Portals into the bottom search dock's - * addon slot so it stays anchored just above the composer pill (and hides - * with it on scroll), but renders as a self-contained floating pill so it - * reads as a batch-selection action rather than composer chrome. + * Mobile/tablet floating compare action. Portals into the search composer's + * addon slot so it stays anchored beside the active result controls, but + * renders as a self-contained floating pill so it reads as a batch-selection + * action rather than composer chrome. */ function DifferentialsMobileCompareBar({ selectedCount, diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 058e04817..8da025690 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -235,7 +235,7 @@ export function MasterSearchHeader({ * search pill sits in the middle of the hero on phones as well as desktop * instead of docking to the bottom edge. */ desktopHomeComposerSlotId?: string; - /** Phone-only slot rendered above the bottom search pill for page-specific dock addons. */ + /** Mobile/tablet slot rendered above the search pill for page-specific composer addons. */ mobileBottomSearchAddonSlotId?: string; mobileLeadingAction?: "menu" | "back"; onMobileBack?: () => void; @@ -1243,7 +1243,7 @@ export function MasterSearchHeader({ )} > {usesBottomComposerPlacement ?