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
2 changes: 1 addition & 1 deletion .design-sync/config.json

Large diffs are not rendered by default.

Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
| 2026-08-16 | PR #2007 / codex/guide-search-chrome-20260815 | 82576f737912e5fc2b601ec1e4f4ca74ebb04e69 | end-to-end PR review and base sync | Merged main without loss; fixed hidden Sheet safe-area retention; preserved focused Guide chrome coverage | Manual adversarial diff pass; TypeScript syntax probes; merge-tree and exact-head CI rechecked |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
| 2026-08-16 | PR #2007 / codex/guide-search-chrome-20260815 | 0874f3f7faa9539b6db30a58cee360adff4bf7a1 | design-system CI repair | Replaced the retired elevated shadow alias and removed two layout-transition exceptions from Guide chrome | Design-system contract diagnosis from exact-head CI; targeted source verification; exact-head CI re-run |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
| 2026-08-17 | codex/guide-search-chrome-20260815 | 7365c7f36751b15f155b5228bdce075d9c4e09ec | PR #2007 CI fix: prettier format, design-sync contract regen, guide-centre scroll-hide race, ui-smoke scroll threshold + a11y (bodyTabIndex) | Fixed 4 CI failures (Static PR checks, Unit coverage x2, Production UI) so PR is ready to merge once CI reruns | npx vitest run (design-sync-contract, design-sync-visual-exports, guide-centre.dom, guide-centre-design-contract.dom); npx eslint on touched files; npx prettier --check; local Playwright production build+run of tests/ui-smoke.spec.ts guide centre test (3 iterations to isolate/fix/verify) and tests/guide-centre-chrome.spec.ts (found unmatched by testMatch, noted not fixed) |
132 changes: 83 additions & 49 deletions src/components/clinical-dashboard/guide-dialog.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,7 @@ import {
X,
type LucideIcon,
} from "lucide-react";
import { useMemo, useRef, useState, useSyncExternalStore, type UIEvent } from "react";
import { useMemo, useRef, useState, type RefObject, type UIEvent } from "react";

import {
guideQuickTasks,
Expand All@@ -44,6 +44,7 @@ import {
saveGuideProgress,
type GuideProgress,
} from "@/components/clinical-dashboard/guide-progress";
import { readChromeCollapseMetrics, useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll";
import { Sheet } from "@/components/ui/sheet";
import {
chatComposerIconButton,
Expand All@@ -57,23 +58,6 @@ import {
} from "@/components/ui-primitives";

const guideAccessibleNameId = "clinical-kb-guide-accessible-name";
const phoneMediaQuery = "(max-width: 639px)";

function subscribeToPhoneMedia(callback: () => void) {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => undefined;
const media = window.matchMedia(phoneMediaQuery);
media.addEventListener("change", callback);
return () => media.removeEventListener("change", callback);
}

function getPhoneMediaSnapshot() {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia(phoneMediaQuery).matches;
}

function usePhoneViewport() {
return useSyncExternalStore(subscribeToPhoneMedia, getPhoneMediaSnapshot, () => false);
}

const topicIcons: Record<GuideTopicId, LucideIcon> = {
"getting-started": BookOpen,
Expand All@@ -88,12 +72,23 @@ const topicIcons: Record<GuideTopicId, LucideIcon> = {

const quickTaskIcons: readonly LucideIcon[] = [HelpCircle, SlidersHorizontal, ShieldCheck, LockKeyhole];

function GuideSearch({ query, onQueryChange }: { query: string; onQueryChange: (query: string) => void }) {
function GuideSearch({
query,
inputRef,
onFocusChange,
onQueryChange,
}: {
query: string;
inputRef: RefObject<HTMLInputElement | null>;
onFocusChange: (focused: boolean) => void;
onQueryChange: (query: string) => void;
}) {
return (
<form
role="search"
aria-label="Search guide content"
onSubmit={(event) => event.preventDefault()}
data-guide-universal-search
className="mx-auto w-full max-w-3xl"
>
<div
Expand All@@ -108,9 +103,12 @@ function GuideSearch({ query, onQueryChange }: { query: string; onQueryChange: (
<label className="min-w-0 flex-1">
<span className="sr-only">Search the guide</span>
<input
ref={inputRef}
type="search"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onFocus={() => onFocusChange(true)}
onBlur={() => onFocusChange(false)}
placeholder="Search the guide"
className={cn(chatComposerInput, "answer-footer-search-input w-full min-w-0")}
autoComplete="off"
Expand DownExpand Up@@ -139,7 +137,10 @@ function GuideTopNavigation({ view, onNavigate }: { view: GuideView; onNavigate:
{ view: "topics", label: "All topics", icon: Grid2X2 },
];
return (
<nav aria-label="Guide views" className="grid grid-cols-3 border-y border-[color:var(--border)]">
<nav
aria-label="Guide views"
className="grid grid-cols-3 border-t border-[color:var(--border)] bg-[color:var(--surface-raised)]"
>
{items.map((item) => {
const active = view === item.view || (view === "topic" && item.view === "topics");
const Icon = item.icon;
Expand All@@ -150,7 +151,7 @@ function GuideTopNavigation({ view, onNavigate }: { view: GuideView; onNavigate:
onClick={() => onNavigate(item.view)}
aria-current={active ? "page" : undefined}
className={cn(
"relative inline-flex min-h-tap items-center justify-center gap-2 px-2 text-xs font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)] sm:text-sm",
"relative inline-flex min-h-tap items-center justify-center gap-1.5 px-1.5 text-xs font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)] sm:gap-2 sm:px-2 sm:text-sm",
active
? "text-[color:var(--clinical-accent)] after:absolute after:inset-x-4 after:bottom-0 after:h-0.5 after:bg-[color:var(--clinical-accent)]"
: "text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]",
Expand DownExpand Up@@ -679,33 +680,46 @@ function GuideDialogSession({ onClose }: { onClose: () => void }) {
const [progress, setProgress] = useState<GuideProgress>(() => loadGuideProgress());
const [tourStepIndex, setTourStepIndex] = useState(0);
const [tourComplete, setTourComplete] = useState(false);
const [searchFocused, setSearchFocused] = useState(false);
const contentStartRef = useRef<HTMLDivElement | null>(null);
const scrollBodyRef = useRef<HTMLDivElement | null>(null);
const headerRef = useRef<HTMLDivElement | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const lastScrollTopRef = useRef(0);
const [mobileFooterHidden, setMobileFooterHidden] = useState(false);
const isPhoneViewport = usePhoneViewport();
const footerHidden = isPhoneViewport && mobileFooterHidden;
const chromeScrollHide = useScrollHideReporter(
searchFocused,
false,
`${view}:${activeTopicId}:${tourStepIndex}:${tourComplete}`,
);
const chromeHidden = chromeScrollHide.hidden;

function focusPageStart() {
window.requestAnimationFrame(() => {
if (scrollBodyRef.current) scrollBodyRef.current.scrollTop = 0;
lastScrollTopRef.current = 0;
setMobileFooterHidden(false);
chromeScrollHide.reset();
contentStartRef.current?.querySelector<HTMLElement>("[data-guide-page-heading]")?.focus({ preventScroll: true });
});
}

function handleBodyScroll(event: UIEvent<HTMLDivElement>) {
const nextScrollTop = event.currentTarget.scrollTop;
if (!isPhoneViewport) {
lastScrollTopRef.current = nextScrollTop;
setMobileFooterHidden(false);
return;
const target = event.currentTarget;
const nextScrollTop = target.scrollTop;
if (document.activeElement === searchInputRef.current && Math.abs(nextScrollTop - lastScrollTopRef.current) > 4) {
searchInputRef.current?.blur();
}
const delta = nextScrollTop - lastScrollTopRef.current;
if (nextScrollTop <= 12) setMobileFooterHidden(false);
else if (delta > 6) setMobileFooterHidden(true);
else if (delta < -6) setMobileFooterHidden(false);
const collapseMetrics = readChromeCollapseMetrics(target);
const headerRelease = headerRef.current?.getBoundingClientRect().height ?? 0;
const reserveRelease = collapseMetrics.collapseBudget ?? 0;
chromeScrollHide.reportScroll({
offset: nextScrollTop,
maxOffset: Math.max(0, target.scrollHeight - target.clientHeight),
...collapseMetrics,
collapseBudget: headerRelease + reserveRelease,
collapseKind: headerRelease > 0 ? "in-flow" : collapseMetrics.collapseKind,
combinedChrome: headerRelease > 0 && reserveRelease > 0,
source: target,
});
lastScrollTopRef.current = nextScrollTop;
}

Expand DownExpand Up@@ -764,14 +778,11 @@ function GuideDialogSession({ onClose }: { onClose: () => void }) {
const footer = (
<div
data-guide-mobile-footer
aria-hidden={footerHidden}
inert={footerHidden || undefined}
className="flex min-w-0 items-center justify-center gap-3 sm:justify-between"
aria-hidden={chromeHidden}
inert={chromeHidden || undefined}
className="mx-auto grid w-full max-w-3xl min-w-0 gap-2.5"
>
<p className={cn("hidden min-w-0 items-center gap-2 text-xs sm:flex", textMuted)}>
<ShieldCheck aria-hidden="true" className="size-icon-md shrink-0" /> Demo content only · Do not enter PHI
</p>
<div className="flex min-w-0 items-center justify-center gap-2 sm:ml-auto sm:justify-end">
<div data-guide-tour-action-row className="flex min-w-0 items-center justify-center gap-2">
{view === "tour" && !tourComplete ? (
<>
<button
Expand DownExpand Up@@ -834,6 +845,23 @@ function GuideDialogSession({ onClose }: { onClose: () => void }) {
</>
)}
</div>
<GuideSearch
query={query}
inputRef={searchInputRef}
onFocusChange={setSearchFocused}
onQueryChange={(nextQuery) => {
setQuery(nextQuery);
if (nextQuery.trim()) {
window.requestAnimationFrame(() => {
if (scrollBodyRef.current) scrollBodyRef.current.scrollTop = 0;
chromeScrollHide.reset();
});
}
}}
/>
<p className={cn("hidden items-center justify-center gap-2 text-xs sm:flex", textMuted)}>
<ShieldCheck aria-hidden="true" className="size-icon-md shrink-0" /> Demo content only · Do not enter PHI
</p>
</div>
);

Expand All@@ -855,13 +883,21 @@ function GuideDialogSession({ onClose }: { onClose: () => void }) {
contentClassName="relative font-sans sm:max-w-none lg:h-[min(56rem,calc(100dvh-3rem))] lg:max-w-[min(94vw,90rem)]"
bodyClassName="p-0 sm:p-0"
bodyRef={scrollBodyRef}
bodyTabIndex={0}
onBodyScroll={handleBodyScroll}
headerClassName="pt-[max(1rem,env(safe-area-inset-top))] sm:pt-5"
headerRef={headerRef}
headerHidden={chromeHidden}
headerBottom={<GuideTopNavigation view={view} onNavigate={navigate} />}
headerClassName={cn(
"guide-centre-header max-h-48 overflow-hidden pt-[max(1rem,env(safe-area-inset-top))] transition-[border-color,opacity] duration-[var(--duration-moderate)] motion-reduce:transition-none sm:pt-5",
chromeHidden &&
"max-h-0 border-transparent p-0 opacity-0 sm:max-h-48 sm:border-[color:var(--border)] sm:p-5 sm:opacity-100",
)}
mobilePlacement="fullscreen"
footer={footer}
footerClassName={cn(
"absolute inset-x-0 bottom-0 z-30 pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))] pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 transition-[transform,opacity] duration-[var(--duration-quick)] sm:static sm:p-4",
footerHidden &&
"absolute inset-x-0 bottom-0 z-30 border-t border-[color:var(--border)] bg-[color:var(--surface-raised)] pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))] pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 shadow-[var(--e4)] transition-[transform,opacity] duration-[var(--duration-moderate)] motion-reduce:transition-none sm:static sm:p-4 sm:shadow-none",
chromeHidden &&
"pointer-events-none translate-y-full opacity-0 sm:pointer-events-auto sm:translate-y-0 sm:opacity-100",
)}
testId="clinical-kb-guide-centre"
Expand All@@ -870,14 +906,12 @@ function GuideDialogSession({ onClose }: { onClose: () => void }) {
<span id={guideAccessibleNameId} className="sr-only">
Clinical KB guide
</span>
<div className="sticky top-0 z-20 bg-[color:var(--surface-raised)] px-3 pb-3 sm:px-5">
<GuideSearch query={query} onQueryChange={setQuery} />
</div>
<GuideTopNavigation view={view} onNavigate={navigate} />
<div
ref={contentStartRef}
data-guide-content
className={cn("space-y-4 p-3 sm:p-5", footerHidden ? "pb-0" : "pb-28")}
data-reserve-owner="guide-search-dock"
data-reserve-hidden-pad="0"
className={cn("space-y-4 p-3 sm:p-5", chromeHidden ? "pb-0 sm:pb-5" : "pb-40 sm:pb-5")}
>
{hasSearch ? (
<SearchResults query={query} onSelect={openTopic} />
Expand Down
34 changes: 31 additions & 3 deletions src/components/ui/sheet.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,13 +52,23 @@ type SheetBaseProps = {
titleAccessory?: ReactNode;
descriptionContent?: ReactNode;
headerActions?: ReactNode;
headerBottom?: ReactNode;
headerHidden?: boolean;
headerRef?: RefObject<HTMLDivElement | null>;
headerClassName?: string;
titleClassName?: string;
closeButtonClassName?: string;
contentClassName?: string;
contentStyle?: CSSProperties;
bodyClassName?: string;
bodyRef?: RefObject<HTMLDivElement | null>;
/**
* Makes the scrollable body reachable by keyboard when its content may not
* always include a focusable descendant (WCAG 2.1.1 / axe
* scrollable-region-focusable). Omit for bodies that always contain
* interactive content.
*/
bodyTabIndex?: number;
onBodyScroll?: UIEventHandler<HTMLDivElement>;
footerClassName?: string;
placement?: "default" | "left";
Expand DownExpand Up@@ -108,13 +118,17 @@ export function Sheet({
titleAccessory,
descriptionContent,
headerActions,
headerBottom,
headerHidden = false,
headerRef,
headerClassName,
titleClassName,
closeButtonClassName,
contentClassName,
contentStyle,
bodyClassName,
bodyRef,
bodyTabIndex,
onBodyScroll,
footerClassName,
placement = "default",
Expand DownExpand Up@@ -459,12 +473,20 @@ export function Sheet({
</div>
{title ? (
<div
ref={headerRef}
data-sheet-header="true"
aria-hidden={headerHidden}
inert={headerHidden || undefined}
className={cn(
"flex items-center justify-between gap-3 border-b border-[color:var(--border)] p-4 sm:p-5",
"flex items-center justify-between gap-x-3 border-b border-[color:var(--border)] p-4 sm:p-5",
Boolean(headerBottom) && "flex-wrap",
headerClassName,
resolvedMobileHeaderSafeArea === "padding" && "pt-[max(1rem,var(--safe-area-top))] sm:pt-5",
resolvedMobileHeaderSafeArea === "offset" && "top-[max(0.75rem,var(--safe-area-top))] sm:top-4",
!headerHidden &&
resolvedMobileHeaderSafeArea === "padding" &&
"pt-[max(1rem,var(--safe-area-top))] sm:pt-5",
!headerHidden &&
resolvedMobileHeaderSafeArea === "offset" &&
"top-[max(0.75rem,var(--safe-area-top))] sm:top-4",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
Expand DownExpand Up@@ -502,11 +524,17 @@ export function Sheet({
<X aria-hidden="true" className="h-4 w-4" />
</button>
</div>
{headerBottom ? (
<div className="order-last -mx-4 mt-3 w-[calc(100%+2rem)] basis-full sm:-mx-5 sm:w-[calc(100%+2.5rem)]">
{headerBottom}
</div>
) : null}
</div>
) : null}
<div
ref={bodyRef}
onScroll={onBodyScroll}
tabIndex={bodyTabIndex}
className={cn("min-h-0 min-w-0 flex-1 overflow-y-auto p-4 polished-scroll sm:p-5", bodyClassName)}
>
{children}
Expand Down
Loading
Loading