Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/branch-review-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -1266,4 +1266,5 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie
| 2026-07-29 | codex/document-reader-condensed-view | 7cefb24e99f9745a61843c7e48c4889f7324ec42 | pr-1380-main-merge-coderabbit-density | merged origin/main; resolved source-panels conflict (kept condensed details + tracking-eyebrow); density in-memory fallback when storage blocked; summary keys + search/plain compact tests; local vitest/lint/typecheck/format/playwright condensed pass; awaiting hosted CI | vitest document suites 20/20; lint; typecheck; format:check; playwright condensed 4/4; merge-tree clean |
| 2026-07-29 | claude/test-coverage-analysis-2vcd8a | 0922d7f56624ef84be8abcb2bbc89205027cf9a6 | PR #1383 babysit | BLOCKER CLEARED: merged origin/main; renumbered coverage follow-ups #098/#099 -> #106/#107 (main claimed #098-#105). Before: CONFLICTING/DIRTY, 4 behind; CI green on prior tip; 0 review threads; 0 Bugbot findings. After: mergeable expected; verify:cheap 424 files/4371 passed; test:coverage exit 0; format:changed + check:rag:fixtures pass. | verify:cheap PASS (424 files, 4371 passed \| 4 skipped); test:coverage PASS (no threshold errors); format:changed PASS; check:rag:fixtures PASS (36 golden); Bugbot: no findings; no provider-backed checks |
| 2026-07-29 | claude/test-coverage-analysis-2vcd8a | 6f476b5f741627cb622af57d1b4665e3989789ca | PR #1383 babysit | CLOSEOUT at tip after ledger bookkeeping commit. Merge conflict cleared; coverage follow-ups live as #106/#107; local gates green; awaiting hosted CI on tip. | same as prior tip 0922d7f5 plus ledger append only; no product code change |
| 2026-07-29 | codex/chat-document-header-overlay-document-header-overlay-20260729 | 48ed6cc95f886837f4ddbb369fdbc611a0958f17 | document phone header overlay | No high-confidence findings; physical iPhone acceptance remains | verify:pr-local unit 4373 pass; build PASS; focused Playwright 2 pass; phone gate contended |
| 2026-07-29 | codex/document-reader-condensed-view | 5678e878d4fe681d33bb58df5b5b3468a138a1c8 | pr-1380-ci-green-resync | hosted CI green on 7150899a (Static/Build/Unit/Advisory/Production UI/PR required/CircleCI); CodeRabbit density fallback + summary keys + search/plain compact tests landed; unresolved review threads none; resynced main after tip went BEHIND by 1 | hosted CI success on 7150899a; merge-tree clean; bugbot no P0/P1 |
11 changes: 10 additions & 1 deletion docs/search-chrome-behaviour.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,25 @@ Choose the hide mechanism from where the host's scrollport lives, because that d
| `ClinicalDashboard` (other modes) | Document on browser phones; `<main>` in standalone and at `sm+` | `strategy: "collapse", wide: "collapse"` | Top-bar row collapses; tablet search stays sticky; desktop search portals into `<main>` page flow |
| `GlobalSearchShell` | Document in browser phones and at `sm+`; `#main-content` only in installed standalone mode | `strategy: "collapse", wide: "sticky"` | Tablet pins [top bar \| search]; desktop portals search into `#main-content`, leaving a sticky auto-hiding top bar |

`GlobalSearchShell` adds `phoneMotion: "overlay"` only on routes matched by
`isDocumentViewerOwnedRoute(pathname)`. On those phone routes, the safe-area
region, universal Documents row, document title/section row, and section track
form one fixed browser/absolute standalone layer. The complete layer translates
over the document without changing the scrollport or content geometry.
`/documents/search` and every non-document route keep the default
`phoneMotion: "collapse"`; tablet and desktop continue using `wide: "sticky"`.

Rules that keep this working:

