From 3956b47598bd57c97d9c60deda835bc99dc85e05 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Jul 2026 15:01:52 +0000 Subject: [PATCH 1/2] fix(ui): hide differentials compare dock on scroll down Re-enable phone bottom-composer scroll-hide when the compare addon is present, keep Compare selected in-flow with the dock, and update the UI regression to assert hide/reveal instead of a pinned dock. --- src/app/globals.css | 10 ++- .../clinical-dashboard/differentials-home.tsx | 6 +- .../global-search-shell.tsx | 4 +- .../master-search-header.tsx | 14 ++- tests/ui-tools.spec.ts | 87 ++++++++++++++----- 5 files changed, 79 insertions(+), 42 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 567962a67b..d778090563 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -746,14 +746,16 @@ summary::-webkit-details-marker { --footer-scrim-height: max(7rem, calc(var(--safe-area-bottom) + 5.5rem)); } +/* In-flow above the search pill inside the phone dock so scroll-hide + translateY moves the Compare bar with the composer (no viewport-fixed jump). */ .differentials-mobile-compare-fab { pointer-events: none; - position: fixed; - inset-inline: max(1rem, var(--safe-area-left)) max(1rem, var(--safe-area-right)); - bottom: max(5.35rem, calc(var(--safe-area-bottom) + 5.05rem)); - z-index: 50; + position: relative; + z-index: 10; display: flex; + width: 100%; justify-content: center; + margin-bottom: 0.5rem; } .differentials-mobile-compare-fab__button { diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index 40c54affd5..e1c4145c0d 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -113,10 +113,8 @@ const candidateIconBySlug: Array<[string, LucideIcon]> = [ ]; /** - * 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. + * Mobile/tablet compare action. Portals into the search composer's addon slot + * so it sits above the search pill as dock chrome and hides/reveals with it. */ function DifferentialsMobileCompareBar({ selectedCount, diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 88b59cf8f3..6a5765dbc2 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -288,8 +288,8 @@ function GlobalStandaloneSearchShellClient({ : "calc(9rem + var(--safe-area-bottom))"; // Release the large bottom reserve only when the phone bottom composer is // actually hidden (MasterSearchHeader's bottomComposerHidden). Header-only - // scroll-hide, pinned compare addons, open menus/sheets, and composer focus - // keep the full reserve so content does not slide under a still-visible dock. + // scroll-hide, open menus/sheets, and composer focus keep the full reserve + // so content does not slide under a still-visible dock. // Safari's bottom safe-area inset includes its translucent browser toolbar. // Reusing that inset after the app composer hides recreates a toolbar-sized // blank band, so the hidden state intentionally keeps only a small content diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 434c8e8d30..60b9bfa225 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -325,9 +325,9 @@ export function MasterSearchHeader({ searchComposerVisible && !desktopHomeComposerSlotId && (isAnswerFooterComposer || mobileSearchPlacement === "bottom"); - const bottomComposerScrollHiddenActive = Boolean( - hideOnScroll && phoneBottomSearchDockActive && !mobileBottomSearchAddonSlotId, - ); + // Compare addon chrome lives inside the phone dock; hide/reveal with it so + // the search pill and Compare selected bar reclaim space together. + const bottomComposerScrollHiddenActive = Boolean(hideOnScroll && phoneBottomSearchDockActive); const bottomComposerHidden = bottomComposerScrollHiddenActive && scrollHidden && @@ -1200,11 +1200,9 @@ export function MasterSearchHeader({ const ModeIdentityIcon = appModeIcons[searchMode]; const hasScopeFooterChip = searchMode === "answer" || searchMode === "documents" || searchMode === "forms"; const usesPhoneFooterDock = usesBottomComposerPlacement && usesPhoneSearchLayout; - // 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); + // 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 diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index c2791757da..7f5375f02a 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1451,7 +1451,8 @@ test.describe("Clinical KB tools launcher", () => { await expectNoPageHorizontalOverflow(page); }); - test("mobile differential compare action stays tappable while results scroll", async ({ page }) => { + test("mobile differential compare dock hides on scroll down and stays tappable when revealed", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); await mockAnswerDashboardApi(page); await mockDifferentialCatalogApi(page); await page.route(/\/api\/search(?:\?.*)?$/, async (route) => { @@ -1491,45 +1492,83 @@ test.describe("Clinical KB tools launcher", () => { await expect(page.locator("#differentials-mobile-compare-addon-slot")).toHaveCount(1); await expect(compareAction).toBeVisible(); await expect(compareAction).toContainText("Compare selected"); + await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); - await mainContent.evaluate((element) => element.scrollTo({ top: 700, behavior: "instant" })); - await expect.poll(() => mainContent.evaluate((element) => element.scrollTop)).toBeGreaterThan(100); - await page.waitForTimeout(300); + // Compare lives in the dock addon slot above the search pill. + const revealedGeometry = await compareAction.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const dockRect = element.closest("form")?.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, + dockTop: dockRect?.top ?? null, + dockBottom: dockRect?.bottom ?? null, + viewportHeight: window.innerHeight, + receivesPointer: hit === element || element.contains(hit), + }; + }); + expect(revealedGeometry.dockTop).not.toBeNull(); + expect(revealedGeometry.top).toBeGreaterThanOrEqual(revealedGeometry.dockTop!); + expect(revealedGeometry.bottom).toBeLessThanOrEqual(revealedGeometry.dockBottom!); + expect(revealedGeometry.receivesPointer).toBe(true); - 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); + // Ensure enough scroll room for hide thresholds even with a short result list. + await page.evaluate(() => { + const container = document.getElementById("main-content"); + if (!container) return; + const spacer = document.createElement("div"); + spacer.id = "test-scroll-spacer"; + spacer.style.height = "2000px"; + spacer.style.minHeight = "2000px"; + spacer.style.display = "block"; + container.appendChild(spacer); + }); + + await expect(async () => { + await input.blur(); + await expect(input).not.toBeFocused({ timeout: 1_000 }); + await scrollPrimarySurface(page, 0); + for (const offset of [40, 80, 120, 160, 200]) { + await scrollPrimarySurface(page, offset); + } + await expect(dock).toHaveAttribute("data-scroll-hidden", "true", { timeout: 1_000 }); + }).toPass({ timeout: 15_000 }); + + await expect + .poll(async () => dock.evaluate((node) => window.getComputedStyle(node).transform !== "none")) + .toBe(true); + await expect + .poll(async () => mainContent.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom))) + .toBeLessThanOrEqual(13); - const compareGeometry = await compareAction.evaluate((element) => { + const hiddenCompare = 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); - expect(compareGeometry.bottom).toBeLessThan(dockTop); + expect(hiddenCompare.top).toBeGreaterThanOrEqual(hiddenCompare.viewportHeight - 1); + + await scrollPrimarySurface(page, 60); + await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); + await expect(compareAction).toBeVisible(); + const restoredPointer = await compareAction.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2); + return hit === element || element.contains(hit); + }); + expect(restoredPointer).toBe(true); await expectNoPageHorizontalOverflow(page); // The result cards and compare bar remain in their non-desktop layout up From 1a4e5c4fdacfef4340cfcbc4484e70df45745541 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Jul 2026 15:08:21 +0000 Subject: [PATCH 2/2] fix(ui): clear compare bar fully on dock scroll-hide Add a small extra translate for the differentials compare addon dock and poll until Compare is off-screen so the hide transition does not leave a viewport-edge peep in UI coverage. --- src/app/globals.css | 6 ++++++ tests/ui-tools.spec.ts | 23 ++++++++++++++--------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index d778090563..cc3f141405 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1848,6 +1848,12 @@ summary::-webkit-details-marker { pointer-events: none; } + /* Compare addon adds in-flow chrome above the pill; clear a little extra so + the Compare bar does not leave a 1px/subpixel peep on the viewport edge. */ + .answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge[data-footer-addon="differentials-compare"][data-scroll-hidden="true"] { + transform: translateY(calc(100% + 0.75rem + env(safe-area-inset-bottom))); + } + /* Document viewer bottom composer mirrors the dock's hide-on-scroll. Unlike the full-bleed dock it floats with a bottom gap (.floating-composer-edge, bottom: max(0.75rem, safe-area)), so the exit transform must also clear diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 7f5375f02a..93309d9291 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1550,15 +1550,20 @@ test.describe("Clinical KB tools launcher", () => { .poll(async () => mainContent.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom))) .toBeLessThanOrEqual(13); - const hiddenCompare = await compareAction.evaluate((element) => { - const rect = element.getBoundingClientRect(); - return { - top: rect.top, - bottom: rect.bottom, - viewportHeight: window.innerHeight, - }; - }); - expect(hiddenCompare.top).toBeGreaterThanOrEqual(hiddenCompare.viewportHeight - 1); + // Wait for the hide transition to finish so the in-dock Compare bar is fully + // off-screen (translateY(100%) parks the dock top on the viewport bottom edge). + await expect + .poll(async () => + compareAction.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + top: rect.top, + viewportHeight: window.innerHeight, + offscreen: rect.top >= window.innerHeight - 1, + }; + }), + ) + .toMatchObject({ offscreen: true }); await scrollPrimarySurface(page, 60); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true");