Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
dbfeda1
fix(ui): match tablet search header to desktop
BigSimmo Jul 30, 2026
fdb0fdc
Merge branch 'main' into codex/fix-header-behavior-on-medium-screens
BigSimmo Jul 30, 2026
365c814
Merge branch 'main' into codex/fix-header-behavior-on-medium-screens
BigSimmo Jul 30, 2026
6289550
Merge remote-tracking branch 'origin/main' into codex/review-pr1435
BigSimmo Jul 30, 2026
df255ee
test: describe tablet search as normal flow
BigSimmo Jul 30, 2026
2537c98
docs: record tablet header review
BigSimmo Jul 30, 2026
b75155e
Merge branch 'main' into codex/fix-header-behavior-on-medium-screens
cursoragent Jul 30, 2026
045f054
Merge remote-tracking branch 'origin/main' into codex/review-pr1435
BigSimmo Jul 30, 2026
9aaa7b6
Merge remote-tracking branch 'origin/main' into codex/review-pr1435
BigSimmo Jul 30, 2026
006f284
Merge branch 'main' into codex/fix-header-behavior-on-medium-screens
BigSimmo Jul 30, 2026
144c9ce
Merge remote-tracking branch 'origin/main' into codex/review-pr1435
BigSimmo Jul 30, 2026
88959cc
Merge branch 'main' into codex/fix-header-behavior-on-medium-screens
cursoragent Jul 31, 2026
2cea044
fix(ui): tighten tablet page-flow search guards
cursoragent Jul 31, 2026
66f84f8
docs: record PR #1435 tablet header readiness review
cursoragent Jul 31, 2026
f1083c8
Merge branch 'main' into codex/fix-header-behavior-on-medium-screens
cursoragent Jul 31, 2026
936562e
docs: record PR #1435 review+bugbot+fix after main sync
cursoragent Jul 31, 2026
f3034e2
Merge branch 'main' into codex/fix-header-behavior-on-medium-screens
cursoragent Jul 31, 2026
ec8a978
Merge branch 'main' into codex/fix-header-behavior-on-medium-screens
cursoragent Jul 31, 2026
b448955
Merge branch 'main' into codex/fix-header-behavior-on-medium-screens
cursoragent Jul 31, 2026
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
25 changes: 15 additions & 10 deletions docs/branch-review-ledger.md

Large diffs are not rendered by default.

