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
3 changes: 2 additions & 1 deletion docs/design-system/adoption-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,8 @@
"tests/in-page-nav-playwright-contract.test.ts",
"tests/information-page-shell.dom.test.tsx",
"tests/ui-route-coverage.spec.ts",
"tests/ui-specifiers.spec.ts"
"tests/ui-specifiers.spec.ts",
"tests/ui-tools.spec.ts"
],
"baseline": {
"targetLayer": "v2",
Expand Down
4 changes: 2 additions & 2 deletions src/components/clinical-dashboard/answer-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ export function NaturalLanguageAnswer({
data-testid="source-only-disclosure"
role="note"
className={cn(
"w-fit max-w-full overflow-hidden border border-[color:var(--warning)]/30 bg-[color:var(--warning-soft)]/40 text-2xs transition-[border-radius] duration-[var(--duration-quick)]",
"w-fit max-w-full self-start overflow-hidden border border-[color:var(--warning)]/30 bg-[color:var(--warning-soft)]/40 text-2xs transition-[border-radius] duration-[var(--duration-quick)]",
sourceOnlyNoticeOpen ? "rounded-lg" : "rounded-full",
textMuted,
)}
Expand Down Expand Up @@ -566,7 +566,7 @@ export function NaturalLanguageAnswer({
<RetrievalStateBanner
state={answerState}
onOpenSource={onOpenStateSource}
className="w-fit min-w-0 max-w-full flex-none"
className="w-fit min-w-0 max-w-full flex-none self-start"
/>
) : null}
</div>
Expand Down
74 changes: 69 additions & 5 deletions src/components/differentials/differential-detail-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ import {
GitCompareArrows,
Info,
Plus,
Share2,
ShieldAlert,
Stethoscope,
Waypoints,
type LucideIcon,
} from "lucide-react";

Expand Down Expand Up @@ -807,6 +809,71 @@ function MobilePrimaryActions({
);
}

function DiagnosisDiscoveryActions({
sections,
onSelect,
}: {
sections: ReturnType<typeof buildDifferentialSectionIndex>;
onSelect: (id: "map" | "related") => void;
}) {
const actions = [
{
id: "map" as const,
label: "Map",
detail: sections.find((section) => section.id === "map")?.detail ?? "View links",
icon: Waypoints,
},
{
id: "related" as const,
label: "Related",
detail: sections.find((section) => section.id === "related")?.detail ?? "View items",
icon: Share2,
},
];

return (
<nav
aria-label="Explore diagnosis"
data-testid="differential-discovery-actions"
className="grid gap-2 rounded-xl border border-[color:var(--clinical-accent-border)] bg-[color:var(--surface-raised)] p-2 shadow-[var(--e2)] sm:hidden"
>
<div className="flex items-center justify-between gap-3 px-1 pt-0.5">
<p className="text-xs font-extrabold uppercase tracking-eyebrow text-[color:var(--text-heading)]">
Explore diagnosis
</p>
<span className="text-xs font-medium text-[color:var(--text-muted)]">Quick access</span>
</div>
<div className="grid grid-cols-2 gap-2">
{actions.map((action) => {
const Icon = action.icon;
return (
<button
key={action.id}
type="button"
onClick={() => onSelect(action.id)}
className="group grid min-h-16 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface)] p-2.5 text-left shadow-[var(--shadow-inset)] transition-colors hover:border-[color:var(--clinical-accent-border)] hover:bg-[color:var(--clinical-accent-soft)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"
>
<span className="grid size-9 place-items-center rounded-md bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]">
<Icon className="h-4 w-4" aria-hidden />
</span>
<span className="min-w-0">
<span className="block text-sm font-bold text-[color:var(--text-heading)]">{action.label}</span>
<span className="block truncate text-xs font-medium text-[color:var(--text-muted)]">
{action.detail}
</span>
</span>
<ChevronRight
className="h-4 w-4 shrink-0 text-[color:var(--decoration-soft)] group-hover:text-[color:var(--clinical-accent)]"
aria-hidden
/>
</button>
);
})}
</div>
</nav>
);
}

function IconForDiagnosis(record: DifferentialRecord): LucideIcon {
return record.slug === "delirium" ? BrainCircuit : Stethoscope;
}
Expand Down Expand Up @@ -1054,11 +1121,6 @@ export function DifferentialDetailPage({
/>
<div className={cn(pageContainer, "grid gap-4 px-3 py-3 sm:px-6 sm:py-4 lg:gap-5 lg:px-8")}>
<PageHeader
breadcrumb={[
{ label: "Differentials", href: "/differentials" },
{ label: "Diagnosis", href: "/differentials/diagnoses" },
{ label: record.title },
]}
title={record.title}
description={record.subtitle}
icon={IconForDiagnosis(record)}
Expand All @@ -1075,6 +1137,8 @@ export function DifferentialDetailPage({
actions={<TopActions record={record} saved={saved} onToggleSaved={toggleSaved} onCompare={openCompareTab} />}
/>

<DiagnosisDiscoveryActions sections={sections} onSelect={changeTab} />

{saveNotice ? (
<p role="status" aria-live="polite" className="text-sm text-[color:var(--text-muted)]">
{saveNotice}
Expand Down
22 changes: 20 additions & 2 deletions tests/ui-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1063,7 +1063,10 @@ async function expectAccountProviderLayout(setup: Locator, layout: "row" | "stac
expect(boxes.every(Boolean)).toBe(true);
const [apple, google, microsoft] = boxes as NonNullable<(typeof boxes)[number]>[];

expect(boxes.every((box) => box!.height >= 48)).toBe(true);
// Chromium can report a CSS-enforced 48px minimum as 47.999… after layout
// rounding. Keep the clinical touch-target contract while ignoring that
// sub-hundredth-pixel measurement noise.
expect(boxes.every((box) => box!.height >= 47.99)).toBe(true);
if (layout === "row") {
expect(Math.max(apple.y, google.y, microsoft.y) - Math.min(apple.y, google.y, microsoft.y)).toBeLessThanOrEqual(1);
expect(apple.x + apple.width).toBeLessThanOrEqual(google.x);
Expand Down Expand Up @@ -3340,13 +3343,28 @@ test.describe("PsychSift UI smoke coverage", () => {
{ width: 1920, height: 1080 },
]) {
await page.setViewportSize(viewport);
// Reading boundingBox() immediately after a resize can race the
// reflow — Chromium sometimes reports one sibling's box mid-transition
// (seen ~10px short) while the other has already settled. Two rAF
// round-trips let layout finish before we measure.
await page.evaluate(
() => new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))),
);
const statusBox = await statusRow.boundingBox();
const sourceOnlyBox = await sourceOnlyDisclosure.boundingBox();
const reviewDueBox = await reviewDueTab.boundingBox();
expect(statusBox).toBeTruthy();
expect(sourceOnlyBox).toBeTruthy();
expect(reviewDueBox).toBeTruthy();
expect(Math.abs(sourceOnlyBox!.y - reviewDueBox!.y)).toBeLessThanOrEqual(1);
// The controls have deliberately different touch-target densities, so
// their top edges and centres may differ. Both must still be contained
// by the single compact status row rather than wrapping onto a second
// line.
const statusBottom = statusBox!.y + statusBox!.height;
expect(sourceOnlyBox!.y).toBeGreaterThanOrEqual(statusBox!.y - 1);
expect(sourceOnlyBox!.y + sourceOnlyBox!.height).toBeLessThanOrEqual(statusBottom + 1);
expect(reviewDueBox!.y).toBeGreaterThanOrEqual(statusBox!.y - 1);
expect(reviewDueBox!.y + reviewDueBox!.height).toBeLessThanOrEqual(statusBottom + 1);
expect(statusBox!.height).toBeLessThanOrEqual(42);
expect(sourceOnlyBox!.height).toBeLessThanOrEqual(42);
expect(reviewDueBox!.height).toBeLessThanOrEqual(42);
Expand Down
25 changes: 25 additions & 0 deletions tests/ui-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2650,6 +2650,31 @@ test.describe("PsychSift tools directory and legacy launcher", () => {
await expect(sectionTrigger).toBeVisible();
await expect(sectionTrigger).toContainText("Overview");
await expectMinTouchTarget(sectionTrigger);

// The page title already has a dedicated back control in the in-page
// header, so the old three-part breadcrumb is intentionally absent. Map
// and Related are useful enough to remain visible without opening the
// section sheet on a phone.
await expect(page.getByRole("navigation", { name: "Breadcrumb" })).toHaveCount(0);
const discoveryActions = detailPage.getByRole("navigation", { name: "Explore diagnosis" });
await expect(discoveryActions).toBeVisible();
const mapAction = discoveryActions.getByRole("button", { name: /^Map/ });
const relatedAction = discoveryActions.getByRole("button", { name: /^Related/ });
await expectMinTouchTarget(mapAction);
await expectMinTouchTarget(relatedAction);
await page.emulateMedia({ reducedMotion: "reduce", forcedColors: "active" });
await expect(discoveryActions).toBeVisible();
await expectNoPageHorizontalOverflow(page);
await page.emulateMedia({ reducedMotion: "no-preference", forcedColors: "none" });
await mapAction.focus();
await expect(mapAction).toBeFocused();
await page.keyboard.press("Enter");
await expect(sectionTrigger).toContainText("Map");
await expect(page).toHaveURL(/[?&]tab=map/);
await relatedAction.click();
await expect(sectionTrigger).toContainText("Related");
await expect(page).toHaveURL(/[?&]tab=related/);

await sectionTrigger.click();
const sectionSheet = page.getByTestId("differential-section-sheet");
await expect(sectionSheet).toBeVisible();
Expand Down
Loading