- **Hide the top bar, not the search field.** The collapse wrapper (`data-testid="universal-header-collapse"`) wraps `header#search` plus page navigation mounted through `PhoneHeaderCollapsePortal` into `#phone-header-collapse-addon-slot`. Keep composers outside the collapse row: tablet search stays pinned independently, and desktop search scrolls with page content rather than being translated by the header.
- **Every production phone navigation header has one collapse owner.** `PhoneHeaderCollapsePortal` moves Therapy section navigation, DocumentViewer navigation, and Differential detail navigation into `#phone-header-collapse-addon-slot` below `sm`; the same subtree stays in its existing page position at `sm+`. Do not add a second sticky/fixed phone header inside `#main-content`: the universal collapse row must own its safe area, focus pinning, timing, clipping, and measured release. Semantic content headings and modal/sheet headers are not viewport chrome and stay in their own flow/scroll context.
- **Document phone headers overlay as one stable stack.** Document detail/source routes keep the complete phone header at a stable height and translate the safe area plus both header rows and the section track together. Hidden overlay chrome is transparent and non-interactive; revealed chrome frosts and covers the document. `readChromeCollapseMetrics` counts zero released top-header geometry for this overlay, while continuing to measure the independently hidden document composer reserve. Reveal must not change the active owner's scroll offset or a stable document/PDF anchor.
- **Feed the reporter from the element that actually scrolls.** Both app hosts run `useDocumentScrollHideReporter` alongside their `<main>` reporter. In browser-mode phones the normal-flow shell and `overflow-y: visible` surface make the document the only vertical owner, which lets Safari minimize its browser UI. Installed standalone mode uses a normal-flow `100vh` shell plus bounded inner surface, so document scroll does not fire there. The hook measures the same collapse budget and blurs the same focused composer for either owner. Page-owned footer chrome must follow the same rule: `DocumentViewer` observes both the document and the inner surface, then combines the signals so only the active owner drives it. Its rendered footer, like calculator and differential page-owned footers, portals to the frame host so observing the inner scroll owner does not make the footer its descendant.
- **Keep browser-phone chrome attached to the viewport without fixing the app root.** Collapse-mode headers use one phone-sticky wrapper; answer overlay headers are fixed only in browser mode and remain absolute over the inner surface in standalone mode. Footer layers are likewise viewport-fixed in browser mode and shell-absolute in standalone. While either header or reserve transition changes document geometry, the corresponding transition marker disables anchoring on the active document/inner scroller so synthetic reverse scroll cannot cause a hide/reveal loop or reading-position jump.
- **Do not treat CSSOM bounds as physical iOS paint proof.** A fixed root can report perfect `getBoundingClientRect()` and hit-testing while WebKit leaves an app-external band. Keep browser/document and standalone/`100vh` static guards, then verify Safari and a freshly relaunched Home Screen app on a physical phone before merge. If iOS reports a web viewport shorter than `screen.height`, pixels outside that viewport are system-owned; keep the root canvas opaque and matching, but do not fake reachability with negative safe-area overscan.
- **Viewport stickiness belongs on the outer [top bar \| search] stack, not on `header#search`.** The top bar sits inside header-height boxes, which leaves a sticky rule on it zero travel. For the same reason the visible stack's ancestor in `GlobalSearchShell` is `display: contents` at every breakpoint rather than a block. The collapse result owns one phone-sticky wrapper (safe-area spacer + stack), while its `sm:` children retain the tablet/desktop offsets. At desktop widths the search portal leaves that same outer stack holding only the top bar.
- **Collapse only the top-bar row inside a sticky stack.** On tablets, translating the whole stack would take the search field off-screen; collapsing just the top bar lets search stay pinned at the viewport top below the wide-layout safe-area spacer. On desktop, the page-flow search is outside the stack entirely.
- **Release the phone top inset with hidden chrome.** `chrome-safe-area-top` is a full-width sibling that is `h-[var(--safe-area-top)]` while the phone header is visible and `h-0` while hidden, using the same transition timing as the top-bar row. `readChromeCollapseMetrics` must charge that released phone height as well as the controls and dock reserve, or short pages clamp and oscillate at the bottom. At `sm+` the spacer remains `h-[var(--safe-area-top)]`, and sticky chrome pins at `top: var(--safe-area-top)`. Do not leave a phone-only surface/status-bar band after the controls collapse.
- **Release the phone top inset with collapsing chrome.** For the default collapse motion, `chrome-safe-area-top` is a full-width sibling that is `h-[var(--safe-area-top)]` while the phone header is visible and `h-0` while hidden, using the same transition timing as the top-bar row. `readChromeCollapseMetrics` must charge that released phone height as well as the controls and dock reserve, or short pages clamp and oscillate at the bottom. The document overlay exception keeps this spacer inside the translated stack at a stable height and charges zero released top geometry. At `sm+` the spacer remains `h-[var(--safe-area-top)]`, and sticky chrome pins at `top: var(--safe-area-top)`. Do not leave a phone-only surface/status-bar band after collapsing controls hide.
- **One transition, no jump.** Phone page navigation belongs inside the universal 1fr → 0fr grid rather than running another scroll hook. The shared reporter may emit one hide on a deliberate descent and one reveal on deliberate upward intent; geometry must move monotonically through the 240ms hide / 200ms reveal and remain still at the bottom edge. Reduced motion removes the animation but not the complete edge release.
- **Do not double-sticky tablet search inside an outer sticky stack.** When `wide: "sticky"` owns the tablet stack, the composer stays `relative` in that stack. A second sticky search with its own `top` overlays page controls (and blocks clicks) once the top bar collapses.
- **Desktop search is page-owned.** `desktop-page-search-composer-slot` is rendered at the top of normal shell/dashboard content and accepts the shared composer only at `min-width: 1024px`. The mode-home hero slot takes precedence. Never give the desktop page composer, its slot, or an ancestor `fixed`/`sticky` positioning.
Expand Down
2 changes: 1 addition & 1 deletion scripts/phone-chrome-plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export function phoneChromePlan(rawFiles, { fullMode = "auto" } = {}) {
focusedBrowserJourneys.push({
file: "tests/ui-phone-scroll.spec.ts",
pattern:
"phone browser results use document scrolling|document detail header and footer follow Safari document scrolling together|compiled standalone PWA rules bind full-height footer chrome|standalone .* is frame-owned",
"phone browser results use document scrolling|document detail header overlay and footer follow|compiled standalone PWA rules bind full-height footer chrome|standalone .* is frame-owned",
});
}
if (runDashboardJourneys && !changedBrowserFileSet.has("tests/ui-smoke.spec.ts")) {
Expand Down
4 changes: 4 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2561,6 +2561,10 @@ td,
position: static;
}

