From 4b08e9bc48058b145736d60936709dcd5182289d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 11:10:17 +0000 Subject: [PATCH 01/10] =?UTF-8?q?fix(ui):=20applications=20launcher=20?= =?UTF-8?q?=E2=80=94=20working=20mobile=20detail=20disclosures,=20migrate?= =?UTF-8?q?=20detail=20dialog=20to=20Sheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three mobile detail rows (Check first / Needed input / Output) were rendered as buttons with a chevron affordance but no handler; they now expand accordion-style with aria-expanded/aria-controls wiring. The hand-rolled detail modal is replaced by the shared Sheet primitive, gaining focus trap, initial focus, and return-focus-on-close. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QEEkTKt4eS4oqftBsaQKpG --- src/components/applications-launcher-page.tsx | 174 +++++++++--------- 1 file changed, 85 insertions(+), 89 deletions(-) diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index 6145f852ee..7cfd191afa 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"; type LauncherStatus = "ready" | "recent" | "review_due"; type LauncherArea = "assessment" | "reference" | "care" | "coordination" | "saved"; type LauncherFilter = "all" | LauncherArea | "more"; @@ -731,99 +731,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"; +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; - function onKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") onClose(); - } +type MobileDetailSectionId = (typeof mobileDetailSections)[number]["id"]; - window.addEventListener("keydown", onKeyDown); - return () => { - window.removeEventListener("keydown", onKeyDown); - document.body.style.overflow = previousOverflow; - }; - }, [onClose, open]); +function MobileDetailSections({ app }: { app: LauncherApp }) { + const [openSection, setOpenSection] = useState(null); - if (!open) return 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}

+
+ +
+ +
+ +
+
+ ); } From d0bc9dca46233b6a5b49b2a94f1ebf1da841af66 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 11:10:17 +0000 Subject: [PATCH 02/10] fix(ui): styled not-found page + clamp document page param notFound() calls previously fell through to the unstyled Next.js default; the new not-found page mirrors the root error boundary's card. The document viewer page param is now parsed and clamped so ?page=abc no longer renders "page NaN" in the header and page input. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QEEkTKt4eS4oqftBsaQKpG --- src/app/documents/[id]/page.tsx | 4 +++- src/app/not-found.tsx | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 src/app/not-found.tsx diff --git a/src/app/documents/[id]/page.tsx b/src/app/documents/[id]/page.tsx index 17f79250aa..19a7cf04bf 100644 --- a/src/app/documents/[id]/page.tsx +++ b/src/app/documents/[id]/page.tsx @@ -8,5 +8,7 @@ export default async function DocumentPage({ searchParams: Promise<{ page?: string; chunk?: string }>; }) { const [{ id }, query] = await Promise.all([params, searchParams]); - return ; + const parsedPage = Number.parseInt(query.page ?? "", 10); + const initialPage = Number.isFinite(parsedPage) && parsedPage >= 1 ? parsedPage : 1; + return ; } diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000000..06b8892929 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,38 @@ +import Link from "next/link"; +import { FileQuestion, Search } from "lucide-react"; +import { cn, primaryControl } from "@/components/ui-primitives"; + +export default function NotFound() { + return ( +
+
+
+ +
+ +

Page not found

+ +

+ The page you are looking for does not exist or may have been moved. Check the address, or head back to search. +

