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
53 changes: 39 additions & 14 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -854,17 +854,19 @@ summary::-webkit-details-marker {
z-index: 0;
height: var(--footer-scrim-height);
overflow: hidden;
/* Bottom of the scrim must reach near-opaque so the safe-area band under the
pill reads as continuous dock chrome (edge-to-edge), not a white strip. */
background: linear-gradient(
180deg,
transparent 0%,
color-mix(in srgb, var(--background) 8%, transparent) 38%,
color-mix(in srgb, var(--background) 18%, transparent) 68%,
color-mix(in srgb, var(--background) 32%, transparent) 88%,
color-mix(in srgb, var(--background) 42%, transparent) 100%
color-mix(in srgb, var(--background) 12%, transparent) 32%,
color-mix(in srgb, var(--background) 48%, transparent) 62%,
color-mix(in srgb, var(--background) 82%, transparent) 84%,
var(--background) 100%
);
backdrop-filter: blur(2px) saturate(130%);
mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 45%) 28%, black 55%, black 100%);
-webkit-mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 45%) 28%, black 55%, black 100%);
mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 55%) 28%, black 52%, black 100%);
-webkit-mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 55%) 28%, black 52%, black 100%);
}

.answer-footer-search-dock .answer-footer-search-backdrop::before,
Expand Down Expand Up @@ -905,9 +907,9 @@ summary::-webkit-details-marker {
background: linear-gradient(
180deg,
transparent 0%,
color-mix(in srgb, var(--background) 45%, transparent) 40%,
color-mix(in srgb, var(--background) 78%, transparent) 72%,
color-mix(in srgb, var(--background) 92%, transparent) 100%
color-mix(in srgb, var(--background) 45%, transparent) 36%,
color-mix(in srgb, var(--background) 82%, transparent) 68%,
var(--background) 100%
);
}

Expand Down Expand Up @@ -950,9 +952,9 @@ summary::-webkit-details-marker {
background: linear-gradient(
180deg,
transparent 0%,
color-mix(in srgb, var(--background) 50%, transparent) 42%,
color-mix(in srgb, var(--background) 82%, transparent) 74%,
color-mix(in srgb, var(--background) 94%, transparent) 100%
color-mix(in srgb, var(--background) 50%, transparent) 36%,
color-mix(in srgb, var(--background) 86%, transparent) 72%,
var(--background) 100%
);
}

Expand Down Expand Up @@ -1794,7 +1796,11 @@ summary::-webkit-details-marker {
0 8px 22px rgb(16 24 40 / 8%);
}

