diff --git a/docs/design-system.md b/docs/design-system.md new file mode 100644 index 0000000000..dfa24d8584 --- /dev/null +++ b/docs/design-system.md @@ -0,0 +1,180 @@ +# Clinical KB Design System — the front door + +This is the single entry point for how UI is designed and built in this app. It states the +contract; the deep documents hold the rationale. Precedence when documents disagree: + +1. **This file** — the working contract for day-to-day UI changes. +2. [`docs/redesign/permanent-colour-direction.md`](./redesign/permanent-colour-direction.md) — the authoritative colour specification ("Clinical White / Aegean Graphite"). Colour disputes end here. +3. [`docs/redesign/02-design-direction.md`](./redesign/02-design-direction.md) — token rationale: type scale, spacing, radii, elevation, motion. +4. [`docs/redesign/09-ui-primitives-recipes.md`](./redesign/09-ui-primitives-recipes.md) — the recipe catalogue for `src/components/ui-primitives.tsx`. + +Design direction is **settled**. Work on the UI is convergence — closing the gap between the +contract and the code — not reinvention. If a change genuinely needs a new direction, update +`permanent-colour-direction.md` first, then the code. + +## 1. Non-negotiables + +- **Tokens only.** Every colour comes from a CSS custom property defined in + `src/app/globals.css` (`:root` + `.dark`). No raw Tailwind palette classes (`red-50`, + `slate-200`, `bg-white`) and no hex values in components. **If you typed a hex or a Tailwind + colour name in a component, you broke dark mode** — those values have no `.dark` override. + The only sanctioned exception: third-party brand marks (Microsoft/Google OAuth tiles). +- **Semantic vs categorical vs brand.** Three token families, never interchangeable: + - Semantic triads (`--info/-soft/-border`, `--success-*`, `--warning-*`, `--danger-*`) mean + something happened or matters clinically. Green is success-only; red is safety/danger-only. + - Categorical triads (`--type-document/table/search/source/service/form` + `-soft`/`-border`) + give _identity_ to kinds of things (chips, icon tiles). They carry no status meaning. + - Brand: `--clinical-accent*` (Aegean) for clinical/evidence identity and primary-action + accents; `--command*` (graphite) for the primary CTA family. +- **Dark mode is class-based and mandatory.** The `.dark` block re-tunes every token; a + pre-paint script in `src/app/layout.tsx` applies the stored theme. Nothing else is required + from components — _if_ they use tokens. +- **Forced-colors and reduced-motion are first-class.** `globals.css` remaps all tokens under + `@media (forced-colors: active)` and zeroes motion under `prefers-reduced-motion: reduce`. + Never inline a style that defeats these. Every bespoke `transition`/`animate` needs + `motion-reduce:` handling or one of the pre-wired `--animate-*` tokens. + +### Legacy-hex migration table + +When you meet a pre-token hardcode (mockups being promoted, old branches), map it: + +| Legacy value | Token | +| --------------------------------------------- | ----------------------------------------------------------------- | +| `#007a78`, `#006d6b` (old teal action) | `var(--clinical-accent)` / `var(--clinical-accent-hover)` | +| `#00669a` (blue icon) | `var(--clinical-accent)` | +| `#061740`, `#071844` (navy ink) | `var(--text-heading)` | +| `#b8dedb` / `#e3f4f5` (teal border/wash) | `var(--clinical-accent-border)` / `var(--clinical-accent-soft)` | +| `#f8fbfd`, `#f8fcfc`, `#fbfdff` (page washes) | `var(--surface-wash)` / `var(--surface-subtle)` | +| `bg-white` | `bg-[color:var(--surface)]` (or `--surface-lux` for raised cards) | +| `slate-200` / `slate-500` / `slate-600` | `var(--border)` / `var(--text-soft)` / `var(--text-muted)` | +| ad-hoc `rgba(...)` shadows | `var(--shadow-tight/soft/hover/elevated/inset)` or `--glow-*` | + +## 2. Type scale + +Named steps live in the `@theme` block of `globals.css` and are **size-only** (no baked +line-height/tracking — set `leading-*`/`tracking-*` at the call site): + +`text-4xs` 8px · `text-3xs` 10px · `text-2xs` 11px · (`text-xs` 12 / `text-sm` 14 / `text-base` +16 from Tailwind) · `text-sm-minus` 13px · `text-base-minus` 15px · (`text-lg` 18 / `text-xl` +20 / `text-2xl` 24 from Tailwind) · `text-lg-minus` 17px · `text-2xl-minus` 22px. + +- Arbitrary `text-[Npx]` is **banned**; `npm run check:type-scale` counts offenders. + **Ratchet:** the count must never rise (baseline recorded in + `docs/process-hardening.md`). When it reaches 0, wire `check:type-scale --strict` into + `verify:cheap`. +- Tailwind's own `text-xs`/`text-sm`/… carry a baked line-height. When retiring a raw px value + onto one of them, check the call site for `leading-*` and pin the current effective leading + explicitly if absent, so nothing shifts. +- **Accepted exceptions:** one-off rem display headings (`text-[2rem]`, `text-[2.7rem]`, …) + on hero/mode-home titles, and `*-mockups` files. Don't add scale steps for one-off display + sizes. + +## 3. Spacing & tap targets + +- 4px grid via Tailwind spacing; safe-area env paddings on shell edges. +- Interactive targets use the `--spacing-tap` token (44px): `min-h-tap` / `min-w-tap` / + `size-tap`. Do **not** hand-write `min-h-11` / `h-[44px]` for tap semantics. +- Exception (documented in `globals.css`): controls scrolled deep inside sheets stay on + `min-h-12` (48px) to satisfy the ui-smoke sub-pixel tap check — do not "fix" them down. + +## 4. Radius & shadows + +- Radii come from `@theme`: `rounded-md` chips/pills · `rounded-lg` controls/cards/panels · + `rounded-xl`+ sheets/dialogs. Never pass a radius token through an arbitrary value + (`rounded-[var(--radius-md)]` → `rounded-md`) — the plain utility is the same token. +- Shadows/elevation: `--shadow-tight/soft/card/hover/elevated/lux/inset` and `--glow-primary/ +soft`, all re-tuned per theme and removed under forced-colors. No literal `box-shadow` values + in components. + +## 5. Z-index ladder + +Documented in `globals.css` next to the radius rules. Rungs: **0–40** in-page layering · +**60** app chrome (master search header) · **80–85** document/table overlays · **95** popovers +that beat overlays · **100** the modal layer (`Sheet`) and the skip link · **max** mockup-only +diagnostics. New overlays go through the `Sheet` primitive; anything else picks an existing +rung — never a new number. + +## 6. Component recipes + +- Check `src/components/ui-primitives.tsx` **before hand-rolling anything**: `cn()`, + `primaryControl`, `fieldControl*`, `toolbarButton`, `metadataPill`, `sourceCapsule`, + `toneSuccess/Danger/Info/Warning/Neutral`, `EmptyState`, `LoadingPanel`, `ToggleSwitch`, + `focusRing`, and ~30 more (catalogue: `docs/redesign/09-ui-primitives-recipes.md`). +- **`src/components/ui/sheet.tsx` is the only modal/overlay primitive.** It provides focus + trap, initial focus, return-focus-on-close, Escape, backdrop dismiss, body scroll lock, + safe-area padding, and dark-mode surfaces. Do not hand-roll `role="dialog"` overlays — + the applications-launcher DetailDialog migration is the template for converting one. +- Empty and loading states use `EmptyState` / `LoadingPanel`, not bespoke markup. +- Composer-chrome caveat: the `answer-footer-search-*` / `desktop-home-search-*` classes are + intentionally **unlayered** and beat Tailwind utilities on the same element — check the class + body before adding a utility there (see "CSS cascade layering" in + `docs/process-hardening.md`). + +## 7. Accessibility requirements + +- Every interactive element has a visible focus state: the global `:focus-visible` rule is the + floor; use the `focusRing` recipe on custom controls. +- Dialogs/popovers: use `Sheet` (focus handling is free). If something genuinely can't use it, + it must implement trap + initial focus + return focus itself. +- Tab patterns: `role="tab"` requires `aria-selected`, `aria-controls`, and a reachable + `role="tabpanel"`. Reference implementations: dashboard upload tabs + (`src/components/ClinicalDashboard.tsx`, search `role="tablist"`) and the mobile evidence + tabs (`src/components/clinical-dashboard/visual-evidence.tsx`). +- Disclosure buttons need `aria-expanded` + `aria-controls` (see `MobileDetailSections` in + `src/components/applications-launcher-page.tsx`). +- Remote images: always provide a fallback alt — `alt={caption?.trim() || "Clinical document +image"}` — never a possibly-empty variable alone. +- Canonical mobile viewport for manual and automated checks: **390×820** + (matches `tests/ui-accessibility.spec.ts`, which drives reduced-motion and forced-colors). + +## 8. Do / Don't + +| Don't | Do | +| ------------------------------------------------------------- | ------------------------------------------------------ | +| `border-red-200 bg-red-50 text-red-700` | `toneDanger` recipe, or the `--danger*` triad | +| `border-cyan-200 bg-cyan-50 text-cyan-700` for identity chips | a categorical `--type-*` triad | +| hand-rolled `role="dialog"` + Escape listener | `` | +| ` diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 630790c43f..56e215dd4b 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1306,7 +1306,7 @@ function PdfCanvasViewer({ url, title, initialPage }: { url: string; title: stri disabled={!pagesReady} aria-label="Fit page width and enter fullscreen" className={cn( - "inline-flex min-h-11 min-w-11 items-center justify-center gap-2 rounded-[var(--radius-md)] border px-3 text-xs font-semibold transition", + "inline-flex min-h-11 min-w-11 items-center justify-center gap-2 rounded-md border px-3 text-xs font-semibold transition", "disabled:cursor-not-allowed disabled:opacity-45", fitWidth || fullscreenActive ? "border-[color:var(--clinical-accent)]/35 bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index f8b9fbf271..d4b64c7317 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -19,13 +19,13 @@ import { Star, Users, Waves, - X, type LucideIcon, } from "lucide-react"; -import { type FormEvent, useEffect, useMemo, useState } from "react"; +import { type FormEvent, useMemo, useState } from "react"; import { ModeHomeVerificationFooter } from "@/components/mode-home-template"; import { cn } from "@/components/ui-primitives"; +import { Sheet } from "@/components/ui/sheet"; import { toolCatalogRecords, type ToolCatalogArea, @@ -56,16 +56,21 @@ const statusLabels: Record = { review_due: "Review due", }; +// Categorical identity tones from the token system (--type-*) so icons stay +// legible in dark mode and forced-colors; "safety" is genuinely semantic and +// uses the danger triad. const iconToneClasses: Record = { - assessment: "border-cyan-200 bg-cyan-50 text-cyan-700", - reference: "border-emerald-200 bg-emerald-50 text-emerald-700", - care: "border-sky-200 bg-sky-50 text-sky-700", + assessment: + "border-[color:var(--type-service-border)] bg-[color:var(--type-service-soft)] text-[color:var(--type-service)]", + reference: "border-[color:var(--type-table-border)] bg-[color:var(--type-table-soft)] text-[color:var(--type-table)]", + care: "border-[color:var(--type-document-border)] bg-[color:var(--type-document-soft)] text-[color:var(--type-document)]", coordination: "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]", - saved: "border-blue-200 bg-blue-50 text-blue-700", - safety: "border-red-200 bg-red-50 text-red-600", - medication: "border-amber-200 bg-amber-50 text-amber-600", - differentials: "border-violet-200 bg-violet-50 text-violet-700", + saved: "border-[color:var(--type-search-border)] bg-[color:var(--type-search-soft)] text-[color:var(--type-search)]", + safety: "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]", + medication: "border-[color:var(--type-form-border)] bg-[color:var(--type-form-soft)] text-[color:var(--type-form)]", + differentials: + "border-[color:var(--type-source-border)] bg-[color:var(--type-source-soft)] text-[color:var(--type-source)]", }; // Presentation-only mapping: the shared tools catalog is icon-free so it can be used by @@ -178,7 +183,7 @@ function StatusChip({ label, tone = "neutral" }: { label: string; tone?: "neutra return ( void; mo {mobile ? action.label : action.desktopLabel} @@ -331,7 +336,9 @@ function FilterTabs({ key={filter.id} type="button" role="tab" + id={`launcher-filter-desktop-${filter.id}`} aria-selected={active} + 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", @@ -354,10 +361,12 @@ function FilterTabs({ key={filter.id} type="button" role="tab" + id={`launcher-filter-mobile-${filter.id}`} aria-selected={active} + 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-[9px] font-bold transition", + "inline-flex min-h-7 shrink-0 items-center justify-center gap-0.5 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)]", @@ -397,7 +406,7 @@ function ToolCard({ "group grid min-h-[9.25rem] grid-cols-[auto_minmax(0,1fr)_auto] gap-4 rounded-lg border bg-[color:var(--surface-lux)] p-4 text-left shadow-[var(--shadow-card)] transition hover:-translate-y-0.5 hover:border-[color:var(--clinical-accent-border)] hover:shadow-[var(--shadow-soft)] motion-reduce:hover:translate-y-0", selected ? app.id === "risk-safety" - ? "border-red-200 bg-red-50/45" + ? "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)]/45" : "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)]/50" : "border-[color:var(--border)]", focusRing, @@ -447,7 +456,7 @@ function MobileToolRow({ "grid min-h-[5.25rem] grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-lg border bg-[color:var(--surface-lux)] px-3 py-3 text-left shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent-border)]", selected ? app.id === "risk-safety" - ? "border-red-200 bg-red-50/45" + ? "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)]/45" : "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)]/55" : "border-[color:var(--border)]", focusRing, @@ -460,7 +469,7 @@ function MobileToolRow({ {app.description} - + {app.actionLabel} @@ -524,99 +533,79 @@ function DetailRows({ app }: { app: LauncherApp }) { ); } -function DetailDialog({ app, open, onClose }: { app: LauncherApp; open: boolean; onClose: () => void }) { - useEffect(() => { - if (!open) return undefined; - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - - function onKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") onClose(); - } - - window.addEventListener("keydown", onKeyDown); - return () => { - window.removeEventListener("keydown", onKeyDown); - document.body.style.overflow = previousOverflow; - }; - }, [onClose, open]); +const mobileDetailSections = [ + { id: "check-first", icon: ShieldCheck, label: "Check first" }, + { id: "needed-input", icon: ClipboardList, label: "Needed input" }, + { id: "output", icon: Waves, label: "Output" }, +] as const; - if (!open) return null; +type MobileDetailSectionId = (typeof mobileDetailSections)[number]["id"]; + +function MobileDetailSections({ app }: { app: LauncherApp }) { + const [openSection, setOpenSection] = useState(null); + + function sectionContent(id: MobileDetailSectionId) { + if (id === "output") return

