From 58d5dc7d5c7e7696ecdddf8f0fe6754fd2170d14 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 19:55:28 +0000 Subject: [PATCH 1/2] fix(chrome): keep tablet/desktop search page-anchored outside sticky header PR #1222 wrapped header+composer in the sticky collapse wrapper, so results search stuck under the top bar on sm+. Only the mode/new-chat bar stays sticky; the composer scrolls with the page. Co-authored-by: BigSimmo --- docs/search-chrome-behaviour.md | 18 +- .../global-search-shell.tsx | 5 +- .../master-search-header.tsx | 559 +++++++++--------- tests/header-scroll-hide-contract.test.ts | 11 + tests/ui-chrome-scroll.spec.ts | 54 +- 5 files changed, 362 insertions(+), 285 deletions(-) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index a9b086bb98..fb9b5e94ea 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -4,13 +4,13 @@ This repo uses one shared search experience across the global shell, dashboard r ## Page ownership model -| 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 | -| Submitted/search-result views | Compact bottom dock on phones; header/inline placement on larger screens | Shell/dashboard `--mobile-composer-reserve` | -| 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 | -| Info/detail pages with no composer | No fixed composer | Idle shell padding only | +| 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 | +| Submitted/search-result views | Compact bottom dock on phones; page-anchored in-flow composer under the top bar on larger screens (scrolls with content — not sticky header chrome) | Shell/dashboard `--mobile-composer-reserve` | +| 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 | +| Info/detail pages with no composer | No fixed composer | Idle shell padding only | ## Invariants @@ -27,7 +27,7 @@ This repo uses one shared search experience across the global shell, dashboard r ## Scroll hide/reveal -The universal header hides on a deliberate scroll down and returns on a deliberate scroll up at **every** breakpoint. The bottom search dock keeps that behaviour on phones only — on tablet and desktop the composer sits in the hero or the header, where there is nothing to reclaim. Both read one `useScrollHideReporter` per host, so the header and the phone dock can never disagree about direction. +The universal header hides on a deliberate scroll down and returns on a deliberate scroll up at **every** breakpoint. The bottom search dock keeps that behaviour on phones only — on tablet and desktop the search composer is page-anchored in flow (or in the mode-home hero), so only the top bar (mode, new chat, etc.) is sticky chrome. Both read one `useScrollHideReporter` per host, so the header and the phone dock can never disagree about direction. Choose the hide mechanism from where the host's scrollport lives, because that decides what hiding costs the reader: @@ -41,7 +41,7 @@ Rules that keep this working: - **Feed the reporter from the element that actually scrolls.** `GlobalSearchShell`'s `#main-content` is the scrollport only on phones, so above that it also runs `useDocumentScrollHideReporter`. That hook self-gates: the phone shell is `fixed inset-0`, so the document cannot scroll and never fires. - **Do not release flow space out of a document-scrolled page.** Collapsing the header row while the document scrolls pulls the whole page up by the header height mid-scroll. Stick and translate instead; `readChromeCollapseBudget` therefore charges the header's height against the scroll runway only while the wrapper is a grid at the current width. -- **Sticky belongs on the collapse wrapper, not on `header#search`.** The header sits inside two header-height boxes, which leaves a sticky rule on it zero travel — that is what made the desktop bar scroll away and only return at the top of the page. For the same reason the wrapper's ancestor in `GlobalSearchShell` is `display: contents` above the phone breakpoint rather than a block. +- **Sticky belongs on the collapse wrapper, not on `header#search`.** The header sits inside two header-height boxes, which leaves a sticky rule on it zero travel — that is what made the desktop bar scroll away and only return at the top of the page. For the same reason the wrapper's ancestor in `GlobalSearchShell` is `display: contents` above the phone breakpoint rather than a block. **Only the top bar belongs inside that sticky wrapper** — keep the tablet/desktop search composer outside it (and without its own `sticky` under-header rule) so results search stays page-anchored. - **Transform only while hidden.** A standing transform on the wrapper would become the containing block for the fixed-position menus and composers rendered inside it. - **Rebase the reporter on geometry switches.** Pass `resetKey` when the host changes the scrollport under it (`ClinicalDashboard` passes `searchMode`, which swaps `
`'s header reserve); otherwise the carried-over offset spends the first post-switch scroll on a spurious hide or reveal. diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 3cb1965658..fdf5d05531 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -720,8 +720,9 @@ function GlobalStandaloneSearchShellClient({ heroComposerBreakpoint="all" // Phones: #main-content owns vertical scroll, so hide-on-scroll // collapses the header/composer to hand space back to content. - // Tablet/desktop: the document scrolls, so the chrome sticks to the - // viewport top and slides away instead of releasing flow space. + // Tablet/desktop: the document scrolls, so only the top bar sticks + // to the viewport top and slides away; the search composer stays + // page-anchored outside that sticky wrapper. hideOnScroll={{ strategy: "collapse", wide: "sticky", scrollHidden: chromeScrollHide.hidden }} onBottomComposerHiddenChange={setBottomComposerHidden} queryInputAutoFocus={searchParams.get("focus") === "1"} diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 3830aa9fa5..c4b19c5c53 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1379,7 +1379,7 @@ export function MasterSearchHeader({ const usesAnswerFooterStyle = isAnswerFooterComposer && !isDesktopHomeComposer; const usesMobileBottomStyle = isMobileBottomComposer && !isDesktopHomeComposer; const usesBottomComposerPlacement = usesAnswerFooterStyle || (usesMobileBottomStyle && usesPhoneSearchLayout); - // Sticky-top result composers (tablet+) share the footer chip layout so the + // In-flow result composers (tablet+) share the footer chip layout so the // pill + chip row looks identical across homes, results, and the answer dock. const usesFooterChipLayout = usesBottomComposerPlacement || isDesktopHomeComposer || usesMobileBottomStyle; // Keep footer suggestion chips on tablet/desktop; phones reach the same actions via "+". @@ -1453,13 +1453,14 @@ export function MasterSearchHeader({ usesPhoneFooterDock ? "document-mobile-search-edge universal-top-search-edge fixed z-40 w-full" : cn( - "document-mobile-search-edge universal-top-search-edge fixed z-40 mx-auto max-w-3xl sm:z-20 sm:w-full sm:px-4 sm:py-3 lg:max-w-4xl", - isHeroDesktopComposer - ? "sm:hidden" - : "sm:sticky sm:top-[calc(4.75rem+env(safe-area-inset-top))]", + // Tablet/desktop results: in-flow under the top bar, not sticky. + // Sticky-under-header pulled the search into the chrome strip; + // page-anchored keeps only mode/new-chat in the sticky header. + "document-mobile-search-edge universal-top-search-edge fixed z-40 mx-auto max-w-3xl sm:static sm:z-20 sm:w-full sm:px-4 sm:py-3 lg:max-w-4xl", + isHeroDesktopComposer ? "sm:hidden" : null, ), ) - : "universal-top-search-edge sticky top-[calc(4.75rem+env(safe-area-inset-top))] z-20 mx-auto box-border w-full px-3 py-3 sm:px-4", + : "universal-top-search-edge relative z-20 mx-auto box-border w-full px-3 py-3 sm:px-4", usesBottomComposerPlacement && "answer-footer-search-edge", usesPhoneFooterDock && "answer-footer-search-dock", usesCompactMobileBottomStyle && "document-mobile-search-compact", @@ -1732,259 +1733,263 @@ export function MasterSearchHeader({ } : undefined; - const headerAndComposer = ( - <> - ); + const searchComposerBlock = searchComposerVisible ? ( + <> + {(desktopHomeComposerActive && desktopHomeComposerHost) || + (desktopHomeComposerSlotId && !desktopHomeComposerFallback) + ? null + : renderSearchComposer("default")} + {desktopHomeComposerActive && desktopHomeComposerHost + ? createPortal(renderSearchComposer("desktop-home"), desktopHomeComposerHost) + : null} + + ) : null; + if (hideStrategy === "collapse") { // Collapse hide-on-scroll: the host renders the header above an internally // scrolling element, so hiding must also release the header's layout space. // A 1fr -> 0fr grid row animates the collapse without any height // measurement; the bottom-anchored inner track makes the chrome slide up - // out of the viewport top. Fixed-position composers (answer footer, mobile - // bottom search) escape the wrapper naturally because it carries no - // transform in this mode. + // out of the viewport top. + // + // Only the top bar lives inside this wrapper. In-flow tablet/desktop search + // composers stay outside so they remain page-anchored and scroll with the + // content; fixed phone docks / answer footers and hero-portaled composers + // never needed the wrapper either. // // Above the phone breakpoint a `wide: "sticky"` host scrolls the document // instead, so this wrapper — not the
inside it, which has no @@ -1992,55 +1997,63 @@ export function MasterSearchHeader({ // and translates away. Releasing the row there would pull the whole page up // by the header height mid-scroll; translating leaves the geometry alone. return ( -
+ <>
- {headerAndComposer} +
+ {headerChrome} +
-
+ {searchComposerBlock} + ); } - return headerAndComposer; + return ( + <> + {headerChrome} + {searchComposerBlock} + + ); } diff --git a/tests/header-scroll-hide-contract.test.ts b/tests/header-scroll-hide-contract.test.ts index 65d33a23fc..f13a909784 100644 --- a/tests/header-scroll-hide-contract.test.ts +++ b/tests/header-scroll-hide-contract.test.ts @@ -82,6 +82,17 @@ describe("shared header hide/reveal wiring", () => { expect(hookSource).toContain("}, [allowAllBreakpoints, resetKey]);"); }); + it("keeps tablet/desktop search composers page-anchored outside sticky header chrome", () => { + // PR #1222 wrapped header+composer in the sticky collapse wrapper, which + // glued the results search pill under the top bar on sm+. Only header#search + // belongs in that wrapper; the in-flow composer must stay outside it and + // must not reintroduce sticky-under-header positioning. + expect(headerSource).toContain("Only the top bar lives inside this wrapper"); + expect(headerSource).toContain("sm:static sm:z-20 sm:w-full sm:px-4 sm:py-3 lg:max-w-4xl"); + expect(headerSource).not.toContain("sm:sticky sm:top-[calc(4.75rem+env(safe-area-inset-top))]"); + expect(headerSource).not.toContain("universal-top-search-edge sticky top-[calc(4.75rem+env(safe-area-inset-top))]"); + }); + it("keeps the bottom search dock a phone-only behaviour", () => { // The user-visible contract: the header hides everywhere, the footer search // bar hides on phones only. Both gates below require the phone layout. diff --git a/tests/ui-chrome-scroll.spec.ts b/tests/ui-chrome-scroll.spec.ts index 1a517391f5..330a9be22b 100644 --- a/tests/ui-chrome-scroll.spec.ts +++ b/tests/ui-chrome-scroll.spec.ts @@ -166,7 +166,7 @@ for (const { name: sizeName, viewport } of breakpoints) { test(`${sizeName}: bottom search composer never scroll-hides on ${surfaceName}`, async ({ page }) => { // The phone dock hide is phone-only by contract; above that breakpoint the - // composer lives in the hero or the header and has nothing to reclaim. + // composer is page-anchored (or in the mode-home hero) and has nothing to reclaim. await page.setViewportSize(viewport); await page.goto(route, { waitUntil: "domcontentloaded" }); await expect(page.locator("header#search").first()).toBeVisible({ timeout: 15_000 }); @@ -183,3 +183,55 @@ for (const { name: sizeName, viewport } of breakpoints) { }); } } + +for (const { name: sizeName, viewport } of breakpoints) { + test(`${sizeName}: services results search stays page-anchored outside sticky header`, async ({ page }) => { + // Regression for the defect where PR #1222's sticky collapse wrapper also + // wrapped the search composer, so /services?q=… glued the pill under the + // top bar on tablet/desktop. Only header#search may stick; the composer + // must leave the viewport as the page scrolls. + await page.setViewportSize(viewport); + await page.goto("/services?q=services&focus=1&run=1", { waitUntil: "domcontentloaded" }); + await expect(page.locator("header#search").first()).toBeVisible({ timeout: 15_000 }); + + const composer = page.locator("form.universal-top-search-edge, form.document-mobile-search-edge").first(); + await expect(composer).toBeVisible({ timeout: 15_000 }); + + const atTop = await page.evaluate(() => { + const header = document.querySelector("header#search"); + const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); + const form = document.querySelector("form.universal-top-search-edge, form.document-mobile-search-edge"); + const headerRect = header?.getBoundingClientRect(); + const formRect = form?.getBoundingClientRect(); + return { + formInsideCollapse: Boolean(collapse && form && collapse.contains(form)), + formTop: formRect ? Math.round(formRect.top) : Number.NaN, + headerBottom: headerRect ? Math.round(headerRect.bottom) : Number.NaN, + formPosition: form ? getComputedStyle(form).position : "", + }; + }); + expect(atTop.formInsideCollapse, "search composer must not live inside sticky header chrome").toBe(false); + expect(atTop.formPosition, "results search is in normal flow, not sticky/fixed under the bar").toBe("static"); + expect(atTop.formTop, "composer starts below the top bar").toBeGreaterThanOrEqual(atTop.headerBottom - 2); + + await waitForRunway(page, requiredRunway); + await page.waitForTimeout(400); + await scrollBy(page, 900, 150); + await page.waitForTimeout(300); + + const afterScroll = await page.evaluate(() => { + const header = document.querySelector("header#search"); + const form = document.querySelector("form.universal-top-search-edge, form.document-mobile-search-edge"); + const headerRect = header?.getBoundingClientRect(); + const formRect = form?.getBoundingClientRect(); + return { + formBottom: formRect ? Math.round(formRect.bottom) : Number.NaN, + headerTop: headerRect ? Math.round(headerRect.top) : Number.NaN, + headerBottom: headerRect ? Math.round(headerRect.bottom) : Number.NaN, + scrollY: Math.round(window.scrollY), + }; + }); + expect(afterScroll.scrollY, "page actually scrolled").toBeGreaterThan(400); + expect(afterScroll.formBottom, "page-anchored search scrolled off with the content").toBeLessThanOrEqual(8); + }); +} From c7e36972a5ed18250a293c7c5a42df7c44bc29d0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 20:01:47 +0000 Subject: [PATCH 2/2] test(chrome): loosen sticky-relative indent assertion after header split Prettier re-indented the collapse sticky gate when the composer moved outside the wrapper; match whitespace-flexibly so the contract stays stable. Co-authored-by: BigSimmo --- tests/header-scroll-hide-contract.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/header-scroll-hide-contract.test.ts b/tests/header-scroll-hide-contract.test.ts index f13a909784..09aa84af18 100644 --- a/tests/header-scroll-hide-contract.test.ts +++ b/tests/header-scroll-hide-contract.test.ts @@ -66,7 +66,7 @@ describe("shared header hide/reveal wiring", () => { it("keeps the header out of sticky positioning wherever its row collapses", () => { // Sticky pins the bar inside the viewport and fights the 1fr -> 0fr grid. - expect(headerSource).toContain('sticksAbovePhones || collapsesAtEveryWidth\n ? "relative"'); + expect(headerSource).toMatch(/sticksAbovePhones \|\| collapsesAtEveryWidth\s*\n\s*\? "relative"/); }); it("counts the collapse budget only where the wrapper really collapses", () => {