diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index b318d051d9..895103af6f 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1266,4 +1266,5 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | codex/document-reader-condensed-view | 7cefb24e99f9745a61843c7e48c4889f7324ec42 | pr-1380-main-merge-coderabbit-density | merged origin/main; resolved source-panels conflict (kept condensed details + tracking-eyebrow); density in-memory fallback when storage blocked; summary keys + search/plain compact tests; local vitest/lint/typecheck/format/playwright condensed pass; awaiting hosted CI | vitest document suites 20/20; lint; typecheck; format:check; playwright condensed 4/4; merge-tree clean | | 2026-07-29 | claude/test-coverage-analysis-2vcd8a | 0922d7f56624ef84be8abcb2bbc89205027cf9a6 | PR #1383 babysit | BLOCKER CLEARED: merged origin/main; renumbered coverage follow-ups #098/#099 -> #106/#107 (main claimed #098-#105). Before: CONFLICTING/DIRTY, 4 behind; CI green on prior tip; 0 review threads; 0 Bugbot findings. After: mergeable expected; verify:cheap 424 files/4371 passed; test:coverage exit 0; format:changed + check:rag:fixtures pass. | verify:cheap PASS (424 files, 4371 passed \| 4 skipped); test:coverage PASS (no threshold errors); format:changed PASS; check:rag:fixtures PASS (36 golden); Bugbot: no findings; no provider-backed checks | | 2026-07-29 | claude/test-coverage-analysis-2vcd8a | 6f476b5f741627cb622af57d1b4665e3989789ca | PR #1383 babysit | CLOSEOUT at tip after ledger bookkeeping commit. Merge conflict cleared; coverage follow-ups live as #106/#107; local gates green; awaiting hosted CI on tip. | same as prior tip 0922d7f5 plus ledger append only; no product code change | +| 2026-07-29 | codex/chat-document-header-overlay-document-header-overlay-20260729 | 48ed6cc95f886837f4ddbb369fdbc611a0958f17 | document phone header overlay | No high-confidence findings; physical iPhone acceptance remains | verify:pr-local unit 4373 pass; build PASS; focused Playwright 2 pass; phone gate contended | | 2026-07-29 | codex/document-reader-condensed-view | 5678e878d4fe681d33bb58df5b5b3468a138a1c8 | pr-1380-ci-green-resync | hosted CI green on 7150899a (Static/Build/Unit/Advisory/Production UI/PR required/CircleCI); CodeRabbit density fallback + summary keys + search/plain compact tests landed; unresolved review threads none; resynced main after tip went BEHIND by 1 | hosted CI success on 7150899a; merge-tree clean; bugbot no P0/P1 | diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 50c2103639..669bae9f17 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -132,16 +132,25 @@ Choose the hide mechanism from where the host's scrollport lives, because that d | `ClinicalDashboard` (other modes) | Document on browser phones; `
` in standalone and at `sm+` | `strategy: "collapse", wide: "collapse"` | Top-bar row collapses; tablet search stays sticky; desktop search portals into `
` page flow | | `GlobalSearchShell` | Document in browser phones and at `sm+`; `#main-content` only in installed standalone mode | `strategy: "collapse", wide: "sticky"` | Tablet pins [top bar \| search]; desktop portals search into `#main-content`, leaving a sticky auto-hiding top bar | +`GlobalSearchShell` adds `phoneMotion: "overlay"` only on routes matched by +`isDocumentViewerOwnedRoute(pathname)`. On those phone routes, the safe-area +region, universal Documents row, document title/section row, and section track +form one fixed browser/absolute standalone layer. The complete layer translates +over the document without changing the scrollport or content geometry. +`/documents/search` and every non-document route keep the default +`phoneMotion: "collapse"`; tablet and desktop continue using `wide: "sticky"`. + 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. +- **Document phone headers overlay as one stable stack.** Document detail/source routes keep the complete phone header at a stable height and translate the safe area plus both header rows and the section track together. Hidden overlay chrome is transparent and non-interactive; revealed chrome frosts and covers the document. `readChromeCollapseMetrics` counts zero released top-header geometry for this overlay, while continuing to measure the independently hidden document composer reserve. Reveal must not change the active owner's scroll offset or a stable document/PDF anchor. - **Feed the reporter from the element that actually scrolls.** Both app hosts run `useDocumentScrollHideReporter` alongside their `
` reporter. In browser-mode phones the normal-flow shell and `overflow-y: visible` surface make the document the only vertical owner, which lets Safari minimize its browser UI. Installed standalone mode uses a normal-flow `100vh` shell plus bounded inner surface, so document scroll does not fire there. The hook measures the same collapse budget and blurs the same focused composer for either owner. Page-owned footer chrome must follow the same rule: `DocumentViewer` observes both the document and the inner surface, then combines the signals so only the active owner drives it. Its rendered footer, like calculator and differential page-owned footers, portals to the frame host so observing the inner scroll owner does not make the footer its descendant. - **Keep browser-phone chrome attached to the viewport without fixing the app root.** Collapse-mode headers use one phone-sticky wrapper; answer overlay headers are fixed only in browser mode and remain absolute over the inner surface in standalone mode. Footer layers are likewise viewport-fixed in browser mode and shell-absolute in standalone. While either header or reserve transition changes document geometry, the corresponding transition marker disables anchoring on the active document/inner scroller so synthetic reverse scroll cannot cause a hide/reveal loop or reading-position jump. - **Do not treat CSSOM bounds as physical iOS paint proof.** A fixed root can report perfect `getBoundingClientRect()` and hit-testing while WebKit leaves an app-external band. Keep browser/document and standalone/`100vh` static guards, then verify Safari and a freshly relaunched Home Screen app on a physical phone before merge. If iOS reports a web viewport shorter than `screen.height`, pixels outside that viewport are system-owned; keep the root canvas opaque and matching, but do not fake reachability with negative safe-area overscan. - **Viewport 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 visible stack's ancestor in `GlobalSearchShell` is `display: contents` at every breakpoint rather than a block. The collapse result owns one phone-sticky wrapper (safe-area spacer + stack), while its `sm:` children retain the tablet/desktop offsets. 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. +- **Release the phone top inset with collapsing chrome.** For the default collapse motion, `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. The document overlay exception keeps this spacer inside the translated stack at a stable height and charges zero released top geometry. 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 collapsing controls hide. - **One transition, no jump.** Phone page navigation belongs inside the universal 1fr → 0fr grid rather than running another scroll hook. The shared reporter may emit one hide on a deliberate descent and one reveal on deliberate upward intent; geometry must move monotonically through the 240ms hide / 200ms reveal and remain still at the bottom edge. Reduced motion removes the animation but not the complete edge release. - **Do not double-sticky tablet search inside an outer sticky stack.** When `wide: "sticky"` owns the tablet stack, the composer stays `relative` in that stack. A second sticky search with its own `top` overlays page controls (and blocks clicks) once the top bar collapses. - **Desktop search is page-owned.** `desktop-page-search-composer-slot` is rendered at the top of normal shell/dashboard content and accepts the shared composer only at `min-width: 1024px`. The mode-home hero slot takes precedence. Never give the desktop page composer, its slot, or an ancestor `fixed`/`sticky` positioning. diff --git a/scripts/phone-chrome-plan.mjs b/scripts/phone-chrome-plan.mjs index 27f18040d9..dfb0627086 100644 --- a/scripts/phone-chrome-plan.mjs +++ b/scripts/phone-chrome-plan.mjs @@ -149,7 +149,7 @@ export function phoneChromePlan(rawFiles, { fullMode = "auto" } = {}) { focusedBrowserJourneys.push({ file: "tests/ui-phone-scroll.spec.ts", pattern: - "phone browser results use document scrolling|document detail header and footer follow Safari document scrolling together|compiled standalone PWA rules bind full-height footer chrome|standalone .* is frame-owned", + "phone browser results use document scrolling|document detail header overlay and footer follow|compiled standalone PWA rules bind full-height footer chrome|standalone .* is frame-owned", }); } if (runDashboardJourneys && !changedBrowserFileSet.has("tests/ui-smoke.spec.ts")) { diff --git a/src/app/globals.css b/src/app/globals.css index 0966653958..3174e6b09a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2561,6 +2561,10 @@ td, position: static; } + .phone-sticky-header-stack.phone-overlay-header { + position: absolute; + } + .phone-footer-layer { position: absolute; } diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 7f1b679961..f25f985023 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -34,6 +34,7 @@ import { PhoneFooterLayerFrame } from "@/components/clinical-dashboard/phone-foo import { PageSecondaryNavigation } from "@/components/page-secondary-navigation"; import { useActiveScrollOwner } from "@/components/clinical-dashboard/use-active-scroll-owner"; import { + isDocumentViewerOwnedRoute, isPageOwnedComposerRoute, resolveMobileComposerReserve, resolveShellVisibleMobileComposerReserve, @@ -853,7 +854,12 @@ function GlobalStandaloneSearchShellBody({ // 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. - hideOnScroll={{ strategy: "collapse", wide: "sticky", scrollHidden: chromeScrollHide.hidden }} + hideOnScroll={{ + strategy: "collapse", + phoneMotion: isDocumentViewerOwnedRoute(pathname) ? "overlay" : "collapse", + wide: "sticky", + scrollHidden: chromeScrollHide.hidden, + }} onBottomComposerHiddenChange={setBottomComposerHidden} queryInputAutoFocus={requestedFocus && !hasSubmittedModeSearch} /> diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index a5adf2b919..8d14879d38 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -267,6 +267,12 @@ export function MasterSearchHeader({ * `useScrollHideReporter` wired to that element's scroll events. */ hideOnScroll?: { strategy: "overlay" | "collapse"; + /** + * Phone-only motion for collapse-strategy hosts. "collapse" releases the + * row's layout height; "overlay" keeps the complete phone stack stable and + * translates it over page content. Defaults to "collapse". + */ + phoneMotion?: "collapse" | "overlay"; /** * Overlay-only: apply the hide/reveal (and the out-of-flow absolute header) * at every breakpoint instead of phones only. The host must reserve @@ -1445,6 +1451,8 @@ export function MasterSearchHeader({ } const hideStrategy = hideOnScroll?.strategy; + const phoneMotion = hideOnScroll?.phoneMotion ?? "collapse"; + const phoneOverlayMotion = hideStrategy === "collapse" && phoneMotion === "overlay"; // Overlay hosts that opt into all breakpoints take the header fully out of // flow (absolute over the scrolling
, which reserves matching top // padding) so content frosts under the glass bar at every width. @@ -2095,16 +2103,29 @@ export function MasterSearchHeader({ const collapsingTopBar = (
@@ -2148,10 +2169,15 @@ export function MasterSearchHeader({ // 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. - "relative z-40 shrink-0 bg-[color:var(--background)] max-sm:transition-[height] motion-reduce:transition-none sm:h-[var(--safe-area-top)]", - headerChromeHidden - ? "max-sm:h-0 max-sm:duration-[240ms] max-sm:ease-[cubic-bezier(0.4,0,0.2,1)]" - : "max-sm:h-[var(--safe-area-top)] max-sm:duration-200 max-sm:ease-[cubic-bezier(0.22,1,0.36,1)]", + "relative z-40 shrink-0 bg-[color:var(--background)] motion-reduce:transition-none sm:h-[var(--safe-area-top)]", + phoneOverlayMotion + ? "max-sm:h-[var(--safe-area-top)]" + : cn( + "max-sm:transition-[height]", + headerChromeHidden + ? "max-sm:h-0 max-sm:duration-[240ms] max-sm:ease-[cubic-bezier(0.4,0,0.2,1)]" + : "max-sm:h-[var(--safe-area-top)] max-sm:duration-200 max-sm:ease-[cubic-bezier(0.22,1,0.36,1)]", + ), sticksAbovePhones && "sm:sticky sm:top-0", )} /> @@ -2159,7 +2185,19 @@ export function MasterSearchHeader({ if (sticksAbovePhones) { return ( -
+
{chromeSafeAreaTop}
{collapsingTopBar} diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index 750d237820..85aff66c77 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -219,12 +219,16 @@ export function readChromeCollapseMetrics( scroller: HTMLElement, ): Pick { const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); + const phoneOverlayMotion = + collapse instanceof HTMLElement && + collapse.dataset.phoneMotion === "overlay" && + window.matchMedia(phoneMediaQuery).matches; // The 1fr -> 0fr grid IS the collapse mechanism, so the wrapper only hands // layout back while it is a grid at the current width. Where it sticks and // translates instead (GlobalSearchShell above the phone breakpoint, which // hands scrolling back to the document), hiding costs the scroller nothing. const headerRelease = - collapse instanceof HTMLElement && window.getComputedStyle(collapse).display === "grid" + collapse instanceof HTMLElement && !phoneOverlayMotion && window.getComputedStyle(collapse).display === "grid" ? collapse.getBoundingClientRect().height : 0; const safeAreaTop = document.querySelector('[data-testid="chrome-safe-area-top"]'); @@ -260,7 +264,7 @@ export function readChromeCollapseMetrics( : padRelease(scroller); return { collapseBudget: headerRelease + phoneSafeAreaRelease + reserveRelease, - collapseKind: collapse instanceof HTMLElement ? "in-flow" : reserveRelease > 0 ? "reserve-only" : undefined, + collapseKind: headerRelease > 0 ? "in-flow" : reserveRelease > 0 ? "reserve-only" : undefined, combinedChrome: headerRelease > 0 && reserveRelease > 0, }; } diff --git a/tests/header-scroll-hide-contract.test.ts b/tests/header-scroll-hide-contract.test.ts index a3e5221d9b..9896a9a27c 100644 --- a/tests/header-scroll-hide-contract.test.ts +++ b/tests/header-scroll-hide-contract.test.ts @@ -102,7 +102,9 @@ describe("shared header hide/reveal wiring", () => { it("picks the hide mechanism from where each host's scrollport lives", () => { // GlobalSearchShell hands scrolling back to the document above phones, so // the outer stack sticks while only the top-bar row collapses. - expect(shellSource).toContain('hideOnScroll={{ strategy: "collapse", wide: "sticky"'); + expect(shellSource).toContain('strategy: "collapse"'); + expect(shellSource).toContain('phoneMotion: isDocumentViewerOwnedRoute(pathname) ? "overlay" : "collapse"'); + expect(shellSource).toContain('wide: "sticky"'); // ClinicalDashboard uses the document on browser phones and
in // standalone/sm+; both feed the same collapse reporter. expect(dashboardSource).toContain('{ strategy: "collapse", wide: "collapse"'); @@ -112,6 +114,18 @@ describe("shared header hide/reveal wiring", () => { expect(dashboardSource).toContain('data-chrome-transitioning={chromeTransitioning ? "true" : undefined}'); }); + it("uses overlay phone motion only for document-viewer-owned routes", () => { + expect(headerSource).toContain('phoneMotion?: "collapse" | "overlay"'); + expect(headerSource).toContain('const phoneMotion = hideOnScroll?.phoneMotion ?? "collapse"'); + expect(headerSource).toContain('hideStrategy === "collapse" && phoneMotion === "overlay"'); + expect(shellSource).toContain("isDocumentViewerOwnedRoute"); + expect(shellSource).toContain('phoneMotion: isDocumentViewerOwnedRoute(pathname) ? "overlay" : "collapse"'); + expect(dashboardSource).not.toContain("phoneMotion:"); + expect(headerSource).toContain("data-phone-motion={phoneMotion}"); + expect(headerSource).toContain("max-sm:pointer-events-none max-sm:-translate-y-full max-sm:opacity-0"); + expect(behaviourDocSource).toContain("`/documents/search` and every non-document route keep the default"); + }); + it("moves submitted search composers into normal page flow on desktop only", () => { expect(composerSlotSource).toContain( 'export const desktopPageComposerSlotId = "desktop-page-search-composer-slot"', @@ -209,7 +223,7 @@ describe("shared header hide/reveal wiring", () => { // strip that before asserting sticky collapse does not revive the sm: // translate path. expect(headerSource).toContain('data-testid="chrome-safe-area-top"'); - expect(headerSource.split('className="phone-sticky-header-stack sm:contents"').length - 1).toBe(2); + expect(headerSource.split('"phone-sticky-header-stack sm:contents"').length - 1).toBe(2); expect(headerSource).toContain('className="sm:sticky sm:top-[var(--safe-area-top)] sm:z-30"'); expect(headerSource.replaceAll("max-sm:-translate-y-full", "")).not.toContain("sm:-translate-y-full"); expect(headerSource).not.toContain('sticksAbovePhones && headerChromeHidden && "sm:-translate-y-full"'); @@ -284,6 +298,9 @@ describe("shared header hide/reveal wiring", () => { // Sticky hosts now collapse only the top-bar row (still `display: grid`), so // the budget correctly charges that height; non-grid wrappers stay at 0. expect(hookSource).toContain('window.getComputedStyle(collapse).display === "grid"'); + expect(hookSource).toContain('collapse.dataset.phoneMotion === "overlay"'); + expect(hookSource).toContain("!phoneOverlayMotion"); + expect(hookSource).toContain('collapseKind: headerRelease > 0 ? "in-flow"'); expect(hookSource).toContain("document.querySelector('[data-testid=\"chrome-safe-area-top\"]')"); expect(hookSource).toContain("window.matchMedia(phoneMediaQuery).matches"); expect(hookSource).toContain("headerRelease + phoneSafeAreaRelease + reserveRelease"); @@ -327,7 +344,7 @@ describe("shared header hide/reveal wiring", () => { expect(behaviourDocSource).toContain("One transition, no jump"); expect(behaviourDocSource).toContain("Do not double-sticky tablet search inside an outer sticky stack"); expect(behaviourDocSource).toContain("desktop-page-search-composer-slot"); - expect(behaviourDocSource).toContain("Release the phone top inset with hidden chrome"); + 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", ); diff --git a/tests/ui-overlay-css-contract.test.ts b/tests/ui-overlay-css-contract.test.ts index f286cecbcd..9c75d9d915 100644 --- a/tests/ui-overlay-css-contract.test.ts +++ b/tests/ui-overlay-css-contract.test.ts @@ -159,6 +159,9 @@ describe("overlay and global CSS contracts", () => { const browserStickyHeaderBlock = browserMediaBlock.match(/\.phone-sticky-header-stack\s*\{[\s\S]*?\}/)?.[0]; const browserFooterBlock = browserMediaBlock.match(/\.phone-footer-layer\s*\{[\s\S]*?\}/)?.[0]; const standaloneStickyHeaderBlock = standaloneMediaBlock.match(/\.phone-sticky-header-stack\s*\{[\s\S]*?\}/)?.[0]; + const standaloneOverlayStackBlock = standaloneMediaBlock.match( + /\.phone-sticky-header-stack\.phone-overlay-header\s*\{[\s\S]*?\}/, + )?.[0]; const standaloneFooterBlock = standaloneMediaBlock.match(/\.phone-footer-layer\s*\{[\s\S]*?\}/)?.[0]; expect(browserMediaBlock).not.toBe(""); @@ -187,6 +190,7 @@ describe("overlay and global CSS contracts", () => { expect(phoneOverlayBlocks.find((block) => block.includes("position: fixed;"))).toContain("top: 0;"); expect(standaloneOverlayBlock).toContain("position: absolute;"); expect(standaloneStickyHeaderBlock).toContain("position: static;"); + expect(standaloneOverlayStackBlock).toContain("position: absolute;"); expect(standaloneFooterBlock).toContain("position: absolute;"); expect(browserMediaBlock).toContain('html:has([data-chrome-transitioning="true"])'); expect(browserMediaBlock).toContain('html:has([data-reserve-transitioning="true"])'); diff --git a/tests/ui-phone-scroll.spec.ts b/tests/ui-phone-scroll.spec.ts index d7202d88ff..0a9cb2046c 100644 --- a/tests/ui-phone-scroll.spec.ts +++ b/tests/ui-phone-scroll.spec.ts @@ -78,6 +78,7 @@ const pageOwnedHeaderRoutes = [ name: "document navigation", route: "/documents/11111111-1111-4111-8111-111111111111?page=1", selector: "header", + phoneMotion: "overlay" as const, }, { name: "differential detail navigation", @@ -196,6 +197,7 @@ interface ScrollGeometry { maxOffset: number; scrollOwner: "document" | "main"; headerHidden: boolean; + headerMotion: "collapse" | "overlay"; docScrollableExcess: number; horizontalOverflow: number; reserveTransitionDuration: string; @@ -204,7 +206,7 @@ interface ScrollGeometry { function readGeometry(page: Page): Promise { return page.evaluate(() => { const main = document.getElementById("main-content"); - const header = document.querySelector('[data-testid="universal-header-collapse"]'); + const header = document.querySelector('[data-testid="universal-header-collapse"]'); const doc = document.scrollingElement ?? document.documentElement; const mainOverflowY = main ? getComputedStyle(main).overflowY : ""; const mainOwnsScroll = Boolean( @@ -217,6 +219,7 @@ function readGeometry(page: Page): Promise { maxOffset: Math.max(0, scrollOwner.scrollHeight - scrollOwner.clientHeight), scrollOwner: mainOwnsScroll ? "main" : "document", headerHidden: header?.getAttribute("data-scroll-hidden") === "true", + headerMotion: header?.dataset.phoneMotion === "overlay" ? "overlay" : "collapse", docScrollableExcess: doc.scrollHeight - doc.clientHeight, horizontalOverflow: Math.max(doc.scrollWidth, document.body?.scrollWidth ?? 0) - window.innerWidth, reserveTransitionDuration: reserveHost ? getComputedStyle(reserveHost).transitionDuration : "", @@ -409,29 +412,185 @@ test("phone browser results use document scrolling so Safari can minimize its br ); }); -test("document detail header and footer follow Safari document scrolling together", async ({ page }) => { - await page.emulateMedia({ reducedMotion: "no-preference" }); - await page.setViewportSize(phoneViewport); - await gotoPhoneSurface(page, "/documents/11111111-1111-4111-8111-111111111111?page=1"); - await expect(page.locator("form.document-viewer-composer")).toBeVisible({ timeout: 20_000 }); - await expect(page.getByTestId("document-viewer-content")).toHaveAttribute("data-phone-scroll-owner", "document"); - await expect(page.getByTestId("document-viewer-content")).toHaveAttribute( - "data-phone-footer-owner", - "document-viewer", - ); - await addPhoneScrollRunway(page); +for (const phoneOwner of ["browser document", "standalone PWA main"] as const) { + test(`document detail header overlay and footer follow ${phoneOwner} scrolling together`, async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.setViewportSize(phoneViewport); + await gotoPhoneSurface(page, "/documents/11111111-1111-4111-8111-111111111111?page=1"); + if (phoneOwner === "standalone PWA main") { + expect(await forceCompiledStandalonePhoneCss(page), "compiled CSS must expose standalone rules").toBeGreaterThan( + 0, + ); + } - await dragScrollBy(page, 720, 24); - await expect(page.getByTestId("universal-header-collapse")).toHaveAttribute("data-scroll-hidden", "true"); - await expect(page.locator("form.document-viewer-composer")).toHaveAttribute("data-scroll-hidden", "true"); + const expectedOwner = phoneOwner === "browser document" ? "document" : "main"; + const collapse = page.getByTestId("universal-header-collapse"); + const overlayStack = page.locator('.phone-sticky-header-stack[data-phone-motion="overlay"]'); + const documentRow = page.locator("[data-document-sticky-header]"); + const sectionTrack = documentRow.locator(':scope > span[aria-hidden="true"]'); + const composer = page.locator("form.document-viewer-composer"); + const content = page.getByTestId("document-viewer-content"); + const sectionTrigger = page.getByTestId("document-section-trigger"); + + await expect(composer).toBeVisible({ timeout: 20_000 }); + await expect(content).toHaveAttribute("data-phone-scroll-owner", expectedOwner); + await expect(content).toHaveAttribute("data-phone-footer-owner", "document-viewer"); + await expect(collapse).toHaveAttribute("data-phone-motion", "overlay"); + await expect(overlayStack).toHaveCount(1); + await expect(documentRow).toBeVisible(); + await expect(sectionTrack).toBeVisible(); + await expect + .poll(async () => (await readPrimaryScrollAndDomGeometry(page, {})).scroll.owner, { + message: `${phoneOwner} document-detail scroll owner`, + }) + .toBe(expectedOwner); + await addPhoneScrollRunway(page); - const owner = await page.evaluate(() => ({ - windowScrollY: window.scrollY, - mainScrollTop: document.getElementById("main-content")?.scrollTop ?? -1, - })); - expect(owner.windowScrollY, "Safari document scroll must drive document-detail chrome").toBeGreaterThan(120); - expect(owner.mainScrollTop, "browser document detail must not retain a competing inner scroller").toBe(0); -}); + const visibleGeometry = await readPrimaryScrollAndDomGeometry(page, { + stack: '.phone-sticky-header-stack[data-phone-motion="overlay"]', + universalRow: "header#search", + documentRow: "[data-document-sticky-header]", + sectionTrack: '[data-document-sticky-header] > span[aria-hidden="true"]', + content: '[data-testid="document-viewer-content"]', + }); + expect(visibleGeometry.nodes.stack.count).toBe(1); + expect(visibleGeometry.nodes.stack.style?.position).toBe(phoneOwner === "browser document" ? "fixed" : "absolute"); + expect(visibleGeometry.nodes.universalRow.rect?.bottom ?? 0).toBeGreaterThan(1); + expect(visibleGeometry.nodes.documentRow.rect?.bottom ?? 0).toBeGreaterThan(1); + expect(visibleGeometry.nodes.sectionTrack.rect?.bottom ?? 0).toBeGreaterThan(1); + + // Portaled page-header focus pins the complete stack, matching the shared + // header controls and preventing keyboard focus from moving off-screen. + await sectionTrigger.focus(); + await expect(sectionTrigger).toBeFocused(); + await dragScrollBy(page, 360, 24); + await expect(collapse, "focused document header keeps the overlay visible").not.toHaveAttribute( + "data-scroll-hidden", + "true", + ); + await sectionTrigger.evaluate((element) => element.blur()); + + await dragScrollBy(page, 720, 24); + await expect(collapse).toHaveAttribute("data-scroll-hidden", "true"); + await expect(overlayStack).toHaveAttribute("data-scroll-hidden", "true"); + await expect(composer).toHaveAttribute("data-scroll-hidden", "true"); + + const hiddenGeometry = await readPrimaryScrollAndDomGeometry(page, { + stack: '.phone-sticky-header-stack[data-phone-motion="overlay"]', + universalRow: "header#search", + documentRow: "[data-document-sticky-header]", + sectionTrack: '[data-document-sticky-header] > span[aria-hidden="true"]', + content: '[data-testid="document-viewer-content"]', + }); + expect(hiddenGeometry.scroll.owner).toBe(expectedOwner); + expect(hiddenGeometry.scroll.scrollTop, `${phoneOwner} must drive document-detail chrome`).toBeGreaterThan(120); + expect(hiddenGeometry.nodes.universalRow.rect?.bottom ?? 1).toBeLessThanOrEqual(1); + expect(hiddenGeometry.nodes.documentRow.rect?.bottom ?? 1).toBeLessThanOrEqual(1); + expect(hiddenGeometry.nodes.sectionTrack.rect?.bottom ?? 1).toBeLessThanOrEqual(1); + await expect(overlayStack).toHaveCSS("pointer-events", "none"); + await expect(overlayStack).toHaveCSS("opacity", "0"); + + // Trigger reveal, then stop changing the scroll owner. The remaining + // transition frames may move only the overlay; the reader and document + // anchor must remain fixed. + await dragScrollBy(page, -48, 8); + await expect(collapse).not.toHaveAttribute("data-scroll-hidden", "true"); + const revealFrames = await page.evaluate(async () => { + const main = document.getElementById("main-content"); + const mainOwnsScroll = Boolean( + main && + /^(?:auto|scroll|overlay)$/.test(getComputedStyle(main).overflowY) && + main.scrollHeight > main.clientHeight + 1, + ); + const owner = mainOwnsScroll && main ? main : (document.scrollingElement ?? document.documentElement); + const stack = document.querySelector('.phone-sticky-header-stack[data-phone-motion="overlay"]'); + const universal = document.querySelector("header#search"); + const documentHeader = document.querySelector("[data-document-sticky-header]"); + const track = documentHeader?.querySelector(':scope > span[aria-hidden="true"]') ?? null; + const anchor = document.querySelector('[data-testid="document-viewer-content"]'); + if (!stack || !universal || !documentHeader || !track || !anchor) { + throw new Error("document overlay geometry nodes were not rendered"); + } + const frames: Array<{ + scrollTop: number; + windowScrollY: number; + anchorTop: number; + stackHeight: number; + stackBottom: number; + universalBottom: number; + documentBottom: number; + trackBottom: number; + }> = []; + for (let frame = 0; frame < 18; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); + frames.push({ + scrollTop: owner.scrollTop, + windowScrollY: window.scrollY, + anchorTop: anchor.getBoundingClientRect().top, + stackHeight: stack.getBoundingClientRect().height, + stackBottom: stack.getBoundingClientRect().bottom, + universalBottom: universal.getBoundingClientRect().bottom, + documentBottom: documentHeader.getBoundingClientRect().bottom, + trackBottom: track.getBoundingClientRect().bottom, + }); + } + return frames; + }); + const revealScrollTop = revealFrames[0]?.scrollTop ?? -1; + const revealWindowScrollY = revealFrames[0]?.windowScrollY ?? -1; + const revealAnchorTop = revealFrames[0]?.anchorTop ?? -1; + const stableStackHeight = revealFrames[0]?.stackHeight ?? -1; + for (const frame of revealFrames) { + expect(frame.scrollTop, "reveal cannot change the settled reading offset").toBeCloseTo(revealScrollTop, 0); + expect(frame.windowScrollY, "reveal cannot change window.scrollY").toBeCloseTo(revealWindowScrollY, 0); + expect(frame.anchorTop, "reveal cannot move the document/PDF anchor").toBeCloseTo(revealAnchorTop, 0); + expect(frame.stackHeight, "overlay stack footprint stays geometrically stable").toBeCloseTo(stableStackHeight, 0); + } + const revealed = revealFrames.at(-1)!; + expect(revealed.universalBottom, "global Documents row returns inside the viewport").toBeGreaterThan(1); + expect(revealed.documentBottom, "document title row returns with the global row").toBeGreaterThan(1); + expect(revealed.trackBottom, "section track returns with both rows").toBeGreaterThan(1); + expect(revealed.stackBottom, "returned stack overlays the document anchor").toBeGreaterThan(revealAnchorTop); + + // The document's own sheet pins its footer and locks the underlying owner; + // opening it from the visible header must not release either chrome edge. + await sectionTrigger.click(); + await expect(page.getByTestId("document-section-sheet")).toBeVisible(); + await expect(collapse).not.toHaveAttribute("data-scroll-hidden", "true"); + await expect(composer).not.toHaveAttribute("data-scroll-hidden", "true"); + await expect(page.locator("body")).toHaveCSS("overflow", "hidden"); + await page.getByRole("button", { name: "Close section list" }).click(); + await expect(page.getByTestId("document-section-sheet")).toHaveCount(0); + await expect(page.locator("body")).not.toHaveCSS("overflow", "hidden"); + await expect(sectionTrigger).toBeFocused(); + await sectionTrigger.evaluate((element) => element.blur()); + + // Reduced motion removes the transition but retains the out-of-flow + // geometry. Prove another hide/reveal cycle cannot displace the reader. + await page.emulateMedia({ reducedMotion: "reduce" }); + await dragScrollBy(page, -480, 16); + await expect(collapse).not.toHaveAttribute("data-scroll-hidden", "true"); + await dragScrollBy(page, 720, 24); + await expect(collapse).toHaveAttribute("data-scroll-hidden", "true"); + const reducedHidden = await readPrimaryScrollAndDomGeometry(page, { + stack: '.phone-sticky-header-stack[data-phone-motion="overlay"]', + content: '[data-testid="document-viewer-content"]', + }); + await expect(overlayStack).toHaveCSS("transition-property", "none"); + await dragScrollBy(page, -48, 8); + await expect(collapse).not.toHaveAttribute("data-scroll-hidden", "true"); + const reducedRevealed = await readPrimaryScrollAndDomGeometry(page, { + stack: '.phone-sticky-header-stack[data-phone-motion="overlay"]', + content: '[data-testid="document-viewer-content"]', + }); + expect(reducedRevealed.scroll.scrollTop).toBeLessThan(reducedHidden.scroll.scrollTop); + expect(reducedRevealed.nodes.stack.rect?.height).toBeCloseTo(reducedHidden.nodes.stack.rect?.height ?? -1, 0); + expect( + (reducedRevealed.nodes.content.rect?.top ?? 0) - (reducedHidden.nodes.content.rect?.top ?? 0), + "reduced-motion reveal moves the anchor only by the intended reverse scroll", + ).toBeCloseTo(reducedHidden.scroll.scrollTop - reducedRevealed.scroll.scrollTop, 0); + }); +} test("compiled standalone PWA rules bind full-height footer chrome to the inner scroller", async ({ page }) => { await page.emulateMedia({ reducedMotion: "no-preference" }); @@ -1210,15 +1369,17 @@ for (const route of [...modeHomeRoutes, ...dashboardRoutes, ...longRoutes]) { // pre-fix, a 32px up-drag revealed the chrome (restoring ~180px of // geometry under the finger), the next down-drag re-hid it, and so on — // the "locks to the bottom" thrash. The collapse-budget gate permits at - // most the one legitimate reveal here and refuses to re-hide with no - // runway left, so the whole nudge cycle allows a single flip. + // most the one legitimate reveal for in-flow chrome and refuses to re-hide + // with no runway left. A zero-budget overlay can safely follow all three + // deliberate direction changes because it releases no layout geometry. for (const nudge of [-32, 48, -32]) { await dragScrollBy(page, nudge, 8); await page.waitForTimeout(350); } const flipsAfterNudges = await readFlipCount(page); + const allowedNudgeFlips = initial.headerMotion === "overlay" ? 3 : 1; expect(flipsAfterNudges - flipsAfterDescent, "bottom-edge nudges must not thrash the chrome").toBeLessThanOrEqual( - 1, + allowedNudgeFlips, ); await page.waitForTimeout(400); expect(await readFlipCount(page), "no chrome flips after the nudges settle").toBe(flipsAfterNudges); @@ -1389,7 +1550,7 @@ for (const { mode, route } of appModeHeaderRoutes) { }); } -for (const { name, route, selector } of pageOwnedHeaderRoutes) { +for (const { name, route, selector, phoneMotion } of pageOwnedHeaderRoutes) { test(`phone ${name} uses the universal collapse owner`, async ({ page }) => { await page.emulateMedia({ reducedMotion: "no-preference" }); await page.setViewportSize({ width: 320, height: 720 }); @@ -1409,16 +1570,27 @@ for (const { name, route, selector } of pageOwnedHeaderRoutes) { expect(pageHeaderBox!.x + pageHeaderBox!.width, "page header cannot expand past the viewport").toBeLessThanOrEqual( 320, ); + const visibleCollapseHeight = await collapse.evaluate((element) => element.getBoundingClientRect().height); + const safeArea = page.getByTestId("chrome-safe-area-top"); + const visibleSafeAreaHeight = await safeArea.evaluate((element) => element.getBoundingClientRect().height); await addPhoneScrollRunway(page); await dragScrollBy(page, 720, 24); await page.waitForTimeout(500); await expect(collapse).toHaveAttribute("data-scroll-hidden", "true"); - expect(await collapse.evaluate((element) => element.getBoundingClientRect().height)).toBeLessThanOrEqual(1); - expect( - await page.getByTestId("chrome-safe-area-top").evaluate((element) => element.getBoundingClientRect().height), - ).toBeLessThanOrEqual(1); + const hiddenCollapseHeight = await collapse.evaluate((element) => element.getBoundingClientRect().height); + const hiddenSafeAreaHeight = await safeArea.evaluate((element) => element.getBoundingClientRect().height); + if (phoneMotion === "overlay") { + expect(hiddenCollapseHeight).toBeCloseTo(visibleCollapseHeight, 0); + expect(hiddenSafeAreaHeight).toBeCloseTo(visibleSafeAreaHeight, 0); + const overlayStack = page.locator('.phone-sticky-header-stack[data-phone-motion="overlay"]'); + await expect(overlayStack).toHaveAttribute("data-scroll-hidden", "true"); + await expect(overlayStack).toHaveCSS("pointer-events", "none"); + } else { + expect(hiddenCollapseHeight).toBeLessThanOrEqual(1); + expect(hiddenSafeAreaHeight).toBeLessThanOrEqual(1); + } }); } diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 5991ade693..a29750ade9 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -41,6 +41,12 @@ async function expectNoPageHorizontalOverflow(page: Page) { expect(overflow).toBeLessThanOrEqual(2); } +async function revealPhoneHeaderControl(page: Page, control: Locator) { + const { scrollTop } = await readPrimaryScrollGeometry(page); + if (scrollTop > 0) await scrollPrimarySurface(page, Math.max(0, scrollTop - 48)); + await expect(control).toBeInViewport(); +} + async function installClipboardMock(page: Page) { await page.addInitScript(() => { let clipboardText = ""; @@ -3540,6 +3546,7 @@ test.describe("Clinical KB UI smoke coverage", () => { // retired in-flow "Document viewer sections" link row. const sectionTrigger = page.getByTestId("document-section-trigger"); const openSection = async (label: RegExp) => { + await revealPhoneHeaderControl(page, sectionTrigger); await sectionTrigger.click(); const sheet = page.getByTestId("document-section-sheet"); await expect(sheet).toBeVisible(); @@ -3556,6 +3563,7 @@ test.describe("Clinical KB UI smoke coverage", () => { page.getByTestId("source-chunk-indexed-text-panel").getByTestId("highlighted-indexed-source-chunk"), ).toBeVisible(); await expect(sectionTrigger).toBeVisible(); + await revealPhoneHeaderControl(page, sectionTrigger); await sectionTrigger.click(); const sectionSheet = page.getByTestId("document-section-sheet"); await expect(sectionSheet.getByRole("button", { name: /Pinned evidence/ })).toBeVisible(); @@ -3743,6 +3751,7 @@ test.describe("Clinical KB UI smoke coverage", () => { const indexingDetails = page.getByTestId("indexing-details"); const sectionTrigger = page.getByTestId("document-section-trigger"); const clickSectionNav = async (label: RegExp) => { + await revealPhoneHeaderControl(page, sectionTrigger); await sectionTrigger.click(); const sheet = page.getByTestId("document-section-sheet"); await expect(sheet).toBeVisible(); diff --git a/tests/verify-phone-chrome.test.ts b/tests/verify-phone-chrome.test.ts index 85acd0fe6f..0b66edca78 100644 --- a/tests/verify-phone-chrome.test.ts +++ b/tests/verify-phone-chrome.test.ts @@ -18,12 +18,19 @@ describe("phoneChromePlan", () => { }); it("uses focused document ownership without escalating page-local work to the full suite", () => { - expect(ids(["src/components/DocumentViewer.tsx"])).toEqual([ + const plan = phoneChromePlan(["src/components/DocumentViewer.tsx"]); + expect(plan.stages.map((candidate) => candidate.id)).toEqual([ "lock-parity", "runtime", "contracts", "focused-browser", ]); + const focusedBrowser = plan.stages.find((candidate) => candidate.id === "focused-browser"); + expect( + focusedBrowser?.command.args.some((argument: string) => + argument.includes("document detail header overlay and footer follow"), + ), + ).toBe(true); }); it("honours an explicit full-suite override while retaining focused stages first", () => {