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
6 changes: 6 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@
--danger-text: #b42318;
--danger-bg: #fef3f2;
--danger-border: #fda29b;
--danger-solid: #b42318;
--danger-solid-contrast: #ffffff;

--info: var(--info-text);
--info-soft: var(--info-bg);
Expand Down Expand Up @@ -355,6 +357,8 @@
--danger-text: #ff9ca4;
--danger-bg: #401c24;
--danger-border: #7f2e36;
--danger-solid: #b42318;
--danger-solid-contrast: #ffffff;

--info: var(--info-text);
--info-soft: var(--info-bg);
Expand Down Expand Up @@ -2072,6 +2076,8 @@ summary::-webkit-details-marker {
--warning-soft: Canvas;
--danger: Mark;
--danger-soft: Canvas;
--danger-solid: Mark;
--danger-solid-contrast: Canvas;
--tone-purple: CanvasText;
--tone-indigo: CanvasText;
--tone-rose: CanvasText;
Expand Down
134 changes: 93 additions & 41 deletions src/components/clinical-dashboard/differentials-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,9 @@ function statusLabel(status: DifferentialRecord["status"]) {
}

function statusTone(status: DifferentialRecord["status"]) {
if (status === "emergent")
return "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]";
if (status === "emergent") {
return "border-transparent bg-[color:var(--danger-solid)] text-[color:var(--danger-solid-contrast)]";
}
if (status === "urgent") {
return "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]";
}
Expand Down Expand Up @@ -199,17 +200,99 @@ function toDifferentialResult(item: DifferentialSearchResultItem): DifferentialR
function StatusBadge({ status, className }: { status: DifferentialRecord["status"]; className?: string }) {
return (
<span
data-testid="differential-status-badge"
className={cn(
"inline-flex min-h-5 items-center rounded-md border px-1.5 text-3xs font-extrabold uppercase leading-none tracking-normal",
"inline-flex h-6 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border px-2.5 text-2xs font-extrabold uppercase leading-tight tracking-normal",
statusTone(status),
className,
)}
>
{status === "emergent" ? (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[color:var(--danger-solid-contrast)]/90" aria-hidden />
) : null}
{statusLabel(status)}
</span>
);
}

type KindFilter = "all" | "presentation" | "diagnosis";

const resultTypeTabFocusRing =
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]";

function ResultTypeTabs({
activeFilter,
onFilterChange,
allCount,
presentationCount,
diagnosisCount,
}: {
activeFilter: KindFilter;
onFilterChange: (filter: KindFilter) => void;
allCount: number;
presentationCount: number;
diagnosisCount: number;
}) {
const tabs = [
{ id: "all" as const, label: "All", count: allCount },
{ id: "presentation" as const, label: "Presentations", count: presentationCount },
{ id: "diagnosis" as const, label: "Diagnoses", count: diagnosisCount },
];

return (
<div
data-testid="differential-result-type-tabs"
role="tablist"
aria-label="Result type"
className="polished-scroll flex max-w-full items-center gap-1 overflow-x-auto rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-raised)] p-1 shadow-[var(--shadow-inset)]"
>
{tabs.map((tab) => {
const active = activeFilter === tab.id;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
aria-label={`${tab.label} (${tab.count})`}
onClick={() => onFilterChange(tab.id)}
className={cn(
"inline-flex min-h-11 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md border px-2.5 text-xs font-bold min-[390px]:text-sm",
resultTypeTabFocusRing,
active
? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)]"
: "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)]",
)}
>
{tab.label}
<span
className={cn(
"nums rounded-full px-1.5 text-3xs leading-tight",
active
? "bg-[color:var(--clinical-accent-contrast)]/15 text-[color:var(--clinical-accent-contrast)]"
: "bg-[color:var(--surface-subtle)] text-[color:var(--text-soft)]",
)}
>
{tab.count}
</span>
</button>
);
})}
<button
type="button"
aria-label="Filters"
className={cn(
"inline-flex min-h-11 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 text-xs font-bold text-[color:var(--text-heading)] min-[390px]:text-sm",
resultTypeTabFocusRing,
)}
>
<ListFilter className="h-4 w-4 shrink-0" aria-hidden />
<span className="hidden min-[430px]:inline">Filters</span>
</button>
</div>
);
}

function MatchBadge({ label }: { label: string }) {
const tone =
label === "Best match"
Expand Down Expand Up @@ -683,16 +766,6 @@ function SearchResultsView({
if (trimmedQuery && onRunSearch) onRunSearch(trimmedQuery);
}

const kindFilterOptions = [
{ id: "all" as const, label: `All (${results.length})`, compact: "All" },
{
id: "presentation" as const,
label: `Presentations (${presentationCount})`,
compact: `Pres (${presentationCount})`,
},
{ id: "diagnosis" as const, label: `Diagnoses (${diagnosisCount})`, compact: `Dx (${diagnosisCount})` },
];

return (
<div
data-testid="differentials-search-results"
Expand Down Expand Up @@ -826,34 +899,13 @@ function SearchResultsView({
selected={selectedIds.has(best.id)}
onToggle={() => toggleSelected(best.id)}
/>
<div className="grid grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)_auto] gap-1.5">
{kindFilterOptions.map((item) => (
<button
key={item.id}
type="button"
aria-label={item.label}
aria-pressed={kindFilter === item.id}
onClick={() => setKindFilter(item.id)}
className={cn(
"min-h-10 min-w-10 rounded-lg border px-2 text-xs font-bold min-[390px]:text-sm",
kindFilter === item.id
? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)]"
: "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)]",
)}
>
<span className="hidden min-[430px]:inline">{item.label}</span>
<span className="min-[430px]:hidden">{item.compact}</span>
</button>
))}
<button
type="button"
aria-label="Filters"
className="inline-flex min-h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-2 text-xs font-bold text-[color:var(--text-heading)] min-[390px]:text-sm"
>
<ListFilter className="h-4 w-4" aria-hidden />
<span className="hidden min-[430px]:inline">Filters</span>
</button>
</div>
<ResultTypeTabs
activeFilter={kindFilter}
onFilterChange={setKindFilter}
allCount={results.length}
presentationCount={presentationCount}
diagnosisCount={diagnosisCount}
/>
<div className="flex items-center justify-between gap-2 text-sm font-medium text-[color:var(--text-muted)]">
<span>
<strong className="text-[color:var(--text-heading)]">
Expand Down
6 changes: 4 additions & 2 deletions tests/ui-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,9 +589,11 @@ async function scrollMobileTableExpandClearOfFooter(page: Page, clinicalTable: L
async function openMobileTableFullscreen(page: Page, clinicalTable: Locator) {
await scrollMobileTableExpandClearOfFooter(page, clinicalTable);
const tableSurface = clinicalTable.getByTestId("accessible-table-surface");
await tableSurface.click({ force: true });
const tableDialog = page.getByTestId("table-fullscreen-dialog");
await expect(tableDialog).toBeVisible({ timeout: 10_000 });
await expect(async () => {
await tableSurface.click({ force: true });
await expect(tableDialog).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 15_000 });
return tableDialog;
}

Expand Down
81 changes: 81 additions & 0 deletions tests/ui-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,87 @@ test.describe("Clinical KB tools launcher", () => {
await expect(page.getByRole("link", { name: "Delirium / Acute Confusion / Encephalopathy" }).first()).toBeVisible();
});

test("differentials search badges stay single-line on narrow viewport", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });

await page.route(/\/api\/setup-status(?:\?.*)?$/, async (route) => {
await route.fulfill({
json: {
demoMode: true,
checks: [
{ id: "env", label: ".env.local configured", status: "ready", detail: "Test environment ready." },
{ id: "project", label: "[REDACTED] target", status: "ready", detail: "Test project ready." },
{ id: "schema", label: "supabase/schema.sql applied", status: "ready", detail: "Test schema ready." },
{ id: "search", label: "Search RPC and vector indexes", status: "ready", detail: "Test search ready." },
{ id: "openai", label: "OpenAI API key available", status: "ready", detail: "Test OpenAI ready." },
],
},
});
});
await page.route(/\/api\/search(?:\?.*)?$/, async (route) => {
await route.fulfill({
json: {
results: [],
visualEvidence: [],
relatedDocuments: [],
documentMatches: [
{
document_id: "11111111-1111-4111-8111-111111111111",
title: "Acute confusion differential guide",
file_name: "acute-confusion-differentials.pdf",
labels: [],
summarySnippet: "Reviewed acute confusion differential guidance.",
bestPages: [1],
bestChunkIds: ["chunk-acute-confusion"],
imageCount: 0,
tableCount: 0,
matchReason: "Matched indexed passage",
score: 0.93,
},
],
relevance: { verdict: "strong", score: 0.91, directSourceCount: 1, weakSourceCount: 0 },
smartPanel: {},
telemetry: { query_class: "differential_compare", retrieval_strategy: "text_fast_path" },
scope: { queryMode: "compare_guidance" },
sourceGovernanceWarnings: [],
demoMode: true,
},
});
});

await gotoLauncher(page, "/differentials");
await page.locator('input[placeholder="Ask or search a presentation"]:visible').first().fill("acute confusion");
await page.locator('button[aria-label="Search differential presentations"]:visible').click();

await expect(page.getByTestId("differentials-search-results")).toBeVisible();
const tabs = page.getByTestId("differential-result-type-tabs");
await expect(tabs).toBeVisible();
await expect(tabs.getByRole("tab", { name: /All \(\d+\)/ })).toBeVisible();
await expect(tabs.getByRole("tab", { name: /Presentations \(\d+\)/ })).toBeVisible();
await expect(tabs.getByRole("tab", { name: /Diagnoses \(\d+\)/ })).toBeVisible();

const tabMetrics = await tabs.getByRole("tab").evaluateAll((buttons) =>
buttons.map((button) => {
const rect = button.getBoundingClientRect();
return { height: rect.height, scrollHeight: button.scrollHeight };
}),
);
for (const tab of tabMetrics) {
expect(tab.scrollHeight).toBeLessThanOrEqual(tab.height + 1);
}

const emergentBadge = page.getByTestId("differential-status-badge").first();
await expect(emergentBadge).toBeVisible();
await expect(emergentBadge).toHaveText(/Emergent/i);
const badgeMetrics = await emergentBadge.evaluate((element) => {
const rect = element.getBoundingClientRect();
return { height: rect.height, scrollHeight: element.scrollHeight };
});
expect(badgeMetrics.height).toBeGreaterThanOrEqual(22);
expect(badgeMetrics.scrollHeight).toBeLessThanOrEqual(badgeMetrics.height + 1);
await expectNoPageHorizontalOverflow(page);
});

test("differentials presentation comparison page stays wired to differentials mode", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 920 });
const workflow = acuteConfusionPresentationWorkflow;
Expand Down
Loading