From 18b8601a07e5a84af5b22b0b6e9f6bf7113e9caa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 16:13:46 +0000 Subject: [PATCH 1/8] feat(ui): one category-identity registry for card glyphs and accents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Icons and category colour were spread across ten independent maps and two of them disagreed, so the same tool rendered differently depending on which screen reached it. - `launcherIconById` (applications-launcher-page.tsx) carried 13 tool ids; `iconByToolId` (tools-search-results-page.tsx) carried 8 with a different fallback, so `guidelines`, `care-plans`, `safety-plan`, `calculators` and `monitoring` showed a real glyph on the launcher and a generic `Grid2X2` in search results. - Colour diverged the same way: the launcher tinted by tool area, the results page painted every tile `--type-source`, so one list read as a single purple family while the other grouped the same tools into five. - `ShieldCheck` was assigned to `guidelines`, to `risk-safety`, and to the "Source-backed" status chip — three unrelated meanings, one glyph, reachable on a single card. - `appIconTone` overrode the area map per id, routing `differentials` and `forms` to a tone key named `differentials`, so the advertised "category colour" was not a category colour. `src/lib/category-identity.ts` is now the single source of truth. It is framework-free (string glyph keys, no lucide) following the `semantic-tone.ts` precedent, so data and server modules can name a glyph without the render layer; `category-identity-icons.ts` is the only place that binds keys to components, resolving through `createElement` as `factsheets-icons.ts` does to satisfy `react-hooks/static-components`. `ToolCatalogRecord.id` is narrowed from `string` to a `ToolCatalogId` union, so `Record` cannot be under-filled: adding a tool without choosing a glyph is now a type error rather than a silent runtime fallback. `appModeIcons` keeps its name and shape but is derived rather than hand-maintained, so its "keep in sync" comment is now a property of the type. Accent delivery is `data-category-accent` → `--cat-accent`/`--cat-soft`/ `--cat-border` in globals.css rather than interpolated class names, which Tailwind's scanner cannot see, and rather than inline styles, which bypass the theme contract. Every accent aliases an existing non-semantic triad (`--type-*`, `--tone-*`), so light, dark and forced-colors need no new declarations. `risk-safety` loses its permanent danger-red tile: red asserted caution about a route rather than about a patient, spending the loudest colour in the system on a navigation target. Safety is carried by the now-unique shield glyph and by the danger-toned selected state, which is a real state. Gates: typecheck, lint, `npm run test` (643 files, 6882 passed / 4 skipped), check:design-system-contract, check:icon-scale, check:type-scale — all green. No provider-backed check was run and none is required; no RAG surface touched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGhewT2mmRoRTynjWfF1Vn --- src/app/globals.css | 94 +++++++++ src/components/applications-launcher-page.tsx | 159 ++++++--------- src/components/category-icon-tile.tsx | 49 +++++ .../tools/tools-search-results-page.tsx | 47 ++--- src/lib/app-mode-icons.ts | 48 ++--- src/lib/category-identity-icons.ts | 77 +++++++ src/lib/category-identity.ts | 188 ++++++++++++++++++ src/lib/tools-catalog.ts | 31 ++- tests/category-identity.test.ts | 104 ++++++++++ 9 files changed, 633 insertions(+), 164 deletions(-) create mode 100644 src/components/category-icon-tile.tsx create mode 100644 src/lib/category-identity-icons.ts create mode 100644 src/lib/category-identity.ts create mode 100644 tests/category-identity.test.ts diff --git a/src/app/globals.css b/src/app/globals.css index 11b5436cc7..c5c0271a37 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -756,6 +756,100 @@ body { overscroll-behavior-x: none; } +/* Category accent delivery. + ------------------------------------------------------------------ + A card names its category with `data-category-accent`, and reads the colour + back through --cat-accent / --cat-soft / --cat-border. Three reasons this is + an attribute→variable indirection rather than per-category utility classes: + + 1. Tailwind's scanner only sees class strings that appear literally in the + source, so `bg-[color:var(--type-${accent}-soft)]` silently produces no + CSS. Every consumer here writes the literal `var(--cat-soft)` instead. + 2. Light, dark AND forced-colors come for free: each value aliases an + existing triad that all three themes already remap, so a new category + never needs a fourth declaration site to stay legible in high contrast. + 3. It replaces the inline `style={{ backgroundColor: theme.soft }}` the + factsheet cards used, which bypassed the class contract entirely. + + Every accent below resolves to a NON-semantic triad. Nothing here may point + at --danger / --warning / --success / --info: those belong to the six-tone + badge system, where the colour is the meaning. A category is not a status. + Held by tests/design-token-contract.test.ts. */ +[data-category-accent="document"] { + --cat-accent: var(--type-document); + --cat-soft: var(--type-document-soft); + --cat-border: var(--type-document-border); +} + +[data-category-accent="table"] { + --cat-accent: var(--type-table); + --cat-soft: var(--type-table-soft); + --cat-border: var(--type-table-border); +} + +[data-category-accent="search"] { + --cat-accent: var(--type-search); + --cat-soft: var(--type-search-soft); + --cat-border: var(--type-search-border); +} + +[data-category-accent="source"] { + --cat-accent: var(--type-source); + --cat-soft: var(--type-source-soft); + --cat-border: var(--type-source-border); +} + +[data-category-accent="service"] { + --cat-accent: var(--type-service); + --cat-soft: var(--type-service-soft); + --cat-border: var(--type-service-border); +} + +[data-category-accent="form"] { + --cat-accent: var(--type-form); + --cat-soft: var(--type-form-soft); + --cat-border: var(--type-form-border); +} + +[data-category-accent="purple"] { + --cat-accent: var(--tone-purple); + --cat-soft: var(--tone-purple-soft); + --cat-border: var(--tone-purple-border); +} + +[data-category-accent="indigo"] { + --cat-accent: var(--tone-indigo); + --cat-soft: var(--tone-indigo-soft); + --cat-border: var(--tone-indigo-border); +} + +[data-category-accent="rose"] { + --cat-accent: var(--tone-rose); + --cat-soft: var(--tone-rose-soft); + --cat-border: var(--tone-rose-border); +} + +[data-category-accent="slate"] { + --cat-accent: var(--tone-slate); + --cat-soft: var(--tone-slate-soft); + --cat-border: var(--tone-slate-border); +} + +[data-category-accent="clinical"] { + --cat-accent: var(--clinical-accent); + --cat-soft: var(--clinical-accent-soft); + --cat-border: var(--clinical-accent-border); +} + +/* Fallback for a surface that opts into the card recipe without naming a + category — the accent collapses to the product accent rather than to an + unresolved variable, so an omitted attribute degrades to today's look. */ +:root { + --cat-accent: var(--clinical-accent); + --cat-soft: var(--clinical-accent-soft); + --cat-border: var(--clinical-accent-border); +} + @layer base { /* Interactive element defaults */ button, diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index d21492da7c..c4127ea63e 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -2,28 +2,22 @@ import Link from "next/link"; import { - Brain, - Calculator, + BadgeCheck, ChevronRight, - ClipboardCheck, ClipboardList, ExternalLink, - FileCheck2, - FileText, Grid2X2, Palette, - Pill, Plus, Search, ShieldCheck, Sparkles, - Star, - Users, Waves, type LucideIcon, } from "lucide-react"; import { type FormEvent, useId, useMemo, useState } from "react"; +import { CategoryIconTile } from "@/components/category-icon-tile"; import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { ModeHomeHero } from "@/components/mode-home-template"; import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; @@ -38,12 +32,15 @@ import { SegmentedControl } from "@/components/ui/segmented-control"; import { cn, EmptyState } from "@/components/ui-primitives"; import { Chip, type ChipStatusTone } from "@/components/ui/chip"; import { Sheet } from "@/components/ui/sheet"; +import { TOOL_AREA_LABEL, toolIdentity } from "@/lib/category-identity"; +import { categoryGlyph } from "@/lib/category-identity-icons"; import { isLocalNoAuthMode, resolveClientDemoMode } from "@/lib/client-env"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { useAuthSession } from "@/lib/supabase/client"; import { toolCatalogRecordsForSession, type ToolCatalogArea, + type ToolCatalogId, type ToolCatalogRecord, type ToolCatalogStatus, } from "@/lib/tools-catalog"; @@ -52,7 +49,10 @@ type LauncherStatus = ToolCatalogStatus; type LauncherArea = ToolCatalogArea; type LauncherFilter = "all" | LauncherArea | "more"; -type LauncherApp = ToolCatalogRecord & { icon: LucideIcon }; +// The catalogue record is the whole app: identity is looked up from the record's +// `id` and `area` rather than carried as an extra field, so a launcher app and a +// search-results tool cannot disagree about their own glyph. +type LauncherApp = ToolCatalogRecord; function launcherAppMatchesFilter(app: LauncherApp, filter: LauncherFilter): boolean { if (filter === "all") return true; @@ -63,13 +63,7 @@ function launcherAppMatchesFilter(app: LauncherApp, filter: LauncherFilter): boo const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; -const areaLabels: Record = { - assessment: "Assess", - reference: "Evidence", - care: "Treat", - coordination: "Coordinate", - saved: "Saved", -}; +const areaLabels = TOOL_AREA_LABEL; const statusLabels: Record = { ready: "Ready", @@ -77,49 +71,20 @@ 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-[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-[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 -// server code (universal search); icons are attached at the UI boundary. -const launcherIconById: Record = { - "clinical-kb-search": Search, - differentials: Brain, - documents: FileText, - guidelines: ShieldCheck, - "risk-safety": ShieldCheck, - "medication-prescribing": Pill, - services: Users, - forms: FileCheck2, - "care-plans": ClipboardCheck, - "safety-plan": ClipboardList, - calculators: Calculator, - monitoring: Waves, - favourites: Star, -}; - +// Glyph and accent both come from `src/lib/category-identity.ts` now. Two maps +// used to live here — a 13-entry `launcherIconById` and an `iconToneClasses` +// keyed by a union of areas *and* three ad-hoc tool ids, reconciled by an +// `appIconTone` function that overrode the area for `differentials`, `forms` and +// `medication-prescribing`. The tools *search results* page carried its own +// 8-entry copy with a different fallback, so five tools showed one glyph on the +// launcher and a generic grid glyph in results, and every results tile was +// painted the same purple regardless of area. Both surfaces now read the one +// registry, so a tool looks like itself wherever it is reached. function launcherAppsForSession(canAccessFavourites: boolean): LauncherApp[] { return toolCatalogRecordsForSession({ authenticated: canAccessFavourites, demoMode: false, - }).map((record) => ({ - ...record, - icon: launcherIconById[record.id] ?? Sparkles, - })); + }); } const toolsLauncherCopy = { @@ -135,16 +100,18 @@ const toolsLauncherCopy = { openSelectedAriaLabel: "Open selected tool", }; +// A third copy of the same id→glyph decision used to live here, so a quick +// action could drift from the card it opens. Only the wording is local now. const quickActionsBase = [ - { label: "Ask", desktopLabel: "Ask evidence", icon: Search, id: "clinical-kb-search" }, - { label: "Compare", desktopLabel: "Compare", icon: Brain, id: "differentials" }, - { label: "Prescribe", desktopLabel: "Prescribe", icon: Pill, id: "medication-prescribing" }, - { label: "Safety", desktopLabel: "Safety check", icon: ShieldCheck, id: "risk-safety" }, - { label: "Docs", desktopLabel: "Documents", icon: FileText, id: "documents" }, - { label: "Refer", desktopLabel: "Refer", icon: Users, id: "services" }, - { label: "Forms", desktopLabel: "Forms", icon: FileCheck2, id: "forms" }, - { label: "Saved", desktopLabel: "Favourites", icon: Star, id: "favourites" }, -] as const; + { label: "Ask", desktopLabel: "Ask evidence", id: "clinical-kb-search" }, + { label: "Compare", desktopLabel: "Compare", id: "differentials" }, + { label: "Prescribe", desktopLabel: "Prescribe", id: "medication-prescribing" }, + { label: "Safety", desktopLabel: "Safety check", id: "risk-safety" }, + { label: "Docs", desktopLabel: "Documents", id: "documents" }, + { label: "Refer", desktopLabel: "Refer", id: "services" }, + { label: "Forms", desktopLabel: "Forms", id: "forms" }, + { label: "Saved", desktopLabel: "Favourites", id: "favourites" }, +] as const satisfies ReadonlyArray<{ label: string; desktopLabel: string; id: ToolCatalogId }>; const desktopFiltersBase: Array<{ id: LauncherFilter; label: string }> = [ { id: "all", label: "All tools" }, @@ -166,11 +133,11 @@ const mobileFilters: Array<{ id: LauncherFilter; label: string }> = [ /** Full catalog length (includes Favourites). Prefer session-filtered lists in UI. */ export const applicationsLauncherItemCount = launcherAppsForSession(true).length; -function appById(id: string, apps: LauncherApp[]) { +function appById(id: ToolCatalogId, apps: LauncherApp[]) { return apps.find((app) => app.id === id) ?? apps[0]; } -function initialToolId(query: string | undefined, apps: LauncherApp[]) { +function initialToolId(query: string | undefined, apps: LauncherApp[]): ToolCatalogId { const normalized = query?.trim().toLowerCase(); if (!normalized) return "risk-safety"; return ( @@ -192,28 +159,20 @@ function desktopFiltersForSession(canAccessFavourites: boolean) { return canAccessFavourites ? desktopFiltersBase : desktopFiltersBase.filter((filter) => filter.id !== "saved"); } -function appIconTone(app: LauncherApp) { - if (app.id === "risk-safety") return iconToneClasses.safety; - if (app.id === "medication-prescribing") return iconToneClasses.medication; - if (app.id === "differentials" || app.id === "forms") return iconToneClasses.differentials; - return iconToneClasses[app.area]; -} - -function ToolIcon({ app, size = "md" }: { app: LauncherApp; size?: "sm" | "md" | "lg" }) { - const Icon = app.icon; - return ( - - - - ); +/** + * Tool identity tile. Colour groups the family (five accents, matching the five + * filter chips a clinician can actually apply); the glyph distinguishes the + * individual tool. + * + * `risk-safety` no longer gets a permanent danger-red tile. Red here asserted + * caution about a *route*, not about a patient, and it spent the loudest colour + * in the system on a navigation target — the same category error the factsheet + * accents make. Safety is carried by the shield glyph, which is now unique to + * it, and by the danger-toned selected state, which is a real state. + */ +function ToolIcon({ app, size = "md" }: { app: LauncherApp; size?: "sm" | "md" }) { + const identity = toolIdentity(app.id, app.area); + return ; } // Launcher status vocabulary mapped onto the design-system `Chip`. The tone and @@ -228,8 +187,12 @@ const statusChipTone: Record = { high: "info", }; +// `source` used ShieldCheck too, so one card could show the same shield three +// times over — on the "Source-backed" chip, on the Guidelines tile, and on the +// Risk & safety tile — for three unrelated meanings. Source-backed is a +// verification claim, so it takes the verification glyph; the shield is safety. const statusChipIcon: Partial> = { - source: ShieldCheck, + source: BadgeCheck, safety: Sparkles, }; @@ -318,7 +281,7 @@ function QuickActions({ apps, canAccessFavourites, }: { - onSelect: (id: string) => void; + onSelect: (id: ToolCatalogId) => void; mobile?: boolean; apps: LauncherApp[]; canAccessFavourites: boolean; @@ -331,7 +294,7 @@ function QuickActions({ > {quickActions.slice(0, mobile ? 8 : 6).map((action) => { const app = appById(action.id, apps); - const Icon = action.icon; + const identity = toolIdentity(app.id, app.area); return ( ); } -function MobileToolRow({ - app, - selected, - onSelect, -}: { - app: LauncherApp; - selected: boolean; - onSelect: (id: ToolCatalogId) => void; -}) { - return ( - - ); +function MobileToolRow(props: { app: LauncherApp; selected: boolean; onSelect: (id: ToolCatalogId) => void }) { + return ; } function DetailSection({ diff --git a/src/components/card-recipes.ts b/src/components/card-recipes.ts index b8bd5f9a5d..52337e0bb8 100644 --- a/src/components/card-recipes.ts +++ b/src/components/card-recipes.ts @@ -81,6 +81,17 @@ export const cardInteractive = cn( */ export const cardSelected = "border-[color:var(--cat-border)] bg-[color:var(--cat-soft)] shadow-[var(--e2)]"; +/** + * Selected state for a card whose subject is genuinely a safety concern. + * + * This is the narrow, legitimate use of the semantic palette on a card: not + * "this card belongs to the safety family" — that is identity, and identity + * uses `cardSelected` — but "the thing you have selected is the safety tool". + * Reach for it only where a semantic tone would be correct on a badge. + */ +export const cardSelectedDanger = + "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)] shadow-[var(--e2)]"; + /** * Optional 3px category edge along the top of a card. * diff --git a/src/components/tools/tools-search-results-page.tsx b/src/components/tools/tools-search-results-page.tsx index dbeb38894b..924470eba3 100644 --- a/src/components/tools/tools-search-results-page.tsx +++ b/src/components/tools/tools-search-results-page.tsx @@ -22,6 +22,7 @@ import { useFavouritesAccess } from "@/components/clinical-dashboard/use-favouri import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; +import { cardSelected, cardSurface, focusRing } from "@/components/card-recipes"; import { CategoryIconTile } from "@/components/category-icon-tile"; import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { cn, controlBase, floatingControl } from "@/components/ui-primitives"; @@ -38,9 +39,6 @@ import { type ToolCatalogRecord, } from "@/lib/tools-catalog"; -const focusRing = - "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; - // A partial second copy of the launcher's icon map used to live here: 8 of the // 13 tools, with a `?? Grid2X2` fallback that silently gave `guidelines`, // `care-plans`, `safety-plan`, `calculators` and `monitoring` a generic grid @@ -416,37 +414,47 @@ export function ToolsSearchResultsPage({
{tool.id === selectedTool?.id ? ( + // The selected rail takes the tool's own category accent + // rather than the product blue, so it agrees with the tile + // beside it instead of overriding it.
)) From efed8b5338aa0464a305f435bf4174c71bcb73e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 17:03:11 +0000 Subject: [PATCH 5/8] feat(ui): adopt the card recipe and category accents on factsheets, calculators and services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factsheets - The three inline `style` objects per card are gone. An inline value cannot be remapped by the dark or forced-colors blocks, so the old cards carried their light-mode tint into both. Verified in Chromium: the four accents now remap correctly in dark, and under forced-colors they flatten to Canvas/CanvasText as they should — identity colour is decoration, and the glyph and category chip carry the meaning in high contrast. - The category browse pills take the same accents, so a pill and the cards it filters to now agree. - Card titles move to `text-lg font-semibold`, and hover tints the title with the card's own category accent rather than the product blue. Calculators - The directory tile was grey until a card opened, so a closed directory showed five domains rendered identically and the domain was findable only by reading the chip. It now carries the domain accent at rest — which is what the chip beside it has always said in words. - `CALCULATOR_DOMAIN_ACCENT` deliberately gives `risk` (suicide risk) an identity accent, not `--danger`. The label already says "Suicide risk", and an instrument is not itself a warning; a red tile on a directory row would claim urgency about a tool rather than about a patient. - Open/closed states move onto `cardSelected` and the --e ladder. Services - Adopts `cardSurface` + `cardSelected`, retiring the fourth "this one is selected" encoding (`ring-1 …/35` — an alpha on a token colour, so what it contrasted against depended on whatever surface sat behind it per theme). - The leading tile deliberately stays a RANK rather than becoming a category glyph: this is a ranked referral list, the number is what the "Best fit" pill refers to, and it doubles as the shortlist checkmark. Services has no single category axis either — records carry facets — so there would be nothing honest to put there. Ratchets moved the right way: legacyShadowAliases 111 -> 107, edge conflicts 19 -> 18. Deferred, unchanged: therapy-compass/therapy-card.tsx (own SVG icon set, own control recipes, own IconTile, and the open rawPadding/rawGap debt from #261), the differentials-home card family, and the forms detail cards — those are detail-panel compositions rather than category-bearing list items. Gates: typecheck, lint, `npm run test` (643 files, 6885 passed / 4 skipped), check:design-system-contract — all green. Chromium inspection at 390/1440 in light, dark and forced-colors against the pinned revision 1234. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGhewT2mmRoRTynjWfF1Vn --- src/components/calculators/directory-grid.tsx | 23 +++++----- .../factsheets/factsheets-home-page.tsx | 46 ++++++++----------- .../services/services-navigator-page.tsx | 18 ++++++-- src/lib/category-identity.ts | 22 +++++++++ 4 files changed, 67 insertions(+), 42 deletions(-) diff --git a/src/components/calculators/directory-grid.tsx b/src/components/calculators/directory-grid.tsx index bfaba3910f..558a4247b6 100644 --- a/src/components/calculators/directory-grid.tsx +++ b/src/components/calculators/directory-grid.tsx @@ -3,7 +3,9 @@ import { BookOpen, Calculator, ChevronDown, Clock3, Info, ListChecks, Search, ShieldCheck, Sigma } from "lucide-react"; import { useMemo, useState } from "react"; +import { cardSelected, cardSurface } from "@/components/card-recipes"; import { cn } from "@/components/ui-primitives"; +import { CALCULATOR_DOMAIN_ACCENT } from "@/lib/category-identity"; import { calculators, @@ -103,11 +105,11 @@ function CalculatorCard({ return (