/* Edge-to-edge phone dock: full-width footer, progressive scrim, inset pill. */
/* Edge-to-edge phone dock: full-width footer flush to the glass. The pill is
inset via padding-bottom only — never by a non-zero `bottom` offset (that
reintroduces a white strip under the bar). Form background paints the
safe-area pad so it reads as dock chrome, not empty page margin. */
.answer-footer-search-dock.answer-footer-search-edge,
.answer-footer-search-dock.dashboard-composer-edge.answer-footer-search-edge,
.answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge {
left: 0;
Expand All @@ -1806,15 +1812,24 @@ summary::-webkit-details-marker {
padding-inline: max(0.75rem, var(--safe-area-left)) max(0.75rem, var(--safe-area-right));
padding-top: 0.5rem;
padding-bottom: max(0.5rem, var(--safe-area-bottom));
background: linear-gradient(
180deg,
transparent 0%,
color-mix(in srgb, var(--background) 72%, transparent) 42%,
color-mix(in srgb, var(--background) 94%, transparent) 72%,
var(--background) 100%
);
}

.answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge.document-mobile-search-compact,
.answer-footer-search-dock.dashboard-composer-edge.answer-footer-search-edge.document-mobile-search-compact {
.answer-footer-search-dock.dashboard-composer-edge.answer-footer-search-edge.document-mobile-search-compact,
.answer-footer-search-dock.answer-footer-search-edge.document-mobile-search-compact {
padding-bottom: max(0.45rem, var(--safe-area-bottom));
}

/* Must beat the edge-to-edge dock rule above (transform: none) so scroll-hide
actually slides the bar off-screen once data-scroll-hidden is set. */
.answer-footer-search-dock.answer-footer-search-edge[data-scroll-hidden="true"],
.answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge[data-scroll-hidden="true"],
.answer-footer-search-dock.dashboard-composer-edge.answer-footer-search-edge[data-scroll-hidden="true"] {
transform: translateY(calc(100% + env(safe-area-inset-bottom)));
Expand Down Expand Up @@ -2226,6 +2241,16 @@ html[data-motion="reduced"] .pwa-notice-card {
}
}

/* Phones: ClinicalDashboard pins with fixed inset-0 utilities. Do not keep a
dvh height clamp here — it recreates the Safari toolbar dead band under
the shell even when inset-0 is present if height still resolves to dvh. */
@media (max-width: 639px) {
.mobile-app-shell {
min-height: 0;
height: auto;
}
}

/* Scroll and print helpers */
.citation-link {
position: relative;
Expand Down
7 changes: 3 additions & 4 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3311,7 +3311,8 @@ export function ClinicalDashboard({
<div
className={cn(
appBackdrop,
"mobile-app-shell flex flex-col overflow-hidden text-[color:var(--text)] md:grid md:grid-cols-[5.25rem_minmax(0,1fr)] md:overflow-hidden",
// Phone: fixed inset-0 (not 100dvh) — matches GlobalSearchShell; avoids Safari toolbar dead band.
"mobile-app-shell flex flex-col overflow-hidden text-[color:var(--text)] max-sm:fixed max-sm:inset-0 max-sm:h-auto max-sm:min-h-0 max-sm:overflow-hidden md:grid md:grid-cols-[5.25rem_minmax(0,1fr)] md:overflow-hidden",
"motion-safe:transition-[grid-template-columns] motion-safe:duration-200 motion-safe:ease-out",
sidebarCollapsed ? "lg:grid-cols-[5.25rem_minmax(0,1fr)]" : "lg:grid-cols-[20rem_minmax(0,1fr)]",
)}
Expand Down Expand Up @@ -3446,9 +3447,7 @@ export function ClinicalDashboard({
// bottom-clamp guard in use-hide-on-scroll prevents false reveals.
"max-sm:pb-[var(--mobile-composer-reserve)] max-sm:[scroll-padding-bottom:var(--mobile-composer-reserve)] sm:mb-24"
: hasMobileBottomSearch
? // Phones dock the compact composer on every non-answer view
// (mode homes included), so they always reserve dock
// clearance; sm+ keeps the in-flow hero/sticky composers.
? // Phone dock reserve; sm+ keeps hero/sticky composers.
"max-sm:pb-[var(--mobile-composer-reserve)] max-sm:[scroll-padding-bottom:var(--mobile-composer-reserve)] sm:mb-0"
: "mb-0",
)}
Expand Down
7 changes: 3 additions & 4 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,10 +345,9 @@ function GlobalStandaloneSearchShellClient({
!isDifferentialPresentationWorkflow &&
(!isInfoPage || isToolDetailWithFooterSearch(pathname));
const reservesFloatingComposer = shouldShowSearchComposer && !isStandaloneModeHome;
// Standalone mode homes keep the hero composer from sm up but dock the
// compact pill on phones, so they reserve dock clearance too. Document viewer
// routes own their own floating composer, so the shell keeps only a small pad
// and lets DocumentViewer manage visible-dock clearance.
// Standalone mode homes keep the in-flow hero pill at every width (no phone
// dock reserve). Document viewer routes own their own floating composer, so
// the shell keeps only a small pad and lets DocumentViewer manage clearance.
// Release the large bottom reserve only when the phone bottom composer is
// actually hidden (MasterSearchHeader's bottomComposerHidden). Header-only
// scroll-hide, pinned compare addons, open menus/sheets, and composer focus
Expand Down
49 changes: 48 additions & 1 deletion tests/ui-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,7 @@ test.describe("Clinical KB tools launcher", () => {
{ path: "/services", testId: "services-home" },
{ path: "/forms", testId: "forms-home" },
{ path: "/differentials", testId: "differentials-home" },
{ path: "/factsheets", testId: "factsheets-home-main" },
{ path: "/favourites", testId: "favourites-hub" },
{ path: "/tools", testId: "tools-home" },
] as const) {
Expand Down Expand Up @@ -883,7 +884,9 @@ test.describe("Clinical KB tools launcher", () => {
expect(metrics?.formCenterY ?? 0).toBeGreaterThan(viewport.height * 0.72);
await expect(page.locator(".answer-footer-search-chip:visible")).toHaveCount(0);
if (route.compactBottomSearch) {
expect(metrics?.formBottom ?? 0).toBeGreaterThanOrEqual(viewport.height - 48);
// Edge-to-edge dock: the form itself must sit flush to the viewport
// bottom (safe-area is padding inside the form, not a `bottom` gap).
expect(metrics?.formBottom ?? 0).toBeGreaterThanOrEqual(viewport.height - 2);
}
} else {
expect(metrics?.position).toBe("sticky");
Expand Down Expand Up @@ -1053,6 +1056,50 @@ test.describe("Clinical KB tools launcher", () => {
await expectNoPageHorizontalOverflow(page);
});

test("phone bottom search dock stays edge-to-edge with safe-area padding inside the form", async ({ page }) => {
// Guards the white-strip regression: a non-zero CSS `bottom` on the dock
// (or a 100dvh shell dead band) leaves blank page chrome under the pill.
// Safe-area must be padding inside a form flush to the viewport bottom.
await page.setViewportSize({ width: 390, height: 844 });
const safeAreaBottom = 34;

for (const route of [
{ path: "/forms?q=transport&run=1", resultsTestId: "form-search-mobile-results" },
{ path: "/differentials?q=acute+confusion&run=1", resultsTestId: "differentials-search-results" },
] as const) {
await gotoLauncher(page, route.path);
await expect(page.getByTestId(route.resultsTestId)).toBeVisible({ timeout: 20_000 });
const dock = page.locator("form.answer-footer-search-dock");
await expect(dock, route.path).toBeVisible();
await expect(dock, route.path).not.toHaveAttribute("data-scroll-hidden", "true");

await page.evaluate((inset) => {
document.documentElement.style.setProperty("--safe-area-bottom", `${inset}px`);
}, safeAreaBottom);

const geometry = await dock.evaluate((node) => {
const style = window.getComputedStyle(node);
const formRect = node.getBoundingClientRect();
const pill = node.querySelector(".answer-footer-search-pill");
const pillRect = pill?.getBoundingClientRect();
return {
bottomCss: style.bottom,
paddingBottom: Number.parseFloat(style.paddingBottom),
formBottom: formRect.bottom,
pillBottom: pillRect?.bottom ?? null,
viewportHeight: window.innerHeight,
};
});

expect(geometry.bottomCss, route.path).toBe("0px");
expect(Math.abs(geometry.formBottom - geometry.viewportHeight), route.path).toBeLessThanOrEqual(1);
expect(geometry.paddingBottom, route.path).toBeGreaterThanOrEqual(safeAreaBottom - 1);
expect(geometry.pillBottom, route.path).not.toBeNull();
// Pill sits above the safe-area pad; do not require exact px (borders/gaps).
expect(geometry.pillBottom!, route.path).toBeLessThanOrEqual(geometry.viewportHeight - safeAreaBottom + 2);
}
});

test("phone bottom search dock hides while scrolling down on search results", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.setViewportSize({ width: 390, height: 844 });
Expand Down
Loading