Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions docs/branch-review-ledger.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. |
2 changes: 1 addition & 1 deletion docs/search-chrome-behaviour.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
35 changes: 25 additions & 10 deletions src/app/globals.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}
Comment on lines +2341 to +2349

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Avoid duplicate declarations that fail Stylelint.

The 100vh/100svh fallback pattern triggers the configured declaration-block-no-duplicate-properties errors. Put the svh override in @supports so the stylesheet passes lint while preserving fallback behavior.

Proposed fix
 `@media` (max-width: 639px) {
.phone-viewport-shell {
position: relative;
width: 100%;
min-height: 100vh;
height: 100vh;
- min-height: 100svh;- height: 100svh;
}
}
++@supports (height: 100svh) {+ `@media` (max-width: 639px) {+ .phone-viewport-shell {+ min-height: 100svh;+ height: 100svh;+ }+ }+}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@media (max-width:639px) {
.phone-viewport-shell {
position: relative;
width:100%;
min-height:100vh;
height:100vh;
min-height:100svh;
height:100svh;
}
`@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;
}
}
}
🧰 Tools
🪛 Stylelint (17.14.0)

[error] 2345-2345: Duplicate property "min-height" (declaration-block-no-duplicate-properties)

(declaration-block-no-duplicate-properties)


[error] 2346-2346: Duplicate property "height" (declaration-block-no-duplicate-properties)

(declaration-block-no-duplicate-properties)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/globals.css` around lines 2341 - 2349, Update the
.phone-viewport-shell declarations inside the max-width 639px media query to
retain 100vh as the fallback, then move the 100svh min-height and height
overrides into an `@supports` block for svh. Preserve the existing sizing behavior
while eliminating duplicate declarations flagged by Stylelint.

Source: Linters/SAST tools

}

.mobile-popover-scroll {
max-height: min(70svh, 28rem);
}
Expand All@@ -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);
}
}

Expand Down
5 changes: 3 additions & 2 deletions src/components/ClinicalDashboard.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3314,8 +3314,9 @@ export function ClinicalDashboard({
<div
className={cn(
appBackdrop,
// Phone: fixed inset-0 (not 100dvh) — matches GlobalSearchShell; avoids Safari toolbar dead band.
"mobile-app-shell flex flex-col overflow-hidden text-[color:var(--text)] max-sm:fixed max-sm:inset-0 max-sm:h-auto max-sm:min-h-0 max-sm:overflow-hidden md:grid md:grid-cols-[5.25rem_minmax(0,1fr)] md:overflow-hidden",
// Share the same in-flow phone viewport contract as GlobalSearchShell.
// Avoiding a viewport-sized fixed root prevents iOS compositor gaps.
"mobile-app-shell phone-viewport-shell flex flex-col overflow-hidden text-[color:var(--text)] max-sm:overflow-hidden md:grid md:grid-cols-[5.25rem_minmax(0,1fr)] md:overflow-hidden",
sidebarColumnTransitionReady &&
"motion-safe:transition-[grid-template-columns] motion-safe:duration-200 motion-safe:ease-out",
sidebarCollapsed ? "lg:grid-cols-[5.25rem_minmax(0,1fr)]" : "lg:grid-cols-[20rem_minmax(0,1fr)]",
Expand Down
12 changes: 5 additions & 7 deletions src/components/calculators/search-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the stale calculator contract assertion

When the required unit suite runs, tests/mobile-chrome-paint-contract.test.ts:62 still requires the exact source text if (!activeCalc) return undefined, so changing this branch to bare return makes that test—and therefore verify:cheap—fail before the UI fix can be handed off. Update the contract assertion to reflect the intentional cleanup-free effect form.

AGENTS.md reference: AGENTS.md:L167-L168

Useful? React with 👍 / 👎.

// 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";
Expand Down
11 changes: 5 additions & 6 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -709,12 +709,11 @@ function GlobalStandaloneSearchShellBody({
return (
<div
className={cn(
// Phone shell height comes from inset-0 alone, never 100dvh: iOS Safari
// re-resolves dvh lazily when its toolbar collapses/expands (especially
// with body scrolling disabled like here), leaving a dead band between
// the clipped shell and the toolbar. Fixed insets track the live
// viewport through the whole transition, so content stays edge to edge.
"sm:min-h-dvh max-sm:fixed max-sm:inset-0 max-sm:overflow-hidden bg-[color:var(--background)] text-[color:var(--text)]",
// Keep the phone shell viewport-bounded without making the whole app a
// viewport-sized fixed layer. Physical iOS can paint a bottom gap under
// fixed inset shells while reporting correct geometry; the shared
// in-flow contract still bounds #main-content for internal scrolling.
"phone-viewport-shell sm:min-h-dvh max-sm:overflow-hidden bg-[color:var(--background)] text-[color:var(--text)]",
shouldShowDesktopSidebar && "md:grid md:grid-cols-[5.25rem_minmax(0,1fr)]",
shouldShowDesktopSidebar &&
sidebarColumnTransitionReady &&
Expand Down
3 changes: 2 additions & 1 deletion src/components/clinical-dashboard/use-hide-on-scroll.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,7 +434,8 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa
* page scrolls the document above the phone breakpoint — GlobalSearchShell,
* where `#main-content` is the scrollport only on phones and its `onScroll`
* therefore never fires on tablet/desktop. Self-gating: while the document
* cannot scroll (the phone shell is `fixed inset-0`) no scroll event arrives,
* cannot scroll (the phone shell is viewport-bounded and overflow-clipped) no
* scroll event arrives,
* so the internal scroller stays the single source at that width.
*/
export function useDocumentScrollHideReporter(reportScroll: (metrics: ScrollMetrics) => void) {
Expand Down
10 changes: 10 additions & 0 deletions tests/clinical-dashboard-merge-artifacts.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand Down
47 changes: 47 additions & 0 deletions tests/ui-phone-scroll.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -290,6 +290,10 @@ test("phone chrome keeps an opaque header and a light edge-to-edge Therapy foote
const dock = document.querySelector<HTMLElement>(".answer-footer-search-dock");
const backdrop = dock?.querySelector<HTMLElement>(".answer-footer-search-backdrop");
const main = document.getElementById("main-content");
const shell = main?.closest<HTMLElement>(".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,
Expand All@@ -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);
Expand All@@ -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<HTMLElement>(".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 ({
Expand DownExpand Up@@ -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");
Expand DownExpand Up@@ -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);
Expand Down
14 changes: 11 additions & 3 deletions tests/ui-tools.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 () =>
Expand DownExpand Up@@ -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()
Expand DownExpand Up@@ -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);
Expand DownExpand Up@@ -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();
Expand Down
Loading