diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 01f3667d4..5091e30c5 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1110,7 +1110,11 @@ This file is append-only. Never rewrite or delete an existing review record; app | 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. | +| 2026-07-27 | `codex/fix-phone-bottom-edge-20260727` | `2cfd726843e034620091c9bb400640cce0fa40d3` | Superseding review of the physical-iOS phone bottom-edge regression | APPROVE pending broad local gates and physical staging acceptance. PR #1266 restored a phone `100dvh` shell and moved the primary regression away from Services, so Chromium parent geometry stayed green while Safari and a freshly relaunched standalone PWA clipped live result content above the physical edge. Both application owners now share a phone-only fixed `inset: 0` shell without a viewport-unit height clamp. The repaired Services journey requires the real result list to own the final pixel and preserves its pre-resize anchor through viewport shrink and expansion. No P0-P3 code or test finding remains. | TDD RED on the base shell (`relative` instead of `fixed`); exact Services Chromium GREEN (1/1); focused shell contracts GREEN (19/19); full Vitest GREEN (393 files, 3520 passed / 2 skipped); independent spec and quality reviews PASS; physical Safari/Home Screen staging proof and broad local gates pending; no provider-backed checks yet. | +| 2026-07-27 | `codex/fix-phone-bottom-edge-20260727` | `3f33b0b4b7c08dab74ba6685fbd6aa672a8e6c91` | Final review of browser and standalone phone edge ownership | APPROVE pending exact staging device acceptance. Supersedes the `2cfd7268` review after physical Safari and cold-launch PWA evidence disproved the fixed-root solution. Browser phones now use document scrolling so Safari can minimize its chrome and paint content through released top and bottom edges; standalone phones retain a bounded 100vh frame with page-owned calculator, DocumentViewer, and differential footers portaled outside the inner scroller. Hidden chrome releases reserve, opacity, hit testing, and last-pixel ownership without a backward scroll jump, while sm+ returns portal content inline. Independent final review found no P0-P3 issue. | `verify:cheap` PASS (393 files; 3526 passed / 2 skipped); focused static contracts PASS (43/43); exact new standalone and responsive production Chromium journeys PASS (4/4); `verify:ui` PASS (323/323); Prettier and `git diff --check` PASS; physical Safari and freshly relaunched Home Screen PWA staging proof pending; no live provider-backed verification. | | 2026-07-27 | `codex/config-reconciliation-current-20260727` | `4400f59730fbd24efc5f4c54adda828506f3835b` | Protected-main review of #054 production configuration reconciliation | APPROVE. GitHub reads are repository-pinned; Railway reads are pinned to the live project, production environment and explicit app/worker services; each provider call has a 30-second bound; output is names-only even though Railway JSON is reduced from values in memory. Multiline Zod and `.env.example` drift are guarded. The correct primary checkout received only three generated gitignored local HMAC/probe values. No P0-P3 finding remains. Residual staging, webhook activation and legal/ZDR work remain #056, #025 and #053 rather than being overstated as complete. | Focused parity/local-presence 22/22 PASS; `verify:cheap` PASS (25 gates; 393 files; 3523 passed / 2 skipped); `verify:pr-local` PASS (same unit matrix + 36 offline RAG fixtures; build correctly skipped as unaffected); production-readiness READY (8 PASS, 2 checkout-file-location warnings); exact provider names-only GitHub/Railway parity PASS; Ops Digest active + latest schedule SUCCESS; Railway app/worker latest deploy SUCCESS; Supabase read-only cron/Vault-name proof; no OpenAI request or live RAG evaluation. | | 2026-07-27 | PR #1271 / `codex/config-reconciliation-current-20260727` | `ada836d167f6a03f2a6514d56d3aee6304c6276c` | Automated-review follow-up for cross-worktree local fill persistence | APPROVE. The P2 was valid: caller-only process secrets could hide missing target-file values during `--root --fill`. Fill mode now computes persistent gaps from target env files while project identity still uses the merged file/process view; report mode retains its existing process override behavior. A dedicated contract proves all caller-only fillable values remain target-file gaps. No other P0-P3 finding remains. | Focused `tests/local-presence.test.ts` PASS (10/10); exact primary `check:local-presence -- --root C:\Dev\Apps\Database` PASS; Prettier + `git diff --check` PASS; earlier exact-tree `verify:cheap` and `verify:pr-local` remain the broad baseline; hosted required checks will rerun on this follow-up. | | 2026-07-27 | PR #1271 / `codex/config-reconciliation-current-20260727` | `3321c1eb1f2d1ac4294caf40e09a63b74fe1f713` | Second automated-review follow-up for safe local fill targeting/reporting | APPROVE. Two valid P2 findings were fixed: an explicit root must now carry the Database package identity before any fill, and fill mode applies file-only state solely to writable HMAC/probe gaps while preserving merged process/file truth for report-only provider rows and project identity. Tests cover an unrelated package root, caller-only fillable values, and caller-only provider reporting. Zero unresolved local findings remain. | Focused `tests/local-presence.test.ts` PASS (11/11); exact primary presence PASS; unrelated-root CLI rejection PASS; Prettier PASS; hosted required checks and automated review must rerun on this head before merge. | +| 2026-07-27 | PR #1270 / `codex/fix-phone-bottom-edge-20260727` | `40d7cb1e4e934b47e96e9d7d8cea6a956472c12c` | Final automated-review follow-up for phone chrome scroll ownership | APPROVE. Three valid minor review findings were fixed: the latest scroll reporter now uses the commit-synchronized event-callback abstraction instead of mutating a ref during render; the 1024px focus regression proves bounded `main` ownership before and after scrolling; and paired answer geometry reads are ordered instead of raced. The component remains within its no-growth budget, visible edge geometry is unchanged, and no P0-P3 finding remains. | `verify:cheap` PASS (25 gates; 393 files; 3532 passed / 2 skipped); focused scroll contracts PASS (32/32); exact affected production Chromium journeys PASS twice (4/4 each); scoped ESLint, maintainability budget, Prettier, and `git diff --check` PASS; no non-GitHub provider-backed checks. | +| 2026-07-27 | PR #1270 / `codex/fix-phone-bottom-edge-20260727` | `931f7cef632921b0e41d0368ad4e5fc117221498` | Hosted Production UI hydration-settlement follow-up | APPROVE pending fresh hosted required checks. The full hosted Chromium matrix exposed one missed strict-locator guard: `/forms` can briefly overlap its server and settled client mode-home trees during production hydration. The test now applies the same one-owner convergence assertion already used by the adjacent shared-home loop, so a transient duplicate waits while a persistent duplicate still fails. No product behavior or edge geometry changed, and no P0-P3 finding remains. | First hosted run: 322/323 Chromium journeys PASS with the sole `/forms` strict-mode duplicate; exact failed production journey PASS 10/10 after the guard; scoped ESLint, Prettier, and `git diff --check` PASS; fresh hosted required checks pending; no non-GitHub provider-backed checks. | | 2026-07-27 | `codex/remaining-safe-fixes-20260727` | `37c1fd9a10fc953013ef9bdbcff9d2bad681dab4` | Protected-main review of focused document-search timeout and reconciliation evidence | APPROVE. The staging tenancy failure was reproduced against the 750 ms federated timeout, then fixed by restoring the historical 6,000 ms budget only when documents are the sole requested domain; multi-domain requests retain the 750 ms cap. The diff does not change retrieval, ranking, ordering, aliases, scores, ownership, or selected results. Current canary, production-content, staging-boundary, and migration-gap evidence is recorded without overstating the remaining browser or schema work. No P0-P3 finding remains. Residual operational risk is the exact 23-migration staging reconciliation and post-merge tenancy proof. | Red/green fake-timer contract PASS; focused search/RAG tests 77/77; offline RAG 36 cases / 309 tests PASS; production-readiness READY (8 PASS, two isolated-checkout file warnings); `verify:cheap` PASS (25 gates); `verify:pr-local` PASS (393 files, 3,526 passed / 2 skipped, production build/client-secret scan, 36 offline RAG fixtures); no new live RAG dispatch or OpenAI spend. | diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 95244e772..6310fb782 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -33,7 +33,9 @@ This repo uses one shared search experience across the global shell, dashboard r 16. Leaving the dashboard shell for a namespaced mode (`selectSearchMode` / `crossModeSearch`) must navigate without rewriting dashboard chrome first. 17. Do not wrap mode-home `{children}` in `ClientHydrationBoundary` — that blanks RSC HTML until JS mounts. Keep hydration guards on the specific leaf that mismatches. Do not call `useSearchParams()` in an ancestor Suspense that also renders route `{children}`: that nests the page segment inside the shell’s incomplete streaming boundary and can leave a persistent hidden `S:` clone (duplicate page-root `data-testid`s). Gate always-standalone pathnames with `isAlwaysStandaloneShellPath`, and bridge search params beside the shell body via `ShellSearchParamsBridge`. 18. Standalone mode-home `loading.tsx` files must render `ModeHomeRouteLoading` (phone top-aligned). Do not reuse unrelated results/medication skeletons. -19. `ClinicalDashboard` must stay out of the shared shell’s static import graph (dynamic import) so namespaced mode routes do not parse the dashboard module. +19. `ClinicalDashboard` must stay out of the shared shell's static import graph (dynamic import) so namespaced mode routes do not parse the dashboard module. +20. Browser-mode phones keep `.phone-viewport-shell` in normal flow and use the document as the vertical scroll owner. This is required for Safari to minimize its own browser chrome; do not restore a fixed/inset root or a phone `overflow-y: auto` canvas. +21. Installed standalone phones use the same normal-flow root with the final `display-mode: standalone` `100vh` bound and an internal `.phone-scroll-surface`. Keep that override after the browser contract; do not substitute `svh`, `dvh`, `visualViewport.height`, or a fixed root on this WebKit workaround path. Every phone footer uses `.phone-footer-layer`: fixed to the viewport in browser tabs and absolute to the positioned 100vh frame in standalone, so the composer and its backdrop share the repaired PWA edge. Page-owned footer layers must render through `PhoneFooterLayerPortal`; `PhoneFooterLayerFrame` provides a frame-scoped, paint-free host after the scroll surface. An absolute footer left inside `.phone-scroll-surface` still scrolls and clips with that surface. ## Scroll hide/reveal @@ -41,18 +43,20 @@ The universal **top bar** (mode, new chat, menu) is the only sticky desktop chro Choose the hide mechanism from where the host's scrollport lives, because that decides what hiding costs the reader: -| Host | Scrollport | `hideOnScroll` | Mechanism | -| --------------------------------- | --------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `ClinicalDashboard` (answer view) | `
` at every width | `strategy: "overlay", allBreakpoints` | Absolute glass top bar translates off; `
` keeps its top reserve; search stays | -| `ClinicalDashboard` (other modes) | `
` at every width | `strategy: "collapse", wide: "collapse"` | Top-bar row collapses; tablet search stays sticky; desktop search portals into `
` page flow | -| `GlobalSearchShell` | `#main-content` on phones, the document above | `strategy: "collapse", wide: "sticky"` | Tablet pins [top bar \| search]; desktop portals search into `#main-content`, leaving a sticky auto-hiding top bar | +| Host | Scrollport | `hideOnScroll` | Mechanism | +| --------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `ClinicalDashboard` (answer view) | Document on browser phones; `
` in standalone and at `sm+` | `strategy: "overlay", allBreakpoints` | Absolute glass top bar translates off; `
` keeps its top reserve; search stays | +| `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 | 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 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. +- **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. - **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. @@ -60,7 +64,7 @@ Rules that keep this working: - **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. - **Collapse-everywhere hosts still drop their own sticky search offset while the top bar is hidden.** Dashboard result composers that clear a visible top bar with `top: 4.75rem + safe-area` must switch to `top: 0` when collapse hide is active — otherwise a dead band the height of the mode bar remains above the search field. - **Rebase the reporter on geometry switches.** Pass `resetKey` when the host changes the scrollport under it (`ClinicalDashboard` passes `searchMode`, which swaps `
`'s header reserve); otherwise the carried-over offset spends the first post-switch scroll on a spurious hide or reveal. Shared mode-home shells should also reset on `pathname` so collapsed chrome/scroll offset does not carry across modes. -- **Do not carry composer focus into submitted result views.** Focus pins both chrome edges for keyboard safety. `GlobalSearchShell` must not pass `focus: true` with `run: true`, must gate `queryInputAutoFocus` on `!hasSubmittedModeSearch`, and must blur the dock input when the result canvas scrolls so hide-on-scroll can reclaim the header and the bottom dock (including its white safe-area rail). +- **Do not carry composer focus into submitted result views.** Focus pins both chrome edges for keyboard safety. `GlobalSearchShell` must not pass `focus: true` with `run: true`, must gate `queryInputAutoFocus` on `!hasSubmittedModeSearch`, and both hosts must blur the dock input when the active result owner scrolls so hide-on-scroll can reclaim the header and bottom dock. Coverage: `tests/header-scroll-hide-contract.test.ts` (wiring), `tests/use-hide-on-scroll.test.ts` (decision logic), `tests/ui-chrome-scroll.spec.ts` (tablet pinned-search behaviour and desktop page-flow search plus top-bar hide/reveal), `tests/ui-phone-scroll.spec.ts` (phone scroll geometry), `tests/ui-therapy-nav-scroll.spec.ts` (Therapy section nav hide/reveal with the top bar). diff --git a/src/app/globals.css b/src/app/globals.css index 0f5985b19..8b3690130 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2332,27 +2332,121 @@ html[data-motion="reduced"] .pwa-notice-card { } /* - * 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. + * Safari only collapses its browser chrome for document scrolling, so phone + * browser tabs keep the shared shell in normal flow and let the document own + * vertical scrolling. `overflow-x: clip` prevents horizontal spill without + * turning `overflow-y: visible` into an implicit element scrollport. + * + * Installed iOS web apps do not have collapsible browser chrome and can + * under-report svh/dvh. Their final override therefore uses the established + * 100vh standalone workaround, still in normal flow, with a bounded inner + * scroll surface. Controls alone consume safe-area padding; hidden chrome + * never adds a content-facing reserve here. */ @media (max-width: 639px) { + .mobile-app-shell, + .phone-viewport-shell { + min-height: 100svh; + height: auto; + } + .phone-viewport-shell { position: relative; + inset: auto; + width: 100%; + overflow: visible; + } + + .phone-viewport-frame { + min-height: 100svh; + height: auto; + overflow: visible; + } + + .phone-scroll-surface { + overflow-x: clip; + overflow-y: visible; + } + + .phone-sticky-header-stack { + position: sticky; + top: 0; + z-index: 30; + } + + .phone-footer-layer { + position: fixed; + } + + .phone-overlay-header { + position: fixed; + inset-inline: 0; + top: 0; width: 100%; + } + + html:has([data-chrome-transitioning="true"]), + html:has([data-reserve-transitioning="true"]), + html:has([data-chrome-transitioning="true"]) body, + html:has([data-reserve-transitioning="true"]) body, + .phone-scroll-surface[data-chrome-transitioning="true"], + .phone-scroll-surface[data-reserve-transitioning="true"], + .phone-scroll-surface:has([data-reserve-transitioning="true"]) { + overflow-anchor: none; + } + + /* Browser tabs scroll the document, while installed PWAs scroll this + surface. Focus targets therefore own their clearance via scroll-margin, + which follows whichever ancestor is the active scroller. Keep the + scroll-padding utilities on the surface as the standalone enhancement. */ + .phone-scroll-surface + :where( + a[href], + button, + input, + select, + textarea, + [contenteditable="true"], + [tabindex]:not([tabindex="-1"]) + ):focus { + scroll-margin-block-start: calc(4.5rem + max(0.5rem, var(--safe-area-top))); + scroll-margin-block-end: var(--phone-focus-bottom-clearance, var(--mobile-composer-reserve, 0rem)); + } + } + + @media (max-width: 639px) and (display-mode: standalone) { + .mobile-app-shell, + .phone-viewport-shell { min-height: 100vh; height: 100vh; + overflow: hidden; } - } - @supports (height: 100svh) { - @media (max-width: 639px) { - .phone-viewport-shell { - min-height: 100svh; - height: 100svh; - } + .phone-viewport-frame { + position: relative; + min-height: 0; + height: 100%; + overflow: hidden; + } + + .phone-scroll-surface { + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior-y: contain; + -webkit-overflow-scrolling: touch; + } + + .phone-overlay-header { + position: absolute; + } + + .phone-sticky-header-stack { + position: static; + } + + .phone-footer-layer { + position: absolute; } } @@ -2361,13 +2455,8 @@ html[data-motion="reduced"] .pwa-notice-card { } @supports (height: 100dvh) { - .mobile-app-shell { - min-height: 100dvh; - height: 100dvh; - } - - @media (max-width: 639px) { - .phone-viewport-shell { + @media (min-width: 640px) { + .mobile-app-shell { min-height: 100dvh; height: 100dvh; } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 181d1428c..29cd4a7ad 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -34,6 +34,7 @@ import { type DocumentDeleteResult } from "@/components/DocumentManagementAction import { useUploadDesktopLayout } from "@/components/clinical-dashboard/use-upload-desktop-layout"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import { resolveScrollBehavior } from "@/lib/scroll-behavior"; +import { ownsVerticalScroll, scrollSurface } from "@/components/clinical-dashboard/scroll-surface"; import { isLocalNoAuthMode, resolveClientDemoMode, resolveUploadReadOnlyMode } from "@/lib/client-env"; import { isAdministratorUser } from "@/lib/authorization"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; @@ -79,6 +80,7 @@ import { } from "@/components/clinical-dashboard/answer-progress"; import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-map-model"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; +import { PhoneFooterLayerFrame } from "@/components/clinical-dashboard/phone-footer-layer-portal"; import { resolveDashboardVisibleMobileComposerReserve, resolveMobileComposerReserve, @@ -89,6 +91,7 @@ import { useDashboardShellActions } from "@/components/clinical-dashboard/use-da import { focusComposerInput as scheduleComposerFocus } from "@/components/clinical-dashboard/focus-composer-input"; import { readChromeCollapseMetrics, + useDocumentScrollHideReporter, useReserveTransitionMarker, useScrollHideReporter, } from "@/components/clinical-dashboard/use-hide-on-scroll"; @@ -366,8 +369,9 @@ export function ClinicalDashboard({ const chromeScrollHide = useScrollHideReporter(false, true, searchMode); const [bottomComposerHidden, setBottomComposerHidden] = useState(false); const reserveTransitioning = useReserveTransitionMarker(bottomComposerHidden, searchMode); - const reportChromeScrollHideRef = useRef(chromeScrollHide.reportScroll); - reportChromeScrollHideRef.current = chromeScrollHide.reportScroll; + const chromeTransitioning = useReserveTransitionMarker(chromeScrollHide.hidden, searchMode); + const reportChromeScrollHide = useEventCallback(chromeScrollHide.reportScroll); + useDocumentScrollHideReporter(chromeScrollHide.reportScroll, mainScrollRoot, composerInputRef); const [modeSearchSubmitted, setModeSearchSubmitted] = useState(() => Boolean(autoRunSearch && initialQuery.trim() && initialSearchMode !== "tools"), ); @@ -441,7 +445,7 @@ export function ClinicalDashboard({ } threadRestoreScrolledRef.current = true; window.requestAnimationFrame(() => { - mainRef.current?.scrollTo({ top: mainRef.current?.scrollHeight ?? 0, behavior: "auto" }); + scrollSurface(mainRef.current, "end"); }); }, [answer]); const resetAnswerThread = useCallback(() => { @@ -1946,7 +1950,7 @@ export function ClinicalDashboard({ setLoading(false); setError(null); rememberRecentQuery(trimmedQuery); - window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() })); + window.requestAnimationFrame(() => scrollSurface(mainRef.current, 0, resolveScrollBehavior())); return; } if (!canRunSearch) { @@ -2159,8 +2163,7 @@ export function ClinicalDashboard({ ); if (isAnswerFollowUp) { window.requestAnimationFrame(() => { - const main = mainRef.current; - main?.scrollTo({ top: main.scrollHeight, behavior: resolveScrollBehavior() }); + scrollSurface(mainRef.current, "end", resolveScrollBehavior()); }); } } @@ -2195,7 +2198,7 @@ export function ClinicalDashboard({ setError(null); setAnswerProgress(null); rememberRecentQuery(trimmedSearchText); - window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() })); + window.requestAnimationFrame(() => scrollSurface(mainRef.current, 0, resolveScrollBehavior())); if (updateUrl) { router.replace(appModeHomeHref("prescribing", { query: trimmedSearchText, queryMode, scopeFilters })); } @@ -2362,7 +2365,7 @@ export function ClinicalDashboard({ setSearchMode(mode); router.push(href); window.requestAnimationFrame(() => { - mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() }); + scrollSurface(mainRef.current, 0, resolveScrollBehavior()); }); } @@ -2438,7 +2441,7 @@ export function ClinicalDashboard({ function answerFromDocument(documentId: string) { setSelectedDocumentIds([documentId]); setSearchMode("answer"); - window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() })); + window.requestAnimationFrame(() => scrollSurface(mainRef.current, 0, resolveScrollBehavior())); } function updateDocumentSearchUrl( @@ -2469,7 +2472,7 @@ export function ClinicalDashboard({ setError(null); setAnswerProgress(null); rememberRecentQuery(trimmedSearchText); - window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() })); + window.requestAnimationFrame(() => scrollSurface(mainRef.current, 0, resolveScrollBehavior())); if (updateUrl) { router.push( documentsSearchHref({ @@ -2504,7 +2507,7 @@ export function ClinicalDashboard({ setSourceGovernanceWarnings([]); setAnswerViewMode("high_yield"); rememberRecentQuery(trimmedSearchText); - window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() })); + window.requestAnimationFrame(() => scrollSurface(mainRef.current, 0, resolveScrollBehavior())); if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode, filtersOverride); const abortController = replaceOwnedAbortController(searchAbortRef); @@ -2664,7 +2667,7 @@ export function ClinicalDashboard({ // Dashboard-internal mode flips keep the same scroller; jump to top so // Answer ↔ Documents does not inherit a mid-page offset + collapsed chrome. window.requestAnimationFrame(() => { - mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() }); + scrollSurface(mainRef.current, 0, resolveScrollBehavior()); }); } @@ -2708,7 +2711,7 @@ export function ClinicalDashboard({ setAnswerViewMode("high_yield"); router.replace(href); window.requestAnimationFrame(() => { - mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() }); + scrollSurface(mainRef.current, 0, resolveScrollBehavior()); }); focusComposerInput(); } @@ -2783,7 +2786,7 @@ export function ClinicalDashboard({ if (href === "#search") { setActiveHash(href); - main.scrollTo({ top: 0, behavior: "auto" }); + scrollSurface(main, 0); if (shouldUpdateHistory) window.history.replaceState(null, "", href); navSyncLockRef.current = window.setTimeout(() => { navSyncLockRef.current = null; @@ -2794,31 +2797,31 @@ export function ClinicalDashboard({ const target = document.querySelector(href); if (!target) return; setActiveHash(href); - const mainTop = main.getBoundingClientRect().top; const targetTop = target.getBoundingClientRect().top; - main.scrollTo({ - top: main.scrollTop + targetTop - mainTop - 8, - behavior: "auto", - }); + const top = ownsVerticalScroll(main) + ? main.scrollTop + targetTop - main.getBoundingClientRect().top - 8 + : window.scrollY + targetTop - 8; + scrollSurface(main, top); if (shouldUpdateHistory) window.history.replaceState(null, "", href); navSyncLockRef.current = window.setTimeout(() => { navSyncLockRef.current = null; }, 350); } - function syncActiveSectionFromScroll() { + const syncActiveSectionFromScroll = useCallback(() => { const main = mainRef.current; if (!main) return; if (main.scrollLeft !== 0) main.scrollLeft = 0; if (navSyncLockRef.current !== null) return; - if (main.scrollTop < 120) { + const innerScrollOwner = ownsVerticalScroll(main); + const offset = innerScrollOwner ? main.scrollTop : window.scrollY; + if (offset < 120) { setActiveHash((current) => (current === "#search" ? current : "#search")); return; } - const mainTop = main.getBoundingClientRect().top; - const marker = mainTop + 96; + const marker = (innerScrollOwner ? main.getBoundingClientRect().top : 0) + 96; const sections = ["#quotes", "#images", "#sources"]; const current = sections @@ -2832,20 +2835,25 @@ export function ClinicalDashboard({ .filter((item): item is { section: string; distance: number } => Boolean(item)) .sort((a, b) => a.distance - b.distance)[0]?.section ?? "#search"; setActiveHash((active) => (active === current ? active : current)); - } + }, []); - function scheduleActiveSectionSync() { + const scheduleActiveSectionSync = useCallback(() => { if (scrollFrameRef.current !== null) return; scrollFrameRef.current = window.requestAnimationFrame(() => { scrollFrameRef.current = null; syncActiveSectionFromScroll(); }); - } + }, [syncActiveSectionFromScroll]); function handleMainScroll() { scheduleActiveSectionSync(); } + useEffect(() => { + window.addEventListener("scroll", scheduleActiveSectionSync, { passive: true }); + return () => window.removeEventListener("scroll", scheduleActiveSectionSync); + }, [scheduleActiveSectionSync]); + useEffect(() => { const main = mainScrollRoot; if (!main) return undefined; @@ -2855,7 +2863,7 @@ export function ClinicalDashboard({ if (frame) return; frame = window.requestAnimationFrame(() => { frame = 0; - reportChromeScrollHideRef.current({ + reportChromeScrollHide({ offset: main.scrollTop, maxOffset: Math.max(0, main.scrollHeight - main.clientHeight), ...readChromeCollapseMetrics(main), @@ -2870,7 +2878,7 @@ export function ClinicalDashboard({ main.removeEventListener("scroll", onScroll); if (frame) window.cancelAnimationFrame(frame); }; - }, [mainScrollRoot]); + }, [mainScrollRoot, reportChromeScrollHide]); async function copyText(action: string, text: string) { let copied = false; @@ -3314,9 +3322,8 @@ export function ClinicalDashboard({
bounded. + "mobile-app-shell phone-viewport-shell flex flex-col text-[color:var(--text)] sm:overflow-hidden md:grid md:grid-cols-[5.25rem_minmax(0,1fr)]", 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)]", @@ -3344,8 +3351,7 @@ export function ClinicalDashboard({ onPrefetchApplications={prefetchApplications} showAccountLibrary={favouritesAccessible} /> - -
+ at every width - // (main reserves matching top padding) so content frosts under the - // glass bar, and it slides away/returns with scroll direction. Other - // modes collapse the header row instead — an absolute header would - // bury their in-flow composer — which works at every width here - // because
is the scrollport at every width, so the released - // strip goes straight to the content. + // Answer view: the header overlays
at every width (main reserves + // matching top padding) so content frosts under the glass bar, and it + // slides away/returns with scroll direction. Other modes collapse the + // row so an absolute header cannot bury their in-flow composer. Both + // document and bounded app scrollports feed the shared hide reporter. hideOnScroll={ searchMode === "answer" ? { strategy: "overlay", allBreakpoints: true, scrollHidden: chromeScrollHide.hidden } @@ -3431,16 +3435,14 @@ export function ClinicalDashboard({ onScroll={handleMainScroll} data-bottom-composer-hidden={bottomComposerHidden ? "true" : undefined} data-reserve-transitioning={reserveTransitioning ? "true" : undefined} + data-chrome-transitioning={chromeTransitioning ? "true" : undefined} className={cn( - "min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain [-webkit-overflow-scrolling:touch] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]", - // Answer view: the glass header is absolute over this scroll container, - // so
reserves its exact height as top padding (72px borderless - // bar = 4rem content/padding + the max(0.5rem, safe-area) top inset — - // measured; must stay 1:1 with the rendered #search height so all the - // dvh-based section floors below keep their meaning). Padding, not - // margin: padding scrolls with content, which is what lets it slide - // up and frost beneath the bar. Kept constant when the header - // scroll-hides — the reserve lives at scroll-start, already off-screen + "phone-scroll-surface min-h-0 flex-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)] sm:overflow-x-hidden sm:overflow-y-auto sm:overscroll-contain sm:[-webkit-overflow-scrolling:touch]", + // Answer view: the glass header is absolute over this surface, so + //
reserves its measured height 1:1 with #search so all the + // section floors below keep their meaning). Padding scrolls with + // content so it can frost beneath the bar. It stays constant when + // scroll-hidden: the reserve is at scroll-start, already off-screen // whenever the header is hidden, so reclaiming it would only jump // the content. searchMode === "answer" && @@ -3448,7 +3450,7 @@ export function ClinicalDashboard({ searchMode === "answer" ? compactMobileModeHome ? "mb-0" - : // Keep the phone scrollport edge-to-edge and reserve the visible + : // Keep the phone content surface edge-to-edge and reserve the visible // dock inside its scrollable content. Padding can collapse when the // dock hides without exposing the app-shell background; the // bottom-clamp guard in use-hide-on-scroll prevents false reveals. @@ -3466,7 +3468,8 @@ export function ClinicalDashboard({ // column top and hide behind the glass bar. Sticky so the recovery // actions stay reachable while the user scrolls — pinned below the // overlaid glass bar in answer mode, just under the in-flow header - // otherwise (main is the scroll container, so sticky works here). + // otherwise. Sticky resolves against the document in a phone browser + // and against
on the bounded app/tablet surfaces.
-
+
); } diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index ac7453600..b3b3194f3 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; +import { PhoneFooterLayerPortal } from "@/components/clinical-dashboard/phone-footer-layer-portal"; import { PhoneHeaderCollapsePortal } from "@/components/clinical-dashboard/phone-header-collapse-portal"; import { PdfPreviewLoading } from "@/components/document-viewer/pdf-preview-loading"; import { @@ -1216,7 +1217,7 @@ export function DocumentViewer({ // beneath its translucent toolbar instead of showing a blank band. composerScrollHidden ? "max-sm:pb-0" - : "max-sm:pb-[calc(9rem+var(--safe-area-bottom)+var(--keyboard-height,0px))]", + : "max-sm:pb-[calc(9rem+var(--safe-area-bottom)+var(--keyboard-height,0px))] max-sm:[--phone-focus-bottom-clearance:calc(9rem+var(--safe-area-bottom)+var(--keyboard-height,0px))]", )} > {downloadError ? ( @@ -1642,54 +1643,56 @@ export function DocumentViewer({ {readyDocument ? ( -
{ - event.preventDefault(); - if (canSummarizeDocument) void summarize(); - }} - data-scroll-hidden={composerScrollHidden ? "true" : undefined} - onFocusCapture={() => setComposerChromeFocused(true)} - onBlurCapture={(event) => { - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setComposerChromeFocused(false); - }} - className={cn( - glassOverlaySurface, - "document-viewer-composer floating-composer-edge dashboard-composer-edge fixed z-40 mx-auto flex min-h-[56px] max-w-3xl items-center gap-2 rounded-full bg-[color:var(--surface-lux)] px-2 shadow-[var(--shadow-lux)] max-sm:transition-[transform,opacity] motion-reduce:transition-none", - composerScrollHidden - ? "max-sm:duration-[240ms] max-sm:ease-[cubic-bezier(0.4,0,0.2,1)]" - : "max-sm:duration-200 max-sm:ease-[cubic-bezier(0.22,1,0.36,1)]", - )} - > - - - -
+ > + + + + + ) : null}
); diff --git a/src/components/calculators/search-page.tsx b/src/components/calculators/search-page.tsx index e0cc2c3fd..6c55216a1 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -19,6 +19,7 @@ import { import { useEffect, useMemo, useRef, useState } from "react"; import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; +import { PhoneFooterLayerPortal } from "@/components/clinical-dashboard/phone-footer-layer-portal"; import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout"; import { useHideOnScroll, useReserveTransitionMarker } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { PrivacyInputNotice } from "@/components/privacy-input-notice"; @@ -504,16 +505,17 @@ export function CalculatorsSearchPage() { // Hide the bottom composer dock on scroll-down in lockstep with the shell's // top header, using the same hook (identical thresholds, phone-only, inert on - // desktop). On phones the shell's #main-content owns vertical scroll - // (max-sm:overflow-y-auto) and drives the header hide; the inner - // searchPageShell
has no vertical overflow, so it never scrolls. Point - // the hook at #main-content so the dock reacts to the same scroll events. The - // hook polls the ref until the shell element resolves. + // desktop). Browser phones scroll the document so Safari can minimize its + // chrome; standalone mode keeps #main-content as its bounded scroller. Feed + // both possible owners and combine them so this page-owned dock follows the + // same active source as the shell header. const scrollContainerRef = useRef(null); useEffect(() => { scrollContainerRef.current = document.querySelector("#main-content"); }, []); - const footerHidden = useHideOnScroll({ containerRef: scrollContainerRef }); + const innerFooterHidden = useHideOnScroll({ containerRef: scrollContainerRef }); + const documentFooterHidden = useHideOnScroll({ documentCollapseRootRef: scrollContainerRef }); + const footerHidden = innerFooterHidden || documentFooterHidden; // Keep the phone dock visible while focused so scroll-hide cannot slide a // focused input off-screen or mark it aria-hidden while still tabbable. const [dockFocused, setDockFocused] = useState(false); @@ -553,7 +555,9 @@ export function CalculatorsSearchPage() { // Page-owned phone dock: shell composer is hidden, so clear space here. // Collapse with the dock on scroll-hide so content reaches the viewport edge. className={cn( - !dockHidden && !activeCalc && "max-sm:pb-[calc(5.5rem+var(--safe-area-bottom))]", + !dockHidden && + !activeCalc && + "max-sm:pb-[calc(5.5rem+var(--safe-area-bottom))] max-sm:[--phone-focus-bottom-clearance:calc(9rem+var(--safe-area-bottom))]", dockHidden && "max-sm:pb-0", )} header={ @@ -675,31 +679,33 @@ export function CalculatorsSearchPage() { placement, and slides away on scroll-down in lockstep with the header. Hidden while a calculator sheet is open. */} {activeCalc ? null : ( -
setDockFocused(true)} - onBlurCapture={(event) => { - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDockFocused(false); - }} - className={cn( - "answer-footer-search-dock answer-footer-search-edge fixed inset-x-0 bottom-0 z-40 px-3 pb-[calc(0.75rem+var(--safe-area-bottom))] pt-3 transition-[transform,opacity] motion-reduce:transition-none sm:hidden", - dockHidden - ? "pointer-events-none duration-[240ms] ease-[cubic-bezier(0.4,0,0.2,1)]" - : "duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]", - )} - aria-hidden={dockHidden} - inert={dockHidden || undefined} - > - + +
setDockFocused(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDockFocused(false); + }} + className={cn( + "phone-footer-layer answer-footer-search-dock answer-footer-search-edge inset-x-0 bottom-0 z-40 px-3 pb-[calc(0.75rem+var(--safe-area-bottom))] pt-3 transition-[transform,opacity] motion-reduce:transition-none sm:hidden", + dockHidden + ? "pointer-events-none duration-[240ms] ease-[cubic-bezier(0.4,0,0.2,1)]" + : "duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]", + )} + aria-hidden={dockHidden} + inert={dockHidden || undefined} + > + + )} {activeCalc ? ( diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index e5cb84174..c91386cac 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -29,6 +29,7 @@ import { GuideDialog } from "@/components/clinical-dashboard/dashboard-shell"; import { landingModeForPreference, readAppPreferences } from "@/components/clinical-dashboard/use-app-preferences"; import { useFavouritesAccess } from "@/components/clinical-dashboard/use-favourites-access"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; +import { PhoneFooterLayerFrame } from "@/components/clinical-dashboard/phone-footer-layer-portal"; import { isPageOwnedComposerRoute, resolveMobileComposerReserve, @@ -314,8 +315,9 @@ function GlobalStandaloneSearchShellBody({ const [mainElement, setMainElement] = useState(null); // The header hides at every breakpoint; only the phone bottom dock stays // phone-gated (MasterSearchHeader keeps that behind its own phone layout - // check). #main-content is the scrollport on phones and the document is the - // scrollport above them, so both sources feed the same reporter. + // check). Browser phones and sm+ use document scrolling; standalone phones + // keep #main-content as the bounded app scroller, so both sources feed one + // reporter. // resetKey=pathname clears carried-over hide state across shared mode homes. const chromeScrollHide = useScrollHideReporter(false, true, pathname); const reportChromeScrollHideRef = useRef(chromeScrollHide.reportScroll); @@ -328,7 +330,8 @@ function GlobalStandaloneSearchShellBody({ setBottomComposerHidden(false); } const reserveTransitioning = useReserveTransitionMarker(bottomComposerHidden, pathname); - useDocumentScrollHideReporter(chromeScrollHide.reportScroll); + const chromeTransitioning = useReserveTransitionMarker(chromeScrollHide.hidden, pathname); + useDocumentScrollHideReporter(chromeScrollHide.reportScroll, mainElement, inputRef); useEffect(() => { reportChromeScrollHideRef.current = chromeScrollHide.reportScroll; }, [chromeScrollHide.reportScroll]); @@ -337,6 +340,7 @@ function GlobalStandaloneSearchShellBody({ useEffect(() => { const main = document.getElementById("main-content"); if (main instanceof HTMLElement) main.scrollTop = 0; + window.scrollTo(0, 0); }, [pathname]); const visibleShellModes = useMemo(() => { const modes = visibleAppModeDefinitions(); @@ -709,11 +713,10 @@ function GlobalStandaloneSearchShellBody({ return (
) : null} -
+ {/* - `contents` above the phone breakpoint: the chrome wrapper pins itself - to the viewport top there, and a plain block here would be a - header-height containing block that leaves that sticky rule no travel - (the header then just scrolled off the page with the content). + `contents` at every visible breakpoint: the chrome wrapper pins itself + to the viewport top, and a plain block here would be a header-height + containing block that leaves that sticky rule no travel (the header + then reports revealed while remaining above the viewport). */} -
+
-
+ 0fr grid row animates the collapse // without height measurement; the bottom-anchored inner track slides the - // top bar up out of the viewport. Fixed-position phone docks escape this - // tree via `position: fixed`; hero composers portal out. + // top bar up out of the viewport. Phone footer layers escape its geometry: + // viewport-fixed in browser tabs, shell-absolute in standalone; hero + // composers portal out. // // Above the phone breakpoint a `wide: "sticky"` host scrolls the document, // so an outer sticky stack pins its chrome below the wide safe-area spacer. // Tablet search remains in that stack; desktop result search portals - // into page flow, so the stack contains only the top bar there. Return a fragment (never a - // wrapping block): GlobalSearchShell uses `sm:contents` on the chrome - // parent so sticky can travel against the viewport. + // into page flow, so the stack contains only the top bar there. The host + // ancestor uses `display: contents`, allowing this semantic sticky owner to + // travel against the browser viewport and become static in standalone. const collapsingTopBar = (
+
{chromeSafeAreaTop}
{collapsingTopBar} {searchComposer}
- +
); } return ( - <> +
{chromeSafeAreaTop} {collapsingTopBar} {searchComposer} - +
); } diff --git a/src/components/clinical-dashboard/phone-footer-layer-portal.tsx b/src/components/clinical-dashboard/phone-footer-layer-portal.tsx new file mode 100644 index 000000000..61090cd66 --- /dev/null +++ b/src/components/clinical-dashboard/phone-footer-layer-portal.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { createContext, useCallback, useContext, useLayoutEffect, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; + +const PhoneFooterLayerHostContext = createContext(null); + +/** + * Provides one paint-free footer host inside the current phone viewport frame. + * The host is rendered after the frame's scroll surface so standalone footer + * layers can anchor to the frame instead of scrolling with page content. + */ +export function PhoneFooterLayerFrame({ children, className }: { children: ReactNode; className: string }) { + const [host, setHost] = useState(null); + const assignHost = useCallback((node: HTMLDivElement | null) => { + setHost((current) => (current === node ? current : node)); + }, []); + + return ( + +
+ {children} +
+
+ + ); +} + +/** + * Moves a page-owned footer into the current viewport frame on phones. At sm+ + * (and when rendered outside a shell) the same subtree remains inline, keeping + * existing tablet/desktop placement and a safe shell-less fallback. + */ +export function PhoneFooterLayerPortal({ children }: { children: ReactNode }) { + const host = useContext(PhoneFooterLayerHostContext); + const [isPhone, setIsPhone] = useState(false); + + useLayoutEffect(() => { + const phoneMedia = window.matchMedia("(max-width: 639px)"); + const sync = () => setIsPhone(phoneMedia.matches); + + sync(); + phoneMedia.addEventListener("change", sync); + return () => phoneMedia.removeEventListener("change", sync); + }, []); + + return isPhone && host ? createPortal(children, host) : children; +} diff --git a/src/components/clinical-dashboard/scroll-surface.ts b/src/components/clinical-dashboard/scroll-surface.ts new file mode 100644 index 000000000..2dfca5494 --- /dev/null +++ b/src/components/clinical-dashboard/scroll-surface.ts @@ -0,0 +1,20 @@ +export type ScrollSurfaceTarget = number | "end"; + +export function ownsVerticalScroll(element: HTMLElement) { + const overflowY = window.getComputedStyle(element).overflowY; + return overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay"; +} + +export function scrollSurface( + element: HTMLElement | null, + target: ScrollSurfaceTarget, + behavior: ScrollBehavior = "auto", +) { + if (!element) return; + if (ownsVerticalScroll(element)) { + element.scrollTo({ top: target === "end" ? element.scrollHeight : target, behavior }); + return; + } + const scrollingElement = document.scrollingElement ?? document.documentElement; + window.scrollTo({ top: target === "end" ? scrollingElement.scrollHeight : target, behavior }); +} diff --git a/src/components/clinical-dashboard/use-document-viewer-chrome-scroll.ts b/src/components/clinical-dashboard/use-document-viewer-chrome-scroll.ts index 4db9127d5..169e796f9 100644 --- a/src/components/clinical-dashboard/use-document-viewer-chrome-scroll.ts +++ b/src/components/clinical-dashboard/use-document-viewer-chrome-scroll.ts @@ -12,10 +12,21 @@ export function useDocumentViewerChromeScroll( composerChromeFocused: boolean, ) { const resetKey = `${documentId}:${activePage}:${activeChunkId ?? ""}`; - const scrollHidden = useHideOnScroll({ - ...(shellScrollContainer ? { scrollContainer: shellScrollContainer } : {}), + // Safari browser tabs deliberately scroll the document so its own toolbar + // can collapse. Installed PWAs retain #main-content as a bounded scroller. + // Observe both potential owners and combine their identical state machines; + // only the active owner emits movement. Binding only to the always-present + // #main-content leaves DocumentViewer's footer pinned open in Safari. + const innerScrollHidden = useHideOnScroll({ + scrollContainer: shellScrollContainer, + disabled: !shellScrollContainer, resetKey, }); + const documentScrollHidden = useHideOnScroll({ + documentCollapseRoot: shellScrollContainer, + resetKey, + }); + const scrollHidden = innerScrollHidden || documentScrollHidden; const composerScrollHidden = scrollHidden && !mobileActionsOpen && !composerChromeFocused; const reserveTransitioning = useReserveTransitionMarker(composerScrollHidden, resetKey); return { composerScrollHidden, reserveTransitioning }; diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index cf7e0ffcc..c13e3c455 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -284,6 +284,10 @@ function useScrollHideActive(disabled = false, allowAllBreakpoints = false) { /** Matches phone reserve padding transition duration in `globals.css`. */ export const reserveTransitionMs = 240; +// React flips the marker from an effect, while the browser starts/finishes the +// CSS transition on animation frames. Keep anchoring suspended for a few frames +// past the nominal duration so the final subpixel settles before it is restored. +const reserveTransitionSettleMs = 64; /** * Keeps a short-lived transition marker active through both composer hide and @@ -305,7 +309,7 @@ export function useReserveTransitionMarker(hidden: boolean, resetKey?: unknown) if (hidden === previousHiddenRef.current) return; previousHiddenRef.current = hidden; setTransitioning(true); - const timer = window.setTimeout(() => setTransitioning(false), reserveTransitionMs); + const timer = window.setTimeout(() => setTransitioning(false), reserveTransitionMs + reserveTransitionSettleMs); return () => window.clearTimeout(timer); }, [hidden, resetKey]); @@ -430,15 +434,17 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa } /** - * Feeds document scroll into a {@link useScrollHideReporter} for hosts whose - * 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 viewport-bounded and overflow-clipped) no - * scroll event arrives, - * so the internal scroller stays the single source at that width. + * Feeds document scroll into a {@link useScrollHideReporter}. Phone browser + * tabs deliberately use the document as their scroll owner so Safari can + * minimize its browser chrome; installed standalone mode keeps the bounded + * inner app scroller and therefore does not emit document scroll events. + * Tablet/desktop document scrolling still has no collapsing-layout cost. */ -export function useDocumentScrollHideReporter(reportScroll: (metrics: ScrollMetrics) => void) { +export function useDocumentScrollHideReporter( + reportScroll: (metrics: ScrollMetrics) => void, + collapseMetricsRoot?: HTMLElement | null, + focusInputRef?: RefObject, +) { useEffect(() => { let frame = 0; @@ -447,12 +453,25 @@ export function useDocumentScrollHideReporter(reportScroll: (metrics: ScrollMetr const scrollingElement = document.scrollingElement ?? document.documentElement; const maxOffset = Math.max(0, scrollingElement.scrollHeight - window.innerHeight); if (maxOffset <= 0) return; + const offset = window.scrollY; + if ( + window.matchMedia(phoneMediaQuery).matches && + offset > topRevealOffset && + focusInputRef?.current && + document.activeElement === focusInputRef.current + ) { + focusInputRef.current.blur(); + } reportScroll({ - offset: window.scrollY, + offset, maxOffset, - // Chrome that sticks to the viewport and translates away releases no - // document layout, so there is no runway to protect here. - collapseBudget: 0, + ...(window.matchMedia(phoneMediaQuery).matches && collapseMetricsRoot + ? readChromeCollapseMetrics(collapseMetricsRoot) + : { + // Wide chrome sticks to the viewport and translates away, so it + // releases no document layout and needs no protected runway. + collapseBudget: 0, + }), source: window, }); }; @@ -467,7 +486,7 @@ export function useDocumentScrollHideReporter(reportScroll: (metrics: ScrollMetr window.removeEventListener("scroll", onScroll); if (frame) window.cancelAnimationFrame(frame); }; - }, [reportScroll]); + }, [collapseMetricsRoot, focusInputRef, reportScroll]); } interface UseHideOnScrollOptions { @@ -478,6 +497,10 @@ interface UseHideOnScrollOptions { containerRef?: RefObject; /** Resolved scroll container; preferred over containerRef when the host sets it via callback ref. */ scrollContainer?: HTMLElement | null; + /** Layout root used to measure collapse cost when the document owns scrolling. */ + documentCollapseRootRef?: RefObject; + /** Resolved layout root; preferred when the host tracks a remounting shell element in state. */ + documentCollapseRoot?: HTMLElement | null; /** Disables the behavior entirely (state resets to visible). */ disabled?: boolean; /** Resets hidden state when the host changes navigation context without remounting. */ @@ -494,6 +517,8 @@ interface UseHideOnScrollOptions { export function useHideOnScroll({ containerRef, scrollContainer = null, + documentCollapseRootRef, + documentCollapseRoot = null, disabled = false, resetKey, }: UseHideOnScrollOptions): boolean { @@ -525,9 +550,11 @@ export function useHideOnScroll({ }; } const scrollingElement = document.scrollingElement ?? document.documentElement; + const collapseRoot = documentCollapseRoot ?? documentCollapseRootRef?.current; return { offset: window.scrollY, maxOffset: Math.max(0, scrollingElement.scrollHeight - window.innerHeight), + ...(collapseRoot ? readChromeCollapseMetrics(collapseRoot) : {}), source: window, }; }; @@ -574,7 +601,7 @@ export function useHideOnScroll({ if (frame) window.cancelAnimationFrame(frame); if (attachFrame) window.cancelAnimationFrame(attachFrame); }; - }, [active, containerRef, scrollContainer, reportScroll]); + }, [active, containerRef, documentCollapseRoot, documentCollapseRootRef, scrollContainer, reportScroll]); return hidden; } diff --git a/src/components/differentials/differential-presentation-workflow-page.tsx b/src/components/differentials/differential-presentation-workflow-page.tsx index 81e31982c..69075f895 100644 --- a/src/components/differentials/differential-presentation-workflow-page.tsx +++ b/src/components/differentials/differential-presentation-workflow-page.tsx @@ -18,6 +18,7 @@ import { } from "lucide-react"; import { CopyAfterReviewButton } from "@/components/differentials/differential-presentation-actions"; +import { PhoneFooterLayerPortal } from "@/components/clinical-dashboard/phone-footer-layer-portal"; import { cn } from "@/components/ui-primitives"; import { acuteConfusionPresentationWorkflow, @@ -567,19 +568,26 @@ function MobileComparison({ ))}
-
- +
- - Comparing ({workflow.selectedCount}) - - -
+ + + Comparing ({workflow.selectedCount}) + + +
+ ); } diff --git a/src/components/therapy-compass/therapy-compass.css b/src/components/therapy-compass/therapy-compass.css index 2c9717112..0059856c7 100644 --- a/src/components/therapy-compass/therapy-compass.css +++ b/src/components/therapy-compass/therapy-compass.css @@ -1,5 +1,5 @@ .tc-root { - min-height: calc(100dvh - var(--shell-header-h)); + min-height: 0; background: var(--background); color: var(--text); } @@ -1294,10 +1294,16 @@ padding: 32px 40px 40px; } .tc-root .tc-workspace-006 { - min-height: calc(100dvh - var(--shell-header-h)); + min-height: 0; background: var(--background); color: var(--text); } +@media (min-width: 640px) { + .tc-root, + .tc-root .tc-workspace-006 { + min-height: calc(100dvh - var(--shell-header-h)); + } +} .tc-root .tc-screens-brief-screen-001 { max-width: 1240px; margin: 0 auto; diff --git a/src/components/ui-primitives.tsx b/src/components/ui-primitives.tsx index 253aea9ea..c3725ba0f 100644 --- a/src/components/ui-primitives.tsx +++ b/src/components/ui-primitives.tsx @@ -120,7 +120,7 @@ export const searchPageCanvas = "bg-[color:var(--background)] text-[color:var(-- // --mobile-composer-reserve so it can collapse when the dock hides. Do not bake // a second dock-sized safe-area pad into page shells. export const searchPageShell = - "min-h-[calc(100dvh-var(--shell-header-h))] overflow-x-hidden px-3 py-3 pb-4 sm:px-5 sm:py-5 sm:pb-8 lg:px-6"; + "min-h-0 overflow-x-clip px-3 py-3 pb-4 sm:min-h-[calc(100dvh-var(--shell-header-h))] sm:px-5 sm:py-5 sm:pb-8 lg:px-6"; export const searchPageContainer = "mx-auto w-full max-w-[1500px]"; // Canonical content-page width. Detail pages (service / form / differential), // medication record + prescribing workspace, and the forms results view converge diff --git a/tests/clinical-dashboard-merge-artifacts.test.ts b/tests/clinical-dashboard-merge-artifacts.test.ts index 2a89ba511..e9d48f01e 100644 --- a/tests/clinical-dashboard-merge-artifacts.test.ts +++ b/tests/clinical-dashboard-merge-artifacts.test.ts @@ -65,6 +65,20 @@ const mobileComposerReserveSource = readFileSync( type FoundDeclaration = { source: string }; +function cssBlock(source: string, marker: string) { + const markerIndex = source.indexOf(marker); + if (markerIndex < 0) return ""; + const openIndex = source.indexOf("{", markerIndex); + if (openIndex < 0) return ""; + let depth = 0; + for (let index = openIndex; index < source.length; index += 1) { + if (source[index] === "{") depth += 1; + if (source[index] === "}") depth -= 1; + if (depth === 0) return source.slice(markerIndex, index + 1); + } + return ""; +} + function findFunctionDeclaration(name: string): FoundDeclaration | null { for (const file of scannedFiles) { let found: string | null = null; @@ -118,14 +132,45 @@ 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", () => { + it("keeps browser phone shells in document flow and standalone shells bounded", () => { 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;"); + const browserMediaBlock = cssBlock( + globalStylesSource, + "@media (max-width: 639px) {\n .mobile-app-shell,\n .phone-viewport-shell", + ); + const standaloneMediaBlock = cssBlock( + globalStylesSource, + "@media (max-width: 639px) and (display-mode: standalone)", + ); + const phoneShellBlocks = [...browserMediaBlock.matchAll(/\.phone-viewport-shell\s*\{[\s\S]*?\}/g)].map( + ([block]) => block, + ); + const browserPhoneShellSizingBlock = phoneShellBlocks.find((block) => block.includes("min-height: 100svh;")); + const browserPhoneShellPositionBlock = phoneShellBlocks.find((block) => block.includes("position: relative;")); + const standalonePhoneShellBlock = standaloneMediaBlock.match(/\.phone-viewport-shell\s*\{[\s\S]*?\}/)?.[0]; + const browserScrollBlock = browserMediaBlock.match(/\.phone-scroll-surface\s*\{[\s\S]*?\}/)?.[0]; + const standaloneScrollBlock = standaloneMediaBlock.match(/\.phone-scroll-surface\s*\{[\s\S]*?\}/)?.[0]; + const browserFooterBlock = browserMediaBlock.match(/\.phone-footer-layer\s*\{[\s\S]*?\}/)?.[0]; + const standaloneFooterBlock = standaloneMediaBlock.match(/\.phone-footer-layer\s*\{[\s\S]*?\}/)?.[0]; + + expect(browserMediaBlock).not.toBe(""); + expect(standaloneMediaBlock).not.toBe(""); + expect(browserPhoneShellSizingBlock).toContain("height: auto;"); + expect(browserPhoneShellPositionBlock).toContain("overflow: visible;"); + expect(browserPhoneShellPositionBlock).not.toContain("position: fixed;"); + expect(browserScrollBlock).toContain("overflow-y: visible;"); + expect(browserFooterBlock).toContain("position: fixed;"); + + expect(standalonePhoneShellBlock).toContain("min-height: 100vh;"); + expect(standalonePhoneShellBlock).toContain("height: 100vh;"); + expect(standalonePhoneShellBlock).toContain("overflow: hidden;"); + expect(standaloneScrollBlock).toContain("overflow-x: hidden;"); + expect(standaloneScrollBlock).toContain("overscroll-behavior-y: contain;"); + expect(standaloneFooterBlock).toContain("position: absolute;"); + expect(browserMediaBlock).toContain('.phone-scroll-surface[data-chrome-transitioning="true"]'); + expect(browserMediaBlock).toContain('.phone-scroll-surface[data-reserve-transitioning="true"]'); + expect(browserMediaBlock).toContain('.phone-scroll-surface:has([data-reserve-transitioning="true"])'); }); it("releases the Safari toolbar reserve only after phone composers hide", () => { @@ -167,9 +212,7 @@ describe("ClinicalDashboard merge-artifact guards", () => { expect(documentViewerSource).toContain('data-testid="document-viewer-content"'); expect(documentViewerSource).toContain('"max-sm:pb-0"'); - expect(documentViewerSource).toContain( - '"max-sm:pb-[calc(9rem+var(--safe-area-bottom)+var(--keyboard-height,0px))]"', - ); + expect(documentViewerSource).toContain("max-sm:pb-[calc(9rem+var(--safe-area-bottom)+var(--keyboard-height,0px))]"); // Hidden document content must not reintroduce Safari toolbar inset padding. expect(documentViewerSource).not.toMatch(/composerScrollHidden\s*\?\s*["']max-sm:pb-\[calc\([^"']*safe-area/); expect(documentViewerSource).toContain("max-sm:duration-[240ms]"); diff --git a/tests/dashboard-scroll-padding.test.ts b/tests/dashboard-scroll-padding.test.ts index ad30b2041..0bd91853c 100644 --- a/tests/dashboard-scroll-padding.test.ts +++ b/tests/dashboard-scroll-padding.test.ts @@ -3,10 +3,11 @@ import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; -// A11Y-FOCUS-01 (WCAG 2.4.11 — focus not obscured). On phones the composer docks -// over the #main-content scrollport, so the scroll container must reserve -// scroll-padding-bottom = the dock height; otherwise the browser scrolls a -// below-fold Tab target to rest underneath the fixed dock. This reservation only +// A11Y-FOCUS-01 (WCAG 2.4.11 — focus not obscured). Browser phones scroll the +// document so Safari can collapse its own toolbar; installed PWAs keep +// #main-content as their bounded scrollport. Focus targets therefore use an +// inherited scroll-margin while the standalone surface retains scroll-padding. +// This reservation only // renders in specific app states (answer-with-content, and every non-answer view) // that are impractical to reach deterministically in a browser test, so it is // pinned at the source instead — the repo's established pattern for phone @@ -34,13 +35,23 @@ function mainContentOpeningTag(dashboardSource: string): string { describe("dashboard scroll-padding keeps keyboard focus clear of fixed chrome", () => { const dashboard = source("src/components/ClinicalDashboard.tsx"); + const globalStyles = source("src/app/globals.css"); const mainContentTag = mainContentOpeningTag(dashboard); - it("keeps the reservation on the #main-content scroll container", () => { - // The scrollport (overflow-y-auto) and the scroll-padding must be the same - // element — padding on a non-scrolling ancestor would not move focus targets. + it("marks #main-content as the adaptive phone scroll surface", () => { expect(mainContentTag).not.toBe(""); - expect(mainContentTag).toContain("overflow-y-auto"); + expect(mainContentTag).toContain("phone-scroll-surface"); + expect(globalStyles).toMatch(/\.phone-scroll-surface\s*\{[\s\S]*?overflow-y:\s*visible;/); + expect(globalStyles).toMatch( + /@media \(max-width: 639px\) and \(display-mode: standalone\)[\s\S]*?\.phone-scroll-surface\s*\{[\s\S]*?overflow-y:\s*auto;/, + ); + }); + + it("gives focused phone controls clearance in either active scroll owner", () => { + expect(globalStyles).toContain(".phone-scroll-surface"); + expect(globalStyles).toContain("scroll-margin-block-start:"); + expect(globalStyles).toContain("--phone-focus-bottom-clearance"); + expect(globalStyles).toContain("var(--mobile-composer-reserve, 0rem)"); }); it("reserves scroll-padding-bottom in both mobile composer dock branches", () => { diff --git a/tests/header-scroll-hide-contract.test.ts b/tests/header-scroll-hide-contract.test.ts index acc3919c3..a7d4040a6 100644 --- a/tests/header-scroll-hide-contract.test.ts +++ b/tests/header-scroll-hide-contract.test.ts @@ -26,9 +26,13 @@ const dashboardResultComposerSlotSource = read( ); const composerSlotSource = read("src/lib/mode-home-composer.ts"); const phoneHeaderPortalSource = read("src/components/clinical-dashboard/phone-header-collapse-portal.tsx"); +const phoneFooterPortalSource = read("src/components/clinical-dashboard/phone-footer-layer-portal.tsx"); const therapyNavSource = read("src/components/therapy-compass/nav.tsx"); const documentViewerSource = read("src/components/DocumentViewer.tsx"); +const calculatorSearchSource = read("src/components/calculators/search-page.tsx"); +const documentViewerChromeHookSource = read("src/components/clinical-dashboard/use-document-viewer-chrome-scroll.ts"); const differentialDetailSource = read("src/components/differentials/differential-detail-page.tsx"); +const differentialPresentationSource = read("src/components/differentials/differential-presentation-workflow-page.tsx"); const behaviourDocSource = read("docs/search-chrome-behaviour.md"); describe("shared header hide/reveal wiring", () => { @@ -42,21 +46,37 @@ describe("shared header hide/reveal wiring", () => { expect(hookSource).toContain("export function useScrollHideReporter(disabled = false, allowAllBreakpoints = false"); }); - it("feeds GlobalSearchShell the document scroll it uses above the phone breakpoint", () => { - // #main-content is the scrollport only on phones there, so its React - // onScroll never fires on tablet/desktop and the chrome could never hide. + it("feeds both app hosts from browser-phone document scrolling", () => { + // Browser phones need document scrolling for Safari chrome collapse; + // standalone phones retain their bounded main scroller. expect(hookSource).toContain("export function useDocumentScrollHideReporter"); - expect(shellSource).toContain("useDocumentScrollHideReporter(chromeScrollHide.reportScroll)"); + expect(shellSource).toContain( + "useDocumentScrollHideReporter(chromeScrollHide.reportScroll, mainElement, inputRef)", + ); + expect(dashboardSource).toContain( + "useDocumentScrollHideReporter(chromeScrollHide.reportScroll, mainScrollRoot, composerInputRef)", + ); + }); + + it("feeds DocumentViewer footer chrome from both possible phone scroll owners", () => { + expect(documentViewerChromeHookSource).toContain("const innerScrollHidden = useHideOnScroll"); + expect(documentViewerChromeHookSource).toContain("scrollContainer: shellScrollContainer"); + expect(documentViewerChromeHookSource).toContain("const documentScrollHidden = useHideOnScroll"); + expect(documentViewerChromeHookSource).toContain("documentCollapseRoot: shellScrollContainer"); + expect(documentViewerChromeHookSource).toContain("innerScrollHidden || documentScrollHidden"); }); 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"'); - // ClinicalDashboard's
is the scrollport at every width (the shell is - // dvh-tall and overflow-hidden), so the released top-bar strip goes to the - // content. + // ClinicalDashboard uses the document on browser phones and
in + // standalone/sm+; both feed the same collapse reporter. expect(dashboardSource).toContain('{ strategy: "collapse", wide: "collapse"'); + expect(headerSource).toContain('className="phone-sticky-header-stack sm:contents"'); + expect(headerSource).toContain('"phone-overlay-header sm:absolute sm:inset-x-0 sm:top-0"'); + expect(shellSource).toContain('data-chrome-transitioning={chromeTransitioning ? "true" : undefined}'); + expect(dashboardSource).toContain('data-chrome-transitioning={chromeTransitioning ? "true" : undefined}'); }); it("moves submitted search composers into normal page flow on desktop only", () => { @@ -147,7 +167,7 @@ describe("shared header hide/reveal wiring", () => { it("gives the sticky chrome stack real travel against the viewport", () => { // A plain block around the stack is a containing block that leaves sticky // nowhere to stick. `contents` removes that box on GlobalSearchShell. - expect(shellSource).toContain('className={mobileChromeVisible ? "sm:contents" : "hidden lg:contents"}'); + expect(shellSource).toContain('className={mobileChromeVisible ? "contents" : "hidden lg:contents"}'); expect(shellSource).not.toContain('mobileChromeVisible ? undefined : "hidden lg:block"'); // Sticky pins the outer [top bar | search] stack below the wide-layout // safe-area spacer. Translating that whole stack would take the search @@ -155,11 +175,35 @@ 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).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"'); }); + it("uses one adaptive phone footer positioning owner", () => { + expect(headerSource).toContain("phone-footer-layer"); + expect(documentViewerSource).toContain("phone-footer-layer document-viewer-composer"); + expect(calculatorSearchSource).toContain("phone-footer-layer answer-footer-search-dock"); + }); + + it("portals every page-owned phone footer beside the standalone scroller", () => { + expect(phoneFooterPortalSource).toContain("PhoneFooterLayerHostContext"); + expect(phoneFooterPortalSource).toContain('className="phone-footer-layer-host contents"'); + expect(phoneFooterPortalSource).toContain('data-testid="phone-footer-layer-host"'); + expect(phoneFooterPortalSource).toMatch(/\{children\}[\s\S]*phone-footer-layer-host/); + expect(phoneFooterPortalSource).toContain("isPhone && host ? createPortal(children, host) : children"); + expect(phoneFooterPortalSource).toContain('window.matchMedia("(max-width: 639px)")'); + + expect(phoneFooterPortalSource).toContain("export function PhoneFooterLayerFrame"); + expect(shellSource).toContain(""); + expect(documentViewerSource).toContain(""); + expect(differentialPresentationSource).toContain(""); + expect(differentialPresentationSource).toContain('data-testid="differential-presentation-phone-footer"'); + }); + it("releases the phone top safe-area with hidden chrome while retaining the wide inset", () => { // A fixed phone safe-area sibling survives the 0fr header collapse as an // opaque band. It must share the hidden state, while sm+ sticky chrome keeps @@ -187,6 +231,12 @@ describe("shared header hide/reveal wiring", () => { expect(hookSource).toContain("headerRelease + phoneSafeAreaRelease + reserveRelease"); }); + it("only blurs the focused dock input on phone document scroll", () => { + expect(hookSource).toMatch( + /window\.matchMedia\(phoneMediaQuery\)\.matches\s*&&\s*offset > topRevealOffset[\s\S]*?focusInputRef\.current\.blur\(\)/, + ); + }); + it("rebases the reporter when a host swaps its scroll geometry", () => { // ClinicalDashboard toggling answer mode adds/removes
's header // reserve; a carried-over offset spends the first post-switch scroll on a @@ -194,6 +244,10 @@ describe("shared header hide/reveal wiring", () => { expect(hookSource).toContain("}, [allowAllBreakpoints, resetKey]);"); }); + it("holds transition anchoring through the final CSS frame", () => { + expect(hookSource).toContain("reserveTransitionMs + reserveTransitionSettleMs"); + }); + it("keeps the bottom search dock a phone-only behaviour", () => { // The user-visible contract: the top bar hides everywhere, the footer // search bar hides on phones only. Both gates below require the phone layout. diff --git a/tests/playwright-scroll.ts b/tests/playwright-scroll.ts index e92fb7443..3ff605d16 100644 --- a/tests/playwright-scroll.ts +++ b/tests/playwright-scroll.ts @@ -1,5 +1,17 @@ import type { Locator, Page } from "playwright/test"; +export type PrimaryScrollOwner = "main" | "document"; + +export type PrimaryScrollGeometry = { + owner: PrimaryScrollOwner; + scrollTop: number; + scrollHeight: number; + clientHeight: number; + maxScrollTop: number; + viewportTop: number; + viewportBottom: number; +}; + /** * Phone dock clearance in CSS pixels for #main-content. * GlobalSearchShell applies the reserve on an inner pad (so it contributes to @@ -13,11 +25,67 @@ export async function readMobileComposerReservePx(main: Locator): Promise { +/** + * Read the scroll owner that is active in the rendered layout. + * + * Browser-mode phones intentionally leave #main-content in normal flow so the + * document owns vertical scrolling and Safari can minimize its browser chrome. + * Standalone phones and wider layouts keep the bounded inner surface. Merely + * finding #main-content is therefore insufficient: it is the owner only when + * its computed overflow permits scrolling and its content actually overflows. + */ +export async function readPrimaryScrollGeometry(page: Page): Promise { + return page.evaluate(() => { + const main = document.getElementById("main-content"); + const mainOverflowY = main ? window.getComputedStyle(main).overflowY : ""; + const mainOwnsScroll = + main !== null && ["auto", "scroll", "overlay"].includes(mainOverflowY) && main.scrollHeight > main.clientHeight; + const documentScroller = document.scrollingElement ?? document.documentElement; + const owner = mainOwnsScroll ? main : documentScroller; + const ownerRect = mainOwnsScroll ? main.getBoundingClientRect() : null; + + return { + owner: mainOwnsScroll ? "main" : "document", + scrollTop: mainOwnsScroll ? main.scrollTop : window.scrollY, + scrollHeight: owner.scrollHeight, + clientHeight: mainOwnsScroll ? main.clientHeight : window.innerHeight, + maxScrollTop: Math.max(0, owner.scrollHeight - (mainOwnsScroll ? main.clientHeight : window.innerHeight)), + viewportTop: ownerRect?.top ?? 0, + viewportBottom: ownerRect?.bottom ?? window.innerHeight, + } satisfies PrimaryScrollGeometry; + }); +} + +/** Add deterministic runway inside app content without assuming who scrolls it. */ +export async function appendPrimaryScrollSpacer( + page: Page, + { heightPx, testId }: { heightPx: number; testId?: string }, +) { + await page.evaluate( + ({ spacerHeight, spacerTestId }) => { + const spacer = document.createElement("div"); + if (spacerTestId) spacer.dataset.testid = spacerTestId; + spacer.style.height = `${spacerHeight}px`; + (document.getElementById("main-content") ?? document.body).appendChild(spacer); + }, + { spacerHeight: heightPx, spacerTestId: testId }, + ); +} + +/** Scroll whichever surface currently owns app scrolling, or its endpoint. */ +export async function scrollPrimarySurface(page: Page, top: number | "end") { + await page.evaluate(async (requestedTop) => { const main = document.getElementById("main-content"); - if (main) { + const mainOverflowY = main ? window.getComputedStyle(main).overflowY : ""; + const mainOwnsScroll = + main !== null && ["auto", "scroll", "overlay"].includes(mainOverflowY) && main.scrollHeight > main.clientHeight; + const documentScroller = document.scrollingElement ?? document.documentElement; + const owner = mainOwnsScroll ? main : documentScroller; + const clientHeight = mainOwnsScroll ? main.clientHeight : window.innerHeight; + const scrollTop = + requestedTop === "end" ? Math.max(0, owner.scrollHeight - clientHeight) : Math.max(0, requestedTop); + + if (mainOwnsScroll) { main.scrollTo({ top: scrollTop, behavior: "auto" }); // WebKit doesn't reliably emit a native scroll event for programmatic scrollTo. main.dispatchEvent(new Event("scroll")); diff --git a/tests/ui-chrome-scroll.spec.ts b/tests/ui-chrome-scroll.spec.ts index ede2073b7..93aa96e8b 100644 --- a/tests/ui-chrome-scroll.spec.ts +++ b/tests/ui-chrome-scroll.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from "playwright/test"; +import { readPrimaryScrollGeometry } from "./playwright-scroll"; /** * Tablet/desktop top-bar hide-and-return, with breakpoint-specific search ownership. @@ -173,6 +174,25 @@ test.beforeEach(async ({ page }) => { await blockExternalRequests(page); }); +test("1024px bounded main scrolling preserves focused page search", async ({ page }) => { + await page.setViewportSize({ width: 1024, height: 768 }); + await page.goto("/?mode=prescribing&q=a&run=1", { waitUntil: "domcontentloaded" }); + const input = page.getByTestId("global-search-input"); + await expect(input).toBeVisible({ timeout: 15_000 }); + await expect + .poll(async () => (await readPrimaryScrollGeometry(page)).maxScrollTop, { timeout: 20_000 }) + .toBeGreaterThanOrEqual(requiredRunway); + await expect.poll(async () => (await readPrimaryScrollGeometry(page)).owner).toBe("main"); + await input.focus(); + await expect(input).toBeFocused(); + + await scrollBy(page, 320, 80); + + await expect.poll(async () => (await readPrimaryScrollGeometry(page)).owner).toBe("main"); + await expect.poll(async () => (await readPrimaryScrollGeometry(page)).scrollTop).toBeGreaterThan(0); + await expect(input, "bounded desktop main scrolling must preserve deliberate keyboard focus").toBeFocused(); +}); + for (const { name: sizeName, viewport } of breakpoints) { for (const { name: surfaceName, route } of surfaces) { test(`${sizeName}: top bar hides on scroll down and returns mid-page on ${surfaceName}`, async ({ page }) => { diff --git a/tests/ui-formulation.spec.ts b/tests/ui-formulation.spec.ts index 1b2f35147..fb12bfc26 100644 --- a/tests/ui-formulation.spec.ts +++ b/tests/ui-formulation.spec.ts @@ -1,6 +1,7 @@ import AxeBuilder from "@axe-core/playwright"; import { expect, test, type Page, type TestInfo } from "playwright/test"; import { stubZeroTouchPoints } from "./helpers/zero-touch"; +import { readPrimaryScrollGeometry } from "./playwright-scroll"; const axeWcagTags = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]; const axeBlockingImpacts = new Set(["critical", "serious"]); @@ -118,24 +119,26 @@ test("keeps mobile search, domain filtering, record actions, and universal chrom await expectNoBlockingAxeViolations(page, testInfo); }); -test("keeps long mobile formulation pages inside the app scrollport", async ({ page }) => { +test("keeps long mobile formulation pages inside the active app scroll surface", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await gotoApp(page, "/formulation/builder?mechanism=rumination&template=5Ps"); await expect(page.getByRole("heading", { name: "Build a formulation that can be tested" })).toBeVisible(); - const geometry = await page.evaluate(() => { - const main = document.getElementById("main-content"); + const scrollGeometry = await readPrimaryScrollGeometry(page); + const mainGeometry = await page.locator("#main-content").evaluate((main) => { + const rect = main.getBoundingClientRect(); return { - documentClientHeight: document.documentElement.clientHeight, - documentScrollHeight: document.documentElement.scrollHeight, - mainClientHeight: main?.clientHeight ?? 0, - mainScrollHeight: main?.scrollHeight ?? 0, + bottom: rect.bottom, + overflowY: window.getComputedStyle(main).overflowY, + viewportHeight: window.innerHeight, }; }); - expect(geometry.documentScrollHeight - geometry.documentClientHeight).toBeLessThanOrEqual(2); - expect(geometry.mainScrollHeight).toBeGreaterThan(geometry.mainClientHeight + 40); + expect(scrollGeometry.owner).toBe("document"); + expect(scrollGeometry.scrollHeight).toBeGreaterThan(scrollGeometry.clientHeight + 40); + expect(mainGeometry.overflowY).toBe("visible"); + expect(mainGeometry.bottom).toBeGreaterThanOrEqual(mainGeometry.viewportHeight - 1); }); test("keeps unavailable builder navigation natively disabled without fading its text", async ({ page }, testInfo) => { diff --git a/tests/ui-overlay-css-contract.test.ts b/tests/ui-overlay-css-contract.test.ts index e30f63900..f286cecbc 100644 --- a/tests/ui-overlay-css-contract.test.ts +++ b/tests/ui-overlay-css-contract.test.ts @@ -10,11 +10,32 @@ const globalStylesSource = read("src/app/globals.css"); const agentsSource = read("AGENTS.md"); const searchChromeBehaviourSource = read("docs/search-chrome-behaviour.md"); const clinicalDashboardSource = read("src/components/ClinicalDashboard.tsx"); +const globalSearchShellSource = read("src/components/clinical-dashboard/global-search-shell.tsx"); +const uiPrimitivesSource = read("src/components/ui-primitives.tsx"); +const therapyStylesSource = read("src/components/therapy-compass/therapy-compass.css"); +const masterSearchHeaderSource = read("src/components/clinical-dashboard/master-search-header.tsx"); +const documentViewerSource = read("src/components/DocumentViewer.tsx"); +const calculatorSearchSource = read("src/components/calculators/search-page.tsx"); +const differentialPresentationSource = read("src/components/differentials/differential-presentation-workflow-page.tsx"); function occurrenceCount(source: string, value: string) { return source.split(value).length - 1; } +function cssBlock(source: string, marker: string) { + const markerIndex = source.indexOf(marker); + if (markerIndex < 0) return ""; + const openIndex = source.indexOf("{", markerIndex); + if (openIndex < 0) return ""; + let depth = 0; + for (let index = openIndex; index < source.length; index += 1) { + if (source[index] === "{") depth += 1; + if (source[index] === "}") depth -= 1; + if (depth === 0) return source.slice(markerIndex, index + 1); + } + return ""; +} + describe("overlay and global CSS contracts", () => { it("uses the Sheet semantic backdrop without answer-surface call-site overrides", () => { expect(sheetSource).toContain("bg-[color:var(--overlay-backdrop)]"); @@ -91,9 +112,7 @@ describe("overlay and global CSS contracts", () => { expect(globalStylesSource).toContain( '.document-viewer-composer.floating-composer-edge:not([data-scroll-hidden="true"])', ); - expect(read("src/components/DocumentViewer.tsx")).toContain( - '"max-sm:pb-[calc(9rem+var(--safe-area-bottom)+var(--keyboard-height,0px))]"', - ); + expect(documentViewerSource).toContain("max-sm:pb-[calc(9rem+var(--safe-area-bottom)+var(--keyboard-height,0px))]"); }); it("keeps the remembered search chrome rules aligned with the hidden reserve contract", () => { expect(agentsSource).toContain(""); @@ -107,4 +126,85 @@ describe("overlay and global CSS contracts", () => { expect(clinicalDashboardSource).toContain("Hidden dock pad must stay at 0rem"); expect(clinicalDashboardSource).not.toContain("Hidden dock pad must stay at 0.75rem"); }); + + it("uses document scrolling in phone browsers and a bounded standalone scroller", () => { + const browserMediaBlock = cssBlock( + globalStylesSource, + "@media (max-width: 639px) {\n .mobile-app-shell,\n .phone-viewport-shell", + ); + const standaloneMediaBlock = cssBlock( + globalStylesSource, + "@media (max-width: 639px) and (display-mode: standalone)", + ); + const phoneShellBlocks = [...browserMediaBlock.matchAll(/\.phone-viewport-shell\s*\{[\s\S]*?\}/g)].map( + ([block]) => block, + ); + const browserShellSizingBlock = phoneShellBlocks.find((block) => block.includes("min-height: 100svh;")); + const browserShellPositionBlock = phoneShellBlocks.find((block) => block.includes("position: relative;")); + const standaloneShellBlock = standaloneMediaBlock.match(/\.phone-viewport-shell\s*\{[\s\S]*?\}/)?.[0]; + const phoneFrameBlocks = [...browserMediaBlock.matchAll(/\.phone-viewport-frame\s*\{[\s\S]*?\}/g)].map( + ([block]) => block, + ); + const browserFrameBlock = phoneFrameBlocks.find((block) => block.includes("min-height: 100svh;")); + const standaloneFrameBlock = standaloneMediaBlock.match(/\.phone-viewport-frame\s*\{[\s\S]*?\}/)?.[0]; + const phoneScrollBlocks = [...browserMediaBlock.matchAll(/\.phone-scroll-surface\s*\{[\s\S]*?\}/g)].map( + ([block]) => block, + ); + const browserScrollBlock = phoneScrollBlocks.find((block) => block.includes("overflow-x: clip;")); + const standaloneScrollBlock = standaloneMediaBlock.match(/\.phone-scroll-surface\s*\{[\s\S]*?\}/)?.[0]; + const phoneOverlayBlocks = [...browserMediaBlock.matchAll(/\.phone-overlay-header\s*\{[\s\S]*?\}/g)].map( + ([block]) => block, + ); + const standaloneOverlayBlock = standaloneMediaBlock.match(/\.phone-overlay-header\s*\{[\s\S]*?\}/)?.[0]; + 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 standaloneFooterBlock = standaloneMediaBlock.match(/\.phone-footer-layer\s*\{[\s\S]*?\}/)?.[0]; + + expect(browserMediaBlock).not.toBe(""); + expect(standaloneMediaBlock).not.toBe(""); + expect(browserShellSizingBlock).toContain("height: auto;"); + expect(browserShellPositionBlock).toContain("position: relative;"); + expect(browserShellPositionBlock).toContain("inset: auto;"); + expect(browserShellPositionBlock).toContain("overflow: visible;"); + expect(browserFrameBlock).toContain("height: auto;"); + expect(browserFrameBlock).toContain("overflow: visible;"); + expect(browserScrollBlock).toContain("overflow-y: visible;"); + expect(browserStickyHeaderBlock).toContain("position: sticky;"); + expect(browserFooterBlock).toContain("position: fixed;"); + expect(phoneShellBlocks.join("\n")).not.toContain("position: fixed;"); + expect(phoneShellBlocks.join("\n")).not.toContain("inset: 0;"); + + expect(standaloneShellBlock).toContain("min-height: 100vh;"); + expect(standaloneShellBlock).toContain("height: 100vh;"); + expect(standaloneShellBlock).toContain("overflow: hidden;"); + expect(standaloneFrameBlock).toContain("min-height: 0;"); + expect(standaloneFrameBlock).toContain("position: relative;"); + expect(standaloneFrameBlock).toContain("overflow: hidden;"); + expect(standaloneScrollBlock).toContain("overflow-x: hidden;"); + expect(standaloneScrollBlock).toContain("overscroll-behavior-y: contain;"); + expect(standaloneScrollBlock).toContain("-webkit-overflow-scrolling: touch;"); + expect(phoneOverlayBlocks.find((block) => block.includes("position: fixed;"))).toContain("top: 0;"); + expect(standaloneOverlayBlock).toContain("position: absolute;"); + expect(standaloneStickyHeaderBlock).toContain("position: static;"); + expect(standaloneFooterBlock).toContain("position: absolute;"); + expect(browserMediaBlock).toContain('html:has([data-chrome-transitioning="true"])'); + expect(browserMediaBlock).toContain('html:has([data-reserve-transitioning="true"])'); + expect(browserMediaBlock).toContain('.phone-scroll-surface[data-chrome-transitioning="true"]'); + expect(browserMediaBlock).toContain('.phone-scroll-surface[data-reserve-transitioning="true"]'); + expect(browserMediaBlock).toContain('.phone-scroll-surface:has([data-reserve-transitioning="true"])'); + expect(browserMediaBlock).toContain("overflow-anchor: none;"); + expect(browserMediaBlock).toContain("--phone-focus-bottom-clearance"); + expect(browserMediaBlock).toContain("var(--mobile-composer-reserve, 0rem)"); + expect(masterSearchHeaderSource).toContain("phone-footer-layer"); + expect(documentViewerSource).toContain("phone-footer-layer document-viewer-composer"); + expect(calculatorSearchSource).toContain("phone-footer-layer answer-footer-search-dock"); + expect(differentialPresentationSource).toContain("phone-footer-layer inset-x-0 bottom-0"); + expect(differentialPresentationSource).not.toContain('className="fixed inset-x-0 bottom-0'); + expect(globalSearchShellSource).toContain("phone-viewport-shell"); + expect(clinicalDashboardSource).toContain("phone-viewport-shell"); + expect(uiPrimitivesSource).toContain('"min-h-0 overflow-x-clip px-3 py-3 pb-4 sm:min-h-['); + expect(therapyStylesSource).toMatch(/\.tc-root\s*\{\s*min-height:\s*0;/); + expect(therapyStylesSource).toContain("@media (min-width: 640px)"); + }); }); diff --git a/tests/ui-phone-scroll.spec.ts b/tests/ui-phone-scroll.spec.ts index 0d884bc8e..e7234d0dd 100644 --- a/tests/ui-phone-scroll.spec.ts +++ b/tests/ui-phone-scroll.spec.ts @@ -1,12 +1,14 @@ -import { expect, test, type Page } from "playwright/test"; +import { expect, test, type Locator, type Page } from "playwright/test"; /** * Phone scroll-geometry guardrail (the #964 regression class). * * Hiding the phone chrome (header grid collapse + dock reserve-pad shrink) - * releases layout back into the #main-content scrollport. When that release - * exceeds the page's remaining scroll runway, the offset clamps onto the new - * bottom edge and any small upward drag snaps the geometry back — a + * releases layout back into the active phone scroll owner. Browser tabs use + * document scrolling so Safari can collapse its own chrome; installed and + * otherwise bounded contexts may retain #main-content as an inner scroller. + * When a chrome release exceeds the remaining scroll runway, the offset + * clamps onto the new bottom edge and any small upward drag snaps the geometry back — a * hide/reveal oscillation that reads as "scroll locks to the bottom" plus a * blank band below the content. use-hide-on-scroll's collapse-budget gate * refuses such hides; this spec sweeps every phone surface and asserts the @@ -82,6 +84,33 @@ const pageOwnedHeaderRoutes = [ }, ]; +const standalonePageOwnedFooterRoutes = [ + { + name: "calculator composer", + route: "/calculators", + selector: '[data-testid="calculators-phone-dock"]', + focusSelector: 'input[aria-label="Search calculators"]', + reserveSelector: '[data-testid="calculators-search-page"]', + flushBottom: true, + }, + { + name: "document composer", + route: "/documents/11111111-1111-4111-8111-111111111111?page=1", + selector: "form.document-viewer-composer", + focusSelector: 'input[placeholder="Search or answer from this document..."]', + reserveSelector: '[data-testid="document-viewer-content"]', + flushBottom: false, + }, + { + name: "differential comparison actions", + route: "/differentials/presentations/acute-confusion-encephalopathy", + selector: '[data-testid="differential-presentation-phone-footer"]', + focusSelector: null, + reserveSelector: null, + flushBottom: true, + }, +] as const; + const phoneViewport = { width: 390, height: 844 }; async function blockExternalRequests(page: Page) { @@ -110,6 +139,41 @@ async function gotoPhoneSurface(page: Page, path: string, safeAreaBottom = 34) { await page.waitForTimeout(700); } +/** + * Chromium cannot emulate the `display-mode` media feature. Apply the exact + * compiled rules from the app's standalone media block after load so computed + * layout and scrolling still exercise that production cascade. The static CSS + * contract separately pins those declarations inside the standalone query; + * physical iOS acceptance remains the final viewport-paint proof. + */ +function forceCompiledStandalonePhoneCss(page: Page): Promise { + return page.evaluate(() => { + const standaloneRules: string[] = []; + const visit = (rules: CSSRuleList) => { + for (const rule of Array.from(rules)) { + if (rule instanceof CSSMediaRule && rule.conditionText.includes("display-mode: standalone")) { + standaloneRules.push(...Array.from(rule.cssRules, (child) => child.cssText)); + continue; + } + const nestedRules = (rule as CSSRule & { cssRules?: CSSRuleList }).cssRules; + if (nestedRules) visit(nestedRules); + } + }; + for (const sheet of Array.from(document.styleSheets)) { + try { + visit(sheet.cssRules); + } catch { + // Ignore browser/extension sheets whose CSSOM is intentionally opaque. + } + } + const style = document.createElement("style"); + style.dataset.testid = "forced-standalone-phone-css"; + style.textContent = `@layer components { ${standaloneRules.join("\n")} }`; + document.head.append(style); + return standaloneRules.length; + }); +} + async function addPhoneScrollRunway(page: Page) { await page.evaluate(() => { const main = document.getElementById("main-content"); @@ -127,6 +191,7 @@ async function addPhoneScrollRunway(page: Page) { interface ScrollGeometry { scrollTop: number; maxOffset: number; + scrollOwner: "document" | "main"; headerHidden: boolean; docScrollableExcess: number; horizontalOverflow: number; @@ -137,11 +202,17 @@ function readGeometry(page: Page): Promise { return page.evaluate(() => { const main = document.getElementById("main-content"); const header = document.querySelector('[data-testid="universal-header-collapse"]'); - const doc = document.documentElement; + const doc = document.scrollingElement ?? document.documentElement; + const mainOverflowY = main ? getComputedStyle(main).overflowY : ""; + const mainOwnsScroll = Boolean( + main && /^(?:auto|scroll|overlay)$/.test(mainOverflowY) && main.scrollHeight > main.clientHeight + 1, + ); + const scrollOwner = mainOwnsScroll && main ? main : doc; const reserveHost = main?.querySelector('[data-testid="mobile-composer-reserve-pad"]') ?? main; return { - scrollTop: main?.scrollTop ?? 0, - maxOffset: main ? Math.max(0, main.scrollHeight - main.clientHeight) : 0, + scrollTop: scrollOwner.scrollTop, + maxOffset: Math.max(0, scrollOwner.scrollHeight - scrollOwner.clientHeight), + scrollOwner: mainOwnsScroll ? "main" : "document", headerHidden: header?.getAttribute("data-scroll-hidden") === "true", docScrollableExcess: doc.scrollHeight - doc.clientHeight, horizontalOverflow: Math.max(doc.scrollWidth, document.body?.scrollWidth ?? 0) - window.innerWidth, @@ -175,20 +246,25 @@ function readFlipCount(page: Page): Promise { } /** - * Drags the phone scroller in deliberate steps (one per frame) so the scroll - * state machine sees real directional intent — a single programmatic jump - * models neither a touch drag nor iOS momentum. + * Drags whichever phone scroller is active in deliberate steps (one per + * frame) so the scroll state machine sees real directional intent. Browser + * tabs move the document; installed/bounded layouts can still move main. */ async function dragScrollBy(page: Page, totalPx: number, stepPx: number) { await page.evaluate( async ({ total, step }) => { const main = document.getElementById("main-content"); if (!main) return; + const mainOverflowY = getComputedStyle(main).overflowY; + const mainOwnsScroll = + /^(?:auto|scroll|overlay)$/.test(mainOverflowY) && main.scrollHeight > main.clientHeight + 1; + const documentScroller = document.scrollingElement ?? document.documentElement; + const scrollOwner = mainOwnsScroll ? main : documentScroller; const steps = Math.max(1, Math.ceil(Math.abs(total) / step)); const direction = total < 0 ? -1 : 1; for (let i = 0; i < steps; i += 1) { - main.scrollTop += direction * step; - main.dispatchEvent(new Event("scroll", { bubbles: true })); + scrollOwner.scrollTop += direction * step; + (mainOwnsScroll ? main : window).dispatchEvent(new Event("scroll", { bubbles: true })); await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); } }, @@ -196,20 +272,384 @@ async function dragScrollBy(page: Page, totalPx: number, stepPx: number) { ); } +interface PageOwnedFooterGeometry { + footerOpacity: number; + footerPosition: string; + footerPointerEvents: string; + footerBottom: number; + footerHidden: boolean; + footerParentIsHost: boolean; + hostFound: boolean; + hostInsideFrame: boolean; + mainContainsFooter: boolean; + mainOverflowY: string; + mainRunway: number; + mainScrollTop: number; + documentScrollTop: number; + ownsLastFramePixel: boolean; + reservePaddingBottom: number; + frameBottom: number; + frameHeight: number; + viewportHeight: number; + focusInsideFooter: boolean; +} + +function readPageOwnedFooterGeometry( + footer: Locator, + reserveSelector: string | null = null, +): Promise { + return footer.evaluate((node, selector) => { + const main = document.getElementById("main-content"); + const host = node.closest(".phone-footer-layer-host"); + const frame = host?.closest(".phone-viewport-frame") ?? null; + const reserve = selector ? document.querySelector(selector) : null; + const footerRect = node.getBoundingClientRect(); + const frameRect = frame?.getBoundingClientRect(); + const framePixelOwner = frameRect + ? document.elementFromPoint( + frameRect.left + frameRect.width / 2, + Math.min(window.innerHeight - 1, frameRect.bottom - 1), + ) + : null; + + return { + footerOpacity: Number.parseFloat(getComputedStyle(node).opacity), + footerPosition: getComputedStyle(node).position, + footerPointerEvents: getComputedStyle(node).pointerEvents, + footerBottom: footerRect.bottom, + footerHidden: node.getAttribute("data-scroll-hidden") === "true", + footerParentIsHost: node.parentElement === host, + hostFound: host !== null, + hostInsideFrame: Boolean(frame?.contains(host)), + mainContainsFooter: Boolean(main?.contains(node)), + mainOverflowY: main ? getComputedStyle(main).overflowY : "missing", + mainRunway: main ? main.scrollHeight - main.clientHeight : -1, + mainScrollTop: main?.scrollTop ?? -1, + documentScrollTop: (document.scrollingElement ?? document.documentElement).scrollTop, + ownsLastFramePixel: Boolean(framePixelOwner && node.contains(framePixelOwner)), + reservePaddingBottom: reserve ? Number.parseFloat(getComputedStyle(reserve).paddingBottom) : -1, + frameBottom: frameRect?.bottom ?? -1, + frameHeight: frameRect?.height ?? -1, + viewportHeight: window.innerHeight, + focusInsideFooter: node.contains(document.activeElement), + }; + }, reserveSelector); +} + test.beforeEach(async ({ page }) => { await blockExternalRequests(page); }); -test("phone chrome keeps an opaque header and a light edge-to-edge Therapy footer that fully releases when hidden", async ({ - page, -}) => { +test("phone browser results use document scrolling so Safari can minimize its browser chrome", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.setViewportSize(phoneViewport); + await gotoPhoneSurface(page, "/services?q=clinic&run=1&focus=1", 112); + await expect(page.getByTestId("service-search-results")).toBeVisible({ timeout: 20_000 }); + await addPhoneScrollRunway(page); + + const initial = await page.evaluate(() => { + const main = document.getElementById("main-content"); + const shell = main?.closest(".phone-viewport-shell"); + const scrollingElement = document.scrollingElement ?? document.documentElement; + return { + shellPosition: shell ? getComputedStyle(shell).position : "missing", + shellOverflowY: shell ? getComputedStyle(shell).overflowY : "missing", + mainOverflowX: main ? getComputedStyle(main).overflowX : "missing", + mainOverflowY: main ? getComputedStyle(main).overflowY : "missing", + documentRunway: scrollingElement.scrollHeight - window.innerHeight, + windowScrollY: window.scrollY, + mainScrollTop: main?.scrollTop ?? -1, + }; + }); + + expect(initial.shellPosition, "the phone browser canvas must not use WebKit's fixed-root compositor path").not.toBe( + "fixed", + ); + expect(initial.shellOverflowY).toBe("visible"); + expect(initial.mainOverflowX, "x clipping must not silently turn the main surface back into a y scroller").toBe( + "clip", + ); + expect(initial.mainOverflowY).toBe("visible"); + expect(initial.documentRunway, "the document must own the Services result runway").toBeGreaterThan(500); + expect(initial.windowScrollY).toBe(0); + expect(initial.mainScrollTop).toBe(0); + + await page.evaluate(async () => { + for (let step = 0; step < 32; step += 1) { + window.scrollBy(0, 24); + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); + } + }); + await expect(page.getByTestId("universal-header-collapse")).toHaveAttribute("data-scroll-hidden", "true"); + await expect(page.locator(".answer-footer-search-dock")).toHaveAttribute("data-scroll-hidden", "true"); + + const hidden = await page.evaluate(() => { + const main = document.getElementById("main-content"); + return { + windowScrollY: window.scrollY, + mainScrollTop: main?.scrollTop ?? -1, + }; + }); + expect(hidden.windowScrollY, "a vertical gesture must move Safari's document scroll owner").toBeGreaterThan(120); + expect(hidden.mainScrollTop, "the legacy inner scroll pane must stay inactive").toBe(0); + + // Reveal while still mid-page. The state flag alone is insufficient: if the + // shared header is no longer sticky it can report visible while remaining + // hundreds of pixels above Safari's viewport. + await dragScrollBy(page, -48, 12); + await expect(page.getByTestId("universal-header-collapse")).not.toHaveAttribute("data-scroll-hidden", "true"); + const revealedHeaderBottom = await page + .getByTestId("universal-header-collapse") + .evaluate((header) => header.getBoundingClientRect().bottom); + expect(revealedHeaderBottom, "an upward gesture must return the shared header inside the viewport").toBeGreaterThan( + 1, + ); +}); + +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 addPhoneScrollRunway(page); + + 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 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); +}); + +test("compiled standalone PWA rules bind full-height footer chrome to the inner scroller", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.setViewportSize(phoneViewport); + await gotoPhoneSurface(page, "/services?q=clinic&run=1&focus=1", 112); + await expect(page.locator("form.answer-footer-search-dock")).toBeVisible({ timeout: 20_000 }); + expect( + await forceCompiledStandalonePhoneCss(page), + "compiled CSS must expose the standalone media rules", + ).toBeGreaterThanOrEqual(4); + await addPhoneScrollRunway(page); + + const initial = await page.evaluate(() => { + const main = document.getElementById("main-content"); + const shell = main?.closest(".phone-viewport-shell"); + if (main) main.dataset.chromeTransitioning = "true"; + const result = { + shellHeight: shell?.getBoundingClientRect().height ?? -1, + viewportHeight: window.innerHeight, + mainOverflowY: main ? getComputedStyle(main).overflowY : "missing", + mainOverflowAnchor: main ? getComputedStyle(main).overflowAnchor : "missing", + footerPosition: document.querySelector("form.answer-footer-search-dock") + ? getComputedStyle(document.querySelector("form.answer-footer-search-dock")!).position + : "missing", + footerBackdropPosition: document.querySelector(".answer-footer-search-backdrop") + ? getComputedStyle(document.querySelector(".answer-footer-search-backdrop")!).position + : "missing", + documentRunway: (document.scrollingElement?.scrollHeight ?? 0) - window.innerHeight, + }; + if (main) delete main.dataset.chromeTransitioning; + return result; + }); + expect(initial.shellHeight, "the installed shell must cover its entire PWA viewport").toBeCloseTo( + initial.viewportHeight, + 0, + ); + expect(initial.mainOverflowY).toBe("auto"); + expect(initial.mainOverflowAnchor, "the active PWA scroller must ignore transition anchoring").toBe("none"); + expect(initial.footerPosition, "the PWA footer must anchor to the 100vh shell").toBe("absolute"); + expect(initial.footerBackdropPosition, "the PWA footer scrim must share its shell-owned edge").toBe("absolute"); + expect(initial.documentRunway, "the PWA document must stay bounded while main owns scrolling").toBeLessThanOrEqual(1); + + await dragScrollBy(page, 720, 24); + await expect(page.getByTestId("universal-header-collapse")).toHaveAttribute("data-scroll-hidden", "true"); + await expect(page.locator("form.answer-footer-search-dock")).toHaveAttribute("data-scroll-hidden", "true"); + const hidden = await page.evaluate(() => ({ + windowScrollY: window.scrollY, + mainScrollTop: document.getElementById("main-content")?.scrollTop ?? -1, + })); + expect(hidden.windowScrollY).toBe(0); + expect(hidden.mainScrollTop, "the PWA footer must follow the inner scroll owner").toBeGreaterThan(120); +}); + +for (const footerCase of standalonePageOwnedFooterRoutes) { + test(`standalone ${footerCase.name} is frame-owned and stays anchored while main scrolls`, async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.setViewportSize(phoneViewport); + await gotoPhoneSurface(page, footerCase.route, 112); + + const footer = page.locator(footerCase.selector); + await expect(footer).toBeVisible({ timeout: 20_000 }); + expect( + await forceCompiledStandalonePhoneCss(page), + "compiled CSS must expose the standalone media rules", + ).toBeGreaterThanOrEqual(4); + await addPhoneScrollRunway(page); + + if (footerCase.focusSelector) { + const focusTarget = footer.locator(footerCase.focusSelector); + await focusTarget.focus(); + await expect(focusTarget).toBeFocused(); + } + + const initial = await readPageOwnedFooterGeometry(footer, footerCase.reserveSelector); + expect(initial.hostFound, "phone footer must be portaled into the frame-owned host").toBe(true); + expect(initial.footerParentIsHost, "the host must directly own the portaled footer element").toBe(true); + expect(initial.hostInsideFrame, "the footer host must remain inside the viewport frame").toBe(true); + expect(initial.mainContainsFooter, "the standalone scroller must not contain its edge footer").toBe(false); + expect(initial.mainOverflowY).toBe("auto"); + expect(initial.mainRunway, "standalone main must have enough inner-scroll runway for this proof").toBeGreaterThan( + 500, + ); + expect(initial.footerPosition, "standalone footer must anchor to the frame rather than the viewport").toBe( + "absolute", + ); + expect(initial.frameHeight).toBeCloseTo(initial.viewportHeight, 0); + expect(initial.frameBottom, "the standalone viewport frame cannot be vertically displaced").toBeCloseTo( + initial.viewportHeight, + 0, + ); + expect(initial.footerBottom, "the anchored footer cannot extend below its viewport frame").toBeLessThanOrEqual( + initial.frameBottom + 1, + ); + if (footerCase.flushBottom) { + expect(initial.footerBottom, "full-width footer paint must reach the frame's final pixel").toBeCloseTo( + initial.frameBottom, + 0, + ); + } else { + expect( + initial.frameBottom - initial.footerBottom, + "the document composer intentionally keeps its floating safe-area gap", + ).toBeGreaterThan(0); + } + if (footerCase.focusSelector) { + expect(initial.focusInsideFooter, "focus must intentionally pin hideable page-owned chrome").toBe(true); + } + + await dragScrollBy(page, 720, 24); + + const afterScroll = await readPageOwnedFooterGeometry(footer, footerCase.reserveSelector); + expect( + afterScroll.mainScrollTop, + "the forced standalone journey must move the inner main scroller", + ).toBeGreaterThan(120); + expect(afterScroll.documentScrollTop, "the standalone document must stay bounded").toBe(0); + expect(afterScroll.mainContainsFooter, "inner scrolling must not pull the frame-owned footer into main").toBe( + false, + ); + expect(afterScroll.footerHidden, "the footer must remain visible for the anchoring assertion").toBe(false); + expect( + afterScroll.frameBottom - afterScroll.footerBottom, + "inner scrolling must preserve the footer's intended frame-bottom inset", + ).toBeCloseTo(initial.frameBottom - initial.footerBottom, 0); + expect(afterScroll.frameBottom).toBeCloseTo(initial.frameBottom, 0); + if (footerCase.focusSelector) { + expect(afterScroll.focusInsideFooter, "focused hideable chrome must stay pinned during inner scrolling").toBe( + true, + ); + + const focusTarget = footer.locator(footerCase.focusSelector); + await focusTarget.blur(); + await expect(focusTarget).not.toBeFocused(); + // The downward intent was recorded while focus intentionally pinned the + // composer. Once focus leaves, React must release that pin before a new + // gesture; dispatching another synthetic scroll in the same task as + // blur can coalesce with the focus-state commit and is not a user-realistic + // ordering. + await expect(footer).toHaveAttribute("data-scroll-hidden", "true"); + await expect + .poll(async () => (await readPageOwnedFooterGeometry(footer, footerCase.reserveSelector)).footerOpacity) + .toBe(0); + await expect + .poll(async () => (await readPageOwnedFooterGeometry(footer, footerCase.reserveSelector)).reservePaddingBottom) + .toBeLessThanOrEqual(1); + + const hidden = await readPageOwnedFooterGeometry(footer, footerCase.reserveSelector); + expect(hidden.footerPointerEvents, "hidden footer cannot retain an interactive edge layer").toBe("none"); + expect(hidden.ownsLastFramePixel, "hidden footer cannot paint or hit-test the frame's last pixel").toBe(false); + expect( + hidden.mainScrollTop, + "hiding the footer cannot jump the reading position backward", + ).toBeGreaterThanOrEqual(afterScroll.mainScrollTop - 1); + expect(hidden.documentScrollTop).toBe(0); + + await dragScrollBy(page, -48, 8); + await expect(footer).not.toHaveAttribute("data-scroll-hidden", "true"); + await expect + .poll(async () => { + const geometry = await readPageOwnedFooterGeometry(footer, footerCase.reserveSelector); + return geometry.frameBottom - geometry.footerBottom; + }) + .toBeCloseTo(initial.frameBottom - initial.footerBottom, 0); + const revealed = await readPageOwnedFooterGeometry(footer, footerCase.reserveSelector); + expect(revealed.mainScrollTop, "upward intent must move the inner scroller monotonically").toBeLessThan( + hidden.mainScrollTop, + ); + expect(revealed.mainScrollTop, "footer reveal cannot introduce an extra backward jump").toBeGreaterThanOrEqual( + hidden.mainScrollTop - 64, + ); + } + }); +} + +test("differential footer returns inline when the viewport leaves phone mode", async ({ page }) => { + await page.setViewportSize(phoneViewport); + await gotoPhoneSurface(page, "/differentials/presentations/acute-confusion-encephalopathy"); + + const footer = page.getByTestId("differential-presentation-phone-footer"); + await expect(footer).toHaveCount(1); + await expect(footer).toBeVisible({ timeout: 20_000 }); + await footer.evaluate((node) => { + const host = node.closest(".phone-footer-layer-host"); + if (!host) throw new Error("differential footer was not portaled into its phone frame host"); + host.dataset.resizeProbe = "true"; + }); + const phoneOwnership = await footer.evaluate((node) => ({ + inMain: Boolean(document.getElementById("main-content")?.contains(node)), + inHost: Boolean(node.closest(".phone-footer-layer-host")), + })); + expect(phoneOwnership).toEqual({ inMain: false, inHost: true }); + + await page.setViewportSize({ width: 768, height: 900 }); + await expect + .poll(() => + footer.evaluate((node) => ({ + inMain: Boolean(document.getElementById("main-content")?.contains(node)), + inMobileSection: Boolean(node.closest('section[aria-label="Mobile differential comparison"]')), + })), + ) + .toEqual({ inMain: true, inMobileSection: true }); + + await expect(footer).toHaveCount(1); + await expect(footer, "md:hidden must continue suppressing the mobile comparison footer").toBeHidden(); + await expect + .poll(() => + page.locator('[data-resize-probe="true"]').evaluate((host) => ({ + childCount: host.childElementCount, + containsFooter: Boolean(host.querySelector('[data-testid="differential-presentation-phone-footer"]')), + })), + ) + .toEqual({ childCount: 0, containsFooter: false }); +}); + +test("Services results keep a continuous browser viewport after shared chrome releases", async ({ page }) => { await page.emulateMedia({ reducedMotion: "no-preference" }); await page.setViewportSize(phoneViewport); - // The user's failing surface is a submitted Therapy search. Its exaggerated - // bottom inset catches paint that only leaks through a notched-phone safe area. - await gotoPhoneSurface(page, "/therapy-compass/search?q=CBT&run=1", 112); + // A submitted Services search exercises GlobalSearchShell's shared phone + // result canvas (rather than a mode-home or page-owned navigation surface). + // The exaggerated inset catches paint that leaks only through a freshly + // relaunched Home Screen PWA's notched-phone safe area. + await gotoPhoneSurface(page, "/services?q=clinic&run=1&focus=1", 112); await expect(page.locator("form.answer-footer-search-dock")).toBeVisible({ timeout: 20_000 }); await expect(page.getByTestId("global-search-input")).not.toBeFocused({ timeout: 5_000 }); + await expect(page.getByTestId("services-navigator")).toBeVisible({ timeout: 20_000 }); + await expect(page.getByTestId("service-search-results")).toBeVisible({ timeout: 20_000 }); const visible = await page.evaluate(() => { const header = document.querySelector("header#search"); @@ -291,9 +731,9 @@ test("phone chrome keeps an opaque header and a light edge-to-edge Therapy foote 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 resultList = document.querySelector('[data-testid="service-search-results"]'); const bottomPaintOwner = document.elementFromPoint(window.innerWidth / 2, window.innerHeight - 1); + const scrollingElement = document.scrollingElement ?? document.documentElement; return { collapseHeight: collapse?.getBoundingClientRect().height ?? -1, dockTop: dock?.getBoundingClientRect().top ?? -1, @@ -303,11 +743,13 @@ test("phone chrome keeps an opaque header and a light edge-to-edge Therapy foote 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, + shellOverflowY: shell ? getComputedStyle(shell).overflowY : "missing", + mainOverflowX: main ? getComputedStyle(main).overflowX : "missing", + mainOverflowY: main ? getComputedStyle(main).overflowY : "missing", + bottomPaintOwnedByResults: Boolean(resultList && bottomPaintOwner && resultList.contains(bottomPaintOwner)), + anchorTop: resultList?.getBoundingClientRect().top ?? -1, + documentScrollTop: scrollingElement.scrollTop, + mainScrollTop: main?.scrollTop ?? -1, viewportHeight: window.innerHeight, }; }); @@ -318,35 +760,67 @@ 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. + expect(hidden.shellPosition, "browser phone canvas must stay out of WebKit's fixed-root path").not.toBe("fixed"); + expect(hidden.shellOverflowY).toBe("visible"); + expect(hidden.mainOverflowX).toBe("clip"); + expect(hidden.mainOverflowY).toBe("visible"); + expect(hidden.documentScrollTop, "the document advances while shared chrome is hidden").toBeGreaterThan(0); + expect(hidden.mainScrollTop, "the browser layout cannot retain a competing inner scroll offset").toBe(0); + expect( + hidden.bottomPaintOwnedByResults, + "the rendered browser viewport has no app-owned band beneath Services results", + ).toBe(true); + + // Safari toolbar changes shrink and expand the visual viewport after a + // scroll. Document ownership must keep the reading offset and its content + // anchor stable without switching back to a fixed or nested canvas. 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 resultList = document.querySelector('[data-testid="service-search-results"]'); const bottomPaintOwner = document.elementFromPoint(window.innerWidth / 2, window.innerHeight - 1); + const scrollingElement = document.scrollingElement ?? document.documentElement; 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, + bottomPaintOwnedByResults: Boolean(resultList && bottomPaintOwner && resultList.contains(bottomPaintOwner)), + anchorTop: resultList?.getBoundingClientRect().top ?? -1, + documentScrollTop: scrollingElement.scrollTop, + mainScrollTop: main?.scrollTop ?? -1, + }; + }); + expect(afterViewportResize.bottomPaintOwnedByResults).toBe(true); + expect(afterViewportResize.anchorTop, "viewport shrink keeps the result content anchor stable").toBeCloseTo( + hidden.anchorTop, + 0, + ); + expect(afterViewportResize.documentScrollTop, "viewport resize does not jump the reading position").toBeCloseTo( + hidden.documentScrollTop, + 0, + ); + expect(afterViewportResize.mainScrollTop).toBe(0); + + await page.setViewportSize(phoneViewport); + await page.waitForTimeout(100); + const afterViewportRestore = await page.evaluate(() => { + const main = document.getElementById("main-content"); + const resultList = document.querySelector('[data-testid="service-search-results"]'); + const bottomPaintOwner = document.elementFromPoint(window.innerWidth / 2, window.innerHeight - 1); + const scrollingElement = document.scrollingElement ?? document.documentElement; + return { + bottomPaintOwnedByResults: Boolean(resultList && bottomPaintOwner && resultList.contains(bottomPaintOwner)), + anchorTop: resultList?.getBoundingClientRect().top ?? -1, + documentScrollTop: scrollingElement.scrollTop, + mainScrollTop: main?.scrollTop ?? -1, }; }); - 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, + expect(afterViewportRestore.bottomPaintOwnedByResults).toBe(true); + expect(afterViewportRestore.documentScrollTop, "viewport expansion does not jump the reading position").toBeCloseTo( + hidden.documentScrollTop, + 0, + ); + expect(afterViewportRestore.mainScrollTop).toBe(0); + expect(afterViewportRestore.anchorTop, "viewport resize keeps the result content anchor stable").toBeCloseTo( + hidden.anchorTop, 0, ); }); @@ -434,6 +908,46 @@ test("calculator dock clears its focus pin after a focused submit opens and clos await expect(dock).toHaveAttribute("data-scroll-hidden", "true"); }); +test("page-owned focus clearance places a below-fold calculator control above the visible dock", async ({ page }) => { + await page.setViewportSize(phoneViewport); + await gotoPhoneSurface(page, "/calculators", 112); + await expect(page.getByTestId("calculators-phone-dock")).toBeVisible(); + + const geometry = await page.evaluate(() => { + const owner = document.querySelector('[data-reserve-owner="calculator"]'); + const dock = document.querySelector('[data-testid="calculators-phone-dock"]'); + if (!owner || !dock) throw new Error("calculator focus-clearance owners were not rendered"); + const runway = document.createElement("div"); + runway.style.height = "1400px"; + runway.setAttribute("aria-hidden", "true"); + const target = document.createElement("button"); + target.type = "button"; + target.textContent = "Focus clearance probe"; + target.dataset.testid = "phone-focus-clearance-probe"; + const tail = document.createElement("div"); + tail.style.height = "400px"; + tail.setAttribute("aria-hidden", "true"); + owner.append(runway, target, tail); + window.scrollTo(0, 0); + target.focus({ preventScroll: true }); + target.scrollIntoView({ block: "nearest" }); + const targetRect = target.getBoundingClientRect(); + const dockRect = dock.getBoundingClientRect(); + return { + targetBottom: targetRect.bottom, + dockTop: dockRect.top, + scrollMarginBottom: Number.parseFloat(getComputedStyle(target).scrollMarginBottom), + dockHidden: dock.getAttribute("data-scroll-hidden") === "true", + }; + }); + + expect(geometry.dockHidden, "measure before hide-on-scroll can release the visible dock").toBe(false); + expect(geometry.scrollMarginBottom, "the page-owned reserve must reach focused descendants").toBeGreaterThan(100); + expect(geometry.targetBottom, "focused content must land above the visible page-owned dock").toBeLessThanOrEqual( + geometry.dockTop + 1, + ); +}); + test("calculator combined chrome stays visible with only 96px of near-bottom runway", async ({ page }) => { await page.emulateMedia({ reducedMotion: "no-preference" }); await page.setViewportSize(phoneViewport); @@ -445,13 +959,16 @@ test("calculator combined chrome stays visible with only 96px of near-bottom run const dock = document.querySelector('[data-testid="calculators-phone-dock"]'); const reserve = document.querySelector('[data-testid="calculators-search-page"]'); if (!main || !dock || !reserve) throw new Error("calculator reserve geometry was not rendered"); + const mainOwnsScroll = + /^(?:auto|scroll|overlay)$/.test(getComputedStyle(main).overflowY) && main.scrollHeight > main.clientHeight + 1; + const scrollOwner = mainOwnsScroll ? main : (document.scrollingElement ?? document.documentElement); const read = () => ({ dockHidden: dock.getAttribute("data-scroll-hidden") === "true", headerHidden: document.querySelector('[data-testid="universal-header-collapse"]')?.getAttribute("data-scroll-hidden") === "true", - scrollTop: main.scrollTop, - maxOffset: main.scrollHeight - main.clientHeight, + scrollTop: scrollOwner.scrollTop, + maxOffset: scrollOwner.scrollHeight - scrollOwner.clientHeight, }); const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); const safeArea = document.querySelector('[data-testid="chrome-safe-area-top"]'); @@ -463,8 +980,8 @@ test("calculator combined chrome stays visible with only 96px of near-bottom run // with 96px remaining after the combined header, safe-area, and calculator // reserve release. The gate must decline both owners rather than allow a // later reserve transition to clamp the reader upward. - main.scrollTop = Math.max(0, main.scrollHeight - main.clientHeight - collapseBudget - 96); - main.dispatchEvent(new Event("scroll", { bubbles: true })); + scrollOwner.scrollTop = Math.max(0, scrollOwner.scrollHeight - scrollOwner.clientHeight - collapseBudget - 96); + (mainOwnsScroll ? main : window).dispatchEvent(new Event("scroll", { bubbles: true })); const frames = [read()]; for (let index = 0; index < 18; index += 1) { await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); @@ -499,12 +1016,16 @@ test("calculator reserve and dock hide and reveal monotonically with sufficient const dock = document.querySelector('[data-testid="calculators-phone-dock"]'); const reserve = document.querySelector('[data-testid="calculators-search-page"]'); if (!main || !dock || !reserve) throw new Error("calculator reserve geometry was not rendered"); + const mainOwnsScroll = + /^(?:auto|scroll|overlay)$/.test(getComputedStyle(main).overflowY) && main.scrollHeight > main.clientHeight + 1; + const scrollOwner = mainOwnsScroll ? main : (document.scrollingElement ?? document.documentElement); const read = () => ({ hidden: dock.getAttribute("data-scroll-hidden") === "true", dockTop: dock.getBoundingClientRect().top, paddingBottom: Number.parseFloat(getComputedStyle(reserve).paddingBottom), - scrollTop: main.scrollTop, - scrollAnchor: getComputedStyle(main).overflowAnchor, + scrollTop: scrollOwner.scrollTop, + maxOffset: scrollOwner.scrollHeight - scrollOwner.clientHeight, + scrollAnchor: getComputedStyle(scrollOwner).overflowAnchor, transitionDuration: getComputedStyle(reserve).transitionDuration, }); const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); @@ -513,15 +1034,19 @@ test("calculator reserve and dock hide and reveal monotonically with sufficient (collapse?.getBoundingClientRect().height ?? 0) + (safeArea?.getBoundingClientRect().height ?? 0) + Number.parseFloat(getComputedStyle(reserve).paddingBottom); - main.scrollTop = Math.max(0, main.scrollHeight - main.clientHeight - collapseBudget - 256); - main.dispatchEvent(new Event("scroll", { bubbles: true })); + const targetOffset = Math.max(0, scrollOwner.scrollHeight - scrollOwner.clientHeight - collapseBudget - 256); + scrollOwner.scrollTop = Math.max(0, targetOffset - 24); + (mainOwnsScroll ? main : window).dispatchEvent(new Event("scroll", { bubbles: true })); + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); + scrollOwner.scrollTop = targetOffset; + (mainOwnsScroll ? main : window).dispatchEvent(new Event("scroll", { bubbles: true })); const hiding = [read()]; for (let index = 0; index < 18; index += 1) { await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); hiding.push(read()); } - main.scrollTop -= 12; - main.dispatchEvent(new Event("scroll", { bubbles: true })); + scrollOwner.scrollTop -= 12; + (mainOwnsScroll ? main : window).dispatchEvent(new Event("scroll", { bubbles: true })); const reveal = [read()]; for (let index = 0; index < 16; index += 1) { await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); @@ -545,9 +1070,10 @@ test("calculator reserve and dock hide and reveal monotonically with sufficient expect(hiding[index].dockTop, "dock never reverses during hide").toBeGreaterThanOrEqual( hiding[index - 1].dockTop - 1, ); - expect(hiding[index].scrollTop, "scroll remains monotonic during hide").toBeGreaterThanOrEqual( - hiding[index - 1].scrollTop - 1, - ); + expect( + hiding[index].scrollTop, + `scroll remains monotonic during hide at frame ${index}: ${JSON.stringify({ previous: hiding[index - 1], current: hiding[index] })}`, + ).toBeGreaterThanOrEqual(hiding[index - 1].scrollTop - 1); } const firstRevealed = frames.reveal.findIndex((frame) => !frame.hidden); expect(firstRevealed, "upward intent reveals the calculator dock").toBeGreaterThan(-1); @@ -570,12 +1096,15 @@ for (const route of [...modeHomeRoutes, ...dashboardRoutes, ...longRoutes]) { await page.emulateMedia({ reducedMotion: "no-preference" }); await page.setViewportSize(phoneViewport); await gotoPhoneSurface(page, route); + await addPhoneScrollRunway(page); await installFlipCounter(page); const initial = await readGeometry(page); - // The document must never be the phone scroller (#main-content owns it), - // and no route may overflow horizontally. - expect(initial.docScrollableExcess, "document must not scroll on phone").toBeLessThanOrEqual(2); + // Regular browser phone routes deliberately use the document scroller so + // Safari can minimize its browser UI; standalone CSS retains an internal + // scroller and is covered by the static display-mode contract. + expect(initial.scrollOwner, "browser phone route must expose document scrolling to Safari").toBe("document"); + expect(initial.docScrollableExcess, "browser document owns vertical runway").toBeGreaterThan(0); expect(initial.horizontalOverflow, "no horizontal overflow").toBeLessThanOrEqual(2); expect(initial.scrollTop).toBe(0); expect(initial.headerHidden, "header visible at the top").toBe(false); @@ -645,7 +1174,8 @@ test("phone scroll stays smooth on /formulation at 430x932", async ({ page }) => await installFlipCounter(page); const initial = await readGeometry(page); - expect(initial.docScrollableExcess).toBeLessThanOrEqual(2); + expect(initial.scrollOwner).toBe("document"); + expect(initial.docScrollableExcess).toBeGreaterThan(0); await dragScrollBy(page, initial.maxOffset + 400, 24); await page.waitForTimeout(500); @@ -888,18 +1418,21 @@ test("phone header hide and reveal animate monotonically without a geometry jump const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); const safeArea = document.querySelector('[data-testid="chrome-safe-area-top"]'); if (!main || !collapse || !safeArea) throw new Error("phone collapse geometry was not rendered"); + const mainOwnsScroll = + /^(?:auto|scroll|overlay)$/.test(getComputedStyle(main).overflowY) && main.scrollHeight > main.clientHeight + 1; + const scrollOwner = mainOwnsScroll ? main : (document.scrollingElement ?? document.documentElement); const frames: Array<{ hidden: boolean; chromeHeight: number; mainTop: number; scrollTop: number }> = []; for (let frame = 0; frame < 55; frame += 1) { if (frame < 20) { - main.scrollTop += 8; - main.dispatchEvent(new Event("scroll", { bubbles: true })); + scrollOwner.scrollTop += 8; + (mainOwnsScroll ? main : window).dispatchEvent(new Event("scroll", { bubbles: true })); } await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); frames.push({ hidden: collapse.getAttribute("data-scroll-hidden") === "true", chromeHeight: collapse.getBoundingClientRect().height + safeArea.getBoundingClientRect().height, mainTop: main.getBoundingClientRect().top, - scrollTop: main.scrollTop, + scrollTop: scrollOwner.scrollTop, }); } return frames; @@ -930,18 +1463,21 @@ test("phone header hide and reveal animate monotonically without a geometry jump const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); const safeArea = document.querySelector('[data-testid="chrome-safe-area-top"]'); if (!main || !collapse || !safeArea) throw new Error("phone collapse geometry was not rendered"); + const mainOwnsScroll = + /^(?:auto|scroll|overlay)$/.test(getComputedStyle(main).overflowY) && main.scrollHeight > main.clientHeight + 1; + const scrollOwner = mainOwnsScroll ? main : (document.scrollingElement ?? document.documentElement); const frames: Array<{ hidden: boolean; chromeHeight: number; mainTop: number; scrollTop: number }> = []; for (let frame = 0; frame < 45; frame += 1) { if (frame < 6) { - main.scrollTop -= 8; - main.dispatchEvent(new Event("scroll", { bubbles: true })); + scrollOwner.scrollTop -= 8; + (mainOwnsScroll ? main : window).dispatchEvent(new Event("scroll", { bubbles: true })); } await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); frames.push({ hidden: collapse.getAttribute("data-scroll-hidden") === "true", chromeHeight: collapse.getBoundingClientRect().height + safeArea.getBoundingClientRect().height, mainTop: main.getBoundingClientRect().top, - scrollTop: main.scrollTop, + scrollTop: scrollOwner.scrollTop, }); } return frames; diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index b40d61e03..6d17abd3d 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1,7 +1,12 @@ import type { Route } from "playwright-core"; import { expect, test, type Locator, type Page } from "playwright/test"; import { stubZeroTouchPoints } from "./helpers/zero-touch"; -import { readMobileComposerReservePx, scrollPrimarySurface } from "./playwright-scroll"; +import { + appendPrimaryScrollSpacer, + readMobileComposerReservePx, + readPrimaryScrollGeometry, + scrollPrimarySurface, +} from "./playwright-scroll"; import { answerThreadStorageKey } from "../src/lib/answer-thread-storage"; import { documentSummaryQuestion } from "../src/lib/answer-contract"; import { demoAnswer, demoDocuments, demoSummary, getDemoDocument, getDemoDocumentPayload } from "../src/lib/demo-data"; @@ -712,17 +717,21 @@ async function scrollMobileTableExpandClearOfFooter(page: Page, clinicalTable: L await clinicalTable.scrollIntoViewIfNeeded(); await page.evaluate(() => { const expand = document.querySelector('[data-testid="table-expand-button"]'); - const scrollContainer = document.querySelector("main#main-content"); + const main = document.querySelector("main#main-content"); const footer = document.querySelector( ".answer-footer-search-dock, .dashboard-composer-edge.answer-footer-search-edge", ); - if (!expand || !scrollContainer) return; + if (!expand || !main) return; + const mainOverflowY = window.getComputedStyle(main).overflowY; + const mainOwnsScroll = + ["auto", "scroll", "overlay"].includes(mainOverflowY) && main.scrollHeight > main.clientHeight; for (let attempt = 0; attempt < 6; attempt += 1) { const expandRect = expand.getBoundingClientRect(); const footerTop = footer?.getBoundingClientRect().top ?? window.innerHeight; const currentOverlap = expandRect.bottom - footerTop + 24; if (currentOverlap <= 0) break; - scrollContainer.scrollTop += currentOverlap; + if (mainOwnsScroll) main.scrollTop += currentOverlap; + else window.scrollBy({ top: currentOverlap, behavior: "auto" }); } }); } @@ -1992,14 +2001,12 @@ test.describe("Clinical KB UI smoke coverage", () => { // final, settled layout — replaces a fixed 400ms sleep. await expect(page.getByTestId("answer-streaming")).toHaveCount(0); + const scrollGeometry = await readPrimaryScrollGeometry(page); const geo = await page.evaluate(() => { - const main = document.querySelector("main#main-content"); const header = document.querySelector("header"); const surface = document.querySelector('[data-dashboard-stage="answer-surface"]'); const alsoMatches = document.querySelector('[data-testid="universal-also-matches"]'); return { - scrollHeight: main?.scrollHeight ?? 0, - clientHeight: main?.clientHeight ?? 0, headerBottom: header ? Math.round(header.getBoundingClientRect().bottom) : 0, surfaceTop: surface ? Math.round(surface.getBoundingClientRect().top) : 0, alsoMatchesHeight: alsoMatches ? Math.ceil(alsoMatches.getBoundingClientRect().height) : 0, @@ -2009,7 +2016,8 @@ test.describe("Clinical KB UI smoke coverage", () => { // matches are real content below the answer, so their compact panel may account // for the overflow; the old viewport floor created much more empty scroll. const permittedOverflow = geo.alsoMatchesHeight > 0 ? geo.alsoMatchesHeight + 24 : 4; - expect(geo.scrollHeight - geo.clientHeight).toBeLessThanOrEqual(permittedOverflow); + expect(scrollGeometry.owner).toBe("document"); + expect(scrollGeometry.maxScrollTop).toBeLessThanOrEqual(permittedOverflow); // Top-aligned: the answer sits just under the header, not pushed toward the dock // (a bottom-anchor regression would push surfaceTop far down the viewport). expect(geo.surfaceTop - geo.headerBottom).toBeGreaterThanOrEqual(0); @@ -2054,34 +2062,34 @@ test.describe("Clinical KB UI smoke coverage", () => { .poll(async () => main.evaluate((el) => Number.parseFloat(window.getComputedStyle(el).paddingBottom))) .toBeGreaterThan(200); + const scrollGeometry = await readPrimaryScrollGeometry(page); const geo = await page.evaluate(() => { const main = document.querySelector("main#main-content"); const header = document.querySelector("header"); const surface = document.querySelector('[data-dashboard-stage="answer-surface"]'); return { - scrollHeight: main?.scrollHeight ?? 0, - clientHeight: main?.clientHeight ?? 0, - mainBottom: main ? Math.round(main.getBoundingClientRect().bottom) : 0, mainMarginBottom: main ? Number.parseFloat(window.getComputedStyle(main).marginBottom) : -1, mainPaddingBottom: main ? Number.parseFloat(window.getComputedStyle(main).paddingBottom) : 0, - viewportHeight: window.innerHeight, headerBottom: header ? Math.round(header.getBoundingClientRect().bottom) : 0, surfaceTop: surface ? Math.round(surface.getBoundingClientRect().top) : 0, }; }); - // A long answer overflows and scrolls, still top-aligned under the header. - expect(geo.scrollHeight).toBeGreaterThan(geo.clientHeight + 40); + // Browser phones intentionally scroll the document so Safari can minimize + // its browser chrome. The long answer still overflows that active owner and + // remains top-aligned under the overlaid header. + expect(scrollGeometry.owner).toBe("document"); + expect(scrollGeometry.scrollHeight).toBeGreaterThan(scrollGeometry.clientHeight + 40); expect(geo.surfaceTop - geo.headerBottom).toBeLessThanOrEqual(160); - // The scrollport itself remains edge-to-edge. Its content padding—not an - // outer margin—keeps the answer endpoint clear of the visible composer and - // Safari toolbar. + // Content padding—not an outer margin—keeps the answer endpoint clear of + // the visible composer and Safari toolbar at the active viewport edge. const composerInputTop = await visibleQuestionInput(page).evaluate((el) => Math.round(el.getBoundingClientRect().top), ); expect(geo.mainMarginBottom).toBe(0); - expect(Math.abs(geo.mainBottom - geo.viewportHeight)).toBeLessThanOrEqual(1); + expect(scrollGeometry.viewportTop).toBe(0); + expect(Math.abs(scrollGeometry.viewportBottom - scrollGeometry.clientHeight)).toBeLessThanOrEqual(1); expect(geo.mainPaddingBottom).toBeGreaterThan(112); - expect(geo.mainPaddingBottom + 4).toBeGreaterThanOrEqual(geo.mainBottom - composerInputTop); + expect(geo.mainPaddingBottom + 4).toBeGreaterThanOrEqual(scrollGeometry.viewportBottom - composerInputTop); // Once the fixed dock is actually hidden, release both the composer and // Safari toolbar reserve. The scrollport dimensions stay stable while its @@ -2089,10 +2097,10 @@ test.describe("Clinical KB UI smoke coverage", () => { // immediately reappearing as a false upward gesture. Do not compare total // scrollHeight here because universal matches can finish streaming while // this test moves the scrollport. - const scrollGeometryBeforeHide = await main.evaluate((el) => ({ - clientHeight: el.clientHeight, - paddingBottom: Number.parseFloat(window.getComputedStyle(el).paddingBottom), - })); + const scrollGeometryBeforeHide = { + ...(await readPrimaryScrollGeometry(page)), + paddingBottom: await main.evaluate((el) => Number.parseFloat(window.getComputedStyle(el).paddingBottom)), + }; // WebKit retains focus on the submitted composer more aggressively than // Chromium. Move focus to the scroll surface to model the user dismissing // the composer before scrolling; focused composer chrome must stay visible. @@ -2108,11 +2116,10 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect .poll(async () => main.evaluate((el) => Number.parseFloat(window.getComputedStyle(el).paddingBottom))) .toBeLessThanOrEqual(13); - const scrollGeometryAfterHide = await main.evaluate((el) => ({ - clientHeight: el.clientHeight, - scrollHeight: el.scrollHeight, - paddingBottom: Number.parseFloat(window.getComputedStyle(el).paddingBottom), - })); + const scrollGeometryAfterHide = { + ...(await readPrimaryScrollGeometry(page)), + paddingBottom: await main.evaluate((el) => Number.parseFloat(window.getComputedStyle(el).paddingBottom)), + }; expect(scrollGeometryBeforeHide.paddingBottom).toBeGreaterThan(200); expect(scrollGeometryAfterHide.clientHeight).toBe(scrollGeometryBeforeHide.clientHeight); expect(scrollGeometryAfterHide.scrollHeight).toBeGreaterThan(scrollGeometryAfterHide.clientHeight); @@ -2160,14 +2167,19 @@ test.describe("Clinical KB UI smoke coverage", () => { // pinned for keyboard safety, so retaining focus here permanently disables // the ordinary touch-scroll hide path. await expect(input).not.toBeFocused(); - const geometry = await main.evaluate((node) => { + const scrollGeometry = await readPrimaryScrollGeometry(page); + const collapseBudget = await main.evaluate((node) => { const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); - const maxOffset = node.scrollHeight - node.clientHeight; - const collapseBudget = - (collapse?.getBoundingClientRect().height ?? 0) + - Number.parseFloat(window.getComputedStyle(node).paddingBottom); - return { maxOffset, collapseBudget, postCollapseMaxOffset: Math.max(0, maxOffset - collapseBudget) }; + return ( + (collapse?.getBoundingClientRect().height ?? 0) + Number.parseFloat(window.getComputedStyle(node).paddingBottom) + ); }); + const geometry = { + maxOffset: scrollGeometry.maxScrollTop, + collapseBudget, + postCollapseMaxOffset: Math.max(0, scrollGeometry.maxScrollTop - collapseBudget), + }; + expect(scrollGeometry.owner).toBe("document"); // Pin the unmodified short-result geometry. Its 39px post-collapse range // clears top-reveal + hide-intent distance (32px), but not the 72px in-flow // activation band; synthetic tail content would hide this distinction. @@ -3559,10 +3571,7 @@ test.describe("Clinical KB UI smoke coverage", () => { // causes a deferred root commit in Firefox; no subsequent target should be // selected against the pre-teardown layout. const openDocumentActions = page.getByRole("button", { name: "Open document actions" }).first(); - await page.locator("#main-content").evaluate((element) => { - element.scrollTop = 0; - element.dispatchEvent(new Event("scroll", { bubbles: true })); - }); + await scrollPrimarySurface(page, 0); await expect(openDocumentActions).toBeInViewport(); await openDocumentActions.click(); const documentActions = page.getByRole("dialog", { name: "This document" }); @@ -3692,23 +3701,18 @@ test.describe("Clinical KB UI smoke coverage", () => { const header = page.locator("header.universal-header"); await expect(header).toBeVisible(); await expect(header).not.toHaveAttribute("data-scroll-hidden", "true"); - // Answer mode takes the header out of flow (absolute over
) so - // content frosts under the glass bar;
must reserve the header's - // exact height as top padding or short answers regain phantom scroll. - await expect.poll(async () => header.evaluate((node) => window.getComputedStyle(node).position)).toBe("absolute"); + // Browser-mode phones attach the overlay to the visual viewport so Safari + // can use document scrolling. Installed standalone mode uses the compiled + // absolute-to-frame override covered by the dedicated PWA contract test. + await expect.poll(async () => header.evaluate((node) => window.getComputedStyle(node).position)).toBe("fixed"); const main = page.locator("main#main-content"); const reserve = await main.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingTop)); const headerHeight = await header.evaluate((node) => node.getBoundingClientRect().height); expect(Math.abs(reserve - headerHeight)).toBeLessThanOrEqual(2); - await waitForReactEventHandler(main, "onScroll"); - await main.evaluate((node) => { - const spacer = document.createElement("div"); - spacer.setAttribute("data-testid", "header-hide-scroll-spacer"); - spacer.style.height = "2000px"; - node.appendChild(spacer); - }); - // Step scroll down so the dashboard main listener sees deliberate movement. + await appendPrimaryScrollSpacer(page, { heightPx: 2000, testId: "header-hide-scroll-spacer" }); + await expect.poll(async () => (await readPrimaryScrollGeometry(page)).owner).toBe("document"); + // Step the active document owner so the dashboard reporter sees deliberate movement. for (const offset of [40, 80, 120, 160, 200]) { await scrollPrimarySurface(page, offset); } @@ -3746,17 +3750,10 @@ test.describe("Clinical KB UI smoke coverage", () => { const alert = page.getByTestId("private-scope-unavailable"); await expect(alert).toBeVisible({ timeout: 15000 }); - const main = page.locator("#main-content"); - await waitForReactEventHandler(main, "onScroll"); - await main.evaluate((node) => { - const spacer = document.createElement("div"); - spacer.style.height = "2000px"; - node.appendChild(spacer); - }); + await appendPrimaryScrollSpacer(page, { heightPx: 2000 }); + await expect.poll(async () => (await readPrimaryScrollGeometry(page)).owner).toBe("document"); for (const offset of [80, 160, 260, 380]) { - await main.evaluate((node, top) => { - node.scrollTop = top; - }, offset); + await scrollPrimarySurface(page, offset); } // Sticky inside
: the recovery actions must remain on-screen (they @@ -3808,16 +3805,10 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect.poll(async () => header.evaluate((node) => window.getComputedStyle(node).position)).toBe("relative"); const main = page.locator("main#main-content"); - await waitForReactEventHandler(main, "onScroll"); - await main.evaluate((node) => { - const spacer = document.createElement("div"); - spacer.style.height = "2000px"; - node.appendChild(spacer); - }); + await appendPrimaryScrollSpacer(page, { heightPx: 2000 }); + await expect.poll(async () => (await readPrimaryScrollGeometry(page)).owner).toBe("document"); for (const offset of [40, 80, 120, 160, 200]) { - await main.evaluate((node, top) => { - node.scrollTop = top; - }, offset); + await scrollPrimarySurface(page, offset); } await expect(collapseHost).toHaveAttribute("data-scroll-hidden", "true"); @@ -3854,18 +3845,12 @@ test.describe("Clinical KB UI smoke coverage", () => { // -budget gate refuses to START a hide at the bottom edge (that is the // #964 "locks to the bottom" trap), so hide with runway remaining first, // then ride the clamp to the bottom while hidden. - await main.evaluate((node) => { - node.scrollTop = 0; - }); + await scrollPrimarySurface(page, 0); await expect(collapseHost).not.toHaveAttribute("data-scroll-hidden", "true"); - const visibleMaxOffset = await main.evaluate((node) => node.scrollHeight - node.clientHeight); - await main.evaluate((node, top) => { - node.scrollTop = top; - }, visibleMaxOffset - 400); + const visibleMaxOffset = (await readPrimaryScrollGeometry(page)).maxScrollTop; + await scrollPrimarySurface(page, visibleMaxOffset - 400); await expect(collapseHost).toHaveAttribute("data-scroll-hidden", "true"); - await main.evaluate((node) => { - node.scrollTop = node.scrollHeight - node.clientHeight; - }); + await scrollPrimarySurface(page, "end"); await expect(collapseHost).toHaveAttribute("data-scroll-hidden", "true"); await expect.poll(async () => collapseHost.getAttribute("data-scroll-hidden"), { timeout: 1_000 }).toBe("true"); // The hidden attribute flips before the 240ms grid-row transition has @@ -3884,7 +3869,7 @@ test.describe("Clinical KB UI smoke coverage", () => { // write can coalesce into the trailing bottom-clamp evaluation and be // rebased away as geometry feedback. A real drag always emits follow-up // events, and the second step is a clean upward delta past reveal intent. - const settledBottomOffset = await main.evaluate((node) => node.scrollTop); + const settledBottomOffset = (await readPrimaryScrollGeometry(page)).scrollTop; for (const rise of [24, 48]) { await scrollPrimarySurface(page, Math.max(0, settledBottomOffset - rise)); } @@ -3923,14 +3908,8 @@ test.describe("Clinical KB UI smoke coverage", () => { ) .toBeGreaterThan(250); - await waitForReactEventHandler(main, "onScroll"); - await page.evaluate(() => { - const main = window.document.getElementById("main-content"); - const spacer = window.document.createElement("div"); - spacer.setAttribute("data-testid", "composer-hide-scroll-spacer"); - spacer.style.height = "2000px"; - (main ?? window.document.body).appendChild(spacer); - }); + await appendPrimaryScrollSpacer(page, { heightPx: 2000, testId: "composer-hide-scroll-spacer" }); + await expect.poll(async () => (await readPrimaryScrollGeometry(page)).owner).toBe("document"); // Hide on deliberate scroll down past the activation offset. The chunk // deep-link effect can finish late in Chromium and move the scrollport once diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index 3a0f94d8d..d6ce3e1b6 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -2,6 +2,7 @@ import type { Route } from "playwright-core"; import { expect, test, type Page } from "playwright/test"; import { stubZeroTouchPoints } from "./helpers/zero-touch"; import { loadMedicationSnapshot } from "../src/lib/medication-snapshot"; +import { readPrimaryScrollGeometry } from "./playwright-scroll"; const longTitle = "Extremely long synthetic shared-care guideline title covering lithium clozapine perinatal risk ADHD medication review emergency escalation and outpatient monitoring pathways"; @@ -497,19 +498,14 @@ test.describe("Medication responsive stress coverage", () => { await page.evaluate( () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))), ); - await expect - .poll(async () => { - const scrollGeometry = await page.locator("main#main-content").evaluate((main) => ({ - clientHeight: main.clientHeight, - scrollHeight: main.scrollHeight, - pageHeight: document.documentElement.scrollHeight, - viewportHeight: document.documentElement.clientHeight, - keyboardHeight: document.documentElement.style.getPropertyValue("--keyboard-height").trim(), - })); - expect(scrollGeometry.scrollHeight).toBeGreaterThan(scrollGeometry.clientHeight); - expect(scrollGeometry.keyboardHeight === "" || scrollGeometry.keyboardHeight === "0px").toBe(true); - return scrollGeometry.pageHeight - scrollGeometry.viewportHeight; - }) - .toBeLessThanOrEqual(2); + await expect.poll(async () => (await readPrimaryScrollGeometry(page)).owner).toBe("document"); + const scrollGeometry = await readPrimaryScrollGeometry(page); + const chromeGeometry = await page.locator("main#main-content").evaluate((main) => ({ + keyboardHeight: document.documentElement.style.getPropertyValue("--keyboard-height").trim(), + overflowY: window.getComputedStyle(main).overflowY, + })); + expect(scrollGeometry.scrollHeight).toBeGreaterThan(scrollGeometry.clientHeight + 40); + expect(chromeGeometry.overflowY).toBe("visible"); + expect(chromeGeometry.keyboardHeight === "" || chromeGeometry.keyboardHeight === "0px").toBe(true); }); }); diff --git a/tests/ui-therapy-nav-scroll.spec.ts b/tests/ui-therapy-nav-scroll.spec.ts index 14609e858..3722476c2 100644 --- a/tests/ui-therapy-nav-scroll.spec.ts +++ b/tests/ui-therapy-nav-scroll.spec.ts @@ -1,7 +1,7 @@ import { expect, test, type Page } from "playwright/test"; import { resolve } from "node:path"; -import { scrollPrimarySurface } from "./playwright-scroll"; +import { appendPrimaryScrollSpacer, readPrimaryScrollGeometry, scrollPrimarySurface } from "./playwright-scroll"; /** * Therapy section nav must hide/reveal with the universal top bar on phones. @@ -49,22 +49,6 @@ async function gotoTherapySearch(page: Page) { await page.waitForTimeout(700); } -async function waitForMainScrollHandler(page: Page) { - const main = page.locator("#main-content"); - await expect - .poll( - async () => - main.evaluate((element) => { - const propsKey = Object.keys(element).find((key) => key.startsWith("__reactProps$")); - if (!propsKey) return false; - const props = (element as unknown as Record>)[propsKey]; - return typeof props?.onScroll === "function"; - }), - { timeout: 15_000 }, - ) - .toBe(true); -} - test.beforeEach(async ({ page }) => { await blockExternalRequests(page); await installTherapyFixtures(page); @@ -96,13 +80,11 @@ test("phone Therapy section nav hides and returns with the universal header", as ) .toBe(true); - await waitForMainScrollHandler(page); - await page.locator("#main-content").evaluate((node) => { - const spacer = document.createElement("div"); - spacer.setAttribute("data-testid", "therapy-nav-hide-scroll-spacer"); - spacer.style.height = "2400px"; - node.appendChild(spacer); - }); + await appendPrimaryScrollSpacer(page, { heightPx: 2400, testId: "therapy-nav-hide-scroll-spacer" }); + // Browser-mode phones deliberately leave #main-content in normal flow so + // Safari can collapse its own toolbar; the document must drive both chrome + // surfaces here. Standalone inner-scroll ownership is covered separately. + await expect.poll(async () => (await readPrimaryScrollGeometry(page)).owner).toBe("document"); await expect(collapseHost).not.toHaveAttribute("data-scroll-hidden", "true"); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 40f8c09b1..0dd0f5b2c 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -9,7 +9,12 @@ import { medicationToSearchResult, rankMedicationRecords } from "../src/lib/medi import { sortResultItems } from "../src/lib/result-sort"; import { serviceRecords } from "../src/lib/services"; import { openAppModeMenu } from "./playwright-app-mode"; -import { readMobileComposerReservePx, scrollPrimarySurface } from "./playwright-scroll"; +import { + appendPrimaryScrollSpacer, + readMobileComposerReservePx, + readPrimaryScrollGeometry, + scrollPrimarySurface, +} from "./playwright-scroll"; const readySetupChecks = [ { id: "env", label: ".env.local configured", status: "ready", detail: "Test environment ready." }, @@ -752,6 +757,7 @@ test.describe("Clinical KB tools launcher", () => { await page.setViewportSize({ width: 390, height: 820 }); await gotoLauncher(page, home.path); const homeRegion = page.getByTestId(home.testId); + await expect(homeRegion).toHaveCount(1, { timeout: 15_000 }); await expect(homeRegion).toBeVisible(); const icon = homeRegion.locator(".mode-home-icon").first(); @@ -1232,14 +1238,19 @@ test.describe("Clinical KB tools launcher", () => { await expect.poll(async () => readMobileComposerReservePx(main)).toBeGreaterThan(112); const visibleMainGeometry = await main.evaluate((node) => { const style = window.getComputedStyle(node); + const rect = node.getBoundingClientRect(); return { - bottom: Math.round(node.getBoundingClientRect().bottom), + bottom: Math.round(rect.bottom), marginBottom: Number.parseFloat(style.marginBottom), + overflowY: style.overflowY, + top: Math.round(rect.top), viewportHeight: window.innerHeight, }; }); expect(visibleMainGeometry.marginBottom).toBe(0); - expect(Math.abs(visibleMainGeometry.bottom - visibleMainGeometry.viewportHeight)).toBeLessThanOrEqual(1); + expect(visibleMainGeometry.overflowY).toBe("visible"); + expect(visibleMainGeometry.top).toBeLessThan(visibleMainGeometry.viewportHeight); + expect(visibleMainGeometry.bottom).toBeGreaterThanOrEqual(visibleMainGeometry.viewportHeight - 1); const transition = await dock.evaluate((node) => { const style = window.getComputedStyle(node); const durationMs = Math.max( @@ -1261,18 +1272,10 @@ test.describe("Clinical KB tools launcher", () => { await input.blur(); await expect(dock).not.toHaveAttribute("data-command-open", "true"); - // Inject a spacer to ensure the container is scrollable even with minimal search results - await page.evaluate(() => { - const container = document.getElementById("main-content"); - if (container) { - const spacer = document.createElement("div"); - spacer.id = "test-scroll-spacer"; - spacer.style.height = "2000px"; - spacer.style.minHeight = "2000px"; - spacer.style.display = "block"; - container.appendChild(spacer); - } - }); + // Inject content through the resolved owner so the browser document and + // standalone-PWA inner scroller exercise the same directional behavior. + await appendPrimaryScrollSpacer(page, { heightPx: 2000, testId: "test-scroll-spacer" }); + await expect.poll(async () => (await readPrimaryScrollGeometry(page)).owner).toBe("document"); // Treat the deliberate scroll and its resulting UI state as one retriable // action. Firefox/WebKit can finish the focus=1 hydration effect after the @@ -1292,14 +1295,17 @@ test.describe("Clinical KB tools launcher", () => { await expect.poll(async () => readMobileComposerReservePx(main)).toBeLessThanOrEqual(13); const hiddenMainGeometry = await main.evaluate((node) => { const style = window.getComputedStyle(node); + const rect = node.getBoundingClientRect(); return { - bottom: Math.round(node.getBoundingClientRect().bottom), + bottom: Math.round(rect.bottom), marginBottom: Number.parseFloat(style.marginBottom), + top: Math.round(rect.top), viewportHeight: window.innerHeight, }; }); expect(hiddenMainGeometry.marginBottom).toBe(0); - expect(Math.abs(hiddenMainGeometry.bottom - hiddenMainGeometry.viewportHeight)).toBeLessThanOrEqual(1); + expect(hiddenMainGeometry.top).toBeLessThan(hiddenMainGeometry.viewportHeight); + expect(hiddenMainGeometry.bottom).toBeGreaterThanOrEqual(hiddenMainGeometry.viewportHeight - 1); await scrollPrimarySurface(page, 60); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); @@ -1686,9 +1692,10 @@ test.describe("Clinical KB tools launcher", () => { expect(badgeMetrics.height).toBeGreaterThanOrEqual(22); expect(badgeMetrics.scrollHeight).toBeLessThanOrEqual(badgeMetrics.height + 1); - // Tall results must be top-aligned: Best Answer stays reachable at scrollTop 0. - const mainContent = page.locator("#main-content"); - await expect.poll(() => mainContent.evaluate((element) => element.scrollTop)).toBe(0); + // Tall browser-phone results must be top-aligned in the document owner: + // Best Answer stays reachable at scrollTop 0 without a competing inner offset. + await expect.poll(async () => (await readPrimaryScrollGeometry(page)).owner).toBe("document"); + await expect.poll(async () => (await readPrimaryScrollGeometry(page)).scrollTop).toBe(0); const bestAnswer = page.getByTestId("differential-best-answer"); await expect(bestAnswer).toBeVisible(); const foldLayout = await bestAnswer.evaluate((best) => { @@ -1698,7 +1705,6 @@ test.describe("Clinical KB tools launcher", () => { const bestRect = best.getBoundingClientRect(); const headerBottom = header?.getBoundingClientRect().bottom ?? main.getBoundingClientRect().top; return { - scrollTop: main.scrollTop, bestTop: bestRect.top, bestBottom: bestRect.bottom, headerBottom, @@ -1706,7 +1712,6 @@ test.describe("Clinical KB tools launcher", () => { }; }); expect(foldLayout).not.toBeNull(); - expect(foldLayout!.scrollTop).toBe(0); // Best Answer must start in the visible upper fold under the consolidated // query, sort, and result-type controls — never clipped above the scrollport. expect(foldLayout!.bestTop).toBeGreaterThanOrEqual(foldLayout!.headerBottom - 2); @@ -1792,7 +1797,14 @@ test.describe("Clinical KB tools launcher", () => { await input.focus(); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); await expect.poll(async () => readMobileComposerReservePx(mainContent)).toBeGreaterThan(180); - await mainContent.evaluate((element) => element.scrollTo({ top: element.scrollHeight, behavior: "instant" })); + await scrollPrimarySurface(page, "end"); + await expect + .poll(async () => { + const geometry = await readPrimaryScrollGeometry(page); + return geometry.maxScrollTop - geometry.scrollTop; + }) + .toBeLessThanOrEqual(1); + expect((await readPrimaryScrollGeometry(page)).owner).toBe("document"); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); await expect.poll(async () => readMobileComposerReservePx(mainContent)).toBeGreaterThan(180); const clearance = await page.evaluate(() => { @@ -1846,17 +1858,8 @@ test.describe("Clinical KB tools launcher", () => { const dockHeight = await dock.evaluate((element) => element.getBoundingClientRect().height); expect(reservePx).toBeGreaterThanOrEqual(dockHeight); - // Ensure enough scroll room for hide thresholds even with a short result list. - await page.evaluate(() => { - const container = document.getElementById("main-content"); - if (!container) return; - const spacer = document.createElement("div"); - spacer.id = "test-scroll-spacer"; - spacer.style.height = "2000px"; - spacer.style.minHeight = "2000px"; - spacer.style.display = "block"; - container.appendChild(spacer); - }); + // Ensure enough owner scroll room for hide thresholds even with a short result list. + await appendPrimaryScrollSpacer(page, { heightPx: 2000, testId: "test-scroll-spacer" }); // Apply the Safari toolbar simulation after the visible-dock clearance // checks above. A collapsed reserve that still includes the toolbar inset @@ -2092,7 +2095,7 @@ test.describe("Clinical KB service detail page", () => { // streaming `S:` clone of the page root under CI load. const servicePage = page.getByTestId("mobile-composer-reserve-pad").getByTestId("service-detail-page"); const footer = servicePage.getByText("Information accuracy may vary. Confirm locally before use."); - const scrollport = page.locator("#main-content"); + const mainContent = page.locator("#main-content"); const dock = page.locator("form.answer-footer-search-dock, form.answer-footer-search-edge").first(); const dockInput = visibleGlobalSearchInput(page).first(); await expect(servicePage).toBeVisible(); @@ -2102,44 +2105,47 @@ test.describe("Clinical KB service detail page", () => { await dockInput.focus(); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); // The compact dock reserve is 5.5rem (88px) plus any safe-area inset. - await expect.poll(async () => readMobileComposerReservePx(scrollport)).toBeGreaterThanOrEqual(80); - await scrollport.evaluate((element) => element.scrollTo({ top: element.scrollHeight, behavior: "instant" })); + await expect.poll(async () => readMobileComposerReservePx(mainContent)).toBeGreaterThanOrEqual(80); + await scrollPrimarySurface(page, "end"); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); await expect - .poll(() => scrollport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .poll(async () => { + const geometry = await readPrimaryScrollGeometry(page); + return geometry.maxScrollTop - geometry.scrollTop; + }) .toBeLessThanOrEqual(1); + const scrollGeometry = await readPrimaryScrollGeometry(page); const clearance = await footer.evaluate((element) => { - const scrollElement = document.querySelector("#main-content"); + const mainElement = document.querySelector("#main-content"); const dockElement = document.querySelector( "form.answer-footer-search-dock, form.answer-footer-search-edge", ); const servicePage = document.querySelector('[data-testid="service-detail-page"]'); - if (!scrollElement || !dockElement) return null; - const scrollStyle = window.getComputedStyle(scrollElement); - const pad = scrollElement.querySelector('[data-testid="mobile-composer-reserve-pad"]'); + if (!mainElement || !dockElement) return null; + const mainStyle = window.getComputedStyle(mainElement); + const pad = mainElement.querySelector('[data-testid="mobile-composer-reserve-pad"]'); return { footerBottom: element.getBoundingClientRect().bottom, - scrollBottom: scrollElement.getBoundingClientRect().bottom, dockTop: dockElement.getBoundingClientRect().top, dockHeight: dockElement.getBoundingClientRect().height, reservePx: pad ? Number.parseFloat(window.getComputedStyle(pad).paddingBottom) - : Number.parseFloat(scrollStyle.paddingBottom), - reserve: scrollStyle.getPropertyValue("--mobile-composer-reserve").trim(), - scrollTop: scrollElement.scrollTop, - scrollHeight: scrollElement.scrollHeight, - clientHeight: scrollElement.clientHeight, + : Number.parseFloat(mainStyle.paddingBottom), + reserve: mainStyle.getPropertyValue("--mobile-composer-reserve").trim(), serviceBottom: servicePage?.getBoundingClientRect().bottom ?? null, serviceHeight: servicePage?.getBoundingClientRect().height ?? null, scrollHidden: dockElement.getAttribute("data-scroll-hidden"), }; }); - expect(clearance, JSON.stringify(clearance)).not.toBeNull(); - expect(clearance!.reservePx, JSON.stringify(clearance)).toBeGreaterThanOrEqual(80); - expect(clearance!.footerBottom, JSON.stringify(clearance)).toBeLessThanOrEqual(clearance!.dockTop - 8); + expect(scrollGeometry.owner).toBe("document"); + expect(clearance, JSON.stringify({ clearance, scrollGeometry })).not.toBeNull(); + expect(clearance!.reservePx, JSON.stringify({ clearance, scrollGeometry })).toBeGreaterThanOrEqual(80); + expect(clearance!.footerBottom, JSON.stringify({ clearance, scrollGeometry })).toBeLessThanOrEqual( + clearance!.dockTop - 8, + ); }); test("service navigator action uses the shared global search route", async ({ page }) => { @@ -2319,7 +2325,15 @@ test.describe("Responsive layout guards", () => { await expect(resultFilter).toBeVisible(); await expect(resultFilter).toHaveAccessibleName("Filter medication results"); await expect(bottomDock).toBeVisible(); - await page.locator("main#main-content").evaluate((main) => main.scrollTo({ top: main.scrollHeight })); + await scrollPrimarySurface(page, "end"); + await expect + .poll(async () => { + const geometry = await readPrimaryScrollGeometry(page); + return geometry.maxScrollTop - geometry.scrollTop; + }) + .toBeLessThanOrEqual(1); + expect((await readPrimaryScrollGeometry(page)).owner).toBe("document"); + await expect(bottomDock).not.toHaveAttribute("data-scroll-hidden", "true"); const resultBox = await resultCard.boundingBox(); const dockBox = await bottomDock.boundingBox(); expect(resultBox).not.toBeNull();