From a8efe4a08f00a2365e2035f83ce2128ec680576f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:32:38 +0800 Subject: [PATCH 1/3] fix(ui): remove iOS fixed-shell bottom gap --- docs/search-chrome-behaviour.md | 2 +- src/app/globals.css | 35 +++++++++++----- src/components/ClinicalDashboard.tsx | 5 ++- .../global-search-shell.tsx | 11 +++-- .../clinical-dashboard/use-hide-on-scroll.ts | 3 +- ...clinical-dashboard-merge-artifacts.test.ts | 10 +++++ tests/ui-phone-scroll.spec.ts | 42 +++++++++++++++++++ 7 files changed, 88 insertions(+), 20 deletions(-) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index dd7a2379e7..95244e772d 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -51,7 +51,7 @@ Rules that keep this working: - **Hide the top bar, not the search field.** The collapse wrapper (`data-testid="universal-header-collapse"`) wraps `header#search` plus page navigation mounted through `PhoneHeaderCollapsePortal` into `#phone-header-collapse-addon-slot`. Keep composers outside the collapse row: tablet search stays pinned independently, and desktop search scrolls with page content rather than being translated by the header. - **Every production phone navigation header has one collapse owner.** `PhoneHeaderCollapsePortal` moves Therapy section navigation, DocumentViewer navigation, and Differential detail navigation into `#phone-header-collapse-addon-slot` below `sm`; the same subtree stays in its existing page position at `sm+`. Do not add a second sticky/fixed phone header inside `#main-content`: the universal collapse row must own its safe area, focus pinning, timing, clipping, and measured release. Semantic content headings and modal/sheet headers are not viewport chrome and stay in their own flow/scroll context. -- **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. +- **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 a bounded, overflow-clipped `.phone-viewport-shell`, so the document cannot scroll and never fires. Keep that shell in normal flow; viewport-sized fixed roots trigger a physical-iOS bottom-gap compositor bug even when DOM geometry is correct. - **Tablet stickiness belongs on the outer [top bar \| search] stack, not on `header#search`.** The top bar sits inside header-height boxes, which leaves a sticky rule on it zero travel. For the same reason the stack's ancestor in `GlobalSearchShell` is `display: contents` above the phone breakpoint rather than a block, and collapse returns a fragment (safe-area spacer + stack) rather than a single root box. At desktop widths the search portal leaves that same outer stack holding only the top bar. - **Collapse only the top-bar row inside a sticky stack.** On tablets, translating the whole stack would take the search field off-screen; collapsing just the top bar lets search stay pinned at the viewport top below the wide-layout safe-area spacer. On desktop, the page-flow search is outside the stack entirely. - **Release the phone top inset with hidden chrome.** `chrome-safe-area-top` is a full-width sibling that is `h-[var(--safe-area-top)]` while the phone header is visible and `h-0` while hidden, using the same transition timing as the top-bar row. `readChromeCollapseMetrics` must charge that released phone height as well as the controls and dock reserve, or short pages clamp and oscillate at the bottom. At `sm+` the spacer remains `h-[var(--safe-area-top)]`, and sticky chrome pins at `top: var(--safe-area-top)`. Do not leave a phone-only surface/status-bar band after the controls collapse. diff --git a/src/app/globals.css b/src/app/globals.css index a056a59a3d..5d7228a162 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2331,6 +2331,24 @@ html[data-motion="reduced"] .pwa-notice-card { height: 100svh; } + /* + * Phone search surfaces keep #main-content as their only scrollport, but the + * viewport shell itself must remain in normal flow. iOS can leave a painted + * gap under viewport-sized position:fixed containers even when CSSOM reports + * the correct bounds. A bounded dynamic-viewport block avoids that compositor + * path while still giving the flex scroller an exact height. + */ + @media (max-width: 639px) { + .phone-viewport-shell { + position: relative; + width: 100%; + min-height: 100vh; + height: 100vh; + min-height: 100svh; + height: 100svh; + } + } + .mobile-popover-scroll { max-height: min(70svh, 28rem); } @@ -2341,18 +2359,15 @@ html[data-motion="reduced"] .pwa-notice-card { height: 100dvh; } - .mobile-popover-scroll { - max-height: min(70dvh, 28rem); + @media (max-width: 639px) { + .phone-viewport-shell { + min-height: 100dvh; + height: 100dvh; + } } - } - /* Phones: ClinicalDashboard pins with fixed inset-0 utilities. Do not keep a - dvh height clamp here — it recreates the Safari toolbar dead band under - the shell even when inset-0 is present if height still resolves to dvh. */ - @media (max-width: 639px) { - .mobile-app-shell { - min-height: 0; - height: auto; + .mobile-popover-scroll { + max-height: min(70dvh, 28rem); } } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index bc965ec286..181d1428cc 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3314,8 +3314,9 @@ export function ClinicalDashboard({
void) { diff --git a/tests/clinical-dashboard-merge-artifacts.test.ts b/tests/clinical-dashboard-merge-artifacts.test.ts index 98b1032f08..2a89ba5118 100644 --- a/tests/clinical-dashboard-merge-artifacts.test.ts +++ b/tests/clinical-dashboard-merge-artifacts.test.ts @@ -118,6 +118,16 @@ describe("ClinicalDashboard merge-artifact guards", () => { expect(globalStylesSource).not.toMatch(/^\s*-webkit-backdrop-filter\s*:/m); }); + it("keeps shared phone shells out of the iOS fixed-viewport compositor path", () => { + expect(globalSearchShellSource).toContain('"phone-viewport-shell sm:min-h-dvh'); + expect(clinicalDashboardSource).toContain('"mobile-app-shell phone-viewport-shell flex'); + expect(globalSearchShellSource).not.toContain("max-sm:fixed max-sm:inset-0"); + expect(clinicalDashboardSource).not.toContain("max-sm:fixed max-sm:inset-0"); + expect(globalStylesSource).toContain(".phone-viewport-shell {"); + expect(globalStylesSource).toContain("position: relative;"); + expect(globalStylesSource).toContain("height: 100dvh;"); + }); + it("releases the Safari toolbar reserve only after phone composers hide", () => { expect(mobileComposerReserveSource).toContain('export const mobileComposerHiddenReserve = "0rem"'); expect(mobileComposerReserveSource).toContain( diff --git a/tests/ui-phone-scroll.spec.ts b/tests/ui-phone-scroll.spec.ts index 3688937d70..9e4fa8fce4 100644 --- a/tests/ui-phone-scroll.spec.ts +++ b/tests/ui-phone-scroll.spec.ts @@ -290,6 +290,10 @@ test("phone chrome keeps an opaque header and a light edge-to-edge Therapy foote const dock = document.querySelector(".answer-footer-search-dock"); const backdrop = dock?.querySelector(".answer-footer-search-backdrop"); const main = document.getElementById("main-content"); + const shell = main?.closest(".phone-viewport-shell"); + const shellRect = shell?.getBoundingClientRect(); + const mainRect = main?.getBoundingClientRect(); + const bottomPaintOwner = document.elementFromPoint(window.innerWidth / 2, window.innerHeight - 1); return { collapseHeight: collapse?.getBoundingClientRect().height ?? -1, dockTop: dock?.getBoundingClientRect().top ?? -1, @@ -298,6 +302,13 @@ test("phone chrome keeps an opaque header and a light edge-to-edge Therapy foote backdropOpacity: backdrop ? getComputedStyle(backdrop).opacity : "", backdropVisibility: backdrop ? getComputedStyle(backdrop).visibility : "", reserve: main ? getComputedStyle(main).getPropertyValue("--mobile-composer-reserve").trim() : "", + shellPosition: shell ? getComputedStyle(shell).position : "missing", + shellTop: shellRect?.top ?? -1, + shellBottom: shellRect?.bottom ?? -1, + mainBottom: mainRect?.bottom ?? -1, + bottomPaintOwnedByMain: Boolean(main && bottomPaintOwner && main.contains(bottomPaintOwner)), + scrollTop: main?.scrollTop ?? -1, + viewportHeight: window.innerHeight, }; }); expect(hidden.collapseHeight).toBeLessThanOrEqual(1); @@ -307,6 +318,37 @@ test("phone chrome keeps an opaque header and a light edge-to-edge Therapy foote expect(hidden.backdropOpacity).toBe("0"); expect(hidden.backdropVisibility).toBe("hidden"); expect(hidden.reserve).toBe("0rem"); + expect(hidden.shellPosition).toBe("relative"); + expect(hidden.shellTop).toBeCloseTo(0, 0); + expect(hidden.shellBottom).toBeCloseTo(hidden.viewportHeight, 0); + expect(hidden.mainBottom).toBeCloseTo(hidden.viewportHeight, 0); + expect(hidden.bottomPaintOwnedByMain, "hidden chrome leaves live content at the last viewport pixel").toBe(true); + + // Safari toolbar changes resize the visual viewport after scrolling. The + // in-flow shell must track that new edge without moving the reading offset; + // a viewport-sized fixed root passes these DOM bounds but can still mispaint + // a white compositor band on physical iOS. + await page.setViewportSize({ width: phoneViewport.width, height: phoneViewport.height - 64 }); + await page.waitForTimeout(100); + const afterViewportResize = await page.evaluate(() => { + const main = document.getElementById("main-content"); + const shell = main?.closest(".phone-viewport-shell"); + const bottomPaintOwner = document.elementFromPoint(window.innerWidth / 2, window.innerHeight - 1); + return { + shellBottom: shell?.getBoundingClientRect().bottom ?? -1, + mainBottom: main?.getBoundingClientRect().bottom ?? -1, + bottomPaintOwnedByMain: Boolean(main && bottomPaintOwner && main.contains(bottomPaintOwner)), + scrollTop: main?.scrollTop ?? -1, + viewportHeight: window.innerHeight, + }; + }); + expect(afterViewportResize.shellBottom).toBeCloseTo(afterViewportResize.viewportHeight, 0); + expect(afterViewportResize.mainBottom).toBeCloseTo(afterViewportResize.viewportHeight, 0); + expect(afterViewportResize.bottomPaintOwnedByMain).toBe(true); + expect(afterViewportResize.scrollTop, "viewport resize does not jump the reading position").toBeCloseTo( + hidden.scrollTop, + 0, + ); }); test("calculators page-owned phone dock uses localized glass and releases its reserve when hidden", async ({ From e48873e3808685c6960d15973cd067a305e9d3c1 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:34:02 +0800 Subject: [PATCH 2/3] docs: record phone shell release review --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 35379bd0a4..34fd121648 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1106,3 +1106,4 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-26 | `codex/phone-footer-glass` | `7e4fd1a23` | Review-follow-up and CI hydration-race review | APPROVE. Scoped the expanded collapse runway only to combined in-flow header plus reserve owners, cleared the calculator dock focus latch after sheet teardown, and made mode-home UI assertions wait for one settled owner during production hydration. Both automated review threads were addressed and resolved. No P0-P3 findings remain; physical iOS/WebKit compositing remains the only material unverified surface. | `verify:cheap` PASS; focused scroll-hide/static contracts 25/25 PASS; focused calculator teardown/geometry Chromium 3/3 PASS; affected mode-home production Chromium 5/5 PASS; no provider-backed checks. | | 2026-07-27 | `codex/phone-footer-paint` | `e1657a4a4e3e25b4a35a1dcf544b3efddb123c3f` | Protected-main release-readiness review of phone footer safe-area paint | APPROVE. The prior footer remained visually opaque because its normal/fallback terminal tints reached 72-90%, all blur masks painted through the physical edge, and hidden WebKit paint had no explicit terminal visibility state. The shared dock now has bounded localized tint, transparent gradient/mask terminals, an 88% pill, and delayed `visibility: hidden` synchronized to the 240ms exit; reduced-motion hides immediately. Therapy and calculator ownership prove the standard shared behavior. No P0-P3 finding remains. Highest residual risk is physical iOS momentum/compositor behavior beyond local WebKit emulation. | `verify:cheap` PASS (393 files; 3518 passed / 2 skipped); focused Therapy Chromium + WebKit PASS; calculator shared-dock Chromium + WebKit PASS; `verify:ui` PASS (314/314); `verify:pr-local` PASS including production build/client-secret scan and 36-case offline RAG fixtures; no live provider-backed checks. | +| 2026-07-27 | `codex/phone-bottom-band-root-20260727` | `a8efe4a08f00a2365e2035f83ce2128ec680576f` | Protected-main release-readiness review of the shared phone viewport shell | APPROVE. The remaining bottom band clipped live result content above the hidden dock because both phone application owners used viewport-sized fixed roots, a physical-iOS paint path that can disagree with correct DOM geometry. Both owners now share a bounded in-flow dynamic-viewport shell; hidden reserve remains zero, the last viewport pixel remains content-owned, and viewport resize preserves the reading offset. No P0-P3 finding remains. Highest residual risk is physical-device iOS compositing beyond desktop WebKit emulation. | `verify:cheap` PASS (393 files; 3519 passed / 2 skipped); focused Therapy and dashboard production WebKit PASS; `verify:ui` PASS (314/314); `verify:pr-local` PASS including production build/client-secret scan and 36-case offline RAG fixtures; no live provider-backed checks. | From 45b6545917c643b059e99a3f44ba9ee7c4f899ee Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:47:54 +0800 Subject: [PATCH 3/3] fix: restore phone-safe dock behavior --- src/components/calculators/search-page.tsx | 12 +++++------- tests/ui-phone-scroll.spec.ts | 5 +++++ tests/ui-tools.spec.ts | 14 +++++++++++--- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/components/calculators/search-page.tsx b/src/components/calculators/search-page.tsx index 0e8202aaae..e0cc2c3fdd 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -520,17 +520,15 @@ export function CalculatorsSearchPage() { const dockHidden = footerHidden && !dockFocused; const reserveTransitioning = useReserveTransitionMarker(dockHidden, activeCalc); useEffect(() => { - if (!activeCalc) return undefined; - let cancelled = false; + if (!activeCalc) return; // Submitting a focused dock input unmounts the dock before React is // guaranteed to dispatch blur. Clear the latch after teardown so the dock - // can resume hide-on-scroll when the calculator sheet closes. + // can resume hide-on-scroll when the calculator sheet closes. This reset + // must survive a fast close: cancelling the microtask during effect cleanup + // can otherwise leave the remounted dock permanently focus-pinned. queueMicrotask(() => { - if (!cancelled) setDockFocused(false); + setDockFocused(false); }); - return () => { - cancelled = true; - }; }, [activeCalc]); const compact = density === "compact"; diff --git a/tests/ui-phone-scroll.spec.ts b/tests/ui-phone-scroll.spec.ts index 9e4fa8fce4..0d884bc8e3 100644 --- a/tests/ui-phone-scroll.spec.ts +++ b/tests/ui-phone-scroll.spec.ts @@ -391,6 +391,10 @@ test("calculators page-owned phone dock uses localized glass and releases its re ) .toBeGreaterThan(112); + // This journey owns the visible/hidden paint contract. Give it explicit + // runway so the separate near-bottom tests remain the sole owner of the + // intentional anti-clamp behavior on naturally short calculator pages. + await addPhoneScrollRunway(page); const geometry = await readGeometry(page); await dragScrollBy(page, Math.min(Math.max(geometry.maxOffset, 500), 900), 24); await expect(dock).toHaveAttribute("data-scroll-hidden", "true"); @@ -423,6 +427,7 @@ test("calculator dock clears its focus pin after a focused submit opens and clos await expect(page.getByRole("button", { name: "Close", exact: true })).toBeVisible(); await page.getByRole("button", { name: "Close", exact: true }).click(); await expect(dock).toBeVisible(); + await expect(input).not.toBeFocused(); await addPhoneScrollRunway(page); await dragScrollBy(page, 900, 24); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index f0900421c9..40f8c09b1e 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -265,7 +265,7 @@ async function gotoLauncher(page: Page, path = "/?mode=tools") { await expect(page.locator("#main-content").first()).toBeVisible({ timeout: 15_000 }); } -async function waitForReactEventHandler(locator: Locator, eventName: "onClick" | "onSubmit" = "onClick") { +async function waitForReactEventHandler(locator: Locator, eventName: "onChange" | "onClick" | "onSubmit" = "onClick") { await expect .poll( async () => @@ -301,6 +301,14 @@ function visibleGlobalSearchInput(page: Page) { return page.locator('[data-testid="global-search-input"]:visible'); } +async function fillHydratedGlobalSearch(page: Page, value: string) { + const input = visibleGlobalSearchInput(page).first(); + await expect(input).toBeVisible(); + await waitForReactEventHandler(input, "onChange"); + await input.fill(value); + await expect(input).toHaveValue(value); +} + async function globalSearchComposerMetrics(page: Page, homeTestId?: string) { return visibleGlobalSearchInput(page) .first() @@ -429,7 +437,7 @@ test.describe("Clinical KB tools launcher", () => { await expect(page.getByTestId("tools-local-search-input")).toHaveCount(0); // Typing in the shared composer live-filters the tools grid, matching /?mode=tools. - await visibleGlobalSearchInput(page).fill("medication"); + await fillHydratedGlobalSearch(page, "medication"); await expect(page.getByTestId("application-card-medication-prescribing")).toBeVisible(); await expect(page.getByTestId("application-card-documents")).toBeHidden(); await expectNoPageHorizontalOverflow(page); @@ -462,7 +470,7 @@ test.describe("Clinical KB tools launcher", () => { await page.setViewportSize({ width: 1280, height: 900 }); await gotoLauncher(page); - await visibleGlobalSearchInput(page).fill("medication"); + await fillHydratedGlobalSearch(page, "medication"); await expect(page.getByTestId("application-card-medication-prescribing")).toBeVisible(); await expect(page.getByTestId("application-card-documents")).toBeHidden();