diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 35379bd0..db89d3b1 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1106,3 +1106,7 @@ 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. | +| 2026-07-27 | `codex/phone-bottom-band-root-20260727` | `a8a72a43d` | CI follow-up review of calculator dock hide lifecycle | APPROVE. Hosted production Chromium exposed a fast-close race where effect cleanup could cancel the queued focus-latch reset, plus a paint-contract journey coupled to natural short-page geometry. The reset now survives rapid sheet teardown, actual input focus is asserted before hide, and explicit runway isolates the paint contract from the anti-clamp boundary tests. No P0-P3 finding remains. | Exact locked Next 16.2.11 / Playwright 1.61.1 production Chromium repeat PASS (20/20); `verify:cheap` PASS (393 files; 3519 passed / 2 skipped); no provider-backed checks. | +| 2026-07-27 | `codex/phone-bottom-band-root-20260727` | `cc63ee90928d402eb9c5dd241e411ed88cc01f27` | Hosted Applications search hydration follow-up review | APPROVE. The final hosted Chromium failure was a test synchronization race: server-rendered content was visible before the controlled global-search input had its React onChange handler, so hydration could discard an immediate fill. Both shared-search tests now wait for the React handler and assert the accepted value before checking filtering. No product behavior changed and no P0-P3 finding remains. | Exact locked Next 16.2.11 / Playwright 1.61.1 production Chromium repeat PASS (20/20); `verify:cheap` PASS (25 gates; 393 files; 3519 passed / 2 skipped); no non-GitHub provider-backed checks. | +| 2026-07-27 | `codex/phone-bottom-band-root-20260727` | `a4802b9373404a00549a3479d86340398e978cc8` | Automated review follow-up for phone viewport fallback layering | APPROVE. Verified the review finding and separated the baseline 100vh declarations from the supported 100svh override, while retaining the later 100dvh override as the preferred dynamic viewport size. This removes duplicate properties without changing the intended fallback order. The ledger date remains the Australia/Perth task completion date. No P0-P3 finding remains. | Focused viewport-shell static contract PASS (8/8); `git diff --check` PASS; prior full `verify:cheap` and hosted required CI were green before this CSS-only declaration-layering follow-up; no non-GitHub provider-backed checks. | diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index dd7a2379..95244e77 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 a056a59a..0f5985b1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2331,6 +2331,31 @@ 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; + } + } + + @supports (height: 100svh) { + @media (max-width: 639px) { + .phone-viewport-shell { + min-height: 100svh; + height: 100svh; + } + } + } + .mobile-popover-scroll { max-height: min(70svh, 28rem); } @@ -2341,18 +2366,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 bc965ec2..181d1428 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3314,8 +3314,9 @@ export function ClinicalDashboard({
{ - 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/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 88c6523b..e5cb8417 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -709,12 +709,11 @@ function GlobalStandaloneSearchShellBody({ return (
void) { diff --git a/tests/clinical-dashboard-merge-artifacts.test.ts b/tests/clinical-dashboard-merge-artifacts.test.ts index 98b1032f..2a89ba51 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/mobile-chrome-paint-contract.test.ts b/tests/mobile-chrome-paint-contract.test.ts index ca544107..e4b91cc6 100644 --- a/tests/mobile-chrome-paint-contract.test.ts +++ b/tests/mobile-chrome-paint-contract.test.ts @@ -59,8 +59,10 @@ describe("mobile chrome paint baseline", () => { it("registers the calculator page reserve with the shared hide and transition contracts", () => { expect(calculatorsSource).toContain("useReserveTransitionMarker(dockHidden, activeCalc)"); - expect(calculatorsSource).toContain("if (!activeCalc) return undefined"); + expect(calculatorsSource).toContain("if (!activeCalc) return;"); expect(calculatorsSource).toContain("queueMicrotask(() =>"); + expect(calculatorsSource).toContain("setDockFocused(false)"); + expect(calculatorsSource).not.toContain("if (!cancelled) setDockFocused(false)"); expect(calculatorsSource).toContain('reserveOwner="calculator"'); expect(calculatorsSource).toContain('reserveHiddenPad="0rem"'); expect(calculatorsSource).toContain("reserveTransitioning={reserveTransitioning}"); diff --git a/tests/ui-phone-scroll.spec.ts b/tests/ui-phone-scroll.spec.ts index 3688937d..0d884bc8 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 ({ @@ -349,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"); @@ -381,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 f0900421..40f8c09b 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();