diff --git a/docs/design-system/DECISIONS.md b/docs/design-system/DECISIONS.md
index 6ba277fcc4..203d43b0e2 100644
--- a/docs/design-system/DECISIONS.md
+++ b/docs/design-system/DECISIONS.md
@@ -213,6 +213,38 @@ convergence.
---
+## C8 · One look for both modes (owner design review, 11 October 2026)
+
+**Chose.** After a review of every Work and Clinical screen at 320 px, 390 px, desktop and
+dark mode, the owner approved these seven rules for both modes:
+
+1. **One look for both modes.** Clinical keeps its own components and behaviour, including
+ the search box at the bottom of the screen and how it works, but shares Work's look: round
+ header buttons on a tinted top bar, the tab bar in the same tint, hairline cards, the same
+ controls and illustrations, in Clinical blue. No search bar is added at the top.
+2. **Calmer bands.** The band and top bar carry no dot texture, and the band's line drawing
+ is fainter (0.08 light, 0.11 dark, from 0.11 and 0.14).
+3. **Primary buttons take the area colour.** Never black or ink-filled buttons, in either mode. First Nations keeps its own standard (v13.1), which holds its olive to the pill, current tab, icon tiles, day track and quotation accents, so its buttons stay neutral.
+4. **Filter chips stay on one line.** They scroll sideways with an edge fade and never wrap to
+ leave one chip alone.
+5. **Red means overdue, lapsed or urgent only,** in both modes. Priority and category labels
+ ("High", "Medium") use neutral tags.
+6. **One way back.** If the header shows Back, the page adds no extra "‹ My Day" style link
+ under the band.
+7. **One card per group,** in both modes: items sit as divided rows in one hairline card, with
+ no cards inside cards and no coloured side bars.
+
+**Rejected.** A top search bar or a title band in Clinical mode. The owner asked that the
+Clinical search box keep its bottom position and behaviour, and that Clinical keep its own
+components; a title band would also have moved the tab bar out of the top bar's collapse
+track and changed how it hides on scroll.
+
+**Why.** The two modes read as two different apps (different header, list, card and button
+styles). Sharing one visual language while keeping each mode's own controls balances them
+without retraining the reader.
+
+---
+
## Q&A record — clinical inputs (31 July 2026)
| # | Question | Answer | Design consequence |
diff --git a/src/app/(search-app)/documents/search/page.tsx b/src/app/(search-app)/documents/search/page.tsx
index 62d3dc8559..c893789951 100644
--- a/src/app/(search-app)/documents/search/page.tsx
+++ b/src/app/(search-app)/documents/search/page.tsx
@@ -1,16 +1,79 @@
import type { Metadata } from "next";
import Link from "next/link";
import { buttonFaceClass } from "@/components/ui/button-face";
+import { cn } from "@/components/ui-primitives";
export const metadata: Metadata = {
title: "Document Search - PsychSift",
description: "Search indexed clinical documents and review matching evidence.",
};
+// Primary buttons wear the area colour, never the shared near-black command
+// fill (design pass, October 2026). Re-pointing the command tokens on the
+// button itself keeps Button's hover and pressed states and leaves every other
+// control on the page alone. In forced colours the accent becomes LinkText,
+// which ButtonText cannot sit on, so the native button pair comes back.
+const clinicalPrimaryAccent =
+ "[--command:var(--clinical-accent)] [--command-hover:var(--clinical-accent-hover)] [--command-active:var(--clinical-accent-active)] [--command-contrast:var(--clinical-accent-contrast)] forced-colors:[--command:ButtonFace] forced-colors:[--command-hover:ButtonFace] forced-colors:[--command-active:ButtonFace]";
+
+// The empty screen's line drawing: two stacked documents and a magnifier, in
+// the area colour. It repeats what the heading says, so it is hidden from
+// screen readers. Fills use tokens through classes so dark mode follows.
+function DocumentsSearchIllustration() {
+ return (
+
+ );
+}
+
export default function DocumentsSearchRoute() {
return (
-
+
+
Indexed sources
Search clinical documents
@@ -19,7 +82,10 @@ export default function DocumentsSearchRoute() {
matching page and passage.
-
+
Open Documents search
diff --git a/src/app/mode-band.css b/src/app/mode-band.css
index c1bf9b9757..9bcef67ed2 100644
--- a/src/app/mode-band.css
+++ b/src/app/mode-band.css
@@ -433,3 +433,39 @@ html[data-mode-band] [data-testid="chrome-safe-area-top"] {
:is(div, header):has(> div:only-child > [data-under-band]:only-child) {
display: contents;
}
+
+/* The clinical band (owner design review, 11 Oct 2026). Every Clinical page
+ but the shared home tints its top bar with the mode's soft colour, and a
+ mode with its own pages (Dictionary, Differentials, Sources and the rest)
+ tints its tab bar to match, with the band's hairline under it, so the two
+ read as one band the way a work page's top bar and band do. The tab bar
+ stays in the top bar's collapse track, so it still hides and reveals with
+ the top bar exactly as before. The round header buttons are already white
+ circles, which the tint now shows. The header marks the page in its server
+ HTML (`data-clinical-band`), so the tint is there from the first paint. A
+ page under a mode band (Medicines, Psychiatry) keeps the band's own colour. */
+html:not([data-mode-band]):has(.universal-header[data-clinical-band]) {
+ --clinical-band-bg: var(--mode-identity-soft);
+ --clinical-band-line: var(--mode-identity-border);
+}
+
+html.dark:not([data-mode-band]):has(.universal-header[data-clinical-band]) {
+ --clinical-band-bg: color-mix(in srgb, var(--mode-identity) 11%, var(--background));
+ --clinical-band-line: color-mix(in srgb, var(--mode-identity) 26%, var(--background));
+}
+
+html:not([data-mode-band]) .universal-header[data-clinical-band],
+html:not([data-mode-band]):has(.universal-header[data-clinical-band]) [data-testid="chrome-safe-area-top"],
+html:not([data-mode-band]):has(.universal-header[data-clinical-band]) #phone-header-collapse-addon-slot .mode-nav-rail {
+ background: var(--clinical-band-bg);
+}
+
+html:not([data-mode-band]):has(.universal-header[data-clinical-band]) #phone-header-collapse-addon-slot .mode-nav-rail {
+ border-bottom-color: var(--clinical-band-line);
+}
+
+/* A tinted top bar with no tab bar under it ends on the band's hairline. */
+html:not([data-mode-band])
+ .universal-header[data-clinical-band]:not(:has(~ #phone-header-collapse-addon-slot .mode-nav-rail)) {
+ box-shadow: inset 0 -1px 0 var(--clinical-band-line);
+}
diff --git a/src/app/work-mode.css b/src/app/work-mode.css
index 6fbad8c776..4b41df967c 100644
--- a/src/app/work-mode.css
+++ b/src/app/work-mode.css
@@ -193,21 +193,6 @@ html[data-work-frame] main {
}
}
-/* The dot texture, lined up with the top bar's so the two read as one. */
-.work-band::before,
-html[data-work-frame] .universal-header[data-work-frame]::after,
-.universal-header[data-work-band]::after {
- content: "";
- position: absolute;
- inset: 0;
- z-index: var(--work-z-under);
- pointer-events: none;
- background-image: radial-gradient(var(--mode-identity-border) 0.7px, transparent 0.8px);
- background-size: 12px 12px;
- background-position: 0 var(--work-band-offset, 0);
- opacity: 0.55;
-}
-
/* The faint line drawing, cropped by its own box at the band's corner. */
.work-band__mark {
position: absolute;
@@ -219,12 +204,13 @@ html[data-work-frame] .universal-header[data-work-frame]::after,
background: var(--mode-identity);
mask: var(--work-band-mark, none) left top / 12.25rem 12.25rem no-repeat;
-webkit-mask: var(--work-band-mark, none) left top / 12.25rem 12.25rem no-repeat;
- opacity: 0.11;
+ /* Fainter since the 11 Oct design review: colour says where you are, the drawing only adds a hint. */
+ opacity: 0.08;
pointer-events: none;
}
.dark .work-band__mark {
- opacity: 0.14;
+ opacity: 0.11;
}
/* Pinned tabs (owner request 8 Oct 2026). Once the title has scrolled under
@@ -1670,18 +1656,70 @@ a.work-hero:focus-visible {
}
/* Chips: filters and choices. A 30px shape inside a 48px tap. */
+/* Chip rows stay on one line and scroll sideways (design pass, October 2026):
+ wrapping left one chip alone on a second line. Where scroll-driven
+ animations exist, a soft fade appears only at an edge with more chips past
+ it, and only while the row actually overflows. Elsewhere (older iPhone
+ Safari) there is no fade at all, so no chip is ever dimmed. The tab swipe
+ already ignores a row that scrolls sideways. */
.work-chips {
display: flex;
- flex-wrap: wrap;
- gap: 0 calc(var(--spacing) * 1.5);
-}
-
-.work-chips[data-scroll] {
flex-wrap: nowrap;
+ gap: 0 calc(var(--spacing) * 1.5);
overflow-x: auto;
+ overscroll-behavior-x: contain;
scrollbar-width: none;
}
+.work-chips::-webkit-scrollbar {
+ display: none;
+}
+
+@property --work-chips-fade-start {
+ syntax: "";
+ inherits: false;
+ initial-value: 0px;
+}
+
+@property --work-chips-fade-end {
+ syntax: "";
+ inherits: false;
+ initial-value: 0px;
+}
+
+@keyframes work-chips-fade {
+ from {
+ --work-chips-fade-start: 0px;
+ --work-chips-fade-end: 1.75rem;
+ }
+ 1% {
+ --work-chips-fade-start: 1.75rem;
+ }
+ 99% {
+ --work-chips-fade-end: 1.75rem;
+ }
+ to {
+ --work-chips-fade-start: 1.75rem;
+ --work-chips-fade-end: 0px;
+ }
+}
+
+@supports (animation-timeline: scroll()) {
+ .work-chips {
+ /* A row that does not overflow has an inactive timeline, so both fades
+ stay at 0px and nothing is masked. */
+ mask-image: linear-gradient(
+ to right,
+ transparent,
+ black var(--work-chips-fade-start),
+ black calc(100% - var(--work-chips-fade-end)),
+ transparent
+ );
+ animation: work-chips-fade linear both;
+ animation-timeline: scroll(self inline);
+ }
+}
+
.work-chip {
position: relative;
display: inline-flex;
diff --git a/src/components/admin/contract/contract-end-page.tsx b/src/components/admin/contract/contract-end-page.tsx
index 3d6c33157d..a1e9108ef4 100644
--- a/src/components/admin/contract/contract-end-page.tsx
+++ b/src/components/admin/contract/contract-end-page.tsx
@@ -19,7 +19,6 @@ import {
createEntry,
deleteEntry,
errorWords,
- JuniorBackLink,
JuniorFootNote,
JuniorNotice,
JuniorSectionLabel,
@@ -392,7 +391,7 @@ export function ContractEndPage({ now: nowProp }: { now?: Date } = {}) {
return (
<>
-
+ {/* One way back: the band's Today tab is Admin Today, so the page adds no second link to it. */}
Contract end
diff --git a/src/components/admin/leave/leave-wallet-page.tsx b/src/components/admin/leave/leave-wallet-page.tsx
index 7e3349833b..53d5be3870 100644
--- a/src/components/admin/leave/leave-wallet-page.tsx
+++ b/src/components/admin/leave/leave-wallet-page.tsx
@@ -4,6 +4,7 @@ import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
BookOpen,
+ ChevronRight,
Copy,
EyeOff,
ExternalLink,
@@ -25,7 +26,6 @@ import { ContractEndEntryLink } from "@/components/admin/contract/contract-entry
import { LeaveEntitlementList } from "@/components/admin/leave/leave-entitlement-list";
import {
copyLabel,
- JuniorBackLink,
JuniorFootNote,
JuniorSectionLabel,
JuniorUndoBar,
@@ -588,7 +588,7 @@ export function LeaveWalletPage({ now: nowProp }: { now?: Date } = {}) {
return (
-
+ {/* One way back: the band's Today tab is Admin Today, so the page adds no second link to it. */}
Leave wallet
@@ -664,14 +664,17 @@ export function LeaveWalletPage({ now: nowProp }: { now?: Date } = {}) {
Your wallet
{/*
- The mockup's wallet: each card's top edge tucks 8 px under the card before it, a deliberate stacked
- deck. Each card is at least 64 px tall, so at least 56 px of every card stays uncovered and tappable,
- and the overlap only ever covers the bottom padding, never a word. A focused card lifts above the
- next so its focus ring shows in full.
+ One grouped card with a hairline between leave types, the same list shape as the rest of Work mode,
+ rather than a stacked deck of separate cards. Each row is a full-width button at least 64 px tall.
*/}
-
+
{visible.map((type, index) => (
-
0 && "-mt-2")}>
+
0 && "border-t border-[color:var(--border)]")}>
))}
diff --git a/src/components/admin/renewals/kind-chips.tsx b/src/components/admin/renewals/kind-chips.tsx
index bcdb216d8e..0e899a2257 100644
--- a/src/components/admin/renewals/kind-chips.tsx
+++ b/src/components/admin/renewals/kind-chips.tsx
@@ -20,9 +20,9 @@ export function checklistKindLabel(kind: ChecklistKindFilter): string {
/**
* The kind filter row (Josh's locked mockup): "All, Registration, Checks,
* Health, Training, Job" as the work kit's chips. `aria-pressed`, not a radio
- * group: a chip narrows the list rather than replacing it. The chips wrap onto
- * a second line on a phone rather than scrolling sideways, so none is ever
- * hidden off the edge (and a sideways drag here never fights the tab swipe).
+ * group: a chip narrows the list rather than replacing it. Like every chip
+ * row, they stay on one line and scroll sideways, with an edge fade showing
+ * there is more; the tab swipe ignores a row that scrolls sideways.
*/
export function ChecklistKindChips({
active,
@@ -34,7 +34,7 @@ export function ChecklistKindChips({
readonly testId?: string;
}) {
return (
-
+
{CHECKLIST_KIND_FILTERS.map((kind) => (
(work-mode.css).
data-work-band={routeWorkFramed ? "" : undefined}
data-mode-identity={routeWorkFrame?.area.identity}
+ data-clinical-band={clinicalBandAttribute(selectedAppMode.id, currentPathname, Boolean(routeWorkArea))}
className={cn(
// No backdrop-filter on the header itself: it would form a backdrop
// root and starve the .edge-glass-header-backdrop scrim (the single
diff --git a/src/components/cme/applications/applications-page.tsx b/src/components/cme/applications/applications-page.tsx
index 7e1c78831f..783833cb87 100644
--- a/src/components/cme/applications/applications-page.tsx
+++ b/src/components/cme/applications/applications-page.tsx
@@ -8,7 +8,6 @@ import { focusRing } from "@/components/card-recipes";
import {
ActionDock,
CpdFeaturePage,
- type CpdFeatureBack,
flatCard,
flatRow,
IconCircle,
@@ -49,8 +48,7 @@ import {
import { perthCalendarDate } from "@/lib/cme/cpd-year";
import { useApplicationsStore } from "@/lib/cme/device-record";
-/** The page's parent, where its link lives. */
-const APPLICATIONS_BACK: CpdFeatureBack = { href: "/cme/summary", label: "CPD summary" };
+// No page-level back link: the band's Summary tab is this page's parent, so a second way back would repeat it.
type DateSheetState = { open: false } | { open: true; stage: ApplicationStageId | null };
type RefereeSheetState = { open: false } | { open: true; id: string | null };
@@ -76,12 +74,7 @@ export function ApplicationsPage({ demoMode, now }: { readonly demoMode: boolean
if (!state) {
return (
-
+
Loading your applications
@@ -193,7 +186,6 @@ export function ApplicationsPage({ demoMode, now }: { readonly demoMode: boolean
return (
{(field) => (
{demoMode ? (
- Demo mode lets you try the form. Saving is available only in your signed-in private record.
+ This is an example, so you can try the form. Saving is available only in your signed-in private record.
) : null}
{choices.length > 0 ? (
diff --git a/src/components/dictionary/dictionary-catalogue-pages.tsx b/src/components/dictionary/dictionary-catalogue-pages.tsx
index ed9d8616db..b7e0366e24 100644
--- a/src/components/dictionary/dictionary-catalogue-pages.tsx
+++ b/src/components/dictionary/dictionary-catalogue-pages.tsx
@@ -48,6 +48,7 @@ import {
parseDictionaryCatalogueParams,
type DictionaryCatalogueScope,
type DictionaryCatalogueSort,
+ type DictionarySearchHit,
} from "@/lib/dictionary";
import {
dictionaryEntryKinds,
@@ -115,6 +116,39 @@ function catalogueNoun(scope: DictionaryCatalogueScope, count: number) {
return count === 1 ? "term" : "terms";
}
+/**
+ * One hairline card per group of rows. Rows divide themselves with a hairline;
+ * the card is the only box, at every width, so a phone sees the same grouped
+ * list a desktop does rather than an edge-to-edge strip.
+ */
+const dictionaryGroupCard =
+ "min-w-0 overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)]";
+
+function dictionaryHitKey(hit: DictionarySearchHit) {
+ return hit.type === "entry"
+ ? `entry-${hit.entry.slug}`
+ : hit.type === "topic"
+ ? `topic-${hit.topic.slug}`
+ : `abbr-${hit.abbreviation}`;
+}
+
+/**
+ * Splits an alphabetical list into letter groups ("A · 6 terms"). A ranked
+ * search result is one group with no heading: its order is relevance, and
+ * cutting it at letter boundaries would scatter the best match.
+ */
+function groupHitsByLetter(hits: readonly DictionarySearchHit[], alphabetical: boolean) {
+ if (!alphabetical) return [{ letter: null, hits: [...hits] }];
+ const groups: { letter: string | null; hits: DictionarySearchHit[] }[] = [];
+ for (const hit of hits) {
+ const letter = dictionaryBrowseLetter(hit);
+ const last = groups.at(-1);
+ if (last && last.letter === letter) last.hits.push(hit);
+ else groups.push({ letter, hits: [hit] });
+ }
+ return groups;
+}
+
/**
* The clinical dictionary catalogue — one destination for the whole list.
*
@@ -144,6 +178,10 @@ export function DictionaryCataloguePage() {
const [letterOpen, setLetterOpen] = useState(false);
const hits = useMemo(() => dictionaryCatalogue(params), [params]);
+ const hitGroups = useMemo(
+ () => groupHitsByLetter(hits, params.sort === "az" || params.sort === "za"),
+ [hits, params.sort],
+ );
// Both counts come from the same predicate as the list, so a segment's number
// is what pressing it actually returns (docs/filter-contract.md). While a
// query runs they are the matches for that query, not the catalogue totals.
@@ -438,28 +476,36 @@ export function DictionaryCataloguePage() {
+ ))}
) : (
// The empty state wears the same card as the result list. Bare on
// the page background it read as a rendering failure rather than an
// answer, and the advice named a filter even when none was applied.
-
diff --git a/src/components/dictionary/dictionary-result-row.tsx b/src/components/dictionary/dictionary-result-row.tsx
index a67941e11e..13cc1c537b 100644
--- a/src/components/dictionary/dictionary-result-row.tsx
+++ b/src/components/dictionary/dictionary-result-row.tsx
@@ -1,50 +1,59 @@
import Link from "next/link";
-import { ArrowRight, Check, GitCompareArrows, Layers3 } from "lucide-react";
+import { ArrowRight, BookOpenText, CaseUpper, Check, GitCompareArrows, Layers3 } from "lucide-react";
import { stretchedRowLinkClass } from "@/components/card-recipes";
import { cn } from "@/components/ui-primitives";
import { dictionaryCompareHref, dictionaryKindLabel, type DictionarySearchHit } from "@/lib/dictionary";
-const tone = {
- entry: {
- spine: "bg-[color:var(--clinical-accent)]",
- badge:
- "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]",
- },
- abbreviation: {
- spine: "bg-[color:var(--tone-purple)]",
- badge:
- "border-[color:var(--tone-purple-border)] bg-[color:var(--tone-purple-soft)] text-[color:var(--tone-purple)]",
- },
- topic: {
- spine: "bg-[color:var(--info)]",
- badge: "border-[color:var(--info-border)] bg-[color:var(--info-bg)] text-[color:var(--info)]",
- },
-} as const;
+// One group, one card: the rows sit inside a hairline card the page draws, so
+// a row carries no card of its own and no coloured side bar. Colour is spent
+// once per row, on the category tag, and only in the current area's tint, so
+// it marks what kind of thing the row is without competing with the title.
+const categoryTag =
+ "rounded-md border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] px-1.5 py-0.5 text-3xs font-extrabold text-[color:var(--clinical-accent)]";
+const neutralTag =
+ "rounded-md border border-[color:var(--border)] bg-[color:var(--surface-subtle)] px-1.5 py-0.5 text-3xs font-bold text-[color:var(--text-muted)]";
-export function DictionaryResultRow({ hit, compareWith }: { hit: DictionarySearchHit; compareWith?: string }) {
+// Rows are divided by a hairline, never boxed. 48 px is the floor for a row
+// that is itself a link target. `relative` is added at each row, where the
+// stretched-link contract test can see it.
+const rowShell =
+ "flex min-h-12 min-w-0 items-start gap-3 border-b border-[color:var(--border)] px-3 py-3.5 last:border-b-0 sm:px-4 sm:py-4";
+
+// The small area-tinted mark at the left of each row. Hidden at
+// phone widths, where its 44 px is better spent on the definition and on
+// keeping "Source linked" and "Add to compare" on one line.
+const rowIcon =
+ "mt-0.5 hidden size-8 shrink-0 place-items-center rounded-lg bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)] sm:grid";
+
+type TitleTag = "h2" | "h3";
+
+export function DictionaryResultRow({
+ hit,
+ compareWith,
+ titleAs: Title = "h2",
+}: {
+ hit: DictionarySearchHit;
+ compareWith?: string;
+ /** `h3` when the row sits under a group heading (a letter or a kind). */
+ titleAs?: TitleTag;
+}) {
if (hit.type === "abbreviation") {
const primary = hit.senses[0];
return (
-
-
-
+ // One hairline card per group with divided rows (no cards inside cards).
+ // From the two-column width each row draws its own bottom and inner
+ // right hairline; the -mb-px tucks the last row's bottom line under the
+ // card's clipped edge so it is never doubled.
+
= {
- high: "bg-[color:var(--danger-solid)] text-[color:var(--danger-solid-contrast)]",
- medium: "border border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]",
- low: "border border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]",
+ high: "border border-[color:var(--border-strong)] bg-[color:var(--surface-subtle)] text-[color:var(--text)]",
+ medium: "text-[color:var(--text-muted)]",
+ low: "font-semibold text-[color:var(--text-muted)]",
};
// The catalogue generates a `purpose` for every form, but most are boilerplate
@@ -246,9 +241,10 @@ function ResultsTable({
{listingRegister ? "All forms" : "Best matches"}
+ {/* The count is stated once, in the band above; this line only says how
+ the list is ordered. */}
- {matches.length} {matches.length === 1 ? "form" : "forms"} ·{" "}
- {sortValue === "alpha" ? "sorted A–Z" : listingRegister ? "by form code" : "ranked by relevance"}
+ {sortValue === "alpha" ? "Sorted A–Z" : listingRegister ? "By form code" : "Ranked by relevance"}
@@ -286,10 +281,7 @@ function ResultsTable({
return (
{chipLabel}
@@ -442,7 +434,7 @@ function VerifiedFooter() {
);
}
-const metaChipClass = "rounded-md px-2 py-1 text-3xs font-extrabold uppercase leading-none";
+const metaChipClass = "rounded-md px-2 py-1 text-2xs font-bold capitalize leading-none";
/**
* When the query IS a form code there is one answer, not N ranked candidates.
@@ -536,10 +528,13 @@ function MobileResultCard({ match, code }: CodedFormMatch) {
href={`/forms/${form.slug}`}
data-testid={`form-search-mobile-result-${form.slug}`}
className={cn(
- // The whole card is the target, so no per-card Open button.
- "flex items-center gap-3 p-3 transition hover:border-[color:var(--clinical-accent-border)] hover:bg-[color:var(--clinical-accent-soft)]",
- panelSubtle,
- searchFocusRing,
+ // The whole row is the target, so no per-row Open button. Rows are
+ // divided lines inside their group's one hairline card, not cards of
+ // their own (one card per group, never cards inside cards).
+ "flex min-h-tap items-center gap-3 px-3 py-2.5 transition hover:bg-[color:var(--clinical-accent-soft)]",
+ // Inset ring: the group card clips its corners, so an outset ring
+ // would be cut off.
+ "focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[color:var(--focus)]",
)}
>
@@ -557,7 +552,7 @@ function MobileResultCard({ match, code }: CodedFormMatch) {
{risk ? (
@@ -597,9 +592,13 @@ function MobileCards({