{app.output}

; + const items = id === "check-first" ? app.checkFirst : app.neededInput; + return ( +
    + {items.map((item) => ( +
  • {item}
  • + ))} +
+ ); + } return ( -
{ - if (event.target === event.currentTarget) onClose(); - }} - > -
event.stopPropagation()} - > -
-
-
-
-
- -
-

- {app.title} -

-
- -
-
-
- -
- -
- -

{app.detail}

-
-
- {[ - { icon: ShieldCheck, label: "Check first" }, - { icon: ClipboardList, label: "Needed input" }, - { icon: Waves, label: "Output" }, - ].map(({ icon: Icon, label }) => ( - - ))} -
-
- -
- + aria-hidden + /> + +
-
-
+ ); + })} +
+ ); +} + +function DetailDialog({ app, open, onClose }: { app: LauncherApp; open: boolean; onClose: () => void }) { + return ( + } + descriptionContent={} + titleClassName="text-xl font-extrabold sm:text-2xl" + contentClassName="sm:max-w-[39rem]" + footer={ +
View example
-
-
+ } + > +
+
+ +

{app.detail}

+
+ +
+ +
+ +
+
+
); } @@ -695,6 +700,15 @@ export function ApplicationsLauncherWorkspace({ ? selectedId : (filteredApps[0]?.id ?? selectedId); const selectedApp = appById(effectiveSelectedId); + // Label the panel by the selected tab's visible label (mobile-only tabs like + // "More" included) so assistive tech hears a region name matching the tab. + const activeFilterLabel = + desktopFilters.find((filter) => filter.id === activeFilter)?.label ?? + mobileFilters.find((filter) => filter.id === activeFilter)?.label; + const resultsPanelLabel = + activeFilterLabel && activeFilterLabel !== copy.allSectionLabel + ? `${activeFilterLabel} tools` + : copy.allSectionLabel; function updateQuery(nextQuery: string) { if (controlledQuery === undefined) setLocalQuery(nextQuery); @@ -784,25 +798,27 @@ export function ApplicationsLauncherWorkspace({ - {filteredApps.length === 0 ? ( -
-

{copy.emptyTitle}

-

{copy.emptyBody}

-
- ) : ( - <> -
- {filteredApps.map((app) => ( - - ))} -
-
- {filteredApps.map((app) => ( - - ))} +
+ {filteredApps.length === 0 ? ( +
+

{copy.emptyTitle}

+

{copy.emptyBody}

- - )} + ) : ( + <> +
+ {filteredApps.map((app) => ( + + ))} +
+
+ {filteredApps.map((app) => ( + + ))} +
+ + )} +

Showing {filteredApps.length > 0 ? "1" : "0"} to {filteredApps.length} of {launcherApps.length}{" "} diff --git a/src/components/clinical-dashboard/account-setup-dialog.tsx b/src/components/clinical-dashboard/account-setup-dialog.tsx index 25113e6563..9f5b7f7903 100644 --- a/src/components/clinical-dashboard/account-setup-dialog.tsx +++ b/src/components/clinical-dashboard/account-setup-dialog.tsx @@ -209,9 +209,7 @@ export function AccountSetupDialog({ open, onClose }: { open: boolean; onClose: )} > - - {source.label} - + {source.label} - {provider} + {provider} ); } diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index 8f7206fb7a..f2c8cda5de 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -149,7 +149,7 @@ export const SourceImage = memo(function SourceImage({ return ( {caption} ) : null} {scope?.warnings?.length ? ( -

@@ -267,7 +267,7 @@ function DocumentLabelReviewPanel({ `restore:${label.id}`, ) } - className={cn(floatingControl, "min-h-8 px-2 text-[11px]")} + className={cn(floatingControl, "min-h-8 px-2 text-2xs")} > Restore @@ -284,7 +284,7 @@ function DocumentLabelReviewPanel({ `approve:${label.id}`, ) } - className={cn(floatingControl, "min-h-8 px-2 text-[11px]")} + className={cn(floatingControl, "min-h-8 px-2 text-2xs")} > Approve @@ -299,7 +299,7 @@ function DocumentLabelReviewPanel({ `hide:${label.id}`, ) } - className={cn(floatingControl, "min-h-8 px-2 text-[11px] text-[color:var(--danger)]")} + className={cn(floatingControl, "min-h-8 px-2 text-2xs text-[color:var(--danger)]")} > Hide @@ -401,7 +401,7 @@ function DocumentTagQualityPanel({ documents }: { documents: ClinicalDocument[]
{(Object.keys(counts) as SmartDocumentTagQualityIssueKind[]).map((kind) => ( - + {tagQualityLabel(kind)}: {counts[kind]} ))} @@ -414,17 +414,17 @@ function DocumentTagQualityPanel({ documents }: { documents: ClinicalDocument[] className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-3" >
- + {tagQualityLabel(issue.kind)}

{issue.label}

{issue.count > 1 ? ( - {issue.count} hits + {issue.count} hits ) : null}

{issue.reason}

{issue.examples.length || issue.documentTitles.length ? ( -

+

{[ issue.examples.length ? `examples: ${issue.examples.join(", ")}` : "", issue.documentTitles.length ? `docs: ${issue.documentTitles.join(", ")}` : "", @@ -496,16 +496,16 @@ function DocumentIndexRepairPanel({ documents }: { documents: ClinicalDocument[] >

{item.document.title}

- + index {Number.isFinite(item.score) ? item.score.toFixed(2) : "n/a"}
- extraction:{item.extractionQuality} - sections:{item.sectionCount} - memory:{item.memoryCardCount} + extraction:{item.extractionQuality} + sections:{item.sectionCount} + memory:{item.memoryCardCount} {item.issues.slice(0, 4).map((issue) => ( - + {issue} ))} @@ -733,7 +733,7 @@ export function DocumentDrawer({
@@ -755,7 +755,7 @@ export function DocumentDrawer({
@@ -777,7 +777,7 @@ export function DocumentDrawer({
@@ -799,7 +799,7 @@ export function DocumentDrawer({
@@ -1032,7 +1032,7 @@ export function DocumentDrawer({ {document.page_count} pages · {document.chunk_count} chunks · {document.image_count} images

{document.summary?.summary && ( -

+

)} @@ -1112,6 +1112,6 @@ function statusFilterLabel(filter: DocumentDrawerStatusFilter) { export function DrawerGroupLabel({ title }: { title: string }) { return ( -

{title}

+

{title}

); } diff --git a/src/components/clinical-dashboard/document-admin/document-drawer.tsx b/src/components/clinical-dashboard/document-admin/document-drawer.tsx index 40449685e1..7fadae1240 100644 --- a/src/components/clinical-dashboard/document-admin/document-drawer.tsx +++ b/src/components/clinical-dashboard/document-admin/document-drawer.tsx @@ -143,14 +143,14 @@ function DocumentLabelReviewPanel({ > {documentDisplayTitle(item.document)} -

+

{item.visible.length} visible · {item.ranking.length} ranking · {item.hidden.length} hidden

{item.needsReview ? ( - Needs review + Needs review ) : ( - Reviewed + Reviewed )}
@@ -164,7 +164,7 @@ function DocumentLabelReviewPanel({ if (!labelRows.length) return null; return (
-

+

{title}

@@ -178,14 +178,14 @@ function DocumentLabelReviewPanel({ {label.displayLabel} - + {label.tier} - + {labelTypeDisplay(label.labelType)}
-

+

{label.source} · {Math.round(label.confidence * 100)}% · {label.reviewStatus}

@@ -202,7 +202,7 @@ function DocumentLabelReviewPanel({ `restore:${label.id}`, ) } - className={cn(floatingControl, "min-h-8 px-2 text-[11px]")} + className={cn(floatingControl, "min-h-8 px-2 text-2xs")} > Restore @@ -219,7 +219,7 @@ function DocumentLabelReviewPanel({ `approve:${label.id}`, ) } - className={cn(floatingControl, "min-h-8 px-2 text-[11px]")} + className={cn(floatingControl, "min-h-8 px-2 text-2xs")} > Approve @@ -234,7 +234,7 @@ function DocumentLabelReviewPanel({ `hide:${label.id}`, ) } - className={cn(floatingControl, "min-h-8 px-2 text-[11px] text-[color:var(--danger)]")} + className={cn(floatingControl, "min-h-8 px-2 text-2xs text-[color:var(--danger)]")} > Hide @@ -336,7 +336,7 @@ function DocumentTagQualityPanel({ documents }: { documents: ClinicalDocument[]
{(Object.keys(counts) as SmartDocumentTagQualityIssueKind[]).map((kind) => ( - + {tagQualityLabel(kind)}: {counts[kind]} ))} @@ -349,17 +349,17 @@ function DocumentTagQualityPanel({ documents }: { documents: ClinicalDocument[] className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-3" >
- + {tagQualityLabel(issue.kind)}

{issue.label}

{issue.count > 1 ? ( - {issue.count} hits + {issue.count} hits ) : null}

{issue.reason}

{issue.examples.length || issue.documentTitles.length ? ( -

+

{[ issue.examples.length ? `examples: ${issue.examples.join(", ")}` : "", issue.documentTitles.length ? `docs: ${issue.documentTitles.join(", ")}` : "", @@ -431,16 +431,16 @@ function DocumentIndexRepairPanel({ documents }: { documents: ClinicalDocument[] >

{item.document.title}

- + index {Number.isFinite(item.score) ? item.score.toFixed(2) : "n/a"}
- extraction:{item.extractionQuality} - sections:{item.sectionCount} - memory:{item.memoryCardCount} + extraction:{item.extractionQuality} + sections:{item.sectionCount} + memory:{item.memoryCardCount} {item.issues.slice(0, 4).map((issue) => ( - + {issue} ))} @@ -668,7 +668,7 @@ export function DocumentDrawer({
@@ -690,7 +690,7 @@ export function DocumentDrawer({
@@ -712,7 +712,7 @@ export function DocumentDrawer({
@@ -734,7 +734,7 @@ export function DocumentDrawer({
@@ -967,7 +967,7 @@ export function DocumentDrawer({ {document.page_count} pages · {document.chunk_count} chunks · {document.image_count} images

{document.summary?.summary && ( -

+

)} @@ -1044,6 +1044,6 @@ function statusFilterLabel(filter: DocumentDrawerStatusFilter) { export function DrawerGroupLabel({ title }: { title: string }) { return ( -

{title}

+

{title}

); } diff --git a/src/components/clinical-dashboard/document-results.tsx b/src/components/clinical-dashboard/document-results.tsx index f5aa15bd51..4c31829567 100644 --- a/src/components/clinical-dashboard/document-results.tsx +++ b/src/components/clinical-dashboard/document-results.tsx @@ -57,7 +57,7 @@ export function RelatedDocumentsPanel({
{document.summary && ( -

+

)} diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index f15cad21f6..e068fce94d 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -229,7 +229,7 @@ export function AnswerSupportSummaryCard({

{priority.detail}

{priority.sourceLabel ? ( - {priority.sourceLabel} + {priority.sourceLabel} ) : null}
) @@ -778,7 +778,7 @@ export function ClinicalNotesChecklistPanel({ {tab.label} {!isWarnRow ? ( - + {activeTab === "actions" ? "Action" : "Source"} ) : null} @@ -844,9 +844,9 @@ export function ClinicalNotesChecklistPanel({
{isWarnRow ? ( - Review + Review ) : ( - + S{row.sourceIndex} )} @@ -895,13 +895,13 @@ export function ClinicalNotesChecklistPanel({ {bestSource ? ( Source ) : ( - + Source @@ -909,7 +909,7 @@ export function ClinicalNotesChecklistPanel({ diff --git a/src/components/clinical-dashboard/output-panel.tsx b/src/components/clinical-dashboard/output-panel.tsx index 705ce66a68..86fb51b27f 100644 --- a/src/components/clinical-dashboard/output-panel.tsx +++ b/src/components/clinical-dashboard/output-panel.tsx @@ -100,7 +100,7 @@ export function ClinicalOutputPanel({ "min-h-12 min-w-0 justify-between gap-2 rounded-lg px-3 py-2 text-left sm:min-h-9", )} > - {item.label} + {item.label} {item.value} ))} @@ -116,7 +116,7 @@ export function ClinicalOutputPanel({

{leadSection.title}

-

+

@@ -160,7 +160,7 @@ export function ClinicalOutputPanel({
-

+

{meta.eyebrow}

@@ -168,7 +168,7 @@ export function ClinicalOutputPanel({

- {itemCount} + {itemCount}
{section.tables?.length ? (
@@ -188,7 +188,7 @@ export function ClinicalOutputPanel({
) : null} {section.items.length ? ( -
    +
      {section.items.map((item, index) => (
    • -

      +

      Clinical context

      -

      +

      {identity.displayName}

      -

      +

      Consultant psychiatrist, Western Australia

      @@ -196,7 +196,7 @@ export function SettingsDialog({
      {settingSections.map((section) => (
      -

      +

      {section.title}

      @@ -234,7 +234,7 @@ export function SettingsDialog({ function SettingsChip({ label }: { label: string }) { return ( - + {label} ); @@ -242,7 +242,7 @@ function SettingsChip({ label }: { label: string }) { function SettingsClinicalContextStrip() { return ( -
      +
      Private workspace{" "} @@ -285,10 +285,10 @@ function SettingsSummaryTile({ - + {label} - + {value} @@ -327,7 +327,7 @@ function SettingsRow({ {label} {value ? ( - + {value} ) : null} @@ -370,7 +370,7 @@ function SettingsHelpFooter({ onClick }: { onClick: () => void }) {
      -
      +
      {!hasStructuredTable ?

      {sourceHeader.title}

      : null} {!hasStructuredTable && sourceHeader.caption ?

      {sourceHeader.caption}

      : null} {displayLabels.map((label) => ( - + {label} ))} @@ -177,14 +177,14 @@ function VisualEvidenceStrip({ clinicalDivider, )} > - + {formatCompactCitationLabel(item)} {cleanDisplayTitle(item.title)}, page {item.page_number ?? "n/a"} {item.image_type && ( - + {item.image_type.replaceAll("_", " ")} )} @@ -377,7 +377,7 @@ function EvidenceGapsPanel({ warnings }: { warnings: string[] }) { key={`${warning}:${index}`} className="grid grid-cols-[auto_minmax(0,1fr)] items-start gap-2 rounded-md border border-[color:var(--warning-border)] bg-[color:var(--warning-soft)]/45 px-2.5 py-2" > - + {index + 1}

      {warning}

      @@ -474,7 +474,7 @@ export function MobileEvidenceSheetContent({ > {tab} - {count ? {count} : null} + {count ? {count} : null} ); })} diff --git a/src/components/mode-home-template.tsx b/src/components/mode-home-template.tsx index 5a3d36e0d9..5f9e25ed76 100644 --- a/src/components/mode-home-template.tsx +++ b/src/components/mode-home-template.tsx @@ -97,7 +97,7 @@ export function ModeHomeHero({

      diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index f03e6793ce..d3c41e8480 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -122,7 +122,7 @@ function Chip({ chip }: { chip: ServiceStatusChip }) { return ( @@ -520,14 +520,14 @@ export function ServicesNavigatorPage() {

      - + {scopedMatches.length}

      Referral matches

      -

      +

      {scopedMatches.length} referral {scopedMatches.length === 1 ? "match" : "matches"}

      @@ -540,7 +540,7 @@ export function ServicesNavigatorPage() {

      -
      - -
      - - - - -
      - -
      -
      - {tags.map((tag, tagIndex) => ( - 2 ? "max-sm:hidden" : "", - )} - > - {tag} - - ))} - {(service.tags?.length ?? 0) + (service.catchments?.length ?? 0) > tags.length ? ( - - +1 - - ) : null} -
      -
      - - - Open - - -
      -
      - - ); -} - -function SearchBar({ - value, - onChange, - compact = false, - showSubmit = true, -}: { - value: string; - onChange: (next: string) => void; - compact?: boolean; - showSubmit?: boolean; -}) { - return ( -
      event.preventDefault()} - > - - - onChange(event.target.value)} - placeholder="Search services..." - className="min-w-0 bg-transparent text-sm font-semibold text-[#061740] outline-none placeholder:text-slate-400" - /> - {value ? ( - - ) : null} - - {showSubmit ? ( - - ) : null} - - ); -} - -function Header() { - return ( -
      -
      - - - -
      -

      Services Navigator

      -

      Psychiatry referral directory

      -
      - - -
      -
      - - - - AK - -
      -
      - ); -} - -function Stepper() { - const steps = [ - ["1", "Search", "Find services"], - ["2", "Shortlist", "Pick best options"], - ["3", "Compare", "Review side by side"], - ["4", "Refer", "Send with confidence"], - ]; - return ( -
      - {steps.map(([number, title, body], index) => ( -
      - - {number} - - - - {title} - - {body} - -
      - ))} -
      - ); -} - -function DesktopRightRail({ - matches, - selected, - onToggleSelected, -}: { - matches: ServiceRecord[]; - selected: ServiceRecord[]; - onToggleSelected: (slug: string) => void; -}) { - const criteria = criterionCounts(matches); - const confidence = confidenceCounts(matches); - const localConfirmationCount = matches.filter((service) => - (service.source?.status ?? "").toLowerCase().includes("confirmation"), - ).length; - const verifiedCount = matches.filter( - (service) => - service.verification?.locallyVerified || (service.source?.status ?? "").toLowerCase().includes("source"), - ).length; - const checklistRows: Array<[string, number, LucideIcon, string]> = [ - ["Meets", criteria.meets, CircleCheck, "text-emerald-600"], - ["Caution", criteria.cautions, CircleAlert, "text-orange-500"], - ["Does not meet", criteria.rejects, CircleX, "text-red-600"], - ["Source verified", verifiedCount, CircleCheck, "text-emerald-600"], - ["Local confirmation", localConfirmationCount, CircleAlert, "text-orange-500"], - ]; - - return ( - - ); -} - -function PhonePreview({ - query, - onQueryChange, - matches, - selectedSlugs, - onToggleSelected, -}: { - query: string; - onQueryChange: (next: string) => void; - matches: ServiceRecord[]; - selectedSlugs: string[]; - onToggleSelected: (slug: string) => void; -}) { - return ( -
      -
      -
      -
      - -
      - - - - Services Navigator -
      - -
      -
      -
      -
      -

      {matches.length} referral matches

      -

      - Best fit for crisis, ATSI-specific, phone referral. -

      -
      - -
      -
      - {["Best fit", "Crisis", "ATSI-specific", "+3"].map((chip, index) => ( - 2 ? "max-sm:hidden" : "", - index === 0 ? "border-[#007a78] bg-[#007a78] text-white" : "border-slate-200 bg-white text-slate-600", - )} - > - {chip} - - ))} -
      - -
      - {matches.slice(0, 3).map((service, index) => ( - - ))} -
      -
      -
      - {selectedSlugs.length} selected - Compare - -
      -
      -
      - - - -
      -
      -
      -
      - ); -} - -export function ServicesNavigatorPreview() { - const [query, setQuery] = useState(defaultQuery); - const matches = useMemo(() => { - const ranked = searchServiceRecords(query); - return ranked.length ? ranked.map((match) => match.service) : serviceRecords; - }, [query]); - const [selectedSlugs, setSelectedSlugs] = useState(() => serviceRecords.slice(0, 2).map((service) => service.slug)); - const selectedServices = serviceRecords.filter((service) => selectedSlugs.includes(service.slug)); - - function toggleSelected(slug: string) { - setSelectedSlugs((current) => { - if (current.includes(slug)) return current.filter((item) => item !== slug); - return [slug, ...current].slice(0, 5); - }); - } - - return ( -
      -
      -
      -
      -
      -
      - - -
      - -
      -
      -
      -
      -

      - {matches.length} referral matches -

      -

      - Best fit for crisis, ATSI-specific, phone referral. -

      -
      - -
      -
      - {["Best fit", "Crisis", "ATSI-specific", "Phone referral", "Free", "WA"].map((chip, index) => ( - - ))} - -
      -
      - {matches.map((service, index) => ( - - ))} -
      -
      - -
      -
      - -
      -
      -
      -
      - -
      event.preventDefault()} - > - - - setQuery(event.target.value)} - placeholder="Search services..." - className="min-w-0 flex-1 bg-transparent text-sm font-semibold text-[#061740] outline-none placeholder:text-slate-400" - /> - {query ? ( - - ) : null} - - - -
      -
      -
      - ); -}