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();