Skip to content
26 changes: 17 additions & 9 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -708,12 +708,13 @@ summary::-webkit-details-marker {
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) 100%
color-mix(in srgb, var(--background) 32%, transparent) 88%,
color-mix(in srgb, var(--background) 42%, transparent) 100%
);
backdrop-filter: blur(2px) saturate(130%);
-webkit-backdrop-filter: blur(2px) saturate(130%);
mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 45%) 28%, black 60%, black 100%);
-webkit-mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 45%) 28%, black 60%, black 100%);
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%);
}

.answer-footer-search-dock .answer-footer-search-backdrop::before,
Expand Down Expand Up @@ -741,8 +742,8 @@ summary::-webkit-details-marker {
.answer-footer-search-dock .answer-footer-search-backdrop::after {
backdrop-filter: blur(22px) saturate(140%);
-webkit-backdrop-filter: blur(22px) saturate(140%);
mask-image: linear-gradient(180deg, transparent 0%, transparent 55%, black 72%, black 100%);
-webkit-mask-image: linear-gradient(180deg, transparent 0%, transparent 55%, black 72%, black 100%);
mask-image: linear-gradient(180deg, transparent 0%, transparent 50%, black 68%, black 100%);
-webkit-mask-image: linear-gradient(180deg, transparent 0%, transparent 50%, black 68%, black 100%);
}

@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
Expand Down Expand Up @@ -1566,17 +1567,22 @@ summary::-webkit-details-marker {
}

@media (max-width: 639px) {
.dashboard-composer-edge.answer-footer-search-edge {
.edge-glass-header {
padding-left: max(0px, var(--safe-area-left));
padding-right: max(0px, var(--safe-area-right));
}

.dashboard-composer-edge.answer-footer-search-edge:not(.answer-footer-search-dock) {
width: min(calc(100vw - 8px - var(--safe-area-left) - var(--safe-area-right)), 400px);
}

.document-mobile-search-edge {
.document-mobile-search-edge:not(.answer-footer-search-dock) {
left: max(0.375rem, var(--safe-area-left));
right: max(0.375rem, var(--safe-area-right));
bottom: max(0.5rem, calc(var(--safe-area-bottom) + 0.375rem));
}

.document-mobile-search-edge.answer-footer-search-edge {
.document-mobile-search-edge.answer-footer-search-edge:not(.answer-footer-search-dock) {
left: 50%;
right: auto;
bottom: max(0.45rem, calc(var(--safe-area-bottom) + 0.35rem));
Expand All @@ -1586,7 +1592,9 @@ summary::-webkit-details-marker {

/* Compact search/result views: no chip row below the pill, so the pill
itself hugs the bottom edge and the scrim shrinks to match. */
.document-mobile-search-edge.answer-footer-search-edge.document-mobile-search-compact {
.document-mobile-search-edge.answer-footer-search-edge.document-mobile-search-compact:not(
.answer-footer-search-dock
) {
bottom: max(0.4rem, calc(var(--safe-area-bottom) + 0.3rem));
}

Expand Down
8 changes: 7 additions & 1 deletion src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
primaryControl,
textMuted,
toneInfo,
toneSuccess,

Check warning on line 41 in src/components/ClinicalDashboard.tsx

View workflow job for this annotation

GitHub Actions / verify

'toneSuccess' is defined but never used
toneWarning,

Check warning on line 42 in src/components/ClinicalDashboard.tsx

View workflow job for this annotation

GitHub Actions / verify

'toneWarning' is defined but never used
} from "@/components/ui-primitives";
import { useAuthSession } from "@/lib/supabase/client";
import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog";
Expand Down Expand Up @@ -3197,6 +3197,7 @@
<div
className={cn(
"mx-auto max-w-7xl space-y-4 overflow-x-hidden px-3 py-4 sm:space-y-5 sm:px-4 sm:py-5 lg:px-8",
compactMobileModeHome && "max-sm:px-0",
// Centred mode homes carry little content, so drop the large
// mobile bottom padding (the fixed composer already has its own
// reserved margin on <main>) to avoid a needless scrollbar.
Expand Down Expand Up @@ -3224,7 +3225,12 @@

<section
className={cn(
"min-h-[calc(100dvh-12.5rem)] sm:min-h-[calc(100dvh-11rem)]",
compactMobileModeHome
? cn(
"max-sm:flex max-sm:min-h-0 max-sm:flex-1 max-sm:flex-col",
centeredModeHome && "max-sm:justify-center",
)
: "min-h-[calc(100dvh-12.5rem)] sm:min-h-[calc(100dvh-11rem)]",
centeredModeHome || showAnswerHome
? // Phones centre the home block mid-screen, matching the
// standalone-route homes; the pop-up action surface picks
Expand Down
19 changes: 18 additions & 1 deletion src/components/DocumentViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1938,7 +1938,24 @@ export function DocumentViewer({
// mobile actions sheet is open or while focus sits inside the composer
// (keyboard users must not tab into invisible controls).
const [composerChromeFocused, setComposerChromeFocused] = useState(false);
const scrollHidden = useHideOnScroll({});
const [shellScrollContainer, setShellScrollContainer] = useState<HTMLElement | null>(null);
useEffect(() => {
let cancelled = false;
const sync = () => {
if (cancelled) return;
const main = window.document.getElementById("main-content");
setShellScrollContainer((current) => (current === main ? current : main));
};
const frame = window.requestAnimationFrame(sync);
const observer = new MutationObserver(sync);
observer.observe(window.document.body, { childList: true, subtree: true });
return () => {
cancelled = true;
window.cancelAnimationFrame(frame);
observer.disconnect();
};
}, []);
const scrollHidden = useHideOnScroll(shellScrollContainer ? { scrollContainer: shellScrollContainer } : {});
const composerScrollHidden = scrollHidden && !mobileActionsOpen && !composerChromeFocused;
const [useNativePdfViewer, setUseNativePdfViewer] = useState(() => getInitialPdfViewerMode().useNativePdfViewer);
const [hasExplicitPdfViewerMode, setHasExplicitPdfViewerMode] = useState(
Expand Down
24 changes: 13 additions & 11 deletions src/components/clinical-dashboard/favourites-home-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,19 @@ export function FavouritesHomePage({ query = "" }: FavouritesHomePageProps) {
const filterQuery = queryOverride?.source === query ? queryOverride.value : query;

return (
<main className="min-h-[calc(100dvh-4rem)] bg-[color:var(--background)] px-3 py-4 text-[color:var(--text)] sm:px-4 sm:py-5 lg:px-8">
<FavouritesHub
query={filterQuery}
onClearQuery={() => {
setQueryOverride({ source: query, value: "" });
router.push(appModeHomeHref("favourites", { focus: true }));
}}
onAddFavourite={() => undefined}
desktopComposerSlotId={modeHomeDesktopComposerSlotId}
headingLevel={1}
/>
<main className="flex min-h-0 w-full flex-1 flex-col bg-[color:var(--background)] px-0 py-4 text-[color:var(--text)] max-sm:pt-[clamp(1.25rem,4vh,2.25rem)] sm:min-h-[calc(100dvh-4rem)] sm:px-4 sm:py-5 lg:px-8">
<div className="px-4 sm:px-0">
<FavouritesHub
query={filterQuery}
onClearQuery={() => {
setQueryOverride({ source: query, value: "" });
router.push(appModeHomeHref("favourites", { focus: true }));
}}
onAddFavourite={() => undefined}
desktopComposerSlotId={modeHomeDesktopComposerSlotId}
headingLevel={1}
/>
</div>
</main>
);
}
116 changes: 82 additions & 34 deletions src/components/clinical-dashboard/global-mockup-search-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
"use client";

import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Suspense, type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import {
Suspense,
type CSSProperties,
type ReactNode,
type UIEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";

import { ClinicalDashboard } from "@/components/clinical-dashboard";
import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog";
Expand All @@ -15,6 +24,7 @@ import {
} from "@/components/clinical-dashboard/ClinicalSidebar";
import { GuideDialog } from "@/components/clinical-dashboard/dashboard-shell";
import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header";
import { useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll";
import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton";
import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed";
import { useTheme } from "@/components/clinical-dashboard/use-theme";
Expand Down Expand Up @@ -95,6 +105,12 @@ function GlobalMockupSearchShellClient({
const pathname = usePathname();
const searchParams = useSearchParams();
const inputRef = useRef<HTMLInputElement>(null);
const [mainElement, setMainElement] = useState<HTMLDivElement | null>(null);
const phoneScrollHide = useScrollHideReporter();
const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll);
useEffect(() => {
reportPhoneScrollHideRef.current = phoneScrollHide.reportScroll;
}, [phoneScrollHide.reportScroll]);
const visibleShellModes = useMemo(() => {
const modes = visibleAppModeDefinitions();
if (!availableModeIds?.length) return modes;
Expand Down Expand Up @@ -168,6 +184,13 @@ function GlobalMockupSearchShellClient({
const effectiveSidebarCollapsed = isDifferentialPresentationWorkflow ? true : sidebarCollapsed;
const effectiveSidebarWidth = shouldShowDesktopSidebar ? (effectiveSidebarCollapsed ? "5.25rem" : "20rem") : "0px";
const shouldShowSearchComposer = searchComposerVisible && !isDifferentialPresentationWorkflow;
const mobileComposerReserve = !shouldShowSearchComposer
? "2rem"
: searchMode === "answer"
? "calc(9rem + env(safe-area-inset-bottom))"
: useCompactBottomSearch
? "calc(5.5rem + env(safe-area-inset-bottom))"
: "calc(9rem + env(safe-area-inset-bottom))";

useEffect(() => {
// Re-derive the mode and query from the URL, but only when the search string
Expand Down Expand Up @@ -294,9 +317,35 @@ function GlobalMockupSearchShellClient({
navigateToMode(mode, { query: crossQuery, focus: true, run: true });
}

function handleMainScroll(event: UIEvent<HTMLDivElement>) {
phoneScrollHide.reportScroll(event.currentTarget.scrollTop);
}

const mainRefCallback = (node: HTMLDivElement | null) => {
setMainElement(node);
};

const isMedicationDetailRoute = /^\/medications\/[^/]+$/.test(pathname);
const shouldRenderClinicalDashboard = !isMedicationDetailRoute && (isHomeRoute || shouldRenderDashboardSearch);

// Page canvases can become nested scrollers when `overflow-x-hidden` pairs with
// a flex height cap (overflow-y becomes auto per CSS). Capture descendant scroll
// so the phone dock/header still hide while users scroll results.
useEffect(() => {
const main = mainElement;
if (!main) return undefined;

const onScrollCapture = (event: Event) => {
const target = event.target;
if (!(target instanceof HTMLElement) || !main.contains(target)) return;
if (target.scrollHeight <= target.clientHeight + 1) return;
reportPhoneScrollHideRef.current(target.scrollTop);
};

main.addEventListener("scroll", onScrollCapture, { capture: true, passive: true });
return () => main.removeEventListener("scroll", onScrollCapture, { capture: true });
}, [mainElement, shouldRenderClinicalDashboard, chromeVisible]);

if (shouldRenderClinicalDashboard) {
return (
<ClinicalDashboard
Expand All @@ -321,7 +370,7 @@ function GlobalMockupSearchShellClient({
return (
<div
className={cn(
"min-h-dvh bg-[color:var(--background)] text-[color:var(--text)]",
"min-h-dvh max-sm:h-dvh max-sm:overflow-hidden bg-[color:var(--background)] text-[color:var(--text)]",
Comment thread
cursor[bot] marked this conversation as resolved.
shouldShowDesktopSidebar && "md:grid md:grid-cols-[5.25rem_minmax(0,1fr)]",
shouldShowDesktopSidebar &&
"motion-safe:transition-[grid-template-columns] motion-safe:duration-200 motion-safe:ease-out",
Expand All @@ -332,6 +381,7 @@ function GlobalMockupSearchShellClient({
{
"--clinical-sidebar-width": effectiveSidebarWidth,
"--clinical-sidebar-width-md": shouldShowDesktopSidebar ? "5.25rem" : "0px",
"--mobile-composer-reserve": mobileComposerReserve,
} as CSSProperties
}
>
Expand All @@ -358,12 +408,8 @@ function GlobalMockupSearchShellClient({
</div>
) : null}

<div className="flex min-h-dvh min-w-0 flex-col">
{/* max-sm:contents lets the header's own `sticky top-0` engage against
the document scroll on phones (a plain wrapper div otherwise caps
its sticking range at its own height), which the phone
hide-on-scroll overlay relies on. */}
<div className={mobileChromeVisible ? "max-sm:contents" : "hidden lg:block"}>
<div className="flex min-h-dvh min-w-0 flex-col max-sm:h-dvh max-sm:min-h-0 max-sm:overflow-hidden">
<div className={mobileChromeVisible ? undefined : "hidden lg:block"}>
<MasterSearchHeader
documents={[]}
documentTotal={0}
Expand Down Expand Up @@ -413,48 +459,50 @@ function GlobalMockupSearchShellClient({
searchComposerVisible={shouldShowSearchComposer}
desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined}
heroComposerFromTablet={isStandaloneModeHome}
// Phone-only: the document scrolls here and the header is sticky,
// so a translate overlay hides it with zero layout shift.
hideOnScroll={{ strategy: "overlay" }}
// Phone-only: #main-content owns vertical scroll, so hide-on-scroll
// collapses the header/composer to hand space back to content.
hideOnScroll={{ strategy: "collapse", scrollHidden: phoneScrollHide.hidden }}
onBottomComposerScrollHiddenChange={setBottomSearchScrollHidden}
queryInputAutoFocus={searchParams.get("focus") === "1"}
/>
</div>

<div
id="main-content"
ref={mainRefCallback}
tabIndex={-1}
onScroll={handleMainScroll}
className={cn(
// Phone: fill the space under the header exactly (the header is
// taller than the 4rem the calc assumed, which forced a phantom
// scrollbar on every standalone page). sm+ keeps the original calc.
"min-w-0 overflow-x-hidden focus:outline-none max-sm:flex-1 sm:min-h-[calc(100dvh-4rem)]",
"min-w-0 overflow-x-hidden focus:outline-none max-sm:flex max-sm:min-h-0 max-sm:flex-1 max-sm:flex-col max-sm:overflow-y-auto max-sm:overscroll-contain max-sm:[-webkit-overflow-scrolling:touch] sm:min-h-[calc(100dvh-4rem)]",
!shouldShowSearchComposer
? "pb-8"
? "max-sm:pb-[var(--mobile-composer-reserve)] sm:pb-8"
: bottomSearchScrollHidden
? "pb-8 sm:pb-8"
? "max-sm:pb-8 sm:pb-8"
: searchMode === "answer"
? "pb-[calc(9rem+env(safe-area-inset-bottom))]"
? "max-sm:pb-[var(--mobile-composer-reserve)] sm:pb-[calc(9rem+env(safe-area-inset-bottom))]"
: useCompactBottomSearch
? "pb-[calc(5.5rem+env(safe-area-inset-bottom))] sm:pb-8"
: "pb-[calc(9rem+env(safe-area-inset-bottom))] sm:pb-8",
? "max-sm:pb-[var(--mobile-composer-reserve)] sm:pb-8"
: "max-sm:pb-[var(--mobile-composer-reserve)] sm:pb-[calc(9rem+env(safe-area-inset-bottom))] sm:pb-8",
)}
>
<ClientHydrationBoundary
fallback={<div className="min-h-[calc(100dvh-4rem)] overflow-x-hidden" aria-hidden />}
>
<SearchCommandProvider
value={{
query,
modeId: searchMode,
commandScopes,
onRemoveScope: (scopeId) => setCommandScopes((current) => current.filter((scope) => scope !== scopeId)),
onClearScopes: () => setCommandScopes([]),
}}
<div className="max-sm:flex max-sm:min-h-0 max-sm:flex-1 max-sm:flex-col">
<ClientHydrationBoundary
fallback={<div className="min-h-[calc(100dvh-4rem)] overflow-x-hidden" aria-hidden />}
>
{children}
</SearchCommandProvider>
</ClientHydrationBoundary>
<SearchCommandProvider
value={{
query,
modeId: searchMode,
commandScopes,
onRemoveScope: (scopeId) =>
setCommandScopes((current) => current.filter((scope) => scope !== scopeId)),
onClearScopes: () => setCommandScopes([]),
}}
>
{children}
</SearchCommandProvider>
</ClientHydrationBoundary>
</div>
</div>
</div>

Expand Down
10 changes: 8 additions & 2 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1092,8 +1092,14 @@ export function MasterSearchHeader({
? "floating-composer-edge dashboard-composer-edge z-40 mx-auto max-w-3xl max-sm:fixed max-sm:bottom-0 sm:sticky sm:top-[calc(4.75rem+env(safe-area-inset-top))] sm:z-20 lg:fixed lg:bottom-0 lg:top-auto lg:max-w-4xl"
: usesMobileBottomStyle
? cn(
"document-mobile-search-edge universal-top-search-edge fixed z-40 mx-auto max-w-3xl sm:z-20 sm:w-full sm:px-4 sm:py-3 lg:max-w-4xl",
isHeroDesktopComposer ? "sm:hidden" : "sm:sticky sm:top-[calc(4.75rem+env(safe-area-inset-top))]",
usesPhoneFooterDock
? "document-mobile-search-edge universal-top-search-edge fixed z-40 w-full"
: cn(
"document-mobile-search-edge universal-top-search-edge fixed z-40 mx-auto max-w-3xl sm:z-20 sm:w-full sm:px-4 sm:py-3 lg:max-w-4xl",
isHeroDesktopComposer
? "sm:hidden"
: "sm:sticky sm:top-[calc(4.75rem+env(safe-area-inset-top))]",
),
)
: "universal-top-search-edge sticky top-[calc(4.75rem+env(safe-area-inset-top))] z-20 mx-auto box-border w-full px-3 py-3 sm:px-4",
usesBottomComposerPlacement && "answer-footer-search-edge",
Expand Down
Loading
Loading