.phone-sticky-header-stack.phone-overlay-header {
position: absolute;
}

.phone-footer-layer {
position: absolute;
}
Expand Down
8 changes: 7 additions & 1 deletion src/components/clinical-dashboard/global-search-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { PhoneFooterLayerFrame } from "@/components/clinical-dashboard/phone-foo
import { PageSecondaryNavigation } from "@/components/page-secondary-navigation";
import { useActiveScrollOwner } from "@/components/clinical-dashboard/use-active-scroll-owner";
import {
isDocumentViewerOwnedRoute,
isPageOwnedComposerRoute,
resolveMobileComposerReserve,
resolveShellVisibleMobileComposerReserve,
Expand Down Expand Up @@ -853,7 +854,12 @@ function GlobalStandaloneSearchShellBody({
// Tablet: the document scrolls, so an outer sticky stack pins
// [top bar | search]. Desktop portals search into normal page flow,
// leaving this stack to own only the auto-hiding top bar.
hideOnScroll={{ strategy: "collapse", wide: "sticky", scrollHidden: chromeScrollHide.hidden }}
hideOnScroll={{
strategy: "collapse",
phoneMotion: isDocumentViewerOwnedRoute(pathname) ? "overlay" : "collapse",
wide: "sticky",
scrollHidden: chromeScrollHide.hidden,
}}
onBottomComposerHiddenChange={setBottomComposerHidden}
queryInputAutoFocus={requestedFocus && !hasSubmittedModeSearch}
/>
Expand Down
58 changes: 48 additions & 10 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,12 @@ export function MasterSearchHeader({
* `useScrollHideReporter` wired to that element's scroll events. */
hideOnScroll?: {
strategy: "overlay" | "collapse";
/**
* Phone-only motion for collapse-strategy hosts. "collapse" releases the
* row's layout height; "overlay" keeps the complete phone stack stable and
* translates it over page content. Defaults to "collapse".
*/
phoneMotion?: "collapse" | "overlay";
/**
* Overlay-only: apply the hide/reveal (and the out-of-flow absolute header)
* at every breakpoint instead of phones only. The host must reserve
Expand Down Expand Up @@ -1445,6 +1451,8 @@ export function MasterSearchHeader({
}

const hideStrategy = hideOnScroll?.strategy;
const phoneMotion = hideOnScroll?.phoneMotion ?? "collapse";
const phoneOverlayMotion = hideStrategy === "collapse" && phoneMotion === "overlay";
// Overlay hosts that opt into all breakpoints take the header fully out of
// flow (absolute over the scrolling <main>, which reserves matching top
// padding) so content frosts under the glass bar at every width.
Expand Down Expand Up @@ -2095,16 +2103,29 @@ export function MasterSearchHeader({
const collapsingTopBar = (
<div
data-scroll-hidden={headerChromeHidden ? "true" : undefined}
data-phone-motion={phoneMotion}
data-testid="universal-header-collapse"
className={cn(
"motion-reduce:transition-none",
collapsesAtEveryWidth || sticksAbovePhones
? cn(
"grid transition-[grid-template-rows]",
phoneOverlayMotion
? "sm:grid sm:transition-[grid-template-rows]"
: "grid transition-[grid-template-rows]",
headerChromeHidden
? "duration-[240ms] ease-[cubic-bezier(0.4,0,0.2,1)]"
: "duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]",
headerChromeHidden ? "[grid-template-rows:0fr]" : "[grid-template-rows:1fr]",
? phoneOverlayMotion
? "sm:duration-[240ms] sm:ease-[cubic-bezier(0.4,0,0.2,1)]"
: "duration-[240ms] ease-[cubic-bezier(0.4,0,0.2,1)]"
: phoneOverlayMotion
? "sm:duration-200 sm:ease-[cubic-bezier(0.22,1,0.36,1)]"
: "duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]",
headerChromeHidden
? phoneOverlayMotion
? "sm:[grid-template-rows:0fr]"
: "[grid-template-rows:0fr]"
: phoneOverlayMotion
? "sm:[grid-template-rows:1fr]"
: "[grid-template-rows:1fr]",
)
: cn(
"max-sm:grid max-sm:transition-[grid-template-rows]",
Expand All @@ -2122,7 +2143,7 @@ export function MasterSearchHeader({
(collapsesAtEveryWidth || sticksAbovePhones) && "sm:flex sm:min-h-0 sm:flex-col sm:justify-end",
// Clip only while hiding so the edge-glass-header gradient that
// extends below the header keeps painting when the chrome is shown.
headerChromeHidden && "max-sm:overflow-hidden",
!phoneOverlayMotion && headerChromeHidden && "max-sm:overflow-hidden",
(collapsesAtEveryWidth || sticksAbovePhones) && headerChromeHidden && "sm:overflow-hidden",
)}
>
Expand All @@ -2148,18 +2169,35 @@ export function MasterSearchHeader({
// header row's timing to avoid a one-frame gap during hide/reveal.
// sm+ keeps its pinned inset because the sticky [bar | search] stack
// is a separate wide-layout contract.
"relative z-40 shrink-0 bg-[color:var(--background)] max-sm:transition-[height] motion-reduce:transition-none sm:h-[var(--safe-area-top)]",
headerChromeHidden
? "max-sm:h-0 max-sm:duration-[240ms] max-sm:ease-[cubic-bezier(0.4,0,0.2,1)]"
: "max-sm:h-[var(--safe-area-top)] max-sm:duration-200 max-sm:ease-[cubic-bezier(0.22,1,0.36,1)]",
"relative z-40 shrink-0 bg-[color:var(--background)] motion-reduce:transition-none sm:h-[var(--safe-area-top)]",
phoneOverlayMotion
? "max-sm:h-[var(--safe-area-top)]"
: cn(
"max-sm:transition-[height]",
headerChromeHidden
? "max-sm:h-0 max-sm:duration-[240ms] max-sm:ease-[cubic-bezier(0.4,0,0.2,1)]"
: "max-sm:h-[var(--safe-area-top)] max-sm:duration-200 max-sm:ease-[cubic-bezier(0.22,1,0.36,1)]",
),
sticksAbovePhones && "sm:sticky sm:top-0",
)}
/>
);

if (sticksAbovePhones) {
return (
<div className="phone-sticky-header-stack sm:contents">
<div
data-phone-motion={phoneMotion}
data-scroll-hidden={phoneOverlayMotion && headerChromeHidden ? "true" : undefined}
className={cn(
"phone-sticky-header-stack sm:contents",
phoneOverlayMotion &&
"phone-overlay-header max-sm:transition-[transform,opacity] motion-reduce:max-sm:transition-none",
phoneOverlayMotion &&
(headerChromeHidden
? "max-sm:pointer-events-none max-sm:-translate-y-full max-sm:opacity-0 max-sm:duration-[240ms] max-sm:ease-[cubic-bezier(0.4,0,0.2,1)]"
: "max-sm:translate-y-0 max-sm:opacity-100 max-sm:duration-200 max-sm:ease-[cubic-bezier(0.22,1,0.36,1)]"),
)}
>
{chromeSafeAreaTop}
<div className="sm:sticky sm:top-[var(--safe-area-top)] sm:z-30">
{collapsingTopBar}
Expand Down
8 changes: 6 additions & 2 deletions src/components/clinical-dashboard/use-hide-on-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,16 @@ export function readChromeCollapseMetrics(
scroller: HTMLElement,
): Pick<ScrollMetrics, "collapseBudget" | "collapseKind" | "combinedChrome"> {
const collapse = document.querySelector('[data-testid="universal-header-collapse"]');
const phoneOverlayMotion =
collapse instanceof HTMLElement &&
collapse.dataset.phoneMotion === "overlay" &&
window.matchMedia(phoneMediaQuery).matches;
// The 1fr -> 0fr grid IS the collapse mechanism, so the wrapper only hands
// layout back while it is a grid at the current width. Where it sticks and
// translates instead (GlobalSearchShell above the phone breakpoint, which
// hands scrolling back to the document), hiding costs the scroller nothing.
const headerRelease =
collapse instanceof HTMLElement && window.getComputedStyle(collapse).display === "grid"
collapse instanceof HTMLElement && !phoneOverlayMotion && window.getComputedStyle(collapse).display === "grid"
? collapse.getBoundingClientRect().height
: 0;
const safeAreaTop = document.querySelector('[data-testid="chrome-safe-area-top"]');
Expand Down Expand Up @@ -260,7 +264,7 @@ export function readChromeCollapseMetrics(
: padRelease(scroller);
return {
collapseBudget: headerRelease + phoneSafeAreaRelease + reserveRelease,
collapseKind: collapse instanceof HTMLElement ? "in-flow" : reserveRelease > 0 ? "reserve-only" : undefined,
collapseKind: headerRelease > 0 ? "in-flow" : reserveRelease > 0 ? "reserve-only" : undefined,
combinedChrome: headerRelease > 0 && reserveRelease > 0,
};
}
Expand Down
Loading
Loading