diff --git a/src/app/applications/layout.tsx b/src/app/applications/layout.tsx new file mode 100644 index 0000000000..c9a0125402 --- /dev/null +++ b/src/app/applications/layout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; + +export default function ApplicationsLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 9d267eb702..de6102785a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -491,24 +491,36 @@ summary::-webkit-details-marker { .answer-footer-search-pill { min-height: 3.8rem; - gap: 0.25rem; - border: 1px solid var(--border-strong); - background: var(--surface); - padding-inline: 0.375rem; + gap: 0.375rem; + border: 1px solid color-mix(in srgb, var(--border-strong) 80%, transparent); + background: color-mix(in srgb, var(--surface) 97%, transparent); + padding-inline: 0.5rem; box-shadow: - 0 1px 2px rgb(16 24 40 / 5%), - 0 8px 22px rgb(16 24 40 / 8%); + 0 1px 2px rgb(16 24 40 / 4%), + 0 6px 16px rgb(16 24 40 / 7%), + 0 20px 48px rgb(16 24 40 / 12%); + backdrop-filter: blur(18px) saturate(140%); + -webkit-backdrop-filter: blur(18px) saturate(140%); transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; } +.answer-footer-search-pill:hover { + border-color: var(--border-strong); + box-shadow: + 0 1px 2px rgb(16 24 40 / 5%), + 0 8px 20px rgb(16 24 40 / 9%), + 0 24px 56px rgb(16 24 40 / 14%); +} + .answer-footer-search-pill:focus-within { border-color: var(--clinical-accent); box-shadow: - 0 0 0 3px color-mix(in srgb, var(--clinical-accent) 18%, transparent), - 0 8px 22px rgb(16 24 40 / 10%); + 0 0 0 3px color-mix(in srgb, var(--clinical-accent) 16%, transparent), + 0 8px 20px rgb(16 24 40 / 8%), + 0 20px 48px rgb(16 24 40 / 12%); } .answer-footer-search-action { @@ -546,26 +558,49 @@ summary::-webkit-details-marker { width: 2.8rem; background: var(--clinical-accent); color: var(--clinical-accent-contrast); - box-shadow: 0 2px 8px color-mix(in srgb, var(--clinical-accent) 30%, transparent); + box-shadow: 0 3px 10px color-mix(in srgb, var(--clinical-accent) 32%, transparent); + transition: + background-color 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; } .answer-footer-search-send:hover { background: var(--clinical-accent-hover); + box-shadow: 0 5px 14px color-mix(in srgb, var(--clinical-accent) 38%, transparent); +} + +@media (prefers-reduced-motion: no-preference) { + .answer-footer-search-send:active { + transform: scale(0.96); + } } .answer-footer-search-chip { display: inline-flex; - min-height: 1.9rem; + min-height: 2.25rem; align-items: center; - gap: 0.35rem; - border: 1px solid var(--border); + gap: 0.4rem; + border: 1px solid color-mix(in srgb, var(--border) 85%, transparent); border-radius: 999px; - background: var(--surface); - padding-inline: 0.65rem; + background: color-mix(in srgb, var(--surface) 94%, transparent); + padding-inline: 0.8rem; color: var(--text-muted); font-size: 0.75rem; font-weight: 700; - box-shadow: none; + box-shadow: 0 2px 10px rgb(16 24 40 / 6%); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + transition: + border-color 160ms ease, + background-color 160ms ease, + color 160ms ease; +} + +.answer-footer-search-chip:hover { + border-color: var(--clinical-accent-border); + background: var(--surface); + color: var(--text); } .answer-footer-search-chip svg { @@ -603,23 +638,35 @@ summary::-webkit-details-marker { .desktop-home-search-pill { min-height: 3.45rem; - gap: 0.35rem; - border-color: var(--border-strong); - background: var(--surface); - padding-inline: 0.45rem; + gap: 0.375rem; + border-color: color-mix(in srgb, var(--border-strong) 80%, transparent); + background: color-mix(in srgb, var(--surface) 96%, transparent); + padding-inline: 0.5rem; box-shadow: - 0 1px 2px rgb(16 24 40 / 5%), - 0 8px 22px rgb(16 24 40 / 8%); + 0 1px 2px rgb(16 24 40 / 4%), + 0 6px 16px rgb(16 24 40 / 6%), + 0 18px 40px rgb(16 24 40 / 9%); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); transition: border-color 180ms ease, box-shadow 180ms ease; } +.desktop-home-search-pill:hover { + border-color: var(--border-strong); + box-shadow: + 0 1px 2px rgb(16 24 40 / 5%), + 0 8px 20px rgb(16 24 40 / 8%), + 0 22px 48px rgb(16 24 40 / 11%); +} + .desktop-home-search-pill:focus-within { border-color: var(--clinical-accent); box-shadow: - 0 0 0 3px color-mix(in srgb, var(--clinical-accent) 18%, transparent), - 0 8px 22px rgb(16 24 40 / 10%); + 0 0 0 3px color-mix(in srgb, var(--clinical-accent) 16%, transparent), + 0 8px 20px rgb(16 24 40 / 8%), + 0 18px 40px rgb(16 24 40 / 9%); } .desktop-home-search-input { @@ -666,8 +713,8 @@ summary::-webkit-details-marker { } .answer-footer-search-chip { - min-height: 2rem; - padding-inline: 0.82rem; + min-height: 2.25rem; + padding-inline: 0.9rem; font-size: 0.8125rem; } diff --git a/src/app/icon.svg b/src/app/icon.svg new file mode 100644 index 0000000000..f1c0fb8e07 --- /dev/null +++ b/src/app/icon.svg @@ -0,0 +1,23 @@ + + + + + + + diff --git a/src/app/medications/layout.tsx b/src/app/medications/layout.tsx new file mode 100644 index 0000000000..31812964b3 --- /dev/null +++ b/src/app/medications/layout.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; + +export default function MedicationsLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index e366a8467a..d5a6610b1c 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -122,6 +122,7 @@ import { AUTH_EMAIL_STORAGE_KEY, useAuthSession } from "@/lib/supabase/client"; import { SafeBoldText } from "@/components/SafeBoldText"; import { Sheet } from "@/components/ui/sheet"; import { AnswerEmptyState, AnswerSkeleton, CopyButton } from "@/components/clinical-dashboard/answer-status"; +import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; import { StatusBadge, StrengthBadge } from "@/components/clinical-dashboard/badges"; import { @@ -271,7 +272,7 @@ function useMobilePreviewSheet() { } const authEmailChangeEvent = "clinical-kb-auth-email-change"; -const recentQueryStorageKey = "clinical-kb-recent-queries"; +export const recentQueryStorageKey = "clinical-kb-recent-queries"; const documentPageSize = 150; const activeIndexingPollFallbackMs = 5_000; const setupRecheckPollMs = 60_000; @@ -4854,7 +4855,7 @@ function DrawerGroupLabel({ title }: { title: string }) { ); } -function SettingsDialog({ +export function SettingsDialog({ open, onClose, identity, @@ -5754,7 +5755,7 @@ export function ClinicalDashboard({ const [guideOpen, setGuideOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); const [documentsDrawerOpen, setDocumentsDrawerOpen] = useState(false); const [documentsDrawerMode, setDocumentsDrawerMode] = useState("library"); const [uploadDrawerOpen, setUploadDrawerOpen] = useState(false); @@ -7310,8 +7311,13 @@ export function ClinicalDashboard({ !modeSearchSubmitted) || (searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) || (activeModeResultKind === "differentials" && !modeSearchSubmitted) || + activeModeResultKind === "favourites" || activeModeResultKind === "tools"); const desktopHomeComposerSlotId = showDesktopHomeComposer ? modeHomeDesktopComposerSlotId : undefined; + // Favourites and Tools are content-rich hubs: they share the centred hero but + // stay top-aligned so their lists start in a stable position. + const centeredModeHome = + showDesktopHomeComposer && activeModeResultKind !== "tools" && activeModeResultKind !== "favourites"; const renderDegradedNotice = () => ( setActionNotice({ tone: "success", message: "Favourite creation is ready to connect." }) } + desktopComposerSlotId={desktopHomeComposerSlotId} /> ) : activeModeResultKind === "documents" ? ( searchMode === "prescribing" ? ( diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index 2dc7a0b177..dcfca0b373 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -3,14 +3,11 @@ import Link from "next/link"; import { BookOpen, - Check, ChevronRight, ClipboardList, ExternalLink, FileText, Grid2X2, - Globe2, - Menu, MoreVertical, Pill, Pin, @@ -18,24 +15,20 @@ import { Plus, Search, Send, - Settings, - Sparkles, Star, X, type LucideIcon, } from "lucide-react"; import { type FormEvent, useMemo, useState } from "react"; +import { ModeHomeHero } from "@/components/mode-home-template"; import { Sheet } from "@/components/ui/sheet"; import { - appBackdrop, chatComposerIconButton, chatComposerInput, chatComposerShell, chatSendButton, cn, - sidebarItem, - sidebarToolTile as sidebarApplicationTile, textMuted, } from "@/components/ui-primitives"; type LauncherStatus = "ready" | "recent" | "review_due"; @@ -79,14 +72,6 @@ const filterOptions = [ { id: "recent", label: "Recent" }, ] as const; -const sidebarApplicationItems = [ - { label: "Answer", icon: Sparkles, href: "/?mode=answer" }, - { label: "Documents", icon: FileText, href: "/?mode=documents" }, - { label: "Services", icon: ClipboardList, href: "/services" }, - { label: "Forms", icon: FileText, href: "/forms" }, - { label: "Meds", icon: Pill, href: "/?mode=prescribing" }, -] as const; - const launcherApps: LauncherApp[] = [ { id: "medication-prescribing", @@ -663,270 +648,6 @@ function DetailPanel({ ); } -function ApplicationsMobileMenu({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { - const recentQueries = ["lithium", "tables", "dosing for lithium", "management of bulimia nervosa"]; - - return ( - onOpenChange(false)} - title="Clinical Guide" - description="Recent chats, applications, help, and settings." - closeLabel="Close Clinical Guide menu" - placement="left" - contentClassName="font-sans lg:hidden" - > -
- onOpenChange(false)} - className="inline-flex min-h-[44px] w-full items-center justify-center gap-2 rounded-lg bg-[color:var(--clinical-accent)] px-3 text-sm font-semibold text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)] hover:bg-[color:var(--clinical-accent-hover)]" - > - - New chat - - - - -
-

- Recent chats -

-
- {recentQueries.map((recent) => ( - - ))} -
-
- -
-

- Applications -

-
- {sidebarApplicationItems.map((item) => { - const Icon = item.icon; - return ( - onOpenChange(false)} - className={sidebarApplicationTile} - > - - {item.label} - - ); - })} -
-
- -
- - -
- - AK - - - Dr A. Khan - Ready - -
-
-
-
- ); -} - -function ApplicationsModeMenu({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { - const options = [ - { - label: "Answer", - description: "Source-backed clinical answer", - href: "/?mode=answer", - icon: Sparkles, - active: false, - }, - { - label: "Documents", - description: "Search indexed PDFs and notes", - href: "/?mode=documents", - icon: FileText, - active: false, - }, - { - label: "Services", - description: "Service records and referral pathways", - href: "/services", - icon: ClipboardList, - active: false, - }, - { - label: "Forms", - description: "Clinical forms and pathways", - href: "/forms", - icon: FileText, - active: false, - }, - { - label: "Applications", - description: "Launch connected applications", - href: "/applications", - icon: Grid2X2, - active: true, - }, - ] as const; - - if (!open) return null; - - return ( -
- {options.map((option) => { - const Icon = option.icon; - return ( - onOpenChange(false)} - aria-current={option.active ? "page" : undefined} - className={cn( - "grid min-h-[3.25rem] w-full grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-2 rounded-md px-2.5 py-2 text-left transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", - option.active - ? "bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" - : "text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]", - )} - > - - - - - {option.label} - - {option.description} - - - {option.active ? : null} - - ); - })} -
- ); -} - -function ApplicationsHeader({ - mobileMenuOpen, - modeMenuOpen, - onMobileMenuOpenChange, - onModeMenuOpenChange, -}: { - mobileMenuOpen: boolean; - modeMenuOpen: boolean; - onMobileMenuOpenChange: (open: boolean) => void; - onModeMenuOpenChange: (open: boolean) => void; -}) { - return ( - - ); -} - type ApplicationsLauncherWorkspaceProps = { variant?: LauncherVariant; query?: string; @@ -946,8 +667,6 @@ export function ApplicationsLauncherWorkspace({ const [activeFilter, setActiveFilter] = useState<(typeof filterOptions)[number]["id"]>("all"); const [selectedId, setSelectedId] = useState("clinical-kb-search"); const [pinnedIds, setPinnedIds] = useState(seedPinnedIds); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const [modeMenuOpen, setModeMenuOpen] = useState(false); const [mobileDetailOpen, setMobileDetailOpen] = useState(false); const isDashboardTools = variant === "dashboard-tools"; const copy = isDashboardTools ? dashboardToolsLauncherCopy : standaloneLauncherCopy; @@ -1001,18 +720,16 @@ export function ApplicationsLauncherWorkspace({ <> {isDashboardTools ? (
-
- - - -
-

- {copy.heading} -

-

{copy.description}

-
+
+ {desktopComposerSlotId ? ( -
+
) : null}
@@ -1218,19 +935,7 @@ export function ApplicationsLauncherWorkspace({ } return ( -
- - - -
- {workspace} -
-
+
{workspace}
); } diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index 1c9d388497..4de23418d2 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import Link from "next/link"; import { BookOpen, + BrainCircuit, ChevronDown, ClipboardList, FileText, @@ -16,11 +17,11 @@ import { Pill, Search, Settings as SettingsIcon, - ShieldAlert, Sparkles, Sun, Wrench, } from "lucide-react"; +import { BrandMark } from "@/components/clinical-dashboard/brand"; import { cn, sidebarItem, sidebarToolTile, statusDotReady, textMuted } from "@/components/ui-primitives"; import { Sheet } from "@/components/ui/sheet"; import { type AppModeId } from "@/lib/app-modes"; @@ -52,6 +53,7 @@ const sidebarToolItems = [ { id: "services", label: "Services", icon: ClipboardList, href: "/services" }, { id: "forms", label: "Forms", icon: FileText, href: "/forms" }, { id: "favourites", label: "Faves", icon: Heart, href: "/favourites" }, + { id: "differentials", label: "Diffs", icon: BrainCircuit, href: "/differentials" }, { id: "prescribing", label: "Meds", icon: Pill, href: "/?mode=prescribing" }, { id: "tools", label: "Tools", icon: Wrench, href: "/?mode=tools" }, ] as const; @@ -60,8 +62,6 @@ const collapsedSidebarButton = "grid h-11 w-11 shrink-0 place-items-center rounded-xl border border-transparent text-[color:var(--text-muted)] transition hover:border-[color:var(--border)] hover:bg-[color:var(--surface)] hover:text-[color:var(--text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; const collapsedSidebarActiveButton = "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)] shadow-[var(--shadow-inset)]"; -const collapsedSidebarPrimaryButton = - "border-transparent bg-[color:var(--command)] text-[color:var(--command-contrast)] shadow-[var(--shadow-tight)] hover:bg-[color:var(--command-hover)] hover:text-[color:var(--command-contrast)]"; export function ClinicalSidebarContent({ recentQueries, @@ -106,18 +106,15 @@ export function ClinicalSidebarContent({ {showHeader ? (
- - - -
-

Clinical Guide

-

Source-backed workspace

-
+ +

+ Clinical Guide +

+
-
+
- ); - })} - - + recentQueries={recentQueries} + identity={sidebarIdentity} + activeMode={searchMode} + onOpenChange={setMobileMenuOpen} + onNewChat={startNewAnswerChat} + onPickRecent={pickRecentQuery} + onOpenGuide={openGuide} + onOpenSettings={openSettings} + theme={theme} + onToggleTheme={toggleTheme} + onPrefetchApplications={prefetchApplications} + />
); } diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index fa3658afbd..257a185db6 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -28,16 +28,12 @@ import { Loader2, Menu, MessageSquarePlus, - Moon, - MoreHorizontal, Pill, Plus, Search, Send, - Settings, ShieldCheck, Sparkles, - Sun, AlertCircle, ArrowLeft, X, @@ -56,7 +52,6 @@ import { } from "@/components/clinical-dashboard/mode-action-popup"; import { cn, - chatComposerIconButton, chatComposerInput, chatComposerShell, chatSendButton, @@ -74,7 +69,6 @@ import { visibleAppModeDefinitions, type AppModeId, } from "@/lib/app-modes"; -import { type ResolvedTheme } from "@/lib/theme"; import type { ClinicalDocument, ClinicalQueryMode } from "@/lib/types"; import { type SearchScopeFilters } from "@/lib/search-scope"; import { tagSearchText } from "@/lib/document-tags"; @@ -161,14 +155,10 @@ export function MasterSearchHeader({ onOpenSourcePdf, onNewChat, onOpenMobileSidebar, - onOpenSettings, - theme, - onToggleTheme, queryModeOptions, queryInputRef, queryInputAutoFocus = false, headerVariant = "default", - modeAlignment = "default", mobileSearchPlacement = "default", desktopSearchPlacement = "default", searchComposerVisible = true, @@ -202,14 +192,10 @@ export function MasterSearchHeader({ onOpenSourcePdf?: () => void; onNewChat?: () => void; onOpenMobileSidebar?: () => void; - onOpenSettings?: () => void; - theme?: ResolvedTheme; - onToggleTheme?: () => void; queryModeOptions: Array<{ value: ClinicalQueryMode; label: string }>; queryInputRef?: Ref; queryInputAutoFocus?: boolean; headerVariant?: "default" | "workflow"; - modeAlignment?: "default" | "center"; mobileSearchPlacement?: "default" | "bottom"; desktopSearchPlacement?: "default" | "hero"; searchComposerVisible?: boolean; @@ -239,21 +225,15 @@ export function MasterSearchHeader({ const [scopeSheetOpen, setScopeSheetOpen] = useState(false); const [actionMenuOpen, setActionMenuOpen] = useState(false); const [modeMenuOpen, setModeMenuOpen] = useState(false); - const [utilityMenuOpen, setUtilityMenuOpen] = useState(false); const [usesScopeSheet, setUsesScopeSheet] = useState(false); const [usesPhoneSearchLayout, setUsesPhoneSearchLayout] = useState(false); const [desktopHomeComposerTarget, setDesktopHomeComposerTarget] = useState(null); const modeMenuRef = useRef(null); const modeButtonRef = useRef(null); const modeOptionRefs = useRef>([]); - const utilityMenuRef = useRef(null); - const utilityButtonRef = useRef(null); const scopePopoverRef = useRef(null); const scopeSummaryRef = useRef(null); const scopeFilterInputRef = useRef(null); - const hasUtilityActions = Boolean(onOpenSettings || (theme && onToggleTheme)); - const UtilityThemeIcon = theme === "dark" ? Sun : Moon; - const utilityThemeLabel = theme === "dark" ? "Light mode" : "Dark mode"; const selectedDocuments = selectedDocumentIds .map((id) => documents.find((document) => document.id === id)) .filter((document): document is ClinicalDocument => Boolean(document)); @@ -307,26 +287,6 @@ export function MasterSearchHeader({ const actionMenuButtonLabel = `Open ${selectedAppMode.label.toLowerCase()} options`; const isStandaloneModeHomeHeader = Boolean(desktopHomeComposerSlotId); const useMobileBackControl = mobileLeadingAction === "back"; - const homeHeaderTitle = - searchMode === "services" - ? "Services Navigator" - : searchMode === "forms" - ? "Forms Navigator" - : searchMode === "favourites" - ? "Favourites" - : searchMode === "differentials" - ? "Differentials Navigator" - : "Clinical Guide"; - const homeHeaderDescription = - searchMode === "services" - ? "Psychiatry referral directory" - : searchMode === "forms" - ? "Mental Health Act forms" - : searchMode === "favourites" - ? "Saved clinical items and sets" - : searchMode === "differentials" - ? "Differential diagnosis workspace" - : selectedAppMode.description; function currentUsesScopeSheet() { return window.matchMedia(mobileSheetMediaQuery).matches; @@ -591,7 +551,6 @@ export function MasterSearchHeader({ }, [desktopHomeComposerSlotId]); const dismissModeMenu = useCallback(() => setModeMenuOpen(false), []); - const dismissUtilityMenu = useCallback(() => setUtilityMenuOpen(false), []); function dismissScope(reason: "outside" | "escape") { closeScope(reason === "escape"); } @@ -603,13 +562,6 @@ export function MasterSearchHeader({ onDismiss: dismissModeMenu, }); - useDismissableLayer({ - enabled: utilityMenuOpen, - refs: [utilityMenuRef, utilityButtonRef], - restoreFocusRef: utilityButtonRef, - onDismiss: dismissUtilityMenu, - }); - useDismissableLayer({ enabled: scopeOpen, refs: [scopePopoverRef, scopeSummaryRef], @@ -620,7 +572,6 @@ export function MasterSearchHeader({ function submit(event: FormEvent) { event.preventDefault(); setActionMenuOpen(false); - setUtilityMenuOpen(false); onAsk(); } @@ -946,7 +897,7 @@ export function MasterSearchHeader({ {submitLabel}
- {usesAnswerFooterStyle ? ( + {usesUniversalFooterStyle ? (
- {isStandaloneModeHomeHeader ? ( -
- - - - - - {homeHeaderTitle} - - - {homeHeaderDescription} - - -
- ) : null}
) : null} - {!isWorkflowHeader && hasUtilityActions ? ( - <> - - {utilityMenuOpen ? ( - - ) : null} - - ) : null}
diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index fb2fca7280..94cb8c28d5 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -28,7 +28,6 @@ import { useState } from "react"; import { ModeHomeTemplate } from "@/components/mode-home-template"; import { - appBackdrop, cn, toneDanger, toneInfo, @@ -1325,32 +1324,17 @@ function MedicationDetail() { export function AcamprosateMedicationPage() { return ( -
-
-
- -
-
+
+
+ +
diff --git a/src/components/clinical-dashboard/use-sidebar-collapsed.ts b/src/components/clinical-dashboard/use-sidebar-collapsed.ts new file mode 100644 index 0000000000..86d3d38948 --- /dev/null +++ b/src/components/clinical-dashboard/use-sidebar-collapsed.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useCallback, useSyncExternalStore } from "react"; + +const storageKey = "clinical-kb-sidebar-collapsed"; +const changeEvent = "clinical-kb-sidebar-collapsed-change"; + +function getSnapshot() { + try { + return window.localStorage.getItem(storageKey) === "1"; + } catch { + return false; + } +} + +function getServerSnapshot() { + return false; +} + +function subscribe(onChange: () => void) { + window.addEventListener("storage", onChange); + window.addEventListener(changeEvent, onChange); + return () => { + window.removeEventListener("storage", onChange); + window.removeEventListener(changeEvent, onChange); + }; +} + +/** + * Desktop sidebar collapse state shared across shells and persisted per + * browser, mirroring the use-theme.ts external-store pattern so the choice + * survives route changes between the dashboard and standalone shells. + */ +export function useSidebarCollapsed() { + const collapsed = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + const setCollapsed = useCallback((next: boolean) => { + try { + window.localStorage.setItem(storageKey, next ? "1" : "0"); + } catch { + // Ignore storage failures (private mode); state simply won't persist. + } + window.dispatchEvent(new Event(changeEvent)); + }, []); + return [collapsed, setCollapsed] as const; +} diff --git a/src/components/compact-record-home-page.tsx b/src/components/compact-record-home-page.tsx deleted file mode 100644 index 44038aa7bd..0000000000 --- a/src/components/compact-record-home-page.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import Link from "next/link"; -import { ArrowRight, ShieldCheck, type LucideIcon } from "lucide-react"; - -import { cn } from "@/components/ui-primitives"; - -export type CompactHomeAction = { - title: string; - description: string; - icon: LucideIcon; - href: string; -}; - -export type CompactHomePillTone = "danger" | "info" | "primary" | "success" | "neutral" | "purple"; - -export type CompactHomePill = { - label: string; - href: string; - icon?: LucideIcon; - tone?: CompactHomePillTone; -}; - -type CompactRecordHomePageProps = { - testId: string; - title: string; - subtitle: string; - icon: LucideIcon; - tasksLabel: string; - taskCards: CompactHomeAction[]; - quickLinksTitle: string; - quickLinks: CompactHomePill[]; - verificationLabel: string; - verificationBody: string; - verifiedCount: number; - totalCount: number; - desktopComposerSlotId?: string; -}; - -const pillToneClass: Record = { - danger: "bg-[color:var(--danger)]", - info: "bg-[color:var(--info)]", - neutral: "bg-[color:var(--text-soft)]", - primary: "bg-[color:var(--clinical-accent)]", - purple: "bg-violet-600", - success: "bg-[color:var(--success)]", - teal: "bg-[color:var(--clinical-accent)]", -}; - -export function CompactRecordHomePage({ - testId, - title, - subtitle, - icon: Icon, - tasksLabel, - taskCards, - quickLinksTitle, - quickLinks, - verificationLabel, - verificationBody, - verifiedCount, - totalCount, - desktopComposerSlotId, -}: CompactRecordHomePageProps) { - return ( -
-
-
- - - -
-

- {title} -

-

- {subtitle} -

-
-
- - {desktopComposerSlotId ? ( -
- ) : null} - -
- {taskCards.map((card, index) => { - const CardIcon = card.icon; - return ( - 0 && "border-t border-[color:var(--border)]", - )} - > - - - - - - {card.title} - - - {card.description} - - - - - ); - })} -
- -
-

{quickLinksTitle}

-
- {quickLinks.map((link) => { - const LinkIcon = link.icon; - return ( - - {LinkIcon ? ( - - - - ) : ( - - )} - {link.label} - - ); - })} -
-
- -

- - - {verificationLabel} - - - {verificationBody} - - {verifiedCount} of {totalCount} records are locally verified. - -

-
-
- ); -} diff --git a/src/components/differentials/differentials-home-page.tsx b/src/components/differentials/differentials-home-page.tsx index bc4f18c031..2fa2791685 100644 --- a/src/components/differentials/differentials-home-page.tsx +++ b/src/components/differentials/differentials-home-page.tsx @@ -1,4 +1,5 @@ import { DifferentialsHome } from "@/components/clinical-dashboard/differentials-home"; +import { ModeHomeMain } from "@/components/mode-home-template"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; type DifferentialsHomePageProps = { @@ -7,8 +8,8 @@ type DifferentialsHomePageProps = { export function DifferentialsHomePage({ query = "" }: DifferentialsHomePageProps) { return ( -
+ -
+ ); } diff --git a/src/components/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx index 753c85672c..6a3251cbeb 100644 --- a/src/components/forms/forms-home-page.tsx +++ b/src/components/forms/forms-home-page.tsx @@ -1,21 +1,17 @@ -import Link from "next/link"; -import { - ArrowLeftRight, - ArrowRight, - ClipboardCheck, - FileText, - Route, - Search, - ShieldCheck, - Truck, - UserRound, -} from "lucide-react"; +import { ArrowLeftRight, ClipboardCheck, FileText, Route, Search, ShieldCheck, Truck, UserRound } from "lucide-react"; +import { + ModeHomeMain, + ModeHomeTemplate, + ModeHomeVerificationFooter, + type ModeHomeAction, + type ModeHomePill, +} from "@/components/mode-home-template"; import { appModeHomeHref } from "@/lib/app-modes"; import { defaultFormSlug, formRecords } from "@/lib/forms"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; -const taskCards = [ +const taskCards: ModeHomeAction[] = [ { title: "Find a form", description: "Search by number, pathway, clock, or keyword.", @@ -40,7 +36,7 @@ const taskCards = [ }, ]; -const commonTasks = [ +const commonTasks: ModeHomePill[] = [ { label: "Transport", icon: Truck, @@ -68,102 +64,27 @@ function verifiedCount() { } export function FormsHomePage() { - const locallyVerifiedCount = verifiedCount(); - return ( -
-
-
- - - -
-

- What do you need from forms? -

-

- Search, check, or follow a pathway. -

-
-
- -
- -
- {taskCards.map((card, index) => { - const CardIcon = card.icon; - return ( - 0 ? "border-t border-[color:var(--border)] md:border-t-0" : "", - ].join(" ")} - > - - - - - - {card.title} - - - {card.description} - - - - - ); - })} -
- -
-

Common tasks

-
- {commonTasks.map((task) => { - const TaskIcon = task.icon; - return ( - - - {task.label} - - ); - })} -
-
- -

- - - Source verified - - - MHA 2014 forms - - {locallyVerifiedCount} of {formRecords.length} records are locally verified. - -

-
-
+ + + } + /> + ); } diff --git a/src/components/mode-home-template.tsx b/src/components/mode-home-template.tsx index beb05de4ae..7816addaec 100644 --- a/src/components/mode-home-template.tsx +++ b/src/components/mode-home-template.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { type ReactNode } from "react"; -import { ArrowRight, type LucideIcon } from "lucide-react"; +import { ArrowRight, ShieldCheck, type LucideIcon } from "lucide-react"; import { cn } from "@/components/ui-primitives"; @@ -19,7 +19,7 @@ export type ModeHomePill = { href?: string; onClick?: () => void; icon?: LucideIcon; - tone?: "teal" | "danger" | "info" | "success" | "neutral" | "purple"; + tone?: "teal" | "primary" | "danger" | "info" | "success" | "neutral" | "purple"; }; type ModeHomeTemplateProps = { @@ -42,16 +42,105 @@ const pillToneClass: Record, string> = { danger: "bg-[color:var(--danger)]", info: "bg-[color:var(--info)]", neutral: "bg-[color:var(--text-soft)]", + primary: "bg-[color:var(--clinical-accent)]", purple: "bg-violet-600", success: "bg-[color:var(--success)]", teal: "bg-[color:var(--clinical-accent)]", }; -export function ModeHomeTemplate({ +export function ModeHomeHero({ testId, title, subtitle, icon: Icon, + headingLevel = 1, +}: { + testId?: string; + title: string; + subtitle: string; + icon: LucideIcon; + headingLevel?: 1 | 2; +}) { + const Heading = headingLevel === 1 ? "h1" : "h2"; + + return ( +
+ + +
+ + {title} + +

+ {subtitle} +

+
+
+ ); +} + +/** + * Standalone-route wrapper that mirrors the dashboard's vertically centred + * Answer home: full-height, centred content, bottom padding reserved for the + * fixed mobile composer. + */ +export function ModeHomeMain({ + testId, + children, + className, +}: { + testId?: string; + children: ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +export function ModeHomeVerificationFooter({ + label, + body, + verifiedCount, + totalCount, +}: { + label: string; + body: string; + verifiedCount: number; + totalCount: number; +}) { + return ( +

+ + + + {body} + + {verifiedCount} of {totalCount} records are locally verified. + +

+ ); +} + +export function ModeHomeTemplate({ + testId, + title, + subtitle, + icon, actions, actionsLabel, desktopComposerSlotId, @@ -62,8 +151,6 @@ export function ModeHomeTemplate({ className, headingLevel = 1, }: ModeHomeTemplateProps) { - const Heading = headingLevel === 1 ? "h1" : "h2"; - return (
-
- - -
- - {title} - -

- {subtitle} -

-
-
+ {desktopComposerSlotId ? (
) : null} -
- {actions.map((action, index) => { - const ActionIcon = action.icon; - const content = ( - <> - - - - - {action.title} + {actions.length ? ( +
+ {actions.map((action, index) => { + const ActionIcon = action.icon; + const content = ( + <> + + - - {action.description} + + + {action.title} + + + {action.description} + - -
+ })} +
+ ) : null} {pills?.length ? (
{pillsTitle || pillsAction ? ( -
+
{pillsTitle ? (

{pillsTitle}

) : ( @@ -158,7 +237,7 @@ export function ModeHomeTemplate({ {pillsAction}
) : null} -
+
{pills.map((pill) => { const PillIcon = pill.icon; const content = ( @@ -174,7 +253,7 @@ export function ModeHomeTemplate({ ); const pillClassName = - "inline-flex min-h-11 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-full border border-[color:var(--border)] bg-[color:var(--surface)] px-4 text-sm font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent-border)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-w-0 sm:px-3"; + "inline-flex min-h-11 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-full border border-[color:var(--border)] bg-[color:var(--surface)] px-4 text-sm font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent-border)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; return pill.href ? ( {content} diff --git a/src/components/services/services-home-page.tsx b/src/components/services/services-home-page.tsx index b43fb0054a..7965126840 100644 --- a/src/components/services/services-home-page.tsx +++ b/src/components/services/services-home-page.tsx @@ -1,15 +1,17 @@ import { FileSearch, MapPinned, Route, Users } from "lucide-react"; import { - CompactRecordHomePage, - type CompactHomeAction, - type CompactHomePill, -} from "@/components/compact-record-home-page"; + ModeHomeMain, + ModeHomeTemplate, + ModeHomeVerificationFooter, + type ModeHomeAction, + type ModeHomePill, +} from "@/components/mode-home-template"; import { appModeHomeHref } from "@/lib/app-modes"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { defaultServiceSlug, serviceRecords } from "@/lib/services"; -const taskCards: CompactHomeAction[] = [ +const taskCards: ModeHomeAction[] = [ { title: "Find a service", description: "Search by need, catchment, provider, or keyword.", @@ -34,7 +36,7 @@ const taskCards: CompactHomeAction[] = [ }, ]; -const commonPathways: CompactHomePill[] = [ +const commonPathways: ModeHomePill[] = [ { label: "Crisis", tone: "danger", @@ -77,20 +79,26 @@ function verifiedCount() { export function ServicesHomePage() { return ( - + + + } + /> + ); } diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 9201e18403..46b726841a 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -64,7 +64,7 @@ test.describe("Clinical KB applications launcher", () => { await expect(desktopLaunchLink).toBeVisible(); await expect(desktopLaunchLink).toHaveAttribute("href", "/?mode=answer"); } - await expect(page.getByLabel("Current app mode: Applications")).toBeVisible(); + await expect(page.getByLabel("Current app mode: Tools")).toBeVisible(); await expect(page.getByPlaceholder("Search applications...")).toBeVisible(); await expect(page.getByLabel("Open selected application")).toBeVisible(); await expectNoPageHorizontalOverflow(page); @@ -339,14 +339,20 @@ test.describe("Clinical KB applications launcher", () => { await expect(page.getByTestId("global-search-input")).toHaveCount(1); const differentialsHomeSearch = page.getByTestId("differentials-home").getByTestId("global-search-input"); await expect(differentialsHomeSearch).toBeVisible(); - const differentialsSearchBox = await differentialsHomeSearch.boundingBox(); - const differentialsHeadingBox = await page.getByRole("heading", { level: 1, name: "Differentials" }).boundingBox(); - expect(differentialsSearchBox).not.toBeNull(); - expect(differentialsHeadingBox).not.toBeNull(); - expect((differentialsHeadingBox?.y ?? 0) + (differentialsHeadingBox?.height ?? 0)).toBeLessThan( - differentialsSearchBox?.y ?? 0, - ); - expect((differentialsSearchBox?.y ?? 0) + (differentialsSearchBox?.height ?? 0) / 2).toBeLessThan(900 * 0.62); + // The shell's Suspense swap after client navigation can briefly detach the + // hero heading, so retry the geometry probe instead of measuring once. + await expect(async () => { + const differentialsSearchBox = await differentialsHomeSearch.boundingBox(); + const differentialsHeadingBox = await page + .getByRole("heading", { level: 1, name: "Differentials" }) + .boundingBox(); + expect(differentialsSearchBox).not.toBeNull(); + expect(differentialsHeadingBox).not.toBeNull(); + expect((differentialsHeadingBox?.y ?? 0) + (differentialsHeadingBox?.height ?? 0)).toBeLessThan( + differentialsSearchBox?.y ?? 0, + ); + expect((differentialsSearchBox?.y ?? 0) + (differentialsSearchBox?.height ?? 0) / 2).toBeLessThan(900 * 0.62); + }).toPass({ timeout: 10_000 }); await expectNoPageHorizontalOverflow(page); });