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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,10 @@ import { SystemNotice, DegradedNotice } from "@/components/clinical-dashboard/da
import { sanitizeAnswerDisplayText, sanitizeDisplayText } from "@/components/clinical-dashboard/display-text";
import { isPreformattedGroundedAnswer } from "@/components/clinical-dashboard/answer-content";
import {
AnswerEmptyState,
AnswerProgressStepper,
AnswerSkeleton,
SearchProgressBanner,
SharedHomeEmptyState,
} from "@/components/clinical-dashboard/answer-status";
import {
type AnswerProgressUpdate,
Expand Down Expand Up @@ -3383,6 +3383,7 @@ export function ClinicalDashboard({
composerFollowUpSuggestions={searchMode === "answer" ? answerFollowUpSuggestions : undefined}
onPickComposerFollowUpSuggestion={handlePickFollowUpSuggestion}
composerFollowUpSuggestionsDisabled={loading}
sharedHomeIdentity={showSharedHome}
composerPlaceholder={searchMode === "answer" && latestAnswerQuery ? "Ask a follow-up..." : undefined}
mobileSearchPlacement={hasMobileBottomSearch ? "bottom" : "default"}
// Every phone dock is the compact single-row pill so content keeps
Expand Down Expand Up @@ -3661,10 +3662,11 @@ export function ClinicalDashboard({

{showSharedHome ? (
// The one home surface, shared by all 13 modes. It sits above every
// mode-specific branch so picking a mode on `/` changes only the pill
// and the composer placeholder; the mode's own content stays behind
// mode-specific branch so picking a mode on `/` changes only its
// presentation and composer target; mode-owned content stays behind
// its own route (/tools, /favourites, /dsm, …).
<AnswerEmptyState
<SharedHomeEmptyState
modeId={searchMode}
desktopComposerSlotId={desktopHomeComposerSlotId}
recentQueries={recentQueries}
onSelectRecent={(recentQuery) => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/answer-home-proposal-mockups.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { cn } from "@/components/ui-primitives";
// Pinned by tests/privacy-ui.test.ts and reproduced verbatim on both sides:
// "Do not enter patient-identifiable information." and the "Privacy and data
// processing" link to /privacy. The subtitle is ordinary product copy
// (src/lib/ui-copy.ts, answerEmptyState.subheading) and is not pinned.
// (src/lib/ui-copy.ts, sharedHomePresentation.answer.subtitle) and is not pinned.

const OBLIGATION = "Do not enter patient-identifiable information.";
const PRIVACY_LINK = "Privacy and data processing";
Expand Down
26 changes: 16 additions & 10 deletions src/components/clinical-dashboard/answer-status.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useLayoutEffect, useRef, type CSSProperties } from "react";
import { Check, Circle, Clipboard, ClipboardCheck, History, Loader2, MessageSquareText, Square } from "lucide-react";
import { Check, Circle, Clipboard, ClipboardCheck, History, Loader2, Square } from "lucide-react";

import {
answerProgressDisplayMessage,
Expand All @@ -14,7 +14,9 @@ import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-su
import { useAppPreferences } from "@/components/clinical-dashboard/use-app-preferences";
import { ModeHomeTemplate } from "@/components/mode-home-template";
import { cn, floatingControl, sourceCard } from "@/components/ui-primitives";
import { answerEmptyState, answerLoading, copyButton } from "@/lib/ui-copy";
import { appModeIcons } from "@/lib/app-mode-icons";
import type { AppModeId } from "@/lib/app-modes";
import { answerLoading, copyButton, sharedHomeEmptyState, sharedHomePresentation } from "@/lib/ui-copy";

export function CopyButton({
label,
Expand Down Expand Up @@ -47,11 +49,13 @@ export function CopyButton({
);
}

export function AnswerEmptyState({
export function SharedHomeEmptyState({
modeId,
desktopComposerSlotId,
recentQueries = [],
onSelectRecent,
}: {
modeId: AppModeId;
desktopComposerSlotId?: string;
recentQueries?: string[];
onSelectRecent?: (query: string) => void;
Expand All @@ -65,25 +69,27 @@ export function AnswerEmptyState({
onSelectRecent && preferences.showRecentOnHome
? recentQueries.filter((entry) => entry.trim().length > 0).slice(0, 5)
: [];
const presentation = sharedHomePresentation[modeId];

return (
<ModeHomeTemplate
testId="answer-empty-state"
title={answerEmptyState.heading}
subtitle={answerEmptyState.subheading}
icon={MessageSquareText}
testId="shared-home-empty-state"
title={presentation.title}
subtitle={presentation.subtitle}
icon={appModeIcons[modeId]}
headingLevel={2}
stabilizePhoneCopy
desktopComposerSlotId={desktopComposerSlotId}
actionsLabel={answerEmptyState.starterActionsLabel}
actionsLabel={sharedHomeEmptyState.starterActionsLabel}
actions={[]}
footer={
<div className="grid w-full gap-3">
{recents.length > 0 && (
<AnswerSuggestionChips
testId="answer-recent-queries"
testId="shared-home-recent-queries"
suggestions={recents}
onPick={(entry) => onSelectRecent?.(entry)}
label={answerEmptyState.recentLabel}
label={sharedHomeEmptyState.recentLabel}
layout="wrap"
className="justify-center"
icon={History}
Expand Down
76 changes: 70 additions & 6 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { PrivacyInputNotice } from "@/components/privacy-input-notice";
import { restoreFocusUnlessMoved, useDismissableLayer } from "@/components/use-dismissable-layer";
import { useHideOnScroll } from "@/components/clinical-dashboard/use-hide-on-scroll";
import { useEventCallback } from "@/components/clinical-dashboard/use-event-callback";
import { BrandMark } from "@/components/clinical-dashboard/brand";
import { PhoneFooterLayerPortal } from "@/components/clinical-dashboard/phone-footer-layer-portal";
import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions";
import { SearchPinsMenu } from "@/components/clinical-dashboard/search-pins-menu";
Expand Down Expand Up @@ -189,6 +190,7 @@ export function MasterSearchHeader({
onPickComposerFollowUpSuggestion,
composerFollowUpSuggestionsDisabled = false,
headerVariant = "default",
sharedHomeIdentity = false,
mobileSearchPlacement = "default",
mobileBottomSearchVariant = "default",
desktopSearchPlacement = "default",
Expand Down Expand Up @@ -241,6 +243,8 @@ export function MasterSearchHeader({
onPickComposerFollowUpSuggestion?: (suggestion: string) => void;
composerFollowUpSuggestionsDisabled?: boolean;
headerVariant?: "default" | "workflow";
/** Keep the product identity stable while `/` retargets between modes. */
sharedHomeIdentity?: boolean;
mobileSearchPlacement?: "default" | "bottom";
/** "compact" drops the phone footer chip row and hugs the bottom edge so
* content keeps maximum screen space. Every phone dock uses it now; the
Expand Down Expand Up @@ -486,6 +490,7 @@ export function MasterSearchHeader({
const modeMenuRef = useRef<HTMLDivElement | null>(null);
const modeButtonRef = useRef<HTMLButtonElement | null>(null);
const modeOptionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const pendingModeSelectionFocusRef = useRef<AppModeId | null>(null);
const prefetchedModeHrefsRef = useRef(new Set<string>());
const scopePopoverRef = useRef<HTMLDivElement | null>(null);
const actionMenuTriggerRef = useRef<HTMLButtonElement | null>(null);
Expand All @@ -501,6 +506,29 @@ export function MasterSearchHeader({
.filter((document): document is ClinicalDocument => Boolean(document)),
[documentById, selectedDocumentIds],
);

useEffect(() => {
const pendingMode = pendingModeSelectionFocusRef.current;
if (modeMenuOpen || pendingMode === null || pendingMode !== searchMode) return undefined;

let settledFrame: number | null = null;
const frame = window.requestAnimationFrame(() => {
settledFrame = window.requestAnimationFrame(() => {
if (pendingModeSelectionFocusRef.current !== searchMode) return;
// Phone Sheet may still mount #app-mode-menu during exit animation.
// Leave pending armed so a later searchMode tick (or same-mode retry)
// can finish restore instead of giving up on a no-op.
if (document.getElementById("app-mode-menu")) return;
restoreFocusUnlessMoved(modeButtonRef.current);
Comment thread
cursor[bot] marked this conversation as resolved.
pendingModeSelectionFocusRef.current = null;
});
});

return () => {
window.cancelAnimationFrame(frame);
if (settledFrame !== null) window.cancelAnimationFrame(settledFrame);
};
}, [modeMenuOpen, searchMode]);
const scopeSummary = selectedDocumentIds.length === 0 ? "All documents" : `${selectedDocumentIds.length} scoped`;
const scopePreview = useMemo(
() =>
Expand Down Expand Up @@ -822,6 +850,33 @@ export function MasterSearchHeader({
function selectAppMode(mode: (typeof appModeDefinitions)[number]) {
setModeMenuOpen(false);
if (isSearchableAppMode(mode.id)) {
// Wait until the URL-owned mode prop settles before returning focus. The
// trigger's accessible name changes with that prop; focusing in the click
// frame races the shared-home URL sync and can fall through to <body>.
//
// Same-mode reselect is different: shared-home replaceState keeps an
// identical URL, so searchMode never changes and the pending-focus effect
// gets only the menu-close tick — which can race phone Sheet teardown.
if (mode.id === searchMode) {
pendingModeSelectionFocusRef.current = mode.id;
onSearchModeChange(mode.id);
const restoreSameModeFocus = () => {
if (pendingModeSelectionFocusRef.current !== mode.id) return;
if (document.getElementById("app-mode-menu")) {
window.setTimeout(restoreSameModeFocus, 50);
return;
}
if (!restoreFocusUnlessMoved(modeButtonRef.current)) {
modeButtonRef.current?.focus({ preventScroll: true });
}
pendingModeSelectionFocusRef.current = null;
};
window.requestAnimationFrame(() => {
window.requestAnimationFrame(restoreSameModeFocus);
});
return;
}
pendingModeSelectionFocusRef.current = mode.id;
Comment thread
cursor[bot] marked this conversation as resolved.
onSearchModeChange(mode.id);
return;
}
Expand Down Expand Up @@ -993,10 +1048,7 @@ export function MasterSearchHeader({
onFocus={() => prefetchModeDestination(mode.id)}
onPointerEnter={() => prefetchModeDestination(mode.id)}
onKeyDown={(event) => handleModeOptionKeyDown(event, index)}
onClick={() => {
selectAppMode(mode);
window.requestAnimationFrame(() => modeButtonRef.current?.focus());
}}
onClick={() => selectAppMode(mode)}
className={cn(
"relative grid w-full items-center gap-2 text-left transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
usesPhoneSearchLayout
Expand Down Expand Up @@ -2063,7 +2115,19 @@ export function MasterSearchHeader({
>
<Menu aria-hidden="true" className="h-5 w-5" />
</button>
{isServicesMode ? (
{sharedHomeIdentity ? (
<div data-testid="shared-home-brand" className="hidden min-w-0 items-center gap-3 lg:flex">
<BrandMark className="h-10 w-10" />
<span className="min-w-0">
<span className="block truncate text-lg font-extrabold leading-5 text-[color:var(--text-heading)]">
Clinical KB
</span>
<span className="block truncate text-xs font-semibold text-[color:var(--text-muted)]">
Source-backed clinical search
</span>
</span>
</div>
) : isServicesMode ? (
<div className="hidden min-w-0 items-center gap-3 lg:flex">
<span className="grid h-10 w-10 place-items-center rounded-xl bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)]">
<ShieldCheck className="h-5 w-5" aria-hidden />
Expand Down Expand Up @@ -2109,7 +2173,7 @@ export function MasterSearchHeader({
"universal-header-mode-button inline-grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-full border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 text-left transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
isWorkflowHeader
? "h-tap w-[min(11rem,calc(100vw-11rem))] sm:w-[12rem] sm:min-w-0 lg:w-[12.5rem]"
: "h-12 w-[min(13rem,calc(100vw-11.5rem))] sm:w-auto sm:min-w-[13rem] sm:pr-3",
: "h-12 w-[min(13rem,calc(100vw-9rem))] sm:w-auto sm:min-w-[13rem] sm:pr-3",
)}
aria-haspopup={usesPhoneSearchLayout ? "dialog" : "menu"}
aria-expanded={modeMenuOpen}
Expand Down
25 changes: 22 additions & 3 deletions src/components/mode-home-template.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type ModeHomeTemplateProps = {
footer?: ReactNode;
className?: string;
headingLevel?: 1 | 2;
stabilizePhoneCopy?: boolean;
};

const pillToneClass: Record<NonNullable<ModeHomePill["tone"]>, string> = {
Expand All @@ -59,12 +60,14 @@ export function ModeHomeHero({
subtitle,
icon: Icon,
headingLevel = 1,
stabilizePhoneCopy = false,
}: {
testId?: string;
title: string;
subtitle: string;
icon: LucideIcon;
headingLevel?: 1 | 2;
stabilizePhoneCopy?: boolean;
}) {
const Heading = headingLevel === 1 ? "h1" : "h2";

Expand All @@ -84,11 +87,19 @@ export function ModeHomeHero({
<div className="grid gap-1 sm:gap-1.5">
<Heading
id={`${testId ?? "mode-home"}-title`}
className="text-balance text-hero font-extrabold leading-display tracking-normal text-[color:var(--text-heading)]"
className={cn(
"text-balance text-hero font-extrabold leading-display tracking-normal text-[color:var(--text-heading)]",
stabilizePhoneCopy && "max-sm:grid max-sm:min-h-[2lh] max-sm:place-items-center",
)}
>
{title}
</Heading>
<p className="mx-auto max-w-2xl text-pretty text-sm font-medium leading-5 text-[color:var(--text-muted)] sm:text-base-minus sm:leading-5 lg:text-base lg:leading-6">
<p
className={cn(
"mx-auto max-w-2xl text-pretty text-sm font-medium leading-5 text-[color:var(--text-muted)] sm:text-base-minus sm:leading-5 lg:text-base lg:leading-6",
stabilizePhoneCopy && "max-sm:grid max-sm:min-h-[2lh] max-sm:place-items-center",
)}
>
Comment thread
cursor[bot] marked this conversation as resolved.
{subtitle}
</p>
</div>
Expand Down Expand Up @@ -261,6 +272,7 @@ export function ModeHomeTemplate({
footer,
className,
headingLevel = 1,
stabilizePhoneCopy = false,
}: ModeHomeTemplateProps) {
return (
<div
Expand All @@ -270,7 +282,14 @@ export function ModeHomeTemplate({
className,
)}
>
<ModeHomeHero testId={testId} title={title} subtitle={subtitle} icon={icon} headingLevel={headingLevel} />
<ModeHomeHero
testId={testId}
title={title}
subtitle={subtitle}
icon={icon}
headingLevel={headingLevel}
stabilizePhoneCopy={stabilizePhoneCopy}
/>

{/* Reserve settled composer height only while adoption is pending or the
portal host is present. SSR starts pending so first paint does not CLS;
Expand Down
2 changes: 1 addition & 1 deletion src/lib/app-modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ export const appModeDefinitions = [
href: "/formulation",
search: {
kind: "formulation",
placeholder: "Describe a pattern, mechanism, or clinical clue...",
placeholder: "Describe a pattern or clinical clue...",
inputAriaLabel: "Search formulation mechanisms by pattern or patient language",
submitIdleLabel: "Find",
submitBusyLabel: "Find",
Expand Down
Loading
Loading