diff --git a/docs/outstanding-issues-inbox/3565972b-02c2-4ad8-b899-3fbde0d54725.json b/docs/outstanding-issues-inbox/3565972b-02c2-4ad8-b899-3fbde0d54725.json new file mode 100644 index 0000000000..2f32169083 --- /dev/null +++ b/docs/outstanding-issues-inbox/3565972b-02c2-4ad8-b899-3fbde0d54725.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "3565972b-02c2-4ad8-b899-3fbde0d54725", + "createdOn": "2026-08-18", + "action": "add", + "payload": { + "pri": "P3", + "type": "issue", + "summary": "Advisory UI mockup spec 'phone filter sheet follows the shared local-filter behavior' fails on main", + "detail": "tests/ui-tools-search-mode-mockup.spec.ts:188 fails at line 198 waiting for '2 showing' inside [data-testid=tools-search-filter-sheet] after searching 'Safety' at 390px. Reproduced locally under --project=chromium-mockups on BOTH claude/card-review-optimize-h0pidc and origin/main (dc7e518), so it is pre-existing and NOT caused by the card branch — attribution was checked before any fix was attempted. It surfaced now only because the ui-advisory lane fires on advisory_ui_changed (a mockup surface changed or the flake ledger is non-empty) and had been skipped on every earlier run of that PR. It is non-blocking: ui-advisory carries continue-on-error true and is absent from pr-required's needs list in ci.yml, and verify:ui excludes @mockup via --grep-invert, which is why a 429-pass local run never touched it. Next: open the trace at test-results/ui-tools-search-mode-mocku-b6163-hared-local-filter-behavior-chromium-mockups/trace.zip and decide whether the expected count of 2 is stale against the current tools catalogue or the facet hint genuinely miscounts; the mockup renders the production ToolsSearchResultsPage, so a real miscount would affect /tools too. Stop: do not change the expected number to match observed output without establishing which is correct.", + "source": "session 2026-08-18; PR #2060 Advisory UI run 32090358678; reproduced on origin/main dc7e518", + "issueUlid": "01M09C8ZY74TBHS81EN8DBS9WF" + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 4bc854abde..d952a66f83 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -806,6 +806,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..5ed2a5f015 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -2,28 +2,23 @@ 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 { cardInteractive, cardSelected, cardSelectedDanger, focusRing } from "@/components/card-recipes"; +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"; @@ -35,15 +30,18 @@ import { import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { useFavouritesAccess } from "@/components/clinical-dashboard/use-favourites-access"; import { SegmentedControl } from "@/components/ui/segmented-control"; -import { cn, EmptyState } from "@/components/ui-primitives"; +import { cn, EmptyState, eyebrowText } 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 +50,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; @@ -60,16 +61,7 @@ function launcherAppMatchesFilter(app: LauncherApp, filter: LauncherFilter): boo return app.area === filter; } -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 +69,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 +98,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 +131,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 +157,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 +185,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 +279,7 @@ function QuickActions({ apps, canAccessFavourites, }: { - onSelect: (id: string) => void; + onSelect: (id: ToolCatalogId) => void; mobile?: boolean; apps: LauncherApp[]; canAccessFavourites: boolean; @@ -331,7 +292,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: string) => void; -}) { - return ( - - ); +function MobileToolRow(props: { app: LauncherApp; selected: boolean; onSelect: (id: ToolCatalogId) => void }) { + return ; } function DetailSection({ @@ -825,7 +801,7 @@ export function ApplicationsLauncherWorkspace({ if (controlledQuery === undefined && !searchCommand) setLocalQuery(nextQuery); } - function openTool(id: string) { + function openTool(id: ToolCatalogId) { setSelection({ queryKey: normalizedQuery, id }); setDetailOpen(true); } 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 (
)) diff --git a/src/lib/app-mode-icons.ts b/src/lib/app-mode-icons.ts index 0f91559cc3..1c692a3b9d 100644 --- a/src/lib/app-mode-icons.ts +++ b/src/lib/app-mode-icons.ts @@ -1,37 +1,17 @@ -import { - BookOpenCheck, - BookOpenText, - BrainCircuit, - Calculator, - Compass, - FileSignature, - FileText, - Heart, - Network, - Pill, - Route, - Tags, - Sparkles, - Wrench, - type LucideIcon, -} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { categoryIconComponent } from "@/lib/category-identity-icons"; import type { AppModeId } from "@/lib/app-modes"; +import { APP_MODE_ICON } from "@/lib/category-identity"; -/** Canonical Lucide icons for each app mode — keep in sync across nav, search, and favourites. */ -export const appModeIcons: Record = { - answer: Sparkles, - documents: FileText, - services: Route, - forms: FileSignature, - favourites: Heart, - differentials: BrainCircuit, - dsm: BookOpenCheck, - specifiers: Tags, - formulation: Network, - prescribing: Pill, - tools: Wrench, - calculators: Calculator, - "therapy-compass": Compass, - factsheets: BookOpenText, -}; +/** + * Canonical Lucide icons for each app mode — nav, universal search, favourites. + * + * The choices now live in `src/lib/category-identity.ts` alongside the tool and + * factsheet axes, so "keep in sync" is a property of the type rather than a + * comment: this record is derived, not maintained. The export name and shape are + * unchanged, so consumers need no edit. + */ +export const appModeIcons: Record = Object.fromEntries( + Object.entries(APP_MODE_ICON).map(([mode, icon]) => [mode, categoryIconComponent(icon)]), +) as Record; diff --git a/src/lib/category-identity-icons.ts b/src/lib/category-identity-icons.ts new file mode 100644 index 0000000000..ad6b94d22f --- /dev/null +++ b/src/lib/category-identity-icons.ts @@ -0,0 +1,77 @@ +import { + BookOpenCheck, + BookOpenText, + BrainCircuit, + Calculator, + ClipboardCheck, + ClipboardList, + Compass, + FileCheck2, + FileSignature, + FileText, + Heart, + MessagesSquare, + Network, + Pill, + Route, + ScrollText, + Search, + ShieldCheck, + Sparkles, + Star, + Tags, + Users, + Waves, + Wrench, + type LucideIcon, +} from "lucide-react"; +import { createElement } from "react"; + +import type { CategoryIconKey } from "@/lib/category-identity"; + +/** + * The single string-key → Lucide resolution for category identity. + * + * The keys live in `src/lib/category-identity.ts` so data and server modules can + * name a glyph without importing `lucide-react`; this file is the only place + * that binds them to components. `factsheets-icons.ts` established the pattern. + */ +const categoryIcons: Record = { + sparkles: Sparkles, + fileText: FileText, + route: Route, + fileSignature: FileSignature, + heart: Heart, + brainCircuit: BrainCircuit, + bookOpenCheck: BookOpenCheck, + tags: Tags, + network: Network, + pill: Pill, + wrench: Wrench, + calculator: Calculator, + compass: Compass, + bookOpenText: BookOpenText, + search: Search, + scrollText: ScrollText, + shieldCheck: ShieldCheck, + users: Users, + fileCheck2: FileCheck2, + clipboardCheck: ClipboardCheck, + clipboardList: ClipboardList, + waves: Waves, + star: Star, + chat: MessagesSquare, +}; + +/** Component form, for the call sites that still take a `LucideIcon` prop. */ +export function categoryIconComponent(icon: CategoryIconKey): LucideIcon { + return categoryIcons[icon]; +} + +/** + * Render a glyph without binding a capitalised component to a render-body local, + * which `react-hooks/static-components` forbids. + */ +export function categoryGlyph(icon: CategoryIconKey, className: string) { + return createElement(categoryIcons[icon], { className, "aria-hidden": "true" }); +} diff --git a/src/lib/category-identity.ts b/src/lib/category-identity.ts new file mode 100644 index 0000000000..24c51ad091 --- /dev/null +++ b/src/lib/category-identity.ts @@ -0,0 +1,266 @@ +// Canonical category identity: which glyph and which accent a card wears. +// +// Before this module the answer was spread across at least ten independent maps +// and two of them disagreed. `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 grid glyph on the results page. Colour diverged the same way: the +// launcher tinted by tool area, the results page painted every tile +// `--type-source`. One tool, two identities, depending on which screen you +// reached it from. +// +// Framework-free on purpose — no `lucide-react`, no JSX — so server code and +// data modules can name a glyph without pulling in the render layer. This is the +// same split `semantic-tone.ts` uses for `SEMANTIC_ICON_KEYS`, and the string +// key is also what lets the render boundary resolve icons through +// `createElement` rather than binding a capitalised component to a render-body +// local (which `react-hooks/static-components` forbids). Resolution lives in +// `src/components/category-identity-icons.ts`. + +import type { AppModeId } from "@/lib/app-modes"; +import type { ToolCatalogArea, ToolCatalogId } from "@/lib/tools-catalog"; + +/** + * Category accents, named for the token triad each one resolves to. + * + * Every value here is deliberately drawn from the two NON-semantic identity + * families — `--type-*` (globals.css) and `--tone-*` — plus the product accent. + * None of them may ever resolve to `--danger`, `--warning`, `--success` or + * `--info`: those six tones belong to `semantic-tone.ts`, where meaning drives + * the colour. A category is not a status, and painting one in warning-amber + * asserts caution about a whole family of content that nothing has reviewed. + * `tests/design-token-contract.test.ts` holds that separation. + */ +export type CategoryAccent = + // --type-* identity triads + | "document" + | "table" + | "search" + | "source" + | "service" + | "form" + // --tone-* triads + | "purple" + | "indigo" + | "rose" + | "slate" + // the product accent + | "clinical"; + +export const CATEGORY_ACCENTS: readonly CategoryAccent[] = [ + "document", + "table", + "search", + "source", + "service", + "form", + "purple", + "indigo", + "rose", + "slate", + "clinical", +] as const; + +/** + * Glyph keys, resolved to Lucide components at the render boundary. + * + * Keys are shared across axes on purpose: the `documents` app mode and the + * `documents` tool are the same thing reached two ways, and they should not + * drift apart again. + */ +export const CATEGORY_ICON_KEYS = [ + "sparkles", + "fileText", + "route", + "fileSignature", + "heart", + "brainCircuit", + "bookOpenCheck", + "tags", + "network", + "pill", + "wrench", + "calculator", + "compass", + "bookOpenText", + "search", + "scrollText", + "shieldCheck", + "users", + "fileCheck2", + "clipboardCheck", + "clipboardList", + "waves", + "star", + "chat", +] as const; + +export type CategoryIconKey = (typeof CATEGORY_ICON_KEYS)[number]; + +/** + * App modes. Exhaustive by type, so a new mode cannot ship without a glyph. + * `src/lib/app-mode-icons.ts` resolves this to Lucide components and keeps its + * own export name, so nav, universal search and favourites are untouched. + */ +export const APP_MODE_ICON: Record = { + answer: "sparkles", + documents: "fileText", + services: "route", + forms: "fileSignature", + favourites: "heart", + differentials: "brainCircuit", + dsm: "bookOpenCheck", + specifiers: "tags", + formulation: "network", + prescribing: "pill", + tools: "wrench", + calculators: "calculator", + "therapy-compass": "compass", + factsheets: "bookOpenText", +}; + +/** + * Tools. Keyed by the catalogue's own id union, so the record cannot be + * under-filled: adding a tool without choosing a glyph is a type error rather + * than a silent `?? Grid2X2`. `registry-mode-nav.tsx` already argues for this + * shape; the tools surfaces are where it was missing. + * + * `guidelines` moved off `shieldCheck` — it shared the glyph with `risk-safety`, + * so one screen showed the same shield for "clinical guidance" and "risk and + * safety". The shield now means safety and nothing else. + */ +export const TOOL_ICON: Record = { + "clinical-kb-search": "search", + differentials: "brainCircuit", + documents: "fileText", + guidelines: "scrollText", + "risk-safety": "shieldCheck", + "medication-prescribing": "pill", + services: "users", + forms: "fileCheck2", + "care-plans": "clipboardCheck", + "safety-plan": "clipboardList", + calculators: "calculator", + monitoring: "waves", + favourites: "star", +}; + +/** + * Tool accents follow the catalogue's `area` — the axis the user can actually + * filter by, so the five colours on screen match the five filter chips. + * + * The launcher previously overrode this per id (`appIconTone`), routing + * `differentials` and `forms` to a tone key literally named `differentials` and + * `medication-prescribing` to one named `medication`. That made the "category + * colour" not a category colour. Identity now comes from the glyph, which is + * already unique per tool; colour groups the family. + * + * `coordination` takes `--tone-indigo` rather than the product accent, so + * `--clinical-accent` is left to mean selection, focus and evidence rather than + * doubling as one category among five. + */ +export const TOOL_AREA_ACCENT: Record = { + assessment: "service", + reference: "table", + care: "document", + coordination: "indigo", + saved: "search", +}; + +/** Human-facing names for the tool areas, used by filters and group headings. */ +export const TOOL_AREA_LABEL: Record = { + assessment: "Assess", + reference: "Evidence", + care: "Treat", + coordination: "Coordinate", + saved: "Saved", +}; + +/** + * Factsheet categories. + * + * The union is declared here rather than imported from the factsheets module so + * `lib` never depends on `components`; `factsheets-data.ts` re-exports it, so + * there is still only one definition. + * + * These four were the only per-category accents in the product, and two of them + * were built from SEMANTIC tokens: Therapies on `--success-text` and Tests & + * procedures on `--warning-text`. A category is a family of content; a semantic + * tone is a claim about safety. Painting an entire factsheet category in + * warning-amber asserts caution about content nothing has reviewed, and it spends + * a colour the badge system needs to mean "pause, check, adjust". All four now + * sit on non-semantic identity triads, which also makes them mutually distinct — + * Medications previously used the product accent, so it was the same blue as + * every selection and focus ring on the page. + */ +export type FactsheetCategoryKey = "Medications" | "Conditions" | "Therapies" | "Tests & procedures"; + +export const FACTSHEET_CATEGORY_IDENTITY: Record = { + Medications: { icon: "pill", accent: "form" }, + Conditions: { icon: "brainCircuit", accent: "source" }, + Therapies: { icon: "chat", accent: "service" }, + "Tests & procedures": { icon: "clipboardList", accent: "table" }, +}; + +export type CategoryIdentity = { + icon: CategoryIconKey; + accent: CategoryAccent; +}; + +/** + * Calculator domains. + * + * Accent only — the glyphs stay in `calculator-fixtures.ts`, which already keys + * them by domain and is the one axis whose icon map never forked. + * + * `risk` (suicide risk) deliberately takes an identity accent rather than + * `--danger`. The domain 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. Danger stays available for what an + * instrument actually scores. + */ +export type CalculatorDomainKey = "mood" | "anxiety" | "substance" | "risk" | "distress"; + +export const CALCULATOR_DOMAIN_ACCENT: Record = { + mood: "indigo", + anxiety: "purple", + substance: "form", + risk: "rose", + distress: "service", +}; + +/** + * The CSS custom properties a category accent resolves to. + * + * Prefer `data-category-accent` on the element and plain `var(--cat-accent)` in + * the class string. This exists for the call sites that must pass a colour as a + * value — a `linear-gradient` stop, a `color-mix` argument — where an attribute + * cannot reach. + */ +const TYPE_ACCENTS: readonly CategoryAccent[] = ["document", "table", "search", "source", "service", "form"]; + +export function categoryAccentVars(accent: CategoryAccent): { + accent: string; + soft: string; + border: string; +} { + if (accent === "clinical") { + return { + accent: "var(--clinical-accent)", + soft: "var(--clinical-accent-soft)", + border: "var(--clinical-accent-border)", + }; + } + const family = TYPE_ACCENTS.includes(accent) ? "type" : "tone"; + return { + accent: `var(--${family}-${accent})`, + soft: `var(--${family}-${accent}-soft)`, + border: `var(--${family}-${accent}-border)`, + }; +} + +/** Resolve a tool's full identity in one call. */ +export function toolIdentity(id: ToolCatalogId, area: ToolCatalogArea): CategoryIdentity { + return { icon: TOOL_ICON[id], accent: TOOL_AREA_ACCENT[area] }; +} diff --git a/src/lib/dsm.ts b/src/lib/dsm.ts index af768424b0..20d90ee6e6 100644 --- a/src/lib/dsm.ts +++ b/src/lib/dsm.ts @@ -11,11 +11,34 @@ export type DsmSpecifier = { description: string | null; }; +/** + * A DSM-5 category as this app consumes it. + * + * The vendored export at `src/data/dsm-clinical-content.json` also ships + * `css_class` (e.g. "gmod") and `color` (a raw hex, e.g. "#C43232" for Psychotic + * Disorders) on every category. Both are deliberately NOT surfaced here, and + * that omission is load-bearing rather than tidiness: + * + * - They were dead. Nothing under `src/` ever read either field, and the + * `.gmod` / `.gpsy` classes the export names do not exist in any stylesheet. + * - Wiring them up would reintroduce the exact defect this branch removed from + * factsheets: the palette assigns red to Psychotic Disorders, green to OCD & + * Related and amber to Mood Disorders, so three diagnostic categories would + * wear the danger, success and warning hues that `semantic-tone.ts` reserves + * for claims about safety. A category is a family of content, not a status. + * - They are raw hex, so they cannot be remapped by the dark-theme or + * forced-colors blocks the way a `--type-*` / `--tone-*` token is. + * + * The JSON keeps the fields because it is a snapshot of the upstream + * `dsm-5-diagnosis` repository and is not generated here — editing it would + * diverge the snapshot from its source and be overwritten by the next export. + * Dropping them from the type is what makes them unreachable: reading + * `category.color` is now a compile error rather than a live wire. + * `tests/dsm-category-colour-boundary.test.ts` holds this. + */ export type DsmCategory = { key: string; label: string; - css_class: string; - color: string; diagnosis_count: number; }; @@ -71,7 +94,19 @@ export const dsmContentMetadata = { scope: exportData.content_scope, } as const; -export const dsmCategories = exportData.categories.filter((category) => category.diagnosis_count > 0); +/** + * Categories the app consumes, projected to the three fields it uses. + * + * The projection is deliberate, not incidental. Spreading the raw export here + * would keep `css_class` and the raw hex `color` on every object at runtime even + * though `DsmCategory` no longer declares them — and because this array is + * handed to `DsmSearchPage` as a prop, those values would be serialised into the + * page payload and shipped to the browser. Picking the fields keeps the runtime + * shape honest to the type and drops the dead bytes. + */ +export const dsmCategories: DsmCategory[] = exportData.categories + .filter((category) => category.diagnosis_count > 0) + .map(({ key, label, diagnosis_count }) => ({ key, label, diagnosis_count })); export const dsmDiagnoses: DsmDiagnosis[] = exportData.diagnoses.map((diagnosis) => ({ ...diagnosis, diff --git a/src/lib/tools-catalog.ts b/src/lib/tools-catalog.ts index 29bbc569c0..ab1a340e94 100644 --- a/src/lib/tools-catalog.ts +++ b/src/lib/tools-catalog.ts @@ -4,13 +4,38 @@ import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search"; // Canonical Tools dataset. Previously duplicated between the live launcher // (applications-launcher-page.tsx inline array) and the mockup fixtures // (tools-page-mockups/tool-fixtures.ts) with divergent fields and two separate filter -// implementations. Icons are UI concerns and stay in the components (keyed by id). +// implementations. Icons remain a UI concern, but the glyph and accent chosen for +// each id now live in one place (`src/lib/category-identity.ts`) rather than in a +// per-surface map, because two such maps had already drifted apart. export type ToolCatalogStatus = "ready" | "recent" | "review_due"; export type ToolCatalogArea = "assessment" | "reference" | "care" | "coordination" | "saved"; +/** + * The catalogue's id space, named rather than left as `string`. + * + * This is what makes `Record` in `category-identity.ts` + * exhaustive: adding a tool without choosing a glyph fails the typecheck instead + * of falling through to a generic placeholder at runtime. `registry-mode-nav.tsx` + * makes the same argument for routed nav ids. + */ +export type ToolCatalogId = + | "clinical-kb-search" + | "differentials" + | "documents" + | "guidelines" + | "risk-safety" + | "medication-prescribing" + | "services" + | "forms" + | "care-plans" + | "safety-plan" + | "calculators" + | "monitoring" + | "favourites"; + export type ToolCatalogRecord = { - id: string; + id: ToolCatalogId; title: string; mobileTitle?: string; description: string; @@ -302,7 +327,7 @@ export const toolCatalogRecords: ToolCatalogRecord[] = [ }, ]; -export function toolCatalogRecordById(id: string) { +export function toolCatalogRecordById(id: string): ToolCatalogRecord { return toolCatalogRecords.find((tool) => tool.id === id) ?? toolCatalogRecords[0]; } diff --git a/tests/category-identity.test.ts b/tests/category-identity.test.ts new file mode 100644 index 0000000000..9e58058e32 --- /dev/null +++ b/tests/category-identity.test.ts @@ -0,0 +1,104 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { appModeIcons } from "../src/lib/app-mode-icons"; +import { appModeIds } from "../src/lib/app-modes"; +import { + APP_MODE_ICON, + CATEGORY_ACCENTS, + CATEGORY_ICON_KEYS, + TOOL_AREA_ACCENT, + TOOL_AREA_LABEL, + TOOL_ICON, + toolIdentity, +} from "../src/lib/category-identity"; +import { categoryIconComponent } from "../src/lib/category-identity-icons"; +import { toolCatalogRecords } from "../src/lib/tools-catalog"; + +const globalsCss = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); + +describe("category identity registry", () => { + it("covers every tool in the catalogue", () => { + for (const tool of toolCatalogRecords) { + expect(TOOL_ICON[tool.id], `tool "${tool.id}" has no glyph`).toBeTruthy(); + expect(TOOL_AREA_ACCENT[tool.area], `area "${tool.area}" has no accent`).toBeTruthy(); + } + }); + + it("covers every app mode", () => { + for (const mode of appModeIds) { + expect(APP_MODE_ICON[mode], `mode "${mode}" has no glyph`).toBeTruthy(); + } + }); + + // The defect this registry was built to stop: `guidelines` and `risk-safety` + // both carried ShieldCheck on the launcher, so one screen showed the same + // shield for "clinical guidance" and for "risk and safety". A glyph is the + // only thing distinguishing two cards that share an accent, so a collision + // inside one axis erases the distinction entirely. + it("gives every tool a glyph no other tool uses", () => { + const used = new Map(); + for (const tool of toolCatalogRecords) { + const key = TOOL_ICON[tool.id]; + used.set(key, [...(used.get(key) ?? []), tool.id]); + } + const collisions = [...used.entries()].filter(([, ids]) => ids.length > 1); + expect(collisions, `glyph reused within the tools axis: ${JSON.stringify(collisions)}`).toEqual([]); + }); + + it("gives every app mode a glyph no other mode uses", () => { + const keys = appModeIds.map((mode) => APP_MODE_ICON[mode]); + expect(new Set(keys).size).toBe(keys.length); + }); + + it("resolves every declared glyph key to a component", () => { + for (const key of CATEGORY_ICON_KEYS) { + expect(typeof categoryIconComponent(key), `glyph "${key}" does not resolve`).not.toBe("undefined"); + } + }); + + it("declares no glyph key nothing uses", () => { + const referenced = new Set([...Object.values(APP_MODE_ICON), ...Object.values(TOOL_ICON)]); + // "chat" is reserved for the factsheets Therapies category, which resolves + // through the same table; everything else must be reachable from an axis. + const unused = CATEGORY_ICON_KEYS.filter((key) => !referenced.has(key) && key !== "chat"); + expect(unused).toEqual([]); + }); + + it("labels every tool area", () => { + for (const area of Object.keys(TOOL_AREA_ACCENT)) { + expect(TOOL_AREA_LABEL[area as keyof typeof TOOL_AREA_LABEL]).toBeTruthy(); + } + }); + + it("resolves a tool's full identity in one call", () => { + expect(toolIdentity("risk-safety", "care")).toEqual({ icon: "shieldCheck", accent: "document" }); + }); + + it("keeps appModeIcons derived rather than a second hand-maintained map", () => { + for (const mode of appModeIds) { + expect(appModeIcons[mode]).toBe(categoryIconComponent(APP_MODE_ICON[mode])); + } + }); + + // Every accent must have a `[data-category-accent="…"]` rule, or the card + // renders with the :root fallback and silently loses its category. + it("delivers every accent through globals.css", () => { + for (const accent of CATEGORY_ACCENTS) { + expect(globalsCss, `no [data-category-accent="${accent}"] rule`).toContain(`[data-category-accent="${accent}"]`); + } + }); + + // The separation that keeps identity out of the six-tone badge vocabulary. + // A category is a family of content; a semantic tone is a claim about safety. + // Painting "Tests & procedures" in --warning asserts caution about a whole + // category that nothing has reviewed, which is what factsheets did before. + it("never resolves a category accent to a semantic token", () => { + const semantic = /--(danger|warning|success|info)\b/; + for (const accent of CATEGORY_ACCENTS) { + const block = globalsCss.split(`[data-category-accent="${accent}"] {`)[1]?.split("}")[0] ?? ""; + expect(block.length, `accent "${accent}" has an empty rule`).toBeGreaterThan(0); + expect(semantic.test(block), `accent "${accent}" resolves to a semantic token: ${block.trim()}`).toBe(false); + } + }); +}); diff --git a/tests/design-token-contract.test.ts b/tests/design-token-contract.test.ts index 0373764b9f..b4ace5efb1 100644 --- a/tests/design-token-contract.test.ts +++ b/tests/design-token-contract.test.ts @@ -471,6 +471,60 @@ describe("focus ring", () => { }); }); +describe("category accents stay out of the semantic palette", () => { + // Identity and status are two different vocabularies sharing one canvas. + // `semantic-tone.ts` owns six tones where the colour IS the claim — danger, + // warning, success, info and the two neutrals. A category accent says only + // "this belongs with those"; it must never borrow a token that says "pause" + // or "this passed a check". + // + // This is not hypothetical: factsheets shipped Therapies on `--success-text` + // and Tests & procedures on `--warning-text`, so an entire category of + // patient handouts wore caution-amber on its hero band without any review + // having produced that judgement. + const semanticFamilies = /--(danger|warning|success|info)[\w-]*/g; + + it("resolves every [data-category-accent] rule to a non-semantic triad", () => { + const rules = [...globals.matchAll(/\[data-category-accent="([\w-]+)"\]\s*\{([^}]*)\}/g)]; + expect(rules.length, "no [data-category-accent] rules found in globals.css").toBeGreaterThan(0); + for (const [, accent, body] of rules) { + const borrowed = body.match(semanticFamilies) ?? []; + expect(borrowed, `category accent "${accent}" borrows semantic token(s): ${borrowed.join(", ")}`).toEqual([]); + expect(body, `category accent "${accent}" declares no --cat-accent`).toContain("--cat-accent:"); + expect(body, `category accent "${accent}" declares no --cat-soft`).toContain("--cat-soft:"); + expect(body, `category accent "${accent}" declares no --cat-border`).toContain("--cat-border:"); + } + }); + + // The defect site itself. `categoryTheme` returns raw CSS value strings that + // ~20 call sites pass to inline `style`, so a semantic token written here + // reaches the page without passing through the `[data-category-accent]` rules + // the assertion above guards. It must stay derived from the registry rather + // than reacquiring a hand-written per-category table. + it("keeps the factsheet category theme derived and off semantic tokens", () => { + const source = readFileSync(new URL("../src/components/factsheets/factsheets-data.ts", import.meta.url), "utf8"); + const block = sourceSegment(source, "export function categoryTheme(", "\n}", { + label: "factsheet categoryTheme", + }); + const borrowed = block.match(semanticFamilies) ?? []; + expect(borrowed, `categoryTheme returns semantic token(s): ${borrowed.join(", ")}`).toEqual([]); + expect(block, "categoryTheme must resolve accents through the shared registry").toContain("categoryAccentVars"); + }); + + // Belt and braces for the type union itself. `CategoryAccent` is what makes a + // semantic accent unrepresentable at every call site at once; if a member is + // ever added from the status palette, the union stops being the guarantee. + it("declares no semantic member on the CategoryAccent union", () => { + const source = readFileSync(new URL("../src/lib/category-identity.ts", import.meta.url), "utf8"); + const union = sourceSegment(source, "export type CategoryAccent =", ";", { label: "CategoryAccent union" }); + for (const forbidden of ["danger", "warning", "success", "info"]) { + expect(union, `CategoryAccent must not offer "${forbidden}" as an identity accent`).not.toContain( + `"${forbidden}"`, + ); + } + }); +}); + describe("responsive breakpoint tokens (Task #336)", () => { it("declares standard named breakpoint tokens in :root, @theme, and ckb-v2", () => { expect(light.get("--bp-phone")).toBe("640px"); diff --git a/tests/dsm-category-colour-boundary.test.ts b/tests/dsm-category-colour-boundary.test.ts new file mode 100644 index 0000000000..de47f3539f --- /dev/null +++ b/tests/dsm-category-colour-boundary.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import dsmClinicalContent from "../src/data/dsm-clinical-content.json"; +import { dsmCategories } from "../src/lib/dsm"; + +/** + * The DSM export ships a per-category colour the app must not adopt. + * + * `src/data/dsm-clinical-content.json` is a vendored snapshot of the upstream + * `dsm-5-diagnosis` repository (no generator lives here), and every category in + * it carries `css_class` plus a raw hex `color`. Three of those hexes sit + * squarely on the semantic palette — red for Psychotic Disorders, green for OCD + * & Related, amber for Mood Disorders — so wiring them into the UI would assert + * danger, success and caution about diagnostic categories that nothing has + * reviewed. That is the same defect removed from factsheets in this branch, and + * `docs/clinical-badge-system-guide.md` states the rule it breaks: meaning + * drives the colour, never the other way round. + * + * The snapshot keeps the fields (editing it would diverge it from its source). + * These tests hold the boundary instead. + */ +describe("DSM category colour boundary", () => { + it("still finds the colours in the vendored export, so this guard stays meaningful", () => { + // If upstream ever drops them, this fails loudly rather than the guard + // quietly passing against nothing. + const raw = dsmClinicalContent as { categories: Array> }; + const withColour = raw.categories.filter((category) => "color" in category || "css_class" in category); + expect(withColour.length, "the export no longer ships category colours — retire this guard").toBeGreaterThan(0); + }); + + it("does not surface colour or css_class on the categories the app consumes", () => { + for (const category of dsmCategories) { + expect(Object.keys(category), `category "${category.key}" leaks a presentation field`).toEqual( + expect.not.arrayContaining(["color", "css_class"]), + ); + } + }); + + it("keeps DsmCategory free of the presentation fields, so reading one cannot compile", () => { + const source = readFileSync(new URL("../src/lib/dsm.ts", import.meta.url), "utf8"); + const start = source.indexOf("export type DsmCategory = {"); + expect(start, "DsmCategory declaration not found").toBeGreaterThan(-1); + const body = source.slice(start, source.indexOf("};", start)); + expect(body, "DsmCategory must not declare css_class").not.toContain("css_class"); + expect(body, "DsmCategory must not declare color").not.toContain("color:"); + }); + + it("has no source file reading a DSM category colour", () => { + // A regex over the type is not enough on its own: someone could reach the + // raw JSON directly and bypass the type entirely. + const offenders: string[] = []; + for (const file of ["src/lib/dsm.ts"]) { + const source = readFileSync(new URL(`../${file}`, import.meta.url), "utf8"); + // Comments explain the omission on purpose, so only look at code lines. + const code = source + .split("\n") + .filter( + (line) => !line.trim().startsWith("*") && !line.trim().startsWith("//") && !line.trim().startsWith("/*"), + ) + .join("\n"); + if (/\.css_class\b/.test(code) || /category\.color\b/.test(code)) offenders.push(file); + } + expect(offenders, `these files read a DSM category colour: ${offenders.join(", ")}`).toEqual([]); + }); +}); diff --git a/tests/dsm-search-empty-state.dom.test.tsx b/tests/dsm-search-empty-state.dom.test.tsx index 3a001c687b..a8ba9f6834 100644 --- a/tests/dsm-search-empty-state.dom.test.tsx +++ b/tests/dsm-search-empty-state.dom.test.tsx @@ -14,9 +14,7 @@ vi.mock("next/navigation", () => ({ // `EmptyState`, whose title was a `

` — so heading navigation silently // skipped the state and no test noticed (ledger #224). The heading is the // contract now, not the markup that happens to produce it. -const categories: DsmCategory[] = [ - { key: "mood", label: "Mood disorders", css_class: "mood", color: "#123456", diagnosis_count: 12 }, -]; +const categories: DsmCategory[] = [{ key: "mood", label: "Mood disorders", diagnosis_count: 12 }]; describe("DsmSearchPage empty state", () => { it("keeps a heading on the no-matches state so heading navigation reaches it", () => { @@ -53,8 +51,6 @@ describe("DsmSearchPage empty state", () => { const anxiety = { key: "anxiety", label: "Anxiety disorders", - css_class: "anxiety", - color: "#654321", diagnosis_count: 8, }; const results: DsmDiagnosisSummary[] = [ diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 969d5252ef..29078dcab7 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -3074,12 +3074,27 @@ test.describe("Responsive layout guards", () => { const privacyRegion = page.getByRole("region", { name: "Safety plan privacy" }); await expect(privacyRegion).toHaveCount(1); await expect(privacyRegion.getByText(/kept only in this browser tab/i)).toBeVisible(); - // Scope to the patient-copy panel — an unscoped getByText can strict-mode-fail - // when Playwright resolves nested/duplicate text nodes for the same notice. + // Pin the single-panel invariant BEFORE reading text out of it. + // + // Scoping to [data-safety-plan-copy] was already an attempt to dodge a + // strict-mode failure, and it proved insufficient: one Production UI run saw + // the notice resolve to two identical

elements, one reachable through + // getByRole("main") and one not. Nothing in the product renders it twice — + // the copy appears once in patient-safety-plan.tsx, the panel carries the + // attribute once, and /safety-plan mounts the component once, verified by + // 240 DOM samples across six loads through the hydration window — so what + // that run caught was a transient second tree during navigation, not a + // duplicate render. + // + // Asserting the count first makes the test wait for a settled single tree + // instead of sampling mid-swap, and it turns "there is exactly one patient + // copy panel" into something the suite states outright rather than assumes. + // That is strictly more coverage than the bare visibility check, so the + // flake is removed by tightening the assertion rather than loosening it. + const patientCopyPanel = page.locator("[data-safety-plan-copy]"); + await expect(patientCopyPanel).toHaveCount(1); await expect( - page - .locator("[data-safety-plan-copy]") - .getByText(/Copying, printing, or saving a PDF moves the plan outside Clinical KB/i), + patientCopyPanel.getByText(/Copying, printing, or saving a PDF moves the plan outside Clinical KB/i), ).toBeVisible(); await page.evaluate(() => {