47 changes: 23 additions & 24 deletions docs/search-chrome-behaviour.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function DashboardDesktopResultComposerSlot({ slotId }: { slotId?: string
<DesktopComposerPortalSlot
id={slotId}
data-testid="desktop-page-search-composer-slot"
className="hidden lg:block lg:empty:hidden"
className="hidden sm:block sm:empty:hidden"
/>
);
}
9 changes: 4 additions & 5 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -857,9 +857,8 @@ function GlobalStandaloneSearchShellBody({
heroComposerBreakpoint="all"
// Phones: #main-content owns vertical scroll, so hide-on-scroll
// collapses the top bar to hand space back to content.
// Tablet: the document scrolls, so an outer sticky stack pins
// [top bar | search]. Desktop portals search into normal page flow,
// leaving this stack to own only the auto-hiding top bar.
// Tablet and desktop portal search into normal page flow. The outer
// sticky stack therefore owns only the auto-hiding top bar.
hideOnScroll={{
strategy: "collapse",
// Phones always overlay. The collapse mechanism is a 1fr -> 0fr
Expand Down Expand Up @@ -934,7 +933,7 @@ function GlobalStandaloneSearchShellBody({
<DesktopComposerPortalSlot
id={desktopPageComposerSlotId}
data-testid="desktop-page-search-composer-slot"
className="hidden lg:block lg:empty:hidden"
className="hidden sm:block sm:empty:hidden"
/>
) : null}
{/*
Expand All @@ -946,7 +945,7 @@ function GlobalStandaloneSearchShellBody({
Subnav (SpecifierSubnav / FormulationSubnav), so the shared mode bar
is skipped for them to avoid a duplicate row on their workflow routes.
Rendered in normal flow (sticky={false}) so it never contends with
the universal collapsing header / pinned search chrome.
the universal collapsing header or page-flow search chrome.
*/}
{searchMode !== "specifiers" && searchMode !== "formulation" ? (
<PageSecondaryNavigation
Expand Down
47 changes: 25 additions & 22 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ const composerPrivacyWarningId = "answer-composer-privacy-warning";

const phoneSearchLayoutMediaQuery = "(max-width: 639px)";
const scopeSheetMediaQuery = "(max-width: 1023px)";
const desktopPageComposerMediaQuery = "(min-width: 1024px)";
const desktopPageComposerMediaQuery = "(min-width: 640px)";
const modeHomeComposerMediaQuery = "(min-width: 0px)";
const modeHomeComposerSmUpMediaQuery = "(min-width: 640px)";

Expand Down Expand Up @@ -248,8 +248,8 @@ export function MasterSearchHeader({
* middle of the hero instead of docking to the bottom edge. Which widths the
* hero owns is controlled by `heroComposerBreakpoint`. */
desktopHomeComposerSlotId?: string;
/** Normal-flow page slot used by submitted/search views on desktop only.
* Below lg the existing phone dock/tablet sticky composer remains the owner. */
/** Normal-flow page slot used by submitted/search views from tablet widths up.
* Phones keep the bottom dock. */
desktopPageComposerSlotId?: string;
/** Widths where the mode-home hero slot hosts the composer. "all" keeps the
* hero pill on phones too (the answer home); "sm-up" reserves the hero for
Expand Down Expand Up @@ -290,9 +290,9 @@ export function MasterSearchHeader({
*
* "sticky" pins an outer stack to the viewport top above phones and still
* collapses only the top-bar row inside that stack — for hosts that hand
* scrolling back to the document (GlobalSearchShell). Tablet search stays
* in that stack; desktop result search may portal into page flow, leaving
* the same stack to own only the top bar.
* scrolling back to the document (GlobalSearchShell). Tablet and desktop
* result search portal into page flow, leaving the stack to own only the
* top bar.
*/
wide?: "collapse" | "sticky";
/** Parent-owned hidden state for hosts that report scroll via React `onScroll`. */
Expand Down Expand Up @@ -1074,7 +1074,8 @@ export function MasterSearchHeader({
// part of the tree had already removed, throwing a null-parentNode error.
// Because the host is stable, React's portal container never disappears.
// Hero slots retain their existing all/sm-up ownership. Generic page slots
// are desktop-only so phone docks and tablet sticky composers do not change.
// start at sm so tablets and desktops share normal-flow search behaviour,
// while phone docks remain unchanged.
const host = document.createElement("div");
// Layout-transparent so the composer lays out as a direct child of the slot.
host.style.display = "contents";
Expand Down Expand Up @@ -1508,12 +1509,13 @@ export function MasterSearchHeader({
const showsComposerPrivacyNotice = usesPhoneSearchLayout ? isDesktopHomeComposer : true;

const commandSurfacePlacement = usesBottomComposerPlacement ? "bottom-dock" : "inline";
// Search sits outside the collapsing top-bar row. Sticky hosts already pin an
// outer [top bar | search] stack, so the composer must stay in normal flow
// inside that stack — a second sticky + top offset would overlay page
// controls once the top bar collapses. Collapse-everywhere hosts have no
// outer stack, so their composer keeps its own sticky and drops the top-bar
// clearance while the bar is hidden.
// Search sits outside the collapsing top-bar row. Sticky hosts pin an outer
// top-bar stack; result composers portal into page flow at sm+, so this
// relative fallback only covers the brief pre-portal default placement (a
// second sticky + top offset would overlay page controls). Collapse-
// everywhere hosts have no outer stack, so their non-portaled composer
// keeps its own sticky and drops the top-bar clearance while the bar is
// hidden.
const stickySearchOwnedByOuterStack = sticksAbovePhones;
const stickySearchClearsTopBar = !(hideStrategy === "collapse" && headerChromeHidden);
const stickySearchTopClass = stickySearchClearsTopBar
Expand Down Expand Up @@ -1566,9 +1568,10 @@ export function MasterSearchHeader({
? "phone-footer-layer document-mobile-search-edge universal-top-search-edge z-40 w-full sm:fixed"
: cn(
"document-mobile-search-edge universal-top-search-edge z-40 mx-auto max-w-3xl sm:z-20 sm:w-full sm:px-4 sm:py-3 lg:max-w-4xl",
// Sticky-stack hosts already pin [top bar | search]. Never
// leave a `fixed`/`sticky` composer in that stack — it
// overlays page controls (Services decision rail).
// Sticky-stack hosts pin the top bar; result search
// portals out at sm+. Never leave a fixed/sticky
// default composer in that stack — it overlays page
// controls (Services decision rail).
stickySearchOwnedByOuterStack
? "relative"
: cn("fixed", isHeroDesktopComposer ? "sm:hidden" : stickySearchPositionClass),
Expand Down Expand Up @@ -2102,8 +2105,8 @@ export function MasterSearchHeader({
//
// Above the phone breakpoint a `wide: "sticky"` host scrolls the document,
// so an outer sticky stack pins its chrome below the wide safe-area spacer.
// Tablet search remains in that stack; desktop result search portals
// into page flow, so the stack contains only the top bar there. The host
// Tablet and desktop result search portal into page flow, so the stack
// contains only the top bar there. The host
// ancestor uses `display: contents`, allowing this semantic sticky owner to
// travel against the browser viewport and become static in standalone.
const collapsingTopBar = (
Expand Down Expand Up @@ -2184,8 +2187,8 @@ export function MasterSearchHeader({
// release it so the scroll surface reaches the physical viewport
// edge instead of leaving an opaque status-bar band. Match the
// header row's timing to avoid a one-frame gap during hide/reveal.
// sm+ keeps its pinned inset because the sticky [bar | search] stack
// is a separate wide-layout contract.
// sm+ keeps its pinned inset because sticky top-bar chrome is a
// separate wide-layout contract from page-flow search.
//
// Paint the header's own surface, not the page background. While the
// spacer is visible it is the top of the header, so `--background`
Expand Down Expand Up @@ -2234,8 +2237,8 @@ export function MasterSearchHeader({
viewport, landing near the top of the screen. Portal it to the
frame footer host on phones — the mechanism invariant 21 already
requires of every phone footer — while `sm+` keeps it inline in
this sticky [top bar | search] stack, because tablet must not
double-sticky the composer.
this sticky stack; result composers have already portaled into
page flow at tablet and desktop widths.
*/}
{phoneOverlayMotion && usesPhoneBottomDock ? (
<PhoneFooterLayerPortal>{searchComposer}</PhoneFooterLayerPortal>
Expand Down
17 changes: 7 additions & 10 deletions tests/header-scroll-hide-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,25 +189,25 @@ describe("shared header hide/reveal wiring", () => {
expect(headerSource).toContain("max-sm:-translate-y-full");
});

it("moves submitted search composers into normal page flow on desktop only", () => {
it("moves submitted search composers into normal page flow on tablets and desktops", () => {
expect(composerSlotSource).toContain(
'export const desktopPageComposerSlotId = "desktop-page-search-composer-slot"',
);
expect(headerSource).toContain('const desktopPageComposerMediaQuery = "(min-width: 1024px)"');
expect(headerSource).toContain('const desktopPageComposerMediaQuery = "(min-width: 640px)"');
expect(headerSource).toContain("desktopHomeComposerSlotId ?? desktopPageComposerSlotId");
expect(headerSource).toContain('placement: "default" | "desktop-home" | "desktop-page"');
expect(headerSource).toContain("data-composer-placement={placement}");
expect(headerSource).toContain(
'"document-mobile-search-edge universal-top-search-edge relative z-20 mx-auto w-full max-w-3xl px-4 py-3 lg:max-w-4xl"',
);
expect(shellSource).toContain('data-testid="desktop-page-search-composer-slot"');
expect(shellSource).toContain('className="hidden lg:block lg:empty:hidden"');
expect(shellSource).toContain('className="hidden sm:block sm:empty:hidden"');
// Dashboard result slot lives in a budget-extracted helper so ClinicalDashboard
// stays under the maintainability no-growth ceiling.
expect(dashboardSource).toContain("DashboardDesktopResultComposerSlot");
expect(dashboardResultComposerSlotSource).toContain('data-testid="desktop-page-search-composer-slot"');
expect(dashboardResultComposerSlotSource).toContain('className="hidden lg:block lg:empty:hidden"');
expect(behaviourDocSource).toContain("Desktop search is page-owned");
expect(dashboardResultComposerSlotSource).toContain('className="hidden sm:block sm:empty:hidden"');
expect(behaviourDocSource).toContain("Tablet and desktop search are page-owned");
});

it("collapses only the top bar and keeps the search composer outside that row", () => {
Expand Down Expand Up @@ -415,17 +415,14 @@ describe("shared header hide/reveal wiring", () => {
expect(headerSource).toContain("const usesPhoneFooterDock = usesBottomComposerPlacement && usesPhoneSearchLayout;");
});

it("documents tablet pinning and desktop page ownership independently from the top bar", () => {
it("documents tablet and desktop page ownership independently from the top bar", () => {
expect(behaviourDocSource).toContain("Hide the top bar, not the search field");
expect(behaviourDocSource).toContain("Top-bar hide/reveal is cross-breakpoint");
expect(behaviourDocSource).toContain("Every production phone navigation header has one collapse owner");
expect(behaviourDocSource).toContain("One transition, no jump");
expect(behaviourDocSource).toContain("Do not double-sticky tablet search inside an outer sticky stack");
expect(behaviourDocSource).toContain("Do not sticky-position tablet or desktop result search");
expect(behaviourDocSource).toContain("desktop-page-search-composer-slot");
expect(behaviourDocSource).toContain("Release the phone top inset with collapsing chrome");
expect(behaviourDocSource).toContain(
"Collapse-everywhere hosts still drop their own sticky search offset while the top bar is hidden",
);
});

it("does not carry dock focus into GlobalSearchShell submitted result views", () => {
Expand Down
38 changes: 11 additions & 27 deletions tests/ui-chrome-scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import { readPrimaryScrollGeometry } from "./playwright-scroll";
*
* 1. Above the phone breakpoint the top bar used to scroll away with the page
* and only return at the very top (no scroll reporter / sticky travel).
* 2. Search must stay pinned on tablets but belong to normal page flow on
* desktop, where it scrolls away independently from the sticky top bar.
* 2. Search belongs to normal page flow on tablets and desktops, where it
* scrolls away independently from the sticky top bar.
*
* The load-bearing assertions are (a) the reveal *mid-page* and (b) the search
* input staying pinned on tablet but leaving the viewport on desktop. Asserting only
* `data-scroll-hidden` would miss both.
* input leaving the viewport while the top bar can return mid-page. Asserting
* only `data-scroll-hidden` would miss both.
*
* The suite-wide `reducedMotion: "reduce"` is kept deliberately: the chrome
* carries `motion-reduce:transition-none`, so geometry settles in one frame and
Expand Down Expand Up @@ -216,7 +216,7 @@ for (const { name: sizeName, viewport } of breakpoints) {
expect(atTop.hidden, "top bar visible at the top").toBe(false);
expect(atTop.headerTop, "top bar starts at the viewport top").toBeLessThanOrEqual(8);
expect(atTop.searchVisible, "search starts on screen").toBe(true);
if (sizeName === "desktop") {
if (sizeName === "tablet" || sizeName === "desktop") {
expect(atTop.searchPlacement).toBe("desktop-page");
expect(atTop.searchInsideDesktopPageSlot).toBe(true);
expect(atTop.searchHasStickyAncestor).toBe(false);
Expand All @@ -230,15 +230,7 @@ for (const { name: sizeName, viewport } of breakpoints) {
expect(scrolledDown.offset, "descent moved the scroller").toBeGreaterThan(requiredRunway - 200);
expect(scrolledDown.hidden, "top bar hides on a deliberate scroll down").toBe(true);
expect(scrolledDown.headerBottom, "hidden top bar is off the top of the viewport").toBeLessThanOrEqual(0);
if (sizeName === "tablet") {
expect(scrolledDown.searchVisible, "header search stays on screen while the top bar is hidden").toBe(true);
expect(
scrolledDown.searchTop,
"header search sits near the viewport top after the top bar collapses",
).toBeLessThanOrEqual(24);
} else {
expect(scrolledDown.searchVisible, "desktop page search scrolls away with page content").toBe(false);
}
expect(scrolledDown.searchVisible, "page search scrolls away with page content").toBe(false);

// Three deliberate upward steps — nowhere near the top of the page.
await scrollBy(page, -360, 120);
Expand All @@ -249,16 +241,12 @@ for (const { name: sizeName, viewport } of breakpoints) {
expect(scrolledUp.hidden, "top bar returns on a deliberate scroll up").toBe(false);
expect(scrolledUp.headerBottom, "returned top bar is actually on screen").toBeGreaterThan(0);
expect(scrolledUp.headerTop, "returned top bar sits at the viewport top").toBeLessThanOrEqual(8);
if (sizeName === "tablet") {
expect(scrolledUp.searchVisible, "header search remains on screen after the top bar returns").toBe(true);
} else {
expect(scrolledUp.searchVisible, "returning the desktop top bar does not re-anchor page search").toBe(false);
}
expect(scrolledUp.searchVisible, "returning the top bar does not re-anchor page search").toBe(false);
});

test(`${sizeName}: search composer keeps its breakpoint owner on ${surfaceName}`, async ({ page }) => {
// data-scroll-hidden is reserved for the phone dock. Tablet search stays
// pinned; desktop search leaves by ordinary page scrolling instead.
// data-scroll-hidden is reserved for the phone dock. Tablet and desktop
// search leave by ordinary page scrolling instead.
await page.setViewportSize(viewport);
await page.goto(route, { waitUntil: "domcontentloaded" });
await expect(page.locator("header#search").first()).toBeVisible({ timeout: 15_000 });
Expand All @@ -276,12 +264,8 @@ for (const { name: sizeName, viewport } of breakpoints) {
scrolledDown.hiddenBottomComposers,
"no composer flips data-scroll-hidden above the phone breakpoint",
).toBe(0);
if (sizeName === "tablet") {
expect(scrolledDown.searchVisible, "header search geometry stays on screen").toBe(true);
} else {
expect(atTop.searchInsideDesktopPageSlot).toBe(true);
expect(scrolledDown.searchVisible, "desktop composer follows page flow off-screen").toBe(false);
}
expect(atTop.searchInsideDesktopPageSlot, "tablet and desktop search start in the page slot").toBe(true);
expect(scrolledDown.searchVisible, "page search scrolls away with page content").toBe(false);
});
}
}
9 changes: 0 additions & 9 deletions tests/ui-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -984,15 +984,6 @@ test.describe("Clinical KB tools launcher", () => {
// bottom (safe-area is padding inside the form, not a `bottom` gap).
expect(metrics?.formBottom ?? 0).toBeGreaterThanOrEqual(viewport.height - 2);
}
} else if (viewport.width < 1024) {
// Sticky-stack shells pin via an outer wrapper; collapse-everywhere hosts
// still self-sticky the form. Tablet behaviour stays unchanged.
expect(
metrics?.position === "sticky" || metrics?.stickyAncestor,
`${route.path} at ${viewport.name} should stick via the form or its sticky stack`,
).toBe(true);
expect(metrics?.formCenterY ?? viewport.height).toBeLessThan(viewport.height * 0.25);
await expect(page.locator(".answer-footer-search-chip:visible")).toHaveCount(0);
} else {
expect(metrics?.composerPlacement).toBe("desktop-page");
expect(metrics?.insideDesktopPageSlot).toBe(true);
Expand Down
Loading