diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 51a31a707a..f627a96144 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -84,6 +84,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 { PageSecondaryNavigation } from "@/components/page-secondary-navigation"; import { resolveDashboardVisibleMobileComposerReserve, resolveMobileComposerReserve, @@ -377,11 +378,11 @@ export function ClinicalDashboard({ const [answerThreadBootstrapped, setAnswerThreadBootstrapped] = useState(false); const [query, setQuery] = useState(initialQuery); const [searchMode, setSearchMode] = useState(initialSearchMode); - // Answer mode hides the glass header at every breakpoint (all-breakpoints - // overlay); other modes keep the phone-only collapse, so the reporter only - // widens past the phone media gate while in answer mode. - const phoneScrollHide = useScrollHideReporter(false, searchMode === "answer"); + // Every mode reports the dashboard's owned scrollport at every breakpoint. + // Answer translates an overlay; other modes collapse their in-flow chrome. + const phoneScrollHide = useScrollHideReporter(false, true); const [bottomComposerHidden, setBottomComposerHidden] = useState(false); + const [headerChromeHidden, setHeaderChromeHidden] = useState(false); const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll); reportPhoneScrollHideRef.current = phoneScrollHide.reportScroll; const [modeSearchSubmitted, setModeSearchSubmitted] = useState(() => @@ -3407,14 +3408,15 @@ export function ClinicalDashboard({ // Answer view: the header overlays the scrolling
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 keep the phone-only collapse (their sm+ composer renders - // in-flow below the header, which an absolute header would bury). + // modes collapse their in-flow header/composer at every width. hideOnScroll={ searchMode === "answer" ? { strategy: "overlay", allBreakpoints: true, scrollHidden: phoneScrollHide.hidden } - : { strategy: "collapse", scrollHidden: phoneScrollHide.hidden } + : { strategy: "collapse", allBreakpoints: true, scrollHidden: phoneScrollHide.hidden } } onBottomComposerHiddenChange={setBottomComposerHidden} + onHeaderChromeHiddenChange={setHeaderChromeHidden} + externalMenuOpen={mobileSidebarOpen} />

Clinical Guide

+ composerInputRef.current?.focus({ preventScroll: true })} + stickyTop={ + searchMode === "answer" && !headerChromeHidden ? "calc(4rem + max(0.5rem, env(safe-area-inset-top)))" : 0 + } + /> {privateScopeStatus === "unavailable" ? ( // Lives inside
(not as a header sibling): in the answer view // the header is absolute, so a sibling alert would reflow to the diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index a98e0fd06e..0d82d9e00c 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -77,7 +77,6 @@ import { ClinicalSummaryProfile, DocumentImage, DocumentSectionSummary, - DocumentViewerAnchors, FormattedHighYieldSummary, IndexedTextPanel, PinnedSourceEvidence, @@ -1330,8 +1329,6 @@ export function DocumentViewer({ ) : null}
- -
{effectiveLoadingDocument ? ( @@ -1491,7 +1488,6 @@ export function DocumentViewer({ ) : null}
- ]; // Re-apply focus shortly after the first frame to survive initial hydration remounts. const focusHydrationRetryDelayMs = 300; +const desktopScrollMediaQuery = "(min-width: 640px)"; +const phoneScrollMediaQuery = "(max-width: 639px)"; type GlobalSearchShellProps = { children: ReactNode; @@ -160,65 +165,6 @@ function GlobalSearchShellClient(props: GlobalSearchShellProps) { ); } -function isInformationPage(pathname: string): boolean { - // Services detail: /services/[slug] - if (pathname.startsWith("/services/") && pathname !== "/services") return true; - - // Forms detail: /forms/[slug] - if (pathname.startsWith("/forms/") && pathname !== "/forms") return true; - - // Medications detail: /medications/[slug] - if (pathname.startsWith("/medications/") && pathname !== "/medications") return true; - - // Psychiatric specifier detail: /specifiers/[slug] - if ( - pathname.startsWith("/specifiers/") && - pathname !== "/specifiers" && - pathname !== "/specifiers/builder" && - pathname !== "/specifiers/compare" && - pathname !== "/specifiers/map" - ) - return true; - - // Clinical formulation detail: /formulation/[slug] - if ( - pathname.startsWith("/formulation/") && - pathname !== "/formulation" && - pathname !== "/formulation/builder" && - pathname !== "/formulation/compare" && - pathname !== "/formulation/map" - ) - return true; - - // Factsheets detail: /factsheets/[slug] - if (pathname.startsWith("/factsheets/") && pathname !== "/factsheets" && pathname !== "/factsheets/search") - return true; - - // Therapy compass detail: /therapy-compass/[slug]/brief or /therapy-compass/[slug]/sheet - if ( - pathname.startsWith("/therapy-compass/") && - pathname !== "/therapy-compass" && - pathname !== "/therapy-compass/compare" && - pathname !== "/therapy-compass/pathways" && - pathname !== "/therapy-compass/recommend" && - pathname !== "/therapy-compass/review" && - pathname !== "/therapy-compass/search" - ) - return true; - - // Differential diagnosis detail: /differentials/diagnoses/[slug] or /differentials/presentations/[slug] - if (pathname.startsWith("/differentials/diagnoses/") || pathname.startsWith("/differentials/presentations/")) - return true; - - // DSM-5 Diagnosis detail: /dsm/diagnoses/[slug] or /dsm/diagnoses/[slug]/differentials or /dsm/compare - if (pathname.startsWith("/dsm/diagnoses/")) return true; - - // Document detail: /documents/[id] (excluding /documents/search) - if (pathname.startsWith("/documents/") && pathname !== "/documents/search") return true; - - return false; -} - function isToolDetailWithFooterSearch(pathname: string): boolean { return ( (pathname.startsWith("/services/") && pathname !== "/services") || @@ -241,8 +187,9 @@ function GlobalStandaloneSearchShellClient({ const pathname = usePathname(); const searchParams = useSearchParams(); const inputRef = useRef(null); + const [secondaryNavigationHost, setSecondaryNavigationHost] = useState(null); const [mainElement, setMainElement] = useState(null); - const phoneScrollHide = useScrollHideReporter(); + const phoneScrollHide = useScrollHideReporter(false, true); const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll); const [bottomComposerHidden, setBottomComposerHidden] = useState(false); useEffect(() => { @@ -534,6 +481,7 @@ function GlobalStandaloneSearchShellClient({ } function handleMainScroll(event: UIEvent) { + if (!window.matchMedia(phoneScrollMediaQuery).matches) return; const target = event.currentTarget; phoneScrollHide.reportScroll({ offset: target.scrollTop, @@ -555,6 +503,7 @@ function GlobalStandaloneSearchShellClient({ if (!main) return undefined; const onScrollCapture = (event: Event) => { + if (!window.matchMedia(phoneScrollMediaQuery).matches) return; const target = event.target; if (!(target instanceof HTMLElement) || !main.contains(target)) return; if (target.scrollHeight <= target.clientHeight + 1) return; @@ -572,6 +521,51 @@ function GlobalStandaloneSearchShellClient({ return () => main.removeEventListener("scroll", onScrollCapture, { capture: true }); }, [mainElement, chromeVisible]); + // Phones scroll #main-content; sm+ intentionally leaves that element out of + // the vertical overflow chain so sticky descendants follow document scroll. + // Report the window separately at those widths rather than turning the page + // into a second desktop scroll container. + useEffect(() => { + if (!chromeVisible) return undefined; + const desktopMedia = window.matchMedia(desktopScrollMediaQuery); + let frame = 0; + const reportWindow = () => { + frame = 0; + if (!desktopMedia.matches) return; + const scrollingElement = document.scrollingElement ?? document.documentElement; + reportPhoneScrollHideRef.current({ + offset: window.scrollY, + maxOffset: Math.max(0, scrollingElement.scrollHeight - window.innerHeight), + collapseBudget: readChromeCollapseBudget(mainElement ?? document.documentElement), + source: window, + }); + }; + const reportCurrentOwner = () => { + if (desktopMedia.matches) { + reportWindow(); + return; + } + if (!mainElement) return; + reportPhoneScrollHideRef.current({ + offset: mainElement.scrollTop, + maxOffset: Math.max(0, mainElement.scrollHeight - mainElement.clientHeight), + collapseBudget: readChromeCollapseBudget(mainElement), + source: mainElement, + }); + }; + const onScroll = () => { + if (desktopMedia.matches && !frame) frame = window.requestAnimationFrame(reportWindow); + }; + window.addEventListener("scroll", onScroll, { passive: true }); + desktopMedia.addEventListener("change", reportCurrentOwner); + reportCurrentOwner(); + return () => { + window.removeEventListener("scroll", onScroll); + desktopMedia.removeEventListener("change", reportCurrentOwner); + if (frame) window.cancelAnimationFrame(frame); + }; + }, [chromeVisible, mainElement]); + if (!chromeVisible) { return (
@@ -587,227 +581,241 @@ function GlobalStandaloneSearchShellClient({ } return ( -
- {shouldShowDesktopSidebar ? ( -
-
- + +
+ {shouldShowDesktopSidebar ? ( +
+
+ +
-
- ) : null} - -
-
- { - setGuideOpen(false); - setSettingsOpen(false); - setMobileMenuOpen(false); - openAccountSetup("favourites"); - }} - onAsk={submitSearch} - onClearQuery={() => { - setQuery(""); - if (isStandaloneModeHome) navigateToMode(searchMode, { focus: true }); - }} - onClearScope={() => undefined} - onQueryModeChange={setQueryMode} - onScopeFiltersChange={setScopeFilters} - onToggleScope={() => undefined} - onOpenUpload={() => - router.push(`${appModeHomeHref("documents", { focus: true, queryMode, scopeFilters })}#sources`) - } - onOpenEvidence={() => navigateToMode("answer", { focus: true })} - onNewChat={startNewAnswerChat} - onOpenMobileSidebar={() => setMobileMenuOpen(true)} - mobileLeadingAction={ - isInfoPage - ? "back" - : pathname === "/differentials" && searchMode === "differentials" && requestedQuery - ? "back" - : "menu" - } - onMobileBack={() => { - if (isInfoPage) { - if (pathname.startsWith("/services/")) { - router.push("/services"); - } else if (pathname.startsWith("/forms/")) { - router.push("/forms"); - } else if (pathname.startsWith("/medications/")) { - router.push("/?mode=prescribing"); - } else if (pathname.startsWith("/differentials/")) { - router.push("/differentials"); - } else if (pathname.startsWith("/dsm/")) { - router.push("/dsm"); - } else if (pathname.startsWith("/specifiers/")) { - router.push("/specifiers"); - } else if (pathname.startsWith("/formulation/")) { - router.push("/formulation"); - } else if (pathname.startsWith("/therapy-compass/")) { - router.push("/therapy-compass"); - } else if (pathname.startsWith("/factsheets/")) { - router.push("/factsheets"); - } else if (pathname.startsWith("/documents/")) { - router.push("/documents/search"); - } else { - router.back(); + ) : null} + +
+
+
+ { + setGuideOpen(false); + setSettingsOpen(false); + setMobileMenuOpen(false); + openAccountSetup("favourites"); + }} + onAsk={submitSearch} + onClearQuery={() => { + setQuery(""); + if (isStandaloneModeHome) navigateToMode(searchMode, { focus: true }); + }} + onClearScope={() => undefined} + onQueryModeChange={setQueryMode} + onScopeFiltersChange={setScopeFilters} + onToggleScope={() => undefined} + onOpenUpload={() => + router.push(`${appModeHomeHref("documents", { focus: true, queryMode, scopeFilters })}#sources`) } - } else { - setQuery(""); - navigateToMode(searchMode, { focus: true }); - } - }} - queryModeOptions={mockupQueryModeOptions} - queryInputRef={inputRef} - recentQueries={recentQueries} - commandScopes={commandScopes} - onCommandScopesChange={setCommandScopes} - onPickRecent={pickRecentQuery} - onCrossModeSearch={crossModeSearch} - headerVariant={isDifferentialPresentationWorkflow ? "workflow" : "default"} - mobileSearchPlacement="bottom" - // Every phone dock is the compact single-row pill so content keeps - // maximum screen space (mode homes and result views alike). - mobileBottomSearchVariant="compact" - mobileBottomSearchAddonSlotId={ - differentialsCompareAddonActive ? differentialsMobileCompareAddonSlotId : undefined - } - desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"} - searchComposerVisible={shouldShowSearchComposer} - desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} - // Standalone mode homes keep the in-flow hero pill at every width, - // phones included — the composer sits in the middle of the hero and - // scrolls with the content, matching the answer home rather than - // docking to the bottom edge. - heroComposerBreakpoint="all" - // 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 }} - onBottomComposerHiddenChange={setBottomComposerHidden} - queryInputAutoFocus={searchParams.get("focus") === "1"} - /> -
+ onOpenEvidence={() => navigateToMode("answer", { focus: true })} + onNewChat={startNewAnswerChat} + onOpenMobileSidebar={() => setMobileMenuOpen(true)} + mobileLeadingAction={ + isInfoPage + ? "back" + : pathname === "/differentials" && searchMode === "differentials" && requestedQuery + ? "back" + : "menu" + } + onMobileBack={() => { + if (isInfoPage) { + if (pathname.startsWith("/services/")) { + router.push("/services"); + } else if (pathname.startsWith("/forms/")) { + router.push("/forms"); + } else if (pathname.startsWith("/medications/")) { + router.push("/?mode=prescribing"); + } else if (pathname.startsWith("/differentials/")) { + router.push("/differentials"); + } else if (pathname.startsWith("/dsm/")) { + router.push("/dsm"); + } else if (pathname.startsWith("/specifiers/")) { + router.push("/specifiers"); + } else if (pathname.startsWith("/formulation/")) { + router.push("/formulation"); + } else if (pathname.startsWith("/therapy-compass/")) { + router.push("/therapy-compass"); + } else if (pathname.startsWith("/factsheets/")) { + router.push("/factsheets"); + } else if (pathname.startsWith("/documents/")) { + router.push("/documents/search"); + } else { + router.back(); + } + } else { + setQuery(""); + navigateToMode(searchMode, { focus: true }); + } + }} + queryModeOptions={mockupQueryModeOptions} + queryInputRef={inputRef} + recentQueries={recentQueries} + commandScopes={commandScopes} + onCommandScopesChange={setCommandScopes} + onPickRecent={pickRecentQuery} + onCrossModeSearch={crossModeSearch} + headerVariant={isDifferentialPresentationWorkflow ? "workflow" : "default"} + mobileSearchPlacement="bottom" + // Every phone dock is the compact single-row pill so content keeps + // maximum screen space (mode homes and result views alike). + mobileBottomSearchVariant="compact" + mobileBottomSearchAddonSlotId={ + differentialsCompareAddonActive ? differentialsMobileCompareAddonSlotId : undefined + } + desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"} + searchComposerVisible={shouldShowSearchComposer} + desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} + // Standalone mode homes keep the in-flow hero pill at every width, + // phones included — the composer sits in the middle of the hero and + // scrolls with the content, matching the answer home rather than + // docking to the bottom edge. + heroComposerBreakpoint="all" + // Phone uses #main-content and sm+ uses window scroll; both report + // into the same collapse state so mode/page navigation can stay at + // the viewport top after the universal chrome moves away. + hideOnScroll={{ strategy: "collapse", allBreakpoints: true, scrollHidden: phoneScrollHide.hidden }} + externalMenuOpen={mobileMenuOpen} + onBottomComposerHiddenChange={setBottomComposerHidden} + queryInputAutoFocus={searchParams.get("focus") === "1"} + /> +
+
+ inputRef.current?.focus({ preventScroll: true })} + sticky={false} + /> +
- - setGuideOpen(false)} /> - setSettingsOpen(false)} - identity={sidebarIdentity} - onSignOut={auth.signOut} - onOpenGuide={openGuide} - /> - - -
+ setGuideOpen(false)} /> + setSettingsOpen(false)} + identity={sidebarIdentity} + onSignOut={auth.signOut} + onOpenGuide={openGuide} + /> + + +
+ ); } diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 1487bd43a2..593b2217b5 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -187,6 +187,8 @@ export function MasterSearchHeader({ onMobileBack, hideOnScroll, onBottomComposerHiddenChange, + onHeaderChromeHiddenChange, + externalMenuOpen = false, canAccessFavourites = false, onRequestAccountSetup, }: { @@ -249,7 +251,7 @@ export function MasterSearchHeader({ mobileBottomSearchAddonSlotId?: string; mobileLeadingAction?: "menu" | "back"; onMobileBack?: () => void; - /** Phone-only hide-on-scroll for the universal header and bottom search dock. + /** Hide-on-scroll for the universal header and phone bottom search dock. * "overlay" translates the sticky header away (host scrolls the document, * content already flows beneath); "collapse" also releases the header's * layout space (host keeps the header above an internally scrolling element). @@ -258,17 +260,17 @@ export function MasterSearchHeader({ * `useScrollHideReporter` wired to that element's scroll events. */ hideOnScroll?: { strategy: "overlay" | "collapse"; - /** - * Overlay-only: apply the hide/reveal (and the out-of-flow absolute header) - * at every breakpoint instead of phones only. The host must reserve - * matching top padding on its scroll container. - */ + /** Apply hide/reveal at every breakpoint. Overlay hosts must reserve + * matching top padding; collapse hosts release their in-flow chrome. */ allBreakpoints?: boolean; /** Parent-owned hidden state for hosts that report scroll via React `onScroll`. */ scrollHidden?: boolean; }; /** Notify hosts when the phone bottom composer is actually hidden (not merely scrolled). */ onBottomComposerHiddenChange?: (hidden: boolean) => void; + onHeaderChromeHiddenChange?: (hidden: boolean) => void; + /** Keeps universal chrome visible while a host-owned header menu is open. */ + externalMenuOpen?: boolean; /** * Favourites are account-scoped. When false, omit Favourites from the mode menu * and route favourites actions to account setup instead of switching mode. @@ -294,6 +296,9 @@ export function MasterSearchHeader({ const selectedSearchable = isSearchableAppMode(searchMode); const isAnswerFooterComposer = searchMode === "answer"; const isWorkflowHeader = headerVariant === "workflow"; + const hideStrategy = hideOnScroll?.strategy; + const overlayAllBreakpoints = hideStrategy === "overlay" && Boolean(hideOnScroll?.allBreakpoints); + const collapseAllBreakpoints = hideStrategy === "collapse" && Boolean(hideOnScroll?.allBreakpoints); const isServicesMode = searchMode === "services"; const isMobileBottomComposer = searchComposerVisible && mobileSearchPlacement === "bottom" && !isAnswerFooterComposer; const isHeroDesktopComposer = desktopSearchPlacement === "hero" && isMobileBottomComposer; @@ -338,9 +343,8 @@ export function MasterSearchHeader({ // (no flash while the portal mounts); once it flips true the inline composer // renders, so the search can never vanish from the page at any width. const [desktopHomeComposerFallback, setDesktopHomeComposerFallback] = useState(false); - // Phone-only hide-on-scroll: never hide while a header-owned surface is open - // or while focus sits inside the header chrome (keyboard users must not tab - // into invisible controls). + // Never hide while a header-owned surface is open or focus sits inside the + // universal chrome (keyboard users must not tab into invisible controls). const [headerChromeFocused, setHeaderChromeFocused] = useState(false); const [composerChromeFocused, setComposerChromeFocused] = useState(false); const internalScrollHidden = useHideOnScroll({ @@ -348,7 +352,14 @@ export function MasterSearchHeader({ }); const scrollHidden = hideOnScroll?.scrollHidden !== undefined ? hideOnScroll.scrollHidden : internalScrollHidden; const headerChromeHidden = - scrollHidden && !modeMenuOpen && !actionMenuOpen && !scopeOpen && !scopeSheetOpen && !headerChromeFocused; + scrollHidden && + !modeMenuOpen && + !actionMenuOpen && + !commandDropdownOpen && + !scopeOpen && + !scopeSheetOpen && + !externalMenuOpen && + !headerChromeFocused; // Mode homes portal the composer into the hero slot. With "all" the hero owns // every width (the answer home keeps its in-flow pill on phones); "sm-up" // hero hosts hand phones the bottom dock instead. @@ -368,12 +379,17 @@ export function MasterSearchHeader({ !commandDropdownOpen && !scopeOpen && !scopeSheetOpen && + !externalMenuOpen && !composerChromeFocused; useEffect(() => { onBottomComposerHiddenChange?.(bottomComposerHidden); }, [bottomComposerHidden, onBottomComposerHiddenChange]); + useEffect(() => { + onHeaderChromeHiddenChange?.(headerChromeHidden); + }, [headerChromeHidden, onHeaderChromeHiddenChange]); + useEffect(() => { if (!loading || !commandDropdownOpen) return undefined; const frame = window.requestAnimationFrame(() => setCommandDropdownOpen(false)); @@ -1400,10 +1416,17 @@ export function MasterSearchHeader({ "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))]", + : collapseAllBreakpoints + ? "sm:relative sm:top-auto" + : "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", + : cn( + "universal-top-search-edge z-20 mx-auto box-border w-full px-3 py-3 sm:px-4", + collapseAllBreakpoints + ? "relative top-auto" + : "sticky top-[calc(4.75rem+env(safe-area-inset-top))]", + ), usesBottomComposerPlacement && "answer-footer-search-edge", usesPhoneFooterDock && "answer-footer-search-dock", usesCompactMobileBottomStyle && "document-mobile-search-compact", @@ -1644,11 +1667,9 @@ export function MasterSearchHeader({ ); } - const hideStrategy = hideOnScroll?.strategy; // Overlay hosts that opt into all breakpoints take the header fully out of // flow (absolute over the scrolling
, which reserves matching top // padding) so content frosts under the glass bar at every width. - const overlayAllBreakpoints = hideStrategy === "overlay" && Boolean(hideOnScroll?.allBreakpoints); const chromeFocusProps = hideOnScroll ? { onFocusCapture: () => setHeaderChromeFocused(true), @@ -1684,7 +1705,9 @@ export function MasterSearchHeader({ // an ancestor of the header. Legacy overlay hosts keep sticky (they ride // document scroll) and can translate away with zero layout shift. hideStrategy === "collapse" - ? "max-sm:relative sm:sticky sm:top-0" + ? collapseAllBreakpoints + ? "relative" + : "max-sm:relative sm:sticky sm:top-0" : overlayAllBreakpoints ? "absolute inset-x-0 top-0" : "sticky top-0", @@ -1906,32 +1929,48 @@ export function MasterSearchHeader({ ); if (hideStrategy === "collapse") { - // Collapse hide-on-scroll (phones): the host renders the header above an + // Collapse hide-on-scroll: the host renders the header above an // internally scrolling element, so hiding must also release the header's // layout space. A 1fr -> 0fr grid row animates the collapse without any // height measurement; the bottom-anchored inner track makes the chrome // slide up out of the viewport top. Fixed-position composers (answer // footer, mobile bottom search) escape the wrapper naturally because it - // never carries a transform, and everything is inert from sm up. + // never carries a transform. Hosts may opt into the same behavior at every + // breakpoint when their scroll reporter covers the desktop scroll owner. return (
{headerAndComposer} diff --git a/src/components/clinical-dashboard/medication-record-page.tsx b/src/components/clinical-dashboard/medication-record-page.tsx index 42d973756a..79bf5c3b0f 100644 --- a/src/components/clinical-dashboard/medication-record-page.tsx +++ b/src/components/clinical-dashboard/medication-record-page.tsx @@ -20,12 +20,13 @@ import { type LucideIcon, } from "lucide-react"; import Link from "next/link"; -import { useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { BadgeCluster } from "@/components/clinical-dashboard/clinical-badge"; import { MedicationConsiderations } from "@/components/clinical-dashboard/medication-considerations"; import { PatientProfilePanel } from "@/components/clinical-dashboard/patient-profile-panel"; import { useMedicationDetail } from "@/components/clinical-dashboard/use-medication-catalog"; +import { SecondaryNavigation } from "@/components/secondary-navigation"; import { medicationAccessBadges, medicationAccessFields, @@ -176,63 +177,30 @@ const detailTabs = [ ] as const; type MedicationTabId = (typeof detailTabs)[number][0]; -function SectionTabs({ active, onChange }: { active: MedicationTabId; onChange: (id: MedicationTabId) => void }) { - const tabRefs = useRef(new Map()); - - function handleKeyDown(event: ReactKeyboardEvent) { - const order = detailTabs.map((tab) => tab[0]); - const index = order.indexOf(active); - const next = - event.key === "ArrowRight" - ? order[(index + 1) % order.length] - : event.key === "ArrowLeft" - ? order[(index - 1 + order.length) % order.length] - : event.key === "Home" - ? order[0] - : event.key === "End" - ? order[order.length - 1] - : null; - if (!next) return; - event.preventDefault(); - if (next !== active) onChange(next); - tabRefs.current.get(next)?.focus(); - } - +function SectionTabs({ + active, + tabs, + onChange, +}: { + active: MedicationTabId; + tabs: ReadonlyArray<(typeof detailTabs)[number]>; + onChange: (id: MedicationTabId) => void; +}) { return ( - + ({ + kind: "action" as const, + id, + elementId: `medication-tab-${id}`, + label, + controlsId: `medication-panel-${id}`, + onSelect: () => onChange(id), + }))} + /> ); } @@ -385,91 +353,100 @@ function MedicationRecordDetail({ }, [record.sections]); const activeSections = sectionsByTab[activeTab]; + const availableTabs = useMemo( + () => detailTabs.filter(([id]) => id === "summary" || sectionsByTab[id].length > 0), + [sectionsByTab], + ); + + useEffect(() => { + if (!availableTabs.some(([id]) => id === activeTab)) setActiveTab("summary"); + }, [activeTab, availableTabs]); return ( -
-
-
-
-
- - -
-

- {record.name} -

-

-

- {indication ? ( -

- {indication} +

+ +
+
+
+
+
+ + +
+

+ {record.name} +

+

+

- ) : null} - + {indication ? ( +

+ {indication} +

+ ) : null} + +
-
-
+ -
- {metrics.map((metric, index) => ( - // Some records repeat a stat label (e.g. adrenaline has two "Route" - // stats), so the label alone is not a unique key — include the index. - - ))} -
- -
- - -
- - +
+ {metrics.map((metric, index) => ( + // Some records repeat a stat label (e.g. adrenaline has two "Route" + // stats), so the label alone is not a unique key — include the index. + + ))} +
+ +
+ + +
+ +
+ {activeSections.length ? ( + activeSections.map((section) => ( + + )) + ) : ( +
+ +
+ )} +
+
-
- {activeSections.length ? ( - activeSections.map((section) => ( - - )) - ) : ( -
- +
-
- - + + +
); diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index fe0fd5a2c7..cdddaaffdc 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -6,7 +6,7 @@ import { mobileComposerHiddenReserveRem } from "@/components/clinical-dashboard/ // Matches phoneSearchLayoutMediaQuery in master-search-header.tsx — the repo's // phone/tablet seam. Hide-on-scroll runs below the sm breakpoint unless the -// host opts into all breakpoints (the ClinicalDashboard glass-header overlay). +// host opts into an all-breakpoint overlay or collapse strategy. const phoneMediaQuery = "(max-width: 639px)"; // Scroll offset (px) that must be passed before the chrome may hide; the @@ -202,7 +202,7 @@ function usePhoneScrollHideActive(disabled = false, allowAllBreakpoints = false) * Imperative scroll-offset reporter for hosts that already own a React `onScroll` * handler on the scrolling element (for example ClinicalDashboard `
`). * Pass `allowAllBreakpoints` when the consumer hides chrome at every width - * (the all-breakpoints glass-header overlay) instead of phones only. + * instead of phones only. */ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = false) { const [hidden, setHidden] = useState(false); diff --git a/src/components/differentials/differential-detail-page.tsx b/src/components/differentials/differential-detail-page.tsx index 3a5d4ccdc0..964af7f481 100644 --- a/src/components/differentials/differential-detail-page.tsx +++ b/src/components/differentials/differential-detail-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { Activity, @@ -28,6 +28,7 @@ import { import type { DifferentialRecordGovernance } from "@/components/clinical-dashboard/use-differential-catalog"; import { DiagnosisMapPanel } from "@/components/differentials/diagnosis-map-panel"; import { CopyAfterReviewButton } from "@/components/differentials/differential-presentation-actions"; +import { SecondaryNavigation } from "@/components/secondary-navigation"; import { cn, pageContainer, toneDanger, toneNeutral, toneWarning } from "@/components/ui-primitives"; import { appModeHomeHref } from "@/lib/app-modes"; import { @@ -858,63 +859,21 @@ function Tabs({ active: DifferentialDetailTabId; onChange: (id: DifferentialDetailTabId) => void; }) { - const tabRefs = useRef(new Map()); - - function handleKeyDown(event: ReactKeyboardEvent) { - const order = detailTabs.map((tab) => tab.id); - const index = order.indexOf(active); - const next = - event.key === "ArrowRight" - ? order[(index + 1) % order.length] - : event.key === "ArrowLeft" - ? order[(index - 1 + order.length) % order.length] - : event.key === "Home" - ? order[0] - : event.key === "End" - ? order[order.length - 1] - : null; - if (!next) return; - event.preventDefault(); - if (next === active) return; - onChange(next); - tabRefs.current.get(next)?.focus(); - } - return ( - + ({ + kind: "action" as const, + id: tab.id, + elementId: `differential-tab-${tab.id}`, + label: tab.label, + controlsId: `differential-panel-${tab.id}`, + onSelect: () => onChange(tab.id), + }))} + /> ); } @@ -1005,6 +964,7 @@ export function DifferentialDetailPage({ className="min-h-dvh bg-[color:var(--background)] pb-24 text-[color:var(--text)] lg:pb-6" > +
@@ -1048,8 +1008,6 @@ export function DifferentialDetailPage({

) : null} - -
+
- +
{selected.map((candidate, index) => ( @@ -584,35 +614,6 @@ function MobileComparison({ ); } -function MobileTabs({ workflow }: { workflow: DifferentialPresentationWorkflow }) { - const firstCandidate = workflow.candidates[0]?.slug ?? "delirium"; - return ( - - ); -} - export function DifferentialPresentationWorkflowPage({ query = "", presentationSlug = "acute-confusion-encephalopathy", @@ -667,7 +668,10 @@ export function DifferentialPresentationWorkflowPage({
-
+

@@ -716,22 +720,32 @@ export function DifferentialPresentationWorkflowPage({

- {/* Tablet / mid (md–lg): safety leads, then the scrollable table, then the review panels reflow into a grid below — no fixed side rail. */}
- + +
+ + +
+
- -
- +
diff --git a/src/components/document-viewer/source-panels.tsx b/src/components/document-viewer/source-panels.tsx index 3d28041465..ace79f8334 100644 --- a/src/components/document-viewer/source-panels.tsx +++ b/src/components/document-viewer/source-panels.tsx @@ -12,7 +12,6 @@ import { Loader2, Quote, Search, - Sparkles, Target, type LucideIcon, } from "lucide-react"; @@ -373,54 +372,6 @@ export function TableReviewPanel({ ); } -export function DocumentViewerAnchors({ - evidenceHref, - textHref, - className, -}: { - evidenceHref: "#source-evidence" | "#source-evidence-rail"; - textHref: "#source-text"; - className?: string; -}) { - const anchors = [ - { label: "PDF", href: "#pdf-preview-section", icon: FileText }, - { label: "Evidence", href: evidenceHref, icon: Quote }, - { label: "Text", href: textHref, icon: Search }, - { label: "Summary", href: "#source-summary", icon: Sparkles }, - { label: "Images", href: "#source-images", icon: FileImage }, - ]; - - return ( - - ); -} - export function DocumentSectionSummary({ icon: Icon, title, diff --git a/src/components/dsm/dsm-diagnosis-page.tsx b/src/components/dsm/dsm-diagnosis-page.tsx index f812f82196..7ff0fe0ac5 100644 --- a/src/components/dsm/dsm-diagnosis-page.tsx +++ b/src/components/dsm/dsm-diagnosis-page.tsx @@ -68,7 +68,7 @@ export function DsmDiagnosisPage({ diagnosis }: { diagnosis: DsmDiagnosis }) {
@@ -98,8 +98,9 @@ export function DsmDiagnosisPage({ diagnosis }: { diagnosis: DsmDiagnosis }) { {diagnosis.key_features.length > 0 && diagnosis.criteria_display.length > 0 ? (
@@ -124,7 +125,7 @@ export function DsmDiagnosisPage({ diagnosis }: { diagnosis: DsmDiagnosis }) {
@@ -159,7 +160,7 @@ export function DsmDiagnosisPage({ diagnosis }: { diagnosis: DsmDiagnosis }) {
@@ -219,7 +220,10 @@ export function DsmDiagnosisPage({ diagnosis }: { diagnosis: DsmDiagnosis }) {
-
+

Record summary

diff --git a/src/components/dsm/dsm-differential-considerations-page.tsx b/src/components/dsm/dsm-differential-considerations-page.tsx index 3559f6c08b..3a7b851dba 100644 --- a/src/components/dsm/dsm-differential-considerations-page.tsx +++ b/src/components/dsm/dsm-differential-considerations-page.tsx @@ -87,7 +87,10 @@ export function DsmDifferentialConsiderationsPage({ />
-
+
@@ -106,7 +109,7 @@ export function DsmDifferentialConsiderationsPage({

Review lens @@ -139,8 +142,9 @@ export function DsmDifferentialConsiderationsPage({ {selected ? (
{visible.length} {visible.length === 1 ? "consideration" : "considerations"} @@ -190,7 +194,10 @@ export function DsmDifferentialConsiderationsPage({
-
+

Differential consideration diff --git a/src/components/factsheets/factsheet-detail-page.tsx b/src/components/factsheets/factsheet-detail-page.tsx index f688bb1c49..6a1557d2a9 100644 --- a/src/components/factsheets/factsheet-detail-page.tsx +++ b/src/components/factsheets/factsheet-detail-page.tsx @@ -15,7 +15,7 @@ import { TriangleAlert, Zap, } from "lucide-react"; -import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react"; +import { useEffect, useMemo, useState, useSyncExternalStore, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { @@ -28,6 +28,7 @@ import { type Factsheet, } from "@/components/factsheets/factsheets-data"; import { factsheetGlyph } from "@/components/factsheets/factsheets-icons"; +import { SecondaryNavigation, type SecondaryNavigationItem } from "@/components/secondary-navigation"; import { cn, toneDanger, toneWarning } from "@/components/ui-primitives"; import { readSavedRegistrySlugs, @@ -44,6 +45,14 @@ function Heading({ children }: { children: ReactNode }) { return

{children}

; } +function factsheetSectionId(label: string) { + return `factsheet-${label + .toLowerCase() + .replace(/[’']/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "")}`; +} + export function FactsheetDetailPage({ factsheet }: { factsheet: Factsheet }) { const theme = categoryTheme(factsheet.category); const [readingLevel, setReadingLevel] = useState<"easy" | "standard">("easy"); @@ -62,6 +71,19 @@ export function FactsheetDetailPage({ factsheet }: { factsheet: Factsheet }) { const related = relatedFactsheets(factsheet.slug); const moreInTopic = sameTopicFactsheets(factsheet.slug); const toc = tocFor(factsheet); + const tocSignature = toc.join("|"); + const navigationItems = useMemo( + () => + toc.map((label) => ({ + kind: "section" as const, + id: factsheetSectionId(label), + label, + targetId: factsheetSectionId(label), + })), + // Reading-level changes intentionally rebuild the destination set even + // when a sheet keeps the same headings in both versions. + [factsheet.slug, readingLevel, tocSignature], + ); const blocks = printBlocks(factsheet, readingLevel); useEffect(() => { @@ -173,6 +195,8 @@ export function FactsheetDetailPage({ factsheet }: { factsheet: Factsheet }) {

+ +
{/* hero band */} @@ -214,7 +238,7 @@ export function FactsheetDetailPage({ factsheet }: { factsheet: Factsheet }) {
{/* sources */} -
+
Where this information comes from
{factsheet.sources.map((source) => { @@ -433,7 +457,8 @@ function FactsheetBody({ return (

@@ -448,13 +473,13 @@ function FactsheetBody({ ))}

-
+
What is {factsheet.title.toLowerCase()}?

{readingLevel === "easy" ? factsheet.whatEasy : factsheet.whatStandard}

-
+
How to take it
{factsheet.howto.map((step) => ( @@ -472,7 +497,7 @@ function FactsheetBody({ ))}
-
+
Side effects
@@ -510,7 +535,10 @@ function FactsheetBody({
-
+
@@ -527,7 +555,8 @@ function FactsheetBody({ return (
{factsheet.sections.map((section) => ( -
+

{section.heading}

{section.body} @@ -549,13 +583,13 @@ function FactsheetBody({ case "condition": return (

-
+
In plain terms

{factsheet.intro}

-
+
Signs to look for
{factsheet.signs.map((sign) => ( @@ -574,13 +608,13 @@ function FactsheetBody({ ))}
-
+
Why it happens

{factsheet.why}

-
+
What helps
{factsheet.helps.map((help) => ( @@ -601,7 +635,8 @@ function FactsheetBody({
-
+
What it is

{factsheet.intro}

-
+
How it works
{factsheet.steps.map((step, index) => ( @@ -657,7 +692,7 @@ function FactsheetBody({ ))}
-
+
What to expect
{factsheet.expect.map((item) => ( @@ -676,13 +711,13 @@ function FactsheetBody({ case "procedure": return (
-
+
Why it matters

{factsheet.why}

-
+
How to prepare
{factsheet.prepare.map((item) => ( @@ -701,7 +736,7 @@ function FactsheetBody({ ))}
-
+
Step by step
{factsheet.timeline.map((step) => ( @@ -717,7 +752,10 @@ function FactsheetBody({ ))}
-
+