+ +
+ + + Back to search + + + + Browse documents + +
+
+
+ ); +} From 74efa2fb6ec8799dcba11c3fca9762c5db48c5cc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 11:10:17 +0000 Subject: [PATCH 03/10] fix(a11y): launcher tab/panel wiring, image alt fallback, dark-aware launcher tones The launcher category filters exposed role=tab with aria-selected but no aria-controls or tabpanel; the results region is now the referenced panel, matching the dashboard upload-tabs precedent. Answer images fall back to a descriptive alt when the caption is empty. Launcher icon tones and the safety selection state move from raw Tailwind palette classes to the categorical --type-* and semantic danger triads so they respond to dark mode and forced-colors. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QEEkTKt4eS4oqftBsaQKpG --- src/components/applications-launcher-page.tsx | 65 +++++++++++-------- .../clinical-dashboard/answer-content.tsx | 2 +- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index 7cfd191afa..15578251cc 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -69,16 +69,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)]", }; const launcherApps: LauncherApp[] = [ @@ -538,7 +543,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", @@ -561,7 +568,9 @@ 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", @@ -604,7 +613,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, @@ -654,7 +663,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, @@ -987,25 +996,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/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index 8f7206fb7a..d685a0e452 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} Date: Mon, 6 Jul 2026 11:10:18 +0000 Subject: [PATCH 04/10] refactor(services): delete dead off-palette navigator preview; tokenize residuals ServicesNavigatorPreview was exported but referenced nowhere; it carried ~80 hardcoded hex values from a pre-token palette. The live navigator's remaining hardcodes (Filters/Sort buttons, best-fit chip glow, badge inset highlight, Suspense fallback wash) now use the design tokens, so the /services route follows dark mode like every other surface. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QEEkTKt4eS4oqftBsaQKpG --- src/app/services/page.tsx | 2 +- .../services/services-navigator-page.tsx | 10 +- .../services/services-navigator-preview.tsx | 771 ------------------ 3 files changed, 6 insertions(+), 777 deletions(-) delete mode 100644 src/components/services/services-navigator-preview.tsx diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index bc4fbff80d..170336c0bc 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -23,7 +23,7 @@ export default async function ServicesIndexRoute({ searchParams }: { searchParam } return ( - }> + }> ); diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index d07f9b17f5..fed691238c 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -506,14 +506,14 @@ export function ServicesNavigatorPage() {

- + {scopedMatches.length}

Referral matches

-

+

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

