diff --git a/src/app/globals.css b/src/app/globals.css index 031708539..ba00a459d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1793,6 +1793,17 @@ summary::-webkit-details-marker { box-shadow: 0 0 0 4px color-mix(in srgb, var(--focus) 25%, transparent) !important; } + /* Underline-tab focus: an inset rounded ring that hugs the label instead of the + default full-height box, which collides with the tablist border. */ + .focus-ring-tab { + border-radius: var(--radius-md); + } + .focus-ring-tab:focus-visible { + outline: 2px solid var(--focus) !important; + outline-offset: -4px !important; + box-shadow: none !important; + } + /* Premium hover transitions for source capsules */ .source-capsule-hover { transition: diff --git a/src/components/DocumentTagCloud.tsx b/src/components/DocumentTagCloud.tsx index 84e4a7151..14a40fb59 100644 --- a/src/components/DocumentTagCloud.tsx +++ b/src/components/DocumentTagCloud.tsx @@ -61,6 +61,15 @@ function confidenceTitle(tag: SmartDocumentTag) { return `${tag.group}: ${tag.source} tag, ${Math.round(tag.confidence * 100)}% confidence`; } +/** + * Renders a document tag with group-specific styling and optional search interaction. + * + * @param tag - The document tag to display. + * @param compact - Whether to use compact sizing. + * @param selected - Whether to indicate the tag as selected. + * @param onTagClick - Optional callback invoked when the tag is clicked. + * @returns The rendered document tag chip. + */ function DocumentTagChip({ tag, compact, @@ -79,8 +88,7 @@ function DocumentTagChip({ groupTone[tag.group], tag.queryMatched && "ring-2 ring-[color:var(--focus)]/25", selected && "ring-2 ring-[color:var(--primary)]/35", - onTagClick && - "cursor-pointer transition hover:-translate-y-0.5 hover:border-[color:var(--border-strong)] focus:outline-none focus:ring-2 focus:ring-[color:var(--focus)]/40", + onTagClick && "cursor-pointer transition hover:-translate-y-0.5 hover:border-[color:var(--border-strong)]", ); const content = ( <> diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index 58e2fa7cb..12d71058d 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -322,6 +322,12 @@ function QuickActions({ onSelect, mobile }: { onSelect: (id: string) => void; mo ); } +/** + * Renders responsive tabs for selecting a tool category. + * + * @param activeFilter - The currently selected tool category. + * @param onFilterChange - Called with the selected category when a tab is activated. + */ function FilterTabs({ activeFilter, onFilterChange, @@ -344,7 +350,7 @@ function FilterTabs({ aria-controls="launcher-results-panel" onClick={() => onFilterChange(filter.id)} className={cn( - "inline-flex min-h-9 items-center justify-center rounded-lg border px-4 text-xs font-bold transition", + "inline-flex min-h-11 items-center justify-center whitespace-nowrap rounded-lg border px-4 text-xs font-bold transition", active ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)]" : "border-[color:var(--border)] bg-[color:var(--surface-lux)] text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text-heading)]", @@ -369,7 +375,7 @@ function FilterTabs({ aria-controls="launcher-results-panel" onClick={() => onFilterChange(filter.id)} className={cn( - "inline-flex min-h-7 shrink-0 items-center justify-center gap-0.5 rounded-lg border px-2 text-4xs font-bold transition", + "inline-flex min-h-11 shrink-0 items-center justify-center gap-0.5 whitespace-nowrap rounded-lg border px-2 text-4xs font-bold transition", active ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)]" : "border-[color:var(--border)] bg-[color:var(--surface-lux)] text-[color:var(--text-muted)]", diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index cd68a125d..054bd4e82 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -362,6 +362,11 @@ function ContinueStrip({ item, onSelect }: { item: FavouriteItem; onSelect: (id: ); } +/** + * Renders a menu of actions for a favourite item. + * + * @param item - The favourite item associated with the available actions + */ function RowActionsMenu({ item }: { item: FavouriteItem }) { const [open, setOpen] = useState(false); const buttonRef = useRef(null); @@ -386,7 +391,7 @@ function RowActionsMenu({ item }: { item: FavouriteItem }) { aria-label={`More actions for ${item.title}`} onClick={() => setOpen((current) => !current)} className={cn( - "grid h-9 w-9 place-items-center rounded-lg text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)]", + "grid h-11 w-11 place-items-center rounded-lg text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)]", focusRing, )} > diff --git a/src/components/clinical-dashboard/favourites-library-nav.tsx b/src/components/clinical-dashboard/favourites-library-nav.tsx index 2c62b51fb..8d2e0f732 100644 --- a/src/components/clinical-dashboard/favourites-library-nav.tsx +++ b/src/components/clinical-dashboard/favourites-library-nav.tsx @@ -210,7 +210,7 @@ function SidebarRow({ entry }: { entry: SidebarEntry }) { aria-pressed={entry.active} onClick={entry.onClick} className={cn( - "flex min-h-9 w-full items-center gap-2.5 rounded-lg border px-2.5 text-left text-sm-minus font-semibold transition", + "flex min-h-11 w-full items-center gap-2.5 rounded-lg border px-2.5 text-left text-sm-minus font-semibold transition", entry.active ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)] shadow-[inset_2px_0_0_var(--clinical-accent)]" : "border-transparent text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]", @@ -224,6 +224,12 @@ function SidebarRow({ entry }: { entry: SidebarEntry }) { ); } +/** + * Renders the favourites library navigation sidebar in expanded or compact mode. + * + * @param collapsed - Whether to display the compact sidebar layout. + * @param onCollapsedChange - Called when the user requests a different sidebar layout. + */ export function FavouritesSidebar({ collapsed, onCollapsedChange, @@ -302,7 +308,7 @@ export function FavouritesSidebar({ type="button" onClick={() => onCollapsedChange(true)} className={cn( - "grid h-9 w-9 shrink-0 place-items-center rounded-lg border border-transparent text-[color:var(--text-muted)] transition hover:border-[color:var(--border)] hover:bg-[color:var(--surface)] hover:text-[color:var(--text)]", + "grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-transparent text-[color:var(--text-muted)] transition hover:border-[color:var(--border)] hover:bg-[color:var(--surface)] hover:text-[color:var(--text)]", focusRing, )} aria-label="Compact library sidebar" diff --git a/src/components/differentials/differential-detail-page.tsx b/src/components/differentials/differential-detail-page.tsx index cd595a6f4..d6526c33a 100644 --- a/src/components/differentials/differential-detail-page.tsx +++ b/src/components/differentials/differential-detail-page.tsx @@ -126,6 +126,14 @@ const sectionItemIconClass: Partial> test: "text-[color:var(--info)]", }; +/** + * Renders section items according to the section tone. + * + * @param section - The section whose tone determines the item layout and styling + * @param items - The items to display + * @param overlapLinks - Maps overlap item labels to diagnosis slugs for linked items + * @returns The rendered section item list + */ function SectionItems({ section, items, @@ -160,13 +168,13 @@ function SectionItems({ {slug ? ( {item} ) : ( - + {item} )} @@ -702,6 +710,14 @@ function FooterStatus({ ); } +/** + * Renders desktop actions for comparing, copying, and saving a diagnosis. + * + * @param record - The diagnosis record whose content is copied. + * @param saved - Whether the diagnosis is currently saved. + * @param onToggleSaved - Called when the saved state is toggled. + * @param onCompare - Called when comparison is requested. + */ function TopActions({ record, saved, @@ -714,11 +730,11 @@ function TopActions({ onCompare: () => void; }) { return ( -
+
diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index d7f3e9268..9d6e0591c 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1259,6 +1259,50 @@ test.describe("Clinical KB tools launcher", () => { await expectNoPageHorizontalOverflow(page); }); + test("diagnosis detail actions stay tappable and tabs stay single-line", async ({ page }) => { + await page.setViewportSize({ width: 1024, height: 800 }); + await gotoLauncher(page, "/differentials/diagnoses/delirium"); + await expect(page.getByTestId("differential-detail-page")).toBeVisible(); + // The desktop action cluster must keep its intrinsic width (shrink-0) so the + // icon action does not get crushed below the 44px tap standard. + await expectMinTouchTarget(page.getByRole("button", { name: "Save diagnosis" })); + + // Tabs: no page overflow and single-line labels at the narrowest width. + await page.setViewportSize({ width: 320, height: 700 }); + await expect(page.getByTestId("differential-detail-page")).toBeVisible(); + await expectNoPageHorizontalOverflow(page); + const overviewTab = page.getByRole("tab", { name: "Overview" }); + await expect(overviewTab).toBeVisible(); + // Count rendered label lines from text-node rects (an icon rect would bridge + // two wrapped lines and mask a wrap); the tab label must stay on one line. + const overviewLineCount = await overviewTab.evaluate((element) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + const rects: DOMRect[] = []; + while (walker.nextNode()) { + const node = walker.currentNode; + if (!(node.textContent ?? "").trim()) continue; + const range = document.createRange(); + range.selectNodeContents(node); + for (const rect of range.getClientRects()) { + if (rect.height > 4 && rect.width > 1) rects.push(rect); + } + } + rects.sort((a, b) => a.top - b.top); + let lines = 0; + let lineBottom = Number.NEGATIVE_INFINITY; + for (const rect of rects) { + if (rect.top >= lineBottom - 4) { + lines += 1; + lineBottom = rect.bottom; + } else { + lineBottom = Math.max(lineBottom, rect.bottom); + } + } + return lines; + }); + expect(overviewLineCount).toBe(1); + }); + test("differentials presentation comparison page stays wired to differentials mode", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 920 }); const workflow = acuteConfusionPresentationWorkflow;