@@ -526,7 +526,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} - - - -
-
-
- ); -} From c09b3ee4ae86215b1548802dc6c43bcfd9e4475c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 11:10:18 +0000 Subject: [PATCH 05/10] =?UTF-8?q?refactor(design):=20type-scale=20migratio?= =?UTF-8?q?n=20(168=E2=86=9220),=20radius=20cleanup,=20z-ladder=20doc,=20m?= =?UTF-8?q?ockups=20noindex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates arbitrary px font sizes to the named size-only scale steps (9→4xs, 10→3xs, 11→2xs, 13→sm-minus, 15→base-minus) across production components; the [12px]/[18px] sites move to text-xs/text-lg with explicit leading pinned where none was set so nothing shifts. Remaining hits are rem display headings (accepted exceptions) and one mockup file. Also: documents the z-index ladder alongside the radius rules in globals.css, replaces the one rounded-[var(--radius-md)] with rounded-md, and adds noindex metadata to the mockups layout. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QEEkTKt4eS4oqftBsaQKpG --- src/app/globals.css | 10 ++++ src/app/mockups/layout.tsx | 7 +++ src/components/ClinicalDashboard.tsx | 37 +++++++------- src/components/DocumentViewer.tsx | 2 +- src/components/applications-launcher-page.tsx | 8 +-- .../account-setup-dialog.tsx | 6 +-- .../clinical-dashboard/answer-content.tsx | 14 +++--- .../answer-result-surface.tsx | 12 ++--- .../clinical-dashboard/auth-panel.tsx | 4 +- .../clinical-dashboard/dashboard-nav.tsx | 13 ++--- .../clinical-dashboard/document-admin.tsx | 50 +++++++++---------- .../document-admin/document-drawer.tsx | 50 +++++++++---------- .../clinical-dashboard/document-results.tsx | 2 +- .../clinical-dashboard/evidence-panels.tsx | 32 ++++++------ .../favourites-library-nav.tsx | 2 +- .../clinical-dashboard/output-panel.tsx | 10 ++-- .../clinical-dashboard/settings-dialog.tsx | 20 ++++---- .../clinical-dashboard/visual-evidence.tsx | 12 ++--- src/components/mode-home-template.tsx | 2 +- .../services/services-navigator-page.tsx | 2 +- 20 files changed, 152 insertions(+), 143 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 409fa258a1..5eccd0da36 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -12,6 +12,16 @@ * rounded-md chips, inner elements, metadata pills * rounded-lg controls, inputs, buttons, cards, panels, drawers * rounded-xl bottom sheets, dialogs, large shells + * + * Z-index ladder (Tailwind v4 has no z theme namespace; arbitrary values are + * the idiom — pick an existing rung, never invent a new number): + * 0–40 in-page layering: sticky headers, badges, count bubbles + * 60 app chrome: master search header, mode menus + * 80–85 document overlays: viewer fullscreen (80), table fullscreen (85) + * 95 popovers that must beat overlays (source preview) + * 100 modal layer — the Sheet primitive — and the skip link + * max mockup-only diagnostics; never in live routes + * New overlays go through components/ui/sheet.tsx rather than a new layer. */ @theme { --radius-xs: 0.25rem; diff --git a/src/app/mockups/layout.tsx b/src/app/mockups/layout.tsx index eaad16382e..c7eceb8df5 100644 --- a/src/app/mockups/layout.tsx +++ b/src/app/mockups/layout.tsx @@ -1,7 +1,14 @@ +import type { Metadata } from "next"; import type { ReactNode } from "react"; import { MockupsLayoutClient } from "./mockups-layout-client"; +// Design-exploration prototypes: shipped for shareability, but never indexed +// (belt-and-braces alongside the robots.ts /mockups/ disallow). +export const metadata: Metadata = { + robots: { index: false, follow: false }, +}; + export default function MockupsLayout({ children }: { children: ReactNode }) { return {children}; } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index d449062956..128725c76b 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -649,7 +649,7 @@ export function SettingsDialog({

Account & app

@@ -660,7 +660,7 @@ export function SettingsDialog({
-

+

Clinical Guide account

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

+

{section.title}

@@ -843,7 +843,7 @@ export function SettingsDialog({ function SettingsChip({ label }: { label: string }) { return ( - + {label} ); @@ -907,7 +907,7 @@ function SettingsProviderMark({ provider }: { provider: "Apple" | "Google" | "Mi function SettingsClinicalContextStrip() { return ( -
+
Private workspace{" "} @@ -950,10 +950,10 @@ function SettingsSummaryTile({ - + {label} - + {value} @@ -992,7 +992,7 @@ function SettingsRow({ {label} {value ? ( - + {value} ) : null} @@ -1035,7 +1035,7 @@ function SettingsHelpFooter({ onClick }: { onClick: () => void }) { 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 15578251cc..22af8fdb05 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -390,7 +390,7 @@ function StatusChip({ label, tone = "neutral" }: { label: string; tone?: "neutra return ( void; mo {mobile ? action.label : action.desktopLabel} @@ -573,7 +573,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-[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)]", @@ -676,7 +676,7 @@ function MobileToolRow({ {app.description} - + {app.actionLabel} 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 d685a0e452..f2c8cda5de 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -180,14 +180,14 @@ export function ScopeAndGovernanceNotice({

) : null} {scope?.warnings?.length ? ( -
    +
      {scope.warnings.slice(0, 3).map((warning) => (
    • {warning}
    • ))}
    ) : null} {groupedWarnings.length ? ( -
      +
        {groupedWarnings.map((warning) => (
      • {warning.message} @@ -392,7 +392,7 @@ function SourcePreviewContent({

        Sources

        - + {sourcePreviewPageCountLabel(previewSources)}
        @@ -416,7 +416,7 @@ function SourcePreviewContent({ )} > {index === 0 ? ( -

        +

        Best match

        @@ -636,7 +636,7 @@ export function NaturalLanguageAnswer({ > Source-only - · verify passages + · verify passages

        This answer was assembled from your documents without the AI model, so it may be less complete. Verify @@ -679,7 +679,7 @@ export function NaturalLanguageAnswer({ title="Sources" description="Open the original PDF page." titleAccessory={ - + {sourcePreviewPageCountLabel(previewSources)} } diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index eb853db4cc..e24ea2cf7c 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -265,7 +265,7 @@ export function StagedAnswerResultSurface({ } titleAccessory={ - + {clinicalNoteDisplayCount} } @@ -281,7 +281,7 @@ export function StagedAnswerResultSurface({ ) : null } headerClassName="gap-2 p-2.5 sm:p-3" - titleClassName="text-[15px] leading-5" + titleClassName="text-base-minus leading-5" closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-md" bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" @@ -308,9 +308,7 @@ export function StagedAnswerResultSurface({ onClose={closeEvidenceReview} title="Evidence" description="Review by evidence type." - titleAccessory={ - {evidenceTrustLabel} - } + titleAccessory={{evidenceTrustLabel}} closeLabel="Close evidence" headerLeading={ @@ -355,12 +353,12 @@ export function StagedAnswerResultSurface({ } titleAccessory={ - + {safetyFindings.length} } headerClassName="gap-2 p-2.5 sm:p-3" - titleClassName="text-[15px] leading-5" + titleClassName="text-base-minus leading-5" closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-lg" bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" diff --git a/src/components/clinical-dashboard/auth-panel.tsx b/src/components/clinical-dashboard/auth-panel.tsx index 7bf3dce2f4..a8187e309e 100644 --- a/src/components/clinical-dashboard/auth-panel.tsx +++ b/src/components/clinical-dashboard/auth-panel.tsx @@ -88,7 +88,7 @@ export function AuthPanel() {

        Real-data sign-in unavailable

        -

        +

        Configure the Supabase public URL and publishable key before using private documents.

        @@ -246,7 +246,7 @@ function ProviderMark({ provider }: { provider: "Apple" | "Google" | "Microsoft" function AuthBenefit({ icon: Icon, label }: { icon: typeof SlidersHorizontal; label: string }) { return ( - + {label} diff --git a/src/components/clinical-dashboard/dashboard-nav.tsx b/src/components/clinical-dashboard/dashboard-nav.tsx index 4ffe93ec21..3e02a4544b 100644 --- a/src/components/clinical-dashboard/dashboard-nav.tsx +++ b/src/components/clinical-dashboard/dashboard-nav.tsx @@ -249,7 +249,7 @@ export function MobileSectionFab({
        @@ -229,7 +229,7 @@ function DocumentLabelReviewPanel({ if (!labelRows.length) return null; return (
        -

        +

        {title}

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

        +

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

@@ -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 fed691238c..68f66a1cf2 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -121,7 +121,7 @@ function Chip({ chip }: { chip: ServiceStatusChip }) { return ( From 7a2901646a66df680ea327365fae4f926484df75 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 11:10:18 +0000 Subject: [PATCH 06/10] docs(design): consolidated design-system guide + process-hardening ratchet docs/design-system.md becomes the single front door for UI work: token contract (semantic vs categorical vs brand), legacy-hex migration table, type-scale rules and ratchet, tap-target and radius/shadow rules, the z-index ladder, Sheet-only modal policy, accessibility requirements, a do/don't gallery drawn from this branch's fixes, verification gates, and file conventions. The deep docs in docs/redesign/ gain entry-point pointers; process-hardening records the new type-scale baseline (20, was 168) and the debts cleared this pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QEEkTKt4eS4oqftBsaQKpG --- docs/design-system.md | 179 ++++++++++++++++++++ docs/process-hardening.md | 6 + docs/redesign/02-design-direction.md | 2 + docs/redesign/09-ui-primitives-recipes.md | 2 + docs/redesign/permanent-colour-direction.md | 2 + 5 files changed, 191 insertions(+) create mode 100644 docs/design-system.md diff --git a/docs/design-system.md b/docs/design-system.md new file mode 100644 index 0000000000..2e389ac855 --- /dev/null +++ b/docs/design-system.md @@ -0,0 +1,179 @@ +# 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 write `rounded-[var(--radius-*)]` — 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 `shadow-[0_…rgba…]`. + +## 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 | `` | +| `

      -
      +
      {filteredApps.length === 0 ? (

      {copy.emptyTitle}

      From 8b0c564c17a5a2e09b380c4e289eb7c25b574639 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 12:46:41 +0000 Subject: [PATCH 10/10] fix(a11y): include mobile filter labels in launcher tabpanel name Fall back to mobileFilters when resolving the results tabpanel label so the More tab announces a matching region name for screen readers. --- src/components/applications-launcher-page.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index 6b2d00d0eb..518088ea5c 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -908,7 +908,9 @@ export function ApplicationsLauncherWorkspace({ : (filteredApps[0]?.id ?? selectedId); const selectedApp = appById(effectiveSelectedId); // "more" behaves as "all" in filtering, so it falls back to the all-tools label. - const activeFilterLabel = desktopFilters.find((filter) => filter.id === activeFilter)?.label; + const activeFilterLabel = + desktopFilters.find((filter) => filter.id === activeFilter)?.label ?? + mobileFilters.find((filter) => filter.id === activeFilter)?.label; const resultsPanelLabel = activeFilterLabel && activeFilterLabel !== copy.allSectionLabel ? `${activeFilterLabel} tools`