From 83d310f34ee1be625d48d969eb436d9ea826071f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:15:40 +0000 Subject: [PATCH 01/36] Compare sheet: pin Done and Reset to the bottom of the phone sheet The picker's Done and Reset sat at the end of the scrolling body, below the screen edge on 390x844 and 390x667. On phones they now render as the sheet's pinned footer, with the list scrolling above; wider screens keep them inline. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../compare/compare-catalog-picker.tsx | 57 ++++++++++++------- src/components/compare/compare-ids-chrome.tsx | 3 + .../compare/compare-picker-shell.tsx | 20 ++++++- .../dictionary/dictionary-compare-page.tsx | 3 + .../differential-compare-picker-control.tsx | 3 + 5 files changed, 63 insertions(+), 23 deletions(-) diff --git a/src/components/compare/compare-catalog-picker.tsx b/src/components/compare/compare-catalog-picker.tsx index 47a6d94157..d2c4dde742 100644 --- a/src/components/compare/compare-catalog-picker.tsx +++ b/src/components/compare/compare-catalog-picker.tsx @@ -26,6 +26,7 @@ export function CompareCatalogPicker({ title, titleId, starters, + hideActions = false, }: { items: readonly CompareCatalogItem[]; query: string; @@ -44,6 +45,12 @@ export function CompareCatalogPicker({ title?: string; titleId?: string; starters?: readonly CompareStarterChip[]; + /** + * Leave Done and Reset out of the body. The phone sheet passes them as its pinned + * footer instead (see `ComparePickerActions`), so they stay on screen while the list + * scrolls above them. + */ + hideActions?: boolean; }) { const inputId = useId(); const inputRef = useRef(null); @@ -230,31 +237,37 @@ export function CompareCatalogPicker({ }) )} - {onDone || onReset ? ( -
- {onReset ? ( - - ) : null} - {onDone ? ( - - ) : null} -
- ) : null} + {!hideActions ? : null}

{announcement ?? ""}

); } + +/** Done and Reset for the compare picker: inline on wider screens, the pinned sheet footer on phones. */ +export function ComparePickerActions({ onDone, onReset }: { onDone?: () => void; onReset?: () => void }) { + if (!onDone && !onReset) return null; + return ( +
+ {onReset ? ( + + ) : null} + {onDone ? ( + + ) : null} +
+ ); +} diff --git a/src/components/compare/compare-ids-chrome.tsx b/src/components/compare/compare-ids-chrome.tsx index bf7445263d..9e0e66ec9a 100644 --- a/src/components/compare/compare-ids-chrome.tsx +++ b/src/components/compare/compare-ids-chrome.tsx @@ -144,6 +144,8 @@ export function CompareIdsChrome({ title={pickerTitle} description={pickerDescription} phone={picker.phone} + onDone={picker.close} + onReset={() => commit(padCompareIds([], maxCount))} id={pickerId} testId={pickerTestId} > @@ -158,6 +160,7 @@ export function CompareIdsChrome({ onChoose={choose} onDone={picker.close} onReset={() => commit(padCompareIds([], maxCount))} + hideActions={picker.phone} searchPlaceholder={searchPlaceholder} emptyHint="No matching items." announcement={announcement} diff --git a/src/components/compare/compare-picker-shell.tsx b/src/components/compare/compare-picker-shell.tsx index 1debbfa0a9..6f4114fbc7 100644 --- a/src/components/compare/compare-picker-shell.tsx +++ b/src/components/compare/compare-picker-shell.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; +import { ComparePickerActions } from "@/components/compare/compare-catalog-picker"; import { Sheet } from "@/components/ui/sheet"; export function ComparePickerShell({ @@ -13,6 +14,8 @@ export function ComparePickerShell({ id, testId, footer, + onDone, + onReset, children, }: { open: boolean; @@ -23,16 +26,31 @@ export function ComparePickerShell({ id?: string; testId?: string; footer?: ReactNode; + /** + * Phone only: Done and Reset are pinned as the sheet footer so they never fall below the + * screen edge. Pass the same handlers to the picker with `hideActions={phone}`; on wider + * screens the picker keeps them inline and these are ignored. + */ + onDone?: () => void; + onReset?: () => void; children: ReactNode; }) { if (phone) { + const pinnedActions = onDone || onReset ? : null; return ( + {footer} + {pinnedActions} + + ) : undefined + } portal placement="responsive-right" id={id} diff --git a/src/components/dictionary/dictionary-compare-page.tsx b/src/components/dictionary/dictionary-compare-page.tsx index 2c92b9adcb..b953ee842a 100644 --- a/src/components/dictionary/dictionary-compare-page.tsx +++ b/src/components/dictionary/dictionary-compare-page.tsx @@ -212,6 +212,8 @@ export function DictionaryComparePage({ a, b }: { a: DictionaryEntry | null; b: title="Change comparison" description="Choose two source-linked entries" phone={picker.phone} + onDone={picker.close} + onReset={() => pushPair(null, null)} id="dictionary-compare-picker" testId="dictionary-compare-picker" > @@ -226,6 +228,7 @@ export function DictionaryComparePage({ a, b }: { a: DictionaryEntry | null; b: onChoose={choose} onDone={picker.close} onReset={() => pushPair(null, null)} + hideActions={picker.phone} searchPlaceholder="Search term or abbreviation" emptyHint="No matching terms." announcement={announcement} diff --git a/src/components/differentials/differential-compare-picker-control.tsx b/src/components/differentials/differential-compare-picker-control.tsx index f4808cd46d..5eb49b9c61 100644 --- a/src/components/differentials/differential-compare-picker-control.tsx +++ b/src/components/differentials/differential-compare-picker-control.tsx @@ -90,6 +90,8 @@ export function DifferentialComparePickerControl({ title="Choose diagnoses to compare" description="Add or replace diagnoses in this comparison queue." phone={picker.phone} + onDone={picker.close} + onReset={() => commit([])} id="differential-compare-picker" testId="differential-compare-picker" > @@ -104,6 +106,7 @@ export function DifferentialComparePickerControl({ onChoose={choose} onDone={picker.close} onReset={() => commit([])} + hideActions={picker.phone} searchPlaceholder="Search diagnosis" emptyHint="No matching diagnoses." filterLocally From 6109d0780528a43e8a6213f061e3c1478f53293a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:17:41 +0000 Subject: [PATCH 02/36] Clinical lists: colour only where a row differs (audit item 8) Differentials urgency, service chips, source grades and medication match labels become grey words with a status shape only where one applies. The service Call bar becomes a compact phone button with the same tel: action. No label wording, urgency category or ranking changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/globals.css | 6 +- .../medication-prescribing-workspace.tsx | 25 ++-- .../differential-stream-workspace.tsx | 24 ++-- src/components/mode-kit/status-tag.tsx | 108 ++++++++++++++++++ .../services/services-navigator-page.tsx | 62 +++++----- .../sources/sources-catalogue-client.tsx | 16 ++- 6 files changed, 179 insertions(+), 62 deletions(-) create mode 100644 src/components/mode-kit/status-tag.tsx diff --git a/src/app/globals.css b/src/app/globals.css index 3a7888c755..2d20232d7f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3150,10 +3150,12 @@ html[data-motion="reduced"] .shared-compare-tray-layer[data-scroll-hidden="true" padding-right: max(1rem, var(--safe-area-right)); } + /* The top hit is a plain card like the rest (Clinical audit, 13 Oct 2026, + item 8): no blue rail and no blue wash, because being first is not a + status. The rule stays so the verdict rules below still win by order. */ .medication-mobile-result[data-selected="true"] { border-color: var(--border); - background: color-mix(in srgb, var(--clinical-accent-soft) 42%, var(--surface-raised)); - box-shadow: inset 3px 0 0 var(--clinical-accent); + background: var(--surface-raised); } /* The patient verdict owns the card edge and surface, including on the selected row. diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 3ff9c626df..0bcea7c59c 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -4,7 +4,6 @@ import { Activity, Ban, CalendarDays, - CircleCheck, ChevronRight, FileSearch, Lock, @@ -51,12 +50,7 @@ import { type MedicationVerdict, } from "@/lib/medication-interactions"; import { evaluatePatientAlerts } from "@/lib/medication-patient-alerts"; -import { - BadgeCluster, - ClinicalBadge, - type ClinicalBadgeItem, - type ClinicalBadgeTone, -} from "@/components/clinical-dashboard/clinical-badge"; +import { BadgeCluster, type ClinicalBadgeItem } from "@/components/clinical-dashboard/clinical-badge"; import { medicationIdentityBadges, type MedicationRecord } from "@/lib/medications"; import { medicationMatchValues, @@ -476,14 +470,15 @@ function ResultToneIcon({ result, accent }: { result: MedicationResult; accent?: return ; } -const matchBadgeTone: Record = { - teal: "clinical", - blue: "info", - slate: "neutral", -}; - +// The match label is quiet grey text, not a coloured pill (Clinical audit, 13 Oct +// 2026, item 8): it describes how the search matched, not a clinical status. +// The words come from src/lib/medications.ts unchanged. function ResultMatchBadge({ result }: { result: MedicationResult }) { - return ; + return ( + + {result.match} + + ); } // Small leading icon for the prescribing-action text: contraindication content is @@ -1041,7 +1036,7 @@ function MedicationResults({ const cardClassName = cn( "medication-mobile-result min-w-0 w-full rounded-lg border bg-[color:var(--surface-raised)] p-2 text-left shadow-[var(--shadow-inset)] transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", selected - ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)]/35" + ? "border-[color:var(--border)]" : result.href ? "border-[color:var(--border)] hover:border-[color:var(--border-strong)]" : "border-[color:var(--border)] opacity-80", diff --git a/src/components/differentials/differential-stream-workspace.tsx b/src/components/differentials/differential-stream-workspace.tsx index f043a130f1..d216cea9bf 100644 --- a/src/components/differentials/differential-stream-workspace.tsx +++ b/src/components/differentials/differential-stream-workspace.tsx @@ -7,6 +7,7 @@ import { ArrowLeft, ArrowRight, Check, ChevronRight, FileText, Network, ShieldAl import { pageHeadingDescriptionClass, pageHeadingTitleClass } from "@/components/ui/page-header"; import { cn } from "@/components/ui-primitives"; +import { StatusTag, type StatusTagShape } from "@/components/mode-kit/status-tag"; import { ResultFilterSheet, @@ -77,17 +78,14 @@ function statusLabel(status: DifferentialStreamItem["status"]) { return "Routine"; } -function statusTone(status: DifferentialStreamItem["status"]) { - // Emergent stays red because it is a genuine urgency, but as a tag rather - // than a solid red block: most pathways carry it, and a wall of solid red - // stops reading as a warning (design pass, October 2026). - if (status === "emergent") { - return "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]"; - } - if (status === "urgent") { - return "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]"; - } - return "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]"; +function statusShape(status: DifferentialStreamItem["status"]): StatusTagShape { + // Urgency is a small status shape beside a grey word (Clinical audit, 13 Oct + // 2026, item 8): most pathways are Emergent, and a wall of red pills stopped + // reading as a warning. Red triangle = emergent, amber dot = urgent, routine + // is a plain grey word. The grouping and the words are unchanged. + if (status === "emergent") return "urgent"; + if (status === "urgent") return "soon"; + return "none"; } function likelihoodTone(likelihood: DifferentialLikelihood) { @@ -185,9 +183,7 @@ function StreamCard({ {isPresentation ? ", open pathway" : ", open"} - - {statusLabel(item.status)} - + {statusLabel(item.status)}

{item.description}

{isPresentation && item.candidateCount !== null ? ( diff --git a/src/components/mode-kit/status-tag.tsx b/src/components/mode-kit/status-tag.tsx new file mode 100644 index 0000000000..3b223be304 --- /dev/null +++ b/src/components/mode-kit/status-tag.tsx @@ -0,0 +1,108 @@ +import type { ReactNode } from "react"; + +import { cn } from "@/components/ui-primitives"; + +/** + * The clinical lists' one quiet status tag (Clinical audit, 13 Oct 2026, item 8). + * + * The same shapes as the work kit's `.work-tag` and Admin's status mark (Ward + * Flow review, 12 Oct 2026), for pages outside the work frame: the word is + * always plain grey and only the small shape carries colour. + * + * urgent red triangle (overdue, emergent, crisis) + * soon amber dot (due soon, urgent but not emergent) + * done tick (done, active, current) + * unrecorded dashed ring (not recorded, not verified) + * none no shape (a plain grey label) + * + * Colour is never the only signal: the word always travels with the shape, and + * the shape is `aria-hidden` so a screen reader hears the word once. + */ +export type StatusTagShape = "urgent" | "soon" | "done" | "unrecorded" | "none"; + +export function StatusTagMark({ shape, className }: { readonly shape: StatusTagShape; readonly className?: string }) { + const base = cn("size-2.5 shrink-0 forced-colors:text-[CanvasText]", className); + if (shape === "urgent") { + return ( + + ); + } + if (shape === "soon") { + return ( + + ); + } + if (shape === "done") { + return ( + + ); + } + if (shape === "unrecorded") { + return ( + + ); + } + return null; +} + +/** A grey word on a flat wash with a hairline, led by its status shape. */ +export function StatusTag({ + shape = "none", + children, + className, + testId, +}: { + readonly shape?: StatusTagShape; + readonly children: ReactNode; + readonly className?: string; + readonly testId?: string; +}) { + return ( + + + {children} + + ); +} diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index 86f89c6a66..ecf4b93f50 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -33,7 +33,7 @@ import { resultFilterFacetGroup, resultFilterGroup, } from "@/components/clinical-dashboard/result-filter-control"; -import { Chip as DesignChip, type ChipStatusTone } from "@/components/ui/chip"; +import { StatusTag, type StatusTagShape } from "@/components/mode-kit/status-tag"; import { cn } from "@/components/ui-primitives"; import { useResultSort } from "@/components/use-result-sort"; import { compactBestUseTitle } from "@/lib/compact-best-use-title"; @@ -79,9 +79,19 @@ function compactText(value: string | null | undefined, maxLength: number, fallba return trimmed ? compactBestUseTitle(trimmed, maxLength) : fallback; } -function chipTone(tone: ServiceStatusChip["tone"] | undefined | null): ChipStatusTone { - if (tone === "danger" || tone === "info" || tone === "warning" || tone === "success") return tone; - return "neutral"; +/** + * One status shape per chip, only where a shape means something (Clinical audit, + * 13 Oct 2026, item 8): crisis or urgent is a red triangle, an unverified + * availability is a dashed ring, an active service is a tick. Every other chip + * (acuity, setting, region) is a plain grey word. The chip words themselves are + * unchanged. + */ +function serviceChipShape(chip: ServiceStatusChip): StatusTagShape { + const label = chip.label?.toLowerCase() ?? ""; + if (/\b(crisis|urgent|emergency)\b/.test(label)) return "urgent"; + if (/unverified|not verified|unconfirmed/.test(label)) return "unrecorded"; + if (/^active$/.test(label.trim())) return "done"; + return "none"; } function serviceChipLabel(chip: ServiceStatusChip) { @@ -147,7 +157,9 @@ function ServiceCard({ // it doubles as the shortlist checkmark. Services also has no single // category axis — records carry facets — so there is nothing honest to // put there instead. - (selected || showBestFit) && cardSelected, + // Only the shortlist tints a card. "Best fit" is a grey word beside the + // title, so colour keeps meaning "you picked this" (item 8). + selected && cardSelected, )} >
@@ -156,7 +168,7 @@ function ServiceCard({ "grid h-9 w-9 place-items-center rounded-lg border text-sm font-extrabold", selected ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)]" - : "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]", + : "border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-heading)]", )} aria-label={`Result ${index + 1}${selected ? ", shortlisted" : ""}`} > @@ -177,11 +189,7 @@ function ServiceCard({ {service.title} - {showBestFit ? ( - - Best fit - - ) : null} + {showBestFit ? Best fit : null}
{/* A fixed 150-character cut clipped short records mid-word at every width ("Immedia…" on 13YARN, ledger #8RWKA0). Clamp by lines on a @@ -193,18 +201,6 @@ function ServiceCard({ "Open the record to review service fit and referral details.", )}

-
- {(service.statusChips ?? []).slice(0, 3).map((chip) => ( - - {serviceChipLabel(chip)} - - ))} -
{/* Favourite, not shortlist: the bookmark persists to the account across sessions, while the shortlist below is this search's @@ -260,16 +256,26 @@ function ServiceCard({ no number anywhere. The card now leads with the phone number and a call button; "Review referral" and "Add to shortlist" sit behind "More" (the whole card still opens the record). */} -
+ {/* Tags, then the call and "More" on the same row (item 8): the call is + a compact 48px phone button rather than a full-width blue bar, so + 252 cards no longer read as a wall of colour. Same tel: action. */} +
+
+ {(service.statusChips ?? []).slice(0, 3).map((chip) => ( + + {serviceChipLabel(chip)} + + ))} +
{call ? ( - Call {call.display} ) : null} - - ); -} +import { therapyKeyFactCards } from "./key-fact-cards"; /** - * The bottom line of a card. It renders even with nothing to say, because a card - * that drops the line is 1rem shorter in its body zone than its neighbours and the - * row stops lining up. The blank stays out of the accessibility tree. - */ -function CardFootnote({ children }: { children?: ReactNode }) { - return ( -

- {children ??  } -

- ); -} - -/** - * The four facts worth reading before anything else, at the top of the record. + * The record's "At a glance" card: Format, Setting and Suits, each in full. * - * Cautions / Format / Setting / Suits. Evidence used to occupy the first tile - * and named a source it did not show; review status is on the hero badge and - * provenance is the collapsed strip at the foot. Long fields are glanced here - * and opened in a sheet — the same contract as form priority-fact cards — - * rather than line-clamped mid-word. The full fields remain in the body. + * These used to be four tiles with a glance line and "Tap for detail", which + * cut the facts short on the screen a clinician reads first. The shared record + * layout shows every value whole instead. Cautions is no longer a tile here: it + * is the full Cautions block directly after this card (`TherapySafetySection`), + * so the safety text is never truncated anywhere on the record. */ export function TherapyKeyFacts({ therapy }: { therapy: Therapy }) { - const cards = therapyKeyFactCards(therapy); - const [activeId, setActiveId] = useState(null); - const active = cards.find((card) => card.id === activeId) ?? null; - - return ( - <> -
- {cards.map((card) => ( - setActiveId(card.id) : undefined} /> - ))} -
+ const facts: RecordGlanceFact[] = therapyKeyFactCards(therapy) + .filter((card) => card.id !== "cautions") + .map((card) => ({ key: card.id, label: card.label, value: card.body })); - setActiveId(null)} - title={active?.label ?? "Key fact"} - testId="therapy-key-fact-sheet" - mobilePlacement="bottom" - > - {active ? : null} - - - ); + return ; } diff --git a/src/components/therapy-compass/record/record-sections.tsx b/src/components/therapy-compass/record/record-sections.tsx index 113cde5762..442fd4eb20 100644 --- a/src/components/therapy-compass/record/record-sections.tsx +++ b/src/components/therapy-compass/record/record-sections.tsx @@ -1,42 +1,22 @@ -import { - ArrowUpRight, - Clock, - FileText, - MapPin, - ShieldCheck, - Target, - TriangleAlert, - Users, - type LucideIcon, -} from "lucide-react"; import type { ReactNode } from "react"; -import { cardSurface } from "@/components/card-recipes"; -import { cn } from "@/components/ui-primitives"; +import { RecordCautions, RecordSection as SharedRecordSection } from "@/components/clinical-record-panels"; import { extractCitations, ProseBlock } from "../prose"; import { parseSteps, splitIndications } from "../data/select"; import type { Therapy } from "../data/types"; +import { therapyKeyFactCards } from "./key-fact-cards"; -function RecordSection({ icon: Icon, title, children }: { icon: LucideIcon; title: string; children: ReactNode }) { - return ( -
- - -
-

{title}

- {children} -
-
- ); +/** One body section in the shared record style: a plain heading, the content below. */ +function RecordSection({ title, children }: { title: string; children: ReactNode }) { + return {children}; } -function TextSection({ icon, title, body }: { icon: LucideIcon; title: string; body: string | null }) { +function TextSection({ title, body }: { title: string; body: string | null }) { const text = body?.trim(); if (!text) return null; return ( - + ); @@ -52,10 +32,10 @@ function TextSection({ icon, title, body }: { icon: LucideIcon; title: string; b */ function DeliverySection({ therapy }: { therapy: Therapy }) { const steps = parseSteps(therapy.deliverySteps); - if (!steps.length) return ; + if (!steps.length) return ; return ( - +
    {steps.map((step, index) => { const { text, citations } = extractCitations(step); @@ -88,30 +68,25 @@ function DeliverySection({ therapy }: { therapy: Therapy }) { * itself. `alternatives` is deliberately not rendered: it is byte-identical to * `limitations` in every record in the catalogue. */ -function SafetySection({ therapy }: { therapy: Therapy }) { +export function TherapySafetySection({ therapy }: { therapy: Therapy }) { const cautions = therapy.contraindicationsOrCautions?.trim() ?? ""; const limitations = therapy.limitations?.trim() ?? ""; const text = limitations && !cautions.includes(limitations) ? `${cautions} ${limitations}`.trim() : cautions; const warnings = therapy.warnings.filter(Boolean); if (!text && !warnings.length) return null; + // Directly after At a glance and never clamped: safety text is read in full. return ( -
    - - -
    -

    Safety & cautions

    - {text ? : null} - {warnings.length ? ( -
      - {warnings.map((warning) => ( -
    • {warning}
    • - ))} -
    - ) : null} -
    -
    + + {text ? : null} + {warnings.length ? ( +
      + {warnings.map((warning) => ( +
    • {warning}
    • + ))} +
    + ) : null} +
    ); } @@ -127,31 +102,34 @@ function SafetySection({ therapy }: { therapy: Therapy }) { * * `homework` is titled "Stepping up or switching" because that is what the * field actually holds in every record; the field name is an import artefact. + * + * A section whose text is exactly the value the At a glance card already shows + * in full (Format, Setting, Suits) is not printed a second time. */ export function TherapyRecordSections({ therapy }: { therapy: Therapy }) { const goals = splitIndications(therapy); + const glanced = new Set( + therapyKeyFactCards(therapy) + .filter((card) => card.id !== "cautions") + .map((card) => card.body.trim()), + ); + const unlessGlanced = (body: string | null | undefined) => { + const text = body?.trim() ?? ""; + return text && glanced.has(text) ? null : (body ?? null); + }; return ( -
    -
    - - - - - - {/* `timeRequired` is prose about session counts, protocol length and - intensity. The Format tile opens it in a sheet; the reading column - still carries the full field. */} - - {/* The key-facts tile glances this as `{first} +N` and opens the full - list in a sheet. Several records list four or five settings, so the - reading column still carries the complete field. */} - - - - -
    - +
    + + + + + + + + + +
    ); } diff --git a/src/components/therapy-compass/screens/detail-screen.tsx b/src/components/therapy-compass/screens/detail-screen.tsx index a7862d7bc6..4598fb475f 100644 --- a/src/components/therapy-compass/screens/detail-screen.tsx +++ b/src/components/therapy-compass/screens/detail-screen.tsx @@ -3,20 +3,18 @@ import { useMemo } from "react"; import { InformationPageFooter, InformationPageShell } from "@/components/information-page-shell"; -import { PageHeader } from "@/components/ui/page-header"; -import { cn } from "@/components/ui-primitives"; +import { RecordBody, RecordIntro } from "@/components/clinical-record-panels"; import { DesktopSplitList, type DesktopSplitGroup } from "@/components/work-frame/desktop-split-list"; import { ClinicalSplit } from "@/components/work-frame/work-desktop-layout"; import { therapyRecordHref, therapyScreenHref } from "@/lib/therapy-compass-navigation"; import { useTcBindings } from "../bindings"; -import { heroCard } from "../controls"; import { cardPreviewText } from "../data/select"; import { TherapyKeyFacts } from "../record/key-facts"; import { RelatedTherapies } from "../record/related-therapies"; import { TherapyCompareAction } from "../record/compare-action"; import { TherapySaveNotice } from "../record/save-notice"; -import { TherapyRecordSections } from "../record/record-sections"; +import { TherapyRecordSections, TherapySafetySection } from "../record/record-sections"; import { TherapySourceProvenance } from "../record/source-provenance"; import { TherapyRecordNavHeader } from "../therapy-record-nav-header"; import { LoadingState, StatusBadge, TagRow } from "../ui"; @@ -93,40 +91,39 @@ export function DetailScreen() { list={} detail={ <> -
    - - -
    - + + + - {t.aliases.length ? ( -

    - Also known as {t.aliases.join(", ")} -

    - ) : null} -
    - - -
    -
    + t.aliases.length ? ( +

    + Also known as {t.aliases.join(", ")} +

    + ) : null } + tags={ + <> + + + + } + actions={} /> -
    - - - - - b.open(slug)} /> - + + + + b.open(slug)} /> + + {/* Therapy records name their sources but do not link to them yet (ledger #05WXHX). */} diff --git a/tests/therapy-compact-row.dom.test.tsx b/tests/therapy-compact-row.dom.test.tsx index 046455229c..9d1f4ba101 100644 --- a/tests/therapy-compact-row.dom.test.tsx +++ b/tests/therapy-compact-row.dom.test.tsx @@ -73,6 +73,7 @@ vi.mock("@/components/clinical-dashboard/universal-search-also-matches", () => ( UniversalSearchAlsoMatches: () => null, })); +import { TherapyKeyFacts } from "@/components/therapy-compass/record/key-facts"; import { TherapyRecordSections } from "@/components/therapy-compass/record/record-sections"; import { SearchScreen } from "@/components/therapy-compass/screens/search-screen"; import { therapyAvoidModifyText } from "@/components/therapy-compass/therapy-card"; @@ -130,7 +131,14 @@ describe("Therapy compact rows", () => { it("still shows what the row used for Best used for and Best fit, word for word, in the record", () => { const record = therapy(); - render(); + // Setting and Suits are shown in full in the At a glance card and are not + // printed a second time in the body, so the record is both together. + render( + <> + + + , + ); for (const text of [record.bestUsedFor, record.targetSymptoms, record.setting, record.patientPopulation]) { expect(screen.getByText(text!, { exact: false })).toBeInTheDocument(); diff --git a/tests/therapy-compass-responsive-contract.test.ts b/tests/therapy-compass-responsive-contract.test.ts index c9ecb23862..c14f8083ae 100644 --- a/tests/therapy-compass-responsive-contract.test.ts +++ b/tests/therapy-compass-responsive-contract.test.ts @@ -19,6 +19,7 @@ const informationPageShellSource = read("src/components/information-page-shell.t const printOutputSource = read("src/components/ui/print-output.tsx"); const detailSource = read(`${therapyPath}/screens/detail-screen.tsx`); const keyFactsSource = read(`${therapyPath}/record/key-facts.tsx`); +const recordPanelsSource = read("src/components/clinical-record-panels.tsx"); const compareSource = read(`${therapyPath}/screens/compare-screen.tsx`); const recommendSource = read(`${therapyPath}/screens/recommend-screen.tsx`); const recommendFieldsSource = read(`${therapyPath}/recommend-scenario-fields.tsx`); @@ -164,12 +165,12 @@ describe("Therapy Compass responsive contract", () => { // tests/mode-home-no-caveat-footer.test.ts rather than by name here. // The record page is a single reading column at every width now, so it has // no multi-column grid of its own to reflow and no sticky rail to un-stick. - // What replaced them is the key-facts strip, which is phone-first in the - // medication `DetailTile` shape: two up on a phone, four across from `sm`. - // Four short tiles at one-per-row would push the record body a full screen - // down, which is the fold problem this page was rebuilt to fix. - expect(keyFactsSource).toContain("grid-cols-2"); - expect(keyFactsSource).toContain("sm:grid-cols-4"); + // What replaced them is the shared record layout's "At a glance" card + // (clinical-record-panels): label/value pairs in full, two up from 22rem and + // one column below it, so the large-text setting on a 320px phone reflows + // to a single column instead of squeezing two. + expect(keyFactsSource).toContain("RecordGlance"); + expect(recordPanelsSource).toContain("min-[22rem]:grid-cols-2"); expect(detailSource).not.toContain("sticky"); expect(responsiveStackCount(compareSource)).toBeGreaterThanOrEqual(1); expect(compareSource).toContain(" { - it("renders four tappable glance cards and opens the full Suits and Format fields", async () => { - const user = userEvent.setup(); + it("renders one At a glance card with Format, Setting and Suits in full", () => { const therapy = bySlug("cognitive-behavioural-therapy-for-insomnia"); renderFacts(therapy); - const facts = screen.getByLabelText("Key facts"); + const facts = screen.getByRole("region", { name: "At a glance" }); expect(within(facts).queryByText("Evidence")).not.toBeInTheDocument(); - expect(within(facts).getByText("Cautions")).toBeInTheDocument(); + // Cautions is the full block directly after this card, not a tile in it. + expect(within(facts).queryByText("Cautions")).not.toBeInTheDocument(); expect(within(facts).getByText("Format")).toBeInTheDocument(); expect(within(facts).getByText("Setting")).toBeInTheDocument(); expect(within(facts).getByText("Suits")).toBeInTheDocument(); - expect(within(facts).getByText("Emergency/acute +2")).toBeInTheDocument(); - expect(within(facts).getByText("Group programme")).toBeInTheDocument(); - expect(within(facts).getAllByText("Tap for detail")).toHaveLength(4); - expect(within(facts).queryByText(therapy.patientPopulation!)).not.toBeInTheDocument(); - - await user.click(within(facts).getByRole("button", { name: /Suits:.*Open detail/i })); - const suitsSheet = await screen.findByTestId("therapy-key-fact-sheet"); - expect(suitsSheet).toHaveTextContent(/sleep diaries/i); - expect(suitsSheet).toHaveTextContent(/sleep restriction or stimulus control/i); - - await user.click(within(suitsSheet).getByRole("button", { name: /close/i })); - expect(screen.queryByTestId("therapy-key-fact-sheet")).not.toBeInTheDocument(); - - await user.click(within(facts).getByRole("button", { name: /Format:.*Open detail/i })); - const formatSheet = await screen.findByTestId("therapy-key-fact-sheet"); - expect(formatSheet).toHaveTextContent(/4–8 sessions/); - expect(formatSheet).toHaveTextContent(/between-session behavioural practice/); + expect(within(facts).queryByText("Tap for detail")).not.toBeInTheDocument(); + expect(within(facts).queryByRole("button")).not.toBeInTheDocument(); + expect(within(facts).getByText(therapy.patientPopulation!.trim())).toBeInTheDocument(); + expect(facts).toHaveTextContent(/sleep diaries/i); + expect(facts).toHaveTextContent(/4–8 sessions/); + expect(facts).toHaveTextContent(/between-session behavioural practice/); }); - it("shows the full Cautions field immediately in its detail sheet", async () => { - const user = userEvent.setup(); - const caution = "Use a coordinated safety plan and review alternatives before proceeding."; - renderFacts({ - ...bySlug("cognitive-behavioural-therapy-for-insomnia"), - contraindicationsOrCautions: Array.from({ length: 8 }, () => caution).join(" "), - limitations: null, - }); + it("shows the whole Setting list rather than a first-item-plus-count glance", () => { + const therapy = bySlug("cognitive-behavioural-therapy-for-insomnia"); + renderFacts(therapy); - const facts = screen.getByLabelText("Key facts"); - await user.click(within(facts).getByRole("button", { name: /Cautions:.*Open detail/i })); - const cautionsSheet = await screen.findByTestId("therapy-key-fact-sheet"); - expect(cautionsSheet).toHaveTextContent(caution); - expect(within(cautionsSheet).queryByRole("button", { name: /Show more of Cautions/i })).not.toBeInTheDocument(); + const facts = screen.getByRole("region", { name: "At a glance" }); + expect(within(facts).queryByText("Emergency/acute +2")).not.toBeInTheDocument(); + expect(within(facts).getByText(therapy.setting!.trim())).toBeInTheDocument(); }); - it("leaves a card inert when the face is the whole field", () => { + it("shows short fields as they are, with nothing to open", () => { renderFacts({ ...bySlug("cognitive-behavioural-therapy-for-insomnia"), contraindicationsOrCautions: "Avoid in acute mania.", @@ -88,10 +69,11 @@ describe("TherapyKeyFacts", () => { patientPopulation: "Adults.", }); - const facts = screen.getByLabelText("Key facts"); - expect(within(facts).queryByRole("button", { name: /Open detail/i })).not.toBeInTheDocument(); - expect(within(facts).queryByText("Tap for detail")).not.toBeInTheDocument(); - expect(within(facts).getByText("Avoid in acute mania.")).toBeInTheDocument(); + const facts = screen.getByRole("region", { name: "At a glance" }); + expect(within(facts).queryByRole("button")).not.toBeInTheDocument(); + expect(within(facts).queryByText("Avoid in acute mania.")).not.toBeInTheDocument(); + expect(within(facts).getByText("Single session")).toBeInTheDocument(); expect(within(facts).getByText("Outpatient")).toBeInTheDocument(); + expect(within(facts).getByText("Adults.")).toBeInTheDocument(); }); }); From ccd8bc15b9d311780d52b494f8213a014ab0072b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:25:00 +0000 Subject: [PATCH 04/36] DSM record: move the body onto the shared record layout Name, one line and tags; the existing ICD-10 copy and Compare in one action row; the four summary tiles become one At a glance card; criteria, key features and specifiers as plain sections; documentation support unchanged; differential considerations, record summary and the reference notice close the record in one reading column. All wording and facts unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/dsm/dsm-diagnosis-page.tsx | 489 +++++++++------------- 1 file changed, 192 insertions(+), 297 deletions(-) diff --git a/src/components/dsm/dsm-diagnosis-page.tsx b/src/components/dsm/dsm-diagnosis-page.tsx index 154eed8004..7f198861eb 100644 --- a/src/components/dsm/dsm-diagnosis-page.tsx +++ b/src/components/dsm/dsm-diagnosis-page.tsx @@ -1,24 +1,14 @@ import Link from "next/link"; -import type { ComponentType } from "react"; -import { - BookOpenCheck, - ChevronRight, - Search, - ClipboardList, - GitCompareArrows, - Gauge, - ListChecks, - ShieldCheck, - Signpost, - SlidersHorizontal, -} from "lucide-react"; +import { BookOpenCheck, ChevronRight, Search, GitCompareArrows, ShieldCheck, Signpost } from "lucide-react"; import { CrossModeLinksSection } from "@/components/clinical-dashboard/cross-mode-links"; import { DsmDiagnosisNavHeader } from "@/components/dsm/dsm-diagnosis-nav-header"; import { DsmDiagnosisNoteBuilder } from "@/components/dsm/dsm-diagnosis-note-builder"; -import { DsmPageHeader } from "@/components/dsm/dsm-page-header"; +import { DsmCodeCopy } from "@/components/dsm/dsm-code-copy"; +import { RecordBody, RecordGlance, RecordIntro, RecordSection } from "@/components/clinical-record-panels"; import { InformationPageShell } from "@/components/information-page-shell"; -import { inPageActionRowClass, inPageAnchor } from "@/components/in-page-nav/in-page-nav-classes"; +import { buttonFaceClass } from "@/components/ui/button-face"; +import { inPageActionRowClass } from "@/components/in-page-nav/in-page-nav-classes"; import { cn, codeText, metadataPill, pageContainer } from "@/components/ui-primitives"; import { DesktopSplitList, type DesktopSplitGroup } from "@/components/work-frame/desktop-split-list"; import { ClinicalSplit } from "@/components/work-frame/work-desktop-layout"; @@ -48,29 +38,6 @@ function countLabel(count: number, singular: string, plural: string) { return `${count} ${count === 1 ? singular : plural}`; } -/** One label/value pair in the at-a-glance row. Non-interactive by design. */ -function SummaryTile({ - icon: Icon, - label, - value, -}: { - icon: ComponentType<{ className?: string }>; - label: string; - value: string; -}) { - return ( -
    -
    - - {label} -
    -
    - {value} -
    -
    - ); -} - function CriteriaRow({ criterion, index, @@ -82,8 +49,8 @@ function CriteriaRow({ }) { const ordinal = criterion.label || index + 1; return ( -
  1. - +
  2. + {ordinal}
    @@ -188,230 +155,180 @@ export function DsmDiagnosisPage({ diagnosis }: { diagnosis: DsmDiagnosis }) { list={} detail={ <> - - -
    - {/* - The shape of the record, not its content. This row previously rendered - `criteria.slice(0, 4)` verbatim, which the `#criteria` panel below already - lists in full — so every diagnosis stated its criteria twice, the second - time truncated. It also rendered `min(criteria.length, 4)` cards, and only - 86 of 146 records carry four or more criteria, so the row was a ragged one - to four tiles rather than a stable strip. +
    + + + {diagnosis.category.label} + Local clinical reference + + } + actions={ + <> + + + + Compare + + + } + /> - Every field read here is populated on all 146 records, so this is always - exactly four tiles. `icd_code` and the category are deliberately absent: - the page header already carries both as chips, and repeating them would be - the same defect in a new place. - */} -
    -
    - - - - -
    -
    + {/* + The shape of the record, not its content: counts, never the + criteria themselves, which the section below lists in full. + Every field read here is populated on all 146 records. `icd_code` + and the category are deliberately absent: the intro already + carries both. + */} + -
    -
    -
    -
    - - - -
    -

    - {isDsmCriteria ? "Core diagnostic criteria" : "Key features"} -

    -

    - {isDsmCriteria - ? "All criteria are shown. No expand controls or hidden sections." - : "The full DSM-5-TR criteria are not included in this record. Everything the record does hold is shown below. Check DSM-5-TR before recording the diagnosis."} -

    -
    -
    - {criteria.length ? ( -
      - {criteria.map((criterion, index) => ( - - ))} -
    - ) : ( -

    - Neither criteria nor key features were included in the supplied record. This is not a - statement that DSM-5-TR defines none. -

    - )} -
    + +

    + {isDsmCriteria + ? "All criteria are shown. No expand controls or hidden sections." + : "The full DSM-5-TR criteria are not included in this record. Everything the record does hold is shown below. Check DSM-5-TR before recording the diagnosis."} +

    + {criteria.length ? ( +
      + {criteria.map((criterion, index) => ( + + ))} +
    + ) : ( +

    + Neither criteria nor key features were included in the supplied record. This is not a statement + that DSM-5-TR defines none. +

    + )} +
    - {diagnosis.key_features.length > 0 && diagnosis.criteria_display.length > 0 ? ( -
    -
    - -

    0 && diagnosis.criteria_display.length > 0 ? ( + +
      + {diagnosis.key_features.map((feature) => ( +
    • - Key features -

    -
    -
      - {diagnosis.key_features.map((feature) => ( -
    • - {feature.label}. - {feature.text} -
    • - ))} -
    -
    - ) : null} + {feature.label}. + {feature.text} +
  3. + ))} + + + ) : null} -
    -
    - -

    - Specifiers -

    - - {specifiers.length} - + {specifiers.length} + } + > + {specifiers.length ? ( +
    + {specifiers.map((specifier) => ( +
    +
    {specifier.name}
    +
    + {specifier.description || "No additional description supplied."} +
    +
    + ))} +
    + ) : ( +
    + {absentNotes.length ? ( + absentNotes.map((note) => ( +

    + {note.name}. + {note.description ? ` ${note.description}` : ""} +

    + )) + ) : ( +

    No specifiers were included in the supplied record.

    + )}
    - {specifiers.length ? ( -
    - {specifiers.map((specifier) => ( -
    -
    - {specifier.name} -
    -
    - {specifier.description || "No additional description supplied."} -
    -
    - ))} -
    - ) : ( -
    - {absentNotes.length ? ( - absentNotes.map((note) => ( -

    - {note.name}. - {note.description ? ` ${note.description}` : ""} -

    - )) - ) : ( -

    No specifiers were included in the supplied record.

    - )} -
    - )} -
    + )} + - + - {/* Renders nothing when no other mode holds a match for this - diagnosis, so a record with no medication, service or form - match keeps the page exactly as it was. */} - -
- - -
+ - {/* The record id and catalogue moved into the Record summary card, - which labels them. Repeating them here left the page ending on two - unlabelled pills that said the same thing. */} -
- DSM-5 Diagnosis -
+
+ DSM-5 Diagnosis +
+ } From e7e04272121211e062bbb421922febac4bb04ad0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:25:28 +0000 Subject: [PATCH 05/36] Clinical lists: phone A to Z strip and tidier Dictionary rows (audit items 13, 14) One shared press-and-drag strip for long A to Z phone lists, used on the Dictionary and on Services when sorted A to Z. Letters that exist only, one keyboard stop, hidden at desktop widths and when large text cannot fit. Dictionary rows drop the unclear Browse result label and keep a labelled source tick and the compare link. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../dictionary/dictionary-catalogue-pages.tsx | 22 +- .../dictionary/dictionary-result-row.tsx | 20 +- src/components/mode-kit/alphabet-strip.tsx | 276 ++++++++++++++++++ .../services/services-navigator-page.tsx | 21 +- 4 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 src/components/mode-kit/alphabet-strip.tsx diff --git a/src/components/dictionary/dictionary-catalogue-pages.tsx b/src/components/dictionary/dictionary-catalogue-pages.tsx index ca5b6f99b4..e71ce91510 100644 --- a/src/components/dictionary/dictionary-catalogue-pages.tsx +++ b/src/components/dictionary/dictionary-catalogue-pages.tsx @@ -26,6 +26,7 @@ import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search- import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { DictionaryResultRow } from "@/components/dictionary/dictionary-result-row"; +import { AlphabetStrip, alphabetStripTargetClass } from "@/components/mode-kit/alphabet-strip"; import { InPageNavHeader } from "@/components/in-page-nav/in-page-nav-header"; import { type PageSection } from "@/components/in-page-nav/page-section-index"; import { useInPageSectionNav } from "@/components/in-page-nav/use-in-page-section-nav"; @@ -132,6 +133,10 @@ function dictionaryHitKey(hit: DictionarySearchHit) { : `abbr-${hit.abbreviation}`; } +function dictionaryLetterTargetId(letter: string) { + return `dictionary-letter-${letter === "#" ? "other" : letter}`; +} + /** * Splits an alphabetical list into letter groups ("A · 6 terms"). A ranked * search result is one group with no heading: its order is relevance, and @@ -481,7 +486,11 @@ export function DictionaryCataloguePage() { {hits.length ? (
{hitGroups.map((group) => ( -
+
{group.letter ? (

@@ -568,6 +577,17 @@ export function DictionaryCataloguePage() {

)}
+ {/* Phone A to Z strip (audit item 13): only while the list is grouped by + letter, and only the letters that are there. */} + {hitGroups.some((group) => group.letter) ? ( + + group.letter ? [{ letter: group.letter, targetId: dictionaryLetterTargetId(group.letter) }] : [], + )} + /> + ) : null} {/* The catalogue shell is `bleed`, so every band owns its own container. Without one this cross-mode tray stretched the full viewport while the result list above it stayed inside the catalogue width, which diff --git a/src/components/dictionary/dictionary-result-row.tsx b/src/components/dictionary/dictionary-result-row.tsx index 13cc1c537b..7675c1fc13 100644 --- a/src/components/dictionary/dictionary-result-row.tsx +++ b/src/components/dictionary/dictionary-result-row.tsx @@ -28,6 +28,9 @@ const rowIcon = type TitleTag = "h2" | "h3"; +/** The reason `src/lib/dictionary.ts` gives every row when nothing is typed. */ +const BROWSE_REASON = "Browse result"; + export function DictionaryResultRow({ hit, compareWith, @@ -191,14 +194,25 @@ export function DictionaryResultRow({ ); } +/** + * A small source tick, then the match reason only when a search produced one + * (Clinical audit, 13 Oct 2026, item 14). While browsing every row said + * "Source linked · Browse result", which was unclear and the same on all 96 + * rows; the tick keeps the "Source linked" meaning as its accessible name. + */ function SourceLine({ label }: { label: string }) { + const reason = label === BROWSE_REASON ? null : label; return ( - + - Source linked - {label} + {reason} ); } diff --git a/src/components/mode-kit/alphabet-strip.tsx b/src/components/mode-kit/alphabet-strip.tsx new file mode 100644 index 0000000000..d2b1162e30 --- /dev/null +++ b/src/components/mode-kit/alphabet-strip.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type KeyboardEvent, + type PointerEvent, +} from "react"; + +import { cn } from "@/components/ui-primitives"; + +/** + * The phone A to Z strip for long alphabetical lists (Clinical audit, 13 Oct + * 2026, item 13), like the index down the side of Contacts on iPhone. + * + * - Phones only: hidden from `sm`, where the lists are short enough on screen + * and the pages already carry their own letter controls. + * - Only letters that exist in the list are drawn, in list order. + * - The whole strip is one press-and-drag target: a press anywhere on it jumps + * to the letter under the finger and dragging keeps jumping, so no single + * letter has to be 48px tall on its own. + * - It sits in the page's right gutter, between the header and the bottom + * search box (`--phone-overlay-chrome-h` and `--mobile-composer-reserve`), + * so it never covers the box or a row's own buttons. It shows only while the + * list itself is on screen. + * - Keyboard: one tab stop; arrow keys, Home and End move between letters and + * Enter or Space jumps, moving focus to that letter's heading. + * - Large text: when the letters no longer fit the strip's height it hides + * itself rather than overlapping or shrinking the letters to nothing. + * - Jumps are instant (no smooth scrolling), so reduced motion holds. + */ +export type AlphabetStripEntry = { + /** The letter as shown, for example "A". */ + readonly letter: string; + /** The id of the element that starts this letter (a heading or first row). */ + readonly targetId: string; +}; + +export function AlphabetStrip({ + entries, + listId, + label = "Jump to letter", + testId = "alphabet-strip", + minEntries = 4, +}: { + readonly entries: readonly AlphabetStripEntry[]; + /** The list the strip indexes. The strip shows only while it is on screen. */ + readonly listId: string; + readonly label?: string; + readonly testId?: string; + /** Fewer letters than this and the strip is not worth drawing. */ + readonly minEntries?: number; +}) { + const navRef = useRef(null); + const listRef = useRef(null); + const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); + const draggingRef = useRef(false); + const lastJumpRef = useRef(null); + const [listOnScreen, setListOnScreen] = useState(false); + const [fits, setFits] = useState(true); + const [focusIndex, setFocusIndex] = useState(0); + const [pressedLetter, setPressedLetter] = useState(null); + + const enabled = entries.length >= minEntries; + + useEffect(() => { + if (!enabled) return; + const list = document.getElementById(listId); + if (!list) return; + if (typeof IntersectionObserver === "undefined") { + // No observer (an old browser or a test): treat the list as on screen. + const frame = requestAnimationFrame(() => setListOnScreen(true)); + return () => cancelAnimationFrame(frame); + } + const observer = new IntersectionObserver(([entry]) => setListOnScreen(Boolean(entry?.isIntersecting))); + observer.observe(list); + return () => observer.disconnect(); + }, [enabled, listId]); + + // Hide rather than overlap when the letters cannot fit (large text, short + // landscape phones). Measured on the letters' own height against the strip's. + useLayoutEffect(() => { + if (!enabled) return; + const nav = navRef.current; + const list = listRef.current; + if (!nav || !list) return; + const measure = () => setFits(list.scrollHeight <= nav.clientHeight + 1); + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(nav); + observer.observe(list); + return () => observer.disconnect(); + }, [enabled, entries.length]); + + const jumpTo = useCallback( + (index: number, moveFocus: boolean) => { + const entry = entries[index]; + if (!entry) return; + const target = document.getElementById(entry.targetId); + if (!target) return; + target.scrollIntoView({ block: "start", behavior: "auto" }); + if (moveFocus) { + if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1"); + target.focus({ preventScroll: true }); + } + }, + [entries], + ); + + const indexAt = useCallback( + (clientY: number) => { + const list = listRef.current; + if (!list || !entries.length) return null; + const rect = list.getBoundingClientRect(); + if (rect.height <= 0) return null; + const fraction = (clientY - rect.top) / rect.height; + return Math.min(entries.length - 1, Math.max(0, Math.floor(fraction * entries.length))); + }, + [entries.length], + ); + + const pressAt = useCallback( + (clientY: number) => { + const index = indexAt(clientY); + if (index === null || index === lastJumpRef.current) return; + lastJumpRef.current = index; + setFocusIndex(index); + setPressedLetter(entries[index]?.letter ?? null); + jumpTo(index, false); + }, + [entries, indexAt, jumpTo], + ); + + const onPointerDown = (event: PointerEvent) => { + // A mouse click on a letter is handled by the button itself. + if (event.pointerType === "mouse") return; + event.preventDefault(); + draggingRef.current = true; + lastJumpRef.current = null; + event.currentTarget.setPointerCapture?.(event.pointerId); + pressAt(event.clientY); + }; + const onPointerMove = (event: PointerEvent) => { + if (draggingRef.current) pressAt(event.clientY); + }; + const endPress = (event: PointerEvent) => { + if (!draggingRef.current) return; + draggingRef.current = false; + lastJumpRef.current = null; + setPressedLetter(null); + event.currentTarget.releasePointerCapture?.(event.pointerId); + }; + + const onKeyDown = (event: KeyboardEvent) => { + let next: number | null = null; + if (event.key === "ArrowDown" || event.key === "ArrowRight") next = Math.min(entries.length - 1, focusIndex + 1); + else if (event.key === "ArrowUp" || event.key === "ArrowLeft") next = Math.max(0, focusIndex - 1); + else if (event.key === "Home") next = 0; + else if (event.key === "End") next = entries.length - 1; + if (next === null) return; + event.preventDefault(); + setFocusIndex(next); + buttonRefs.current[next]?.focus(); + }; + + if (!enabled) return null; + + const shown = listOnScreen && fits; + + return ( + <> + + {pressedLetter ? ( + // The letter under the finger, large in the middle of the screen while + // dragging, so the reader sees where they are. No animation. + + ) : null} + + ); +} + +/** + * Scroll margin for a strip target, so a jump lands the letter just under the + * phone header instead of behind it. + */ +export const alphabetStripTargetClass = "scroll-mt-[calc(var(--phone-overlay-chrome-h,4rem)+0.75rem)]"; + +/** + * Turns an ordered list of items into strip entries: the first item for each + * starting letter. Anything that does not start with A to Z is grouped as "#". + * + * Returns no entries unless the list really is in A to Z order: a ranked list + * gets no strip, because jumping to "M" there would land in the wrong place. + * A lone row filed out of place in an otherwise A to Z list (a catalogue that + * sorts "WACHS Goldfields" under G) is skipped rather than starting its own + * letter: a row counts only when its letter sits between its neighbours'. + */ +export function alphabetStripEntries( + items: readonly T[], + nameOf: (item: T) => string, + idOf: (item: T) => string, +): AlphabetStripEntry[] { + const letters = items.map((item) => alphabetStripLetter(nameOf(item))); + const entries: AlphabetStripEntry[] = []; + let outOfPlace = 0; + for (let index = 0; index < items.length; index += 1) { + const letter = letters[index]!; + const before = letters[index - 1]; + const after = letters[index + 1]; + const inPlace = (before === undefined || before <= letter) && (after === undefined || letter <= after); + if (!inPlace) { + outOfPlace += 1; + continue; + } + const previous = entries.at(-1)?.letter; + if (letter === previous) continue; + if (previous !== undefined && letter < previous) return []; + entries.push({ letter, targetId: idOf(items[index]!) }); + } + // More than a few strays means the list is not really alphabetical. + if (outOfPlace > Math.max(2, items.length * 0.05)) return []; + return entries; +} + +/** The index letter for a name: its first A to Z letter, upper case, or "#". */ +export function alphabetStripLetter(name: string): string { + const first = name.trim().normalize("NFD").replace(/[̀-ͯ]/g, "").charAt(0).toUpperCase(); + return /[A-Z]/.test(first) ? first : "#"; +} diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index ecf4b93f50..4082a1cedf 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -33,6 +33,7 @@ import { resultFilterFacetGroup, resultFilterGroup, } from "@/components/clinical-dashboard/result-filter-control"; +import { AlphabetStrip, alphabetStripEntries, alphabetStripTargetClass } from "@/components/mode-kit/alphabet-strip"; import { StatusTag, type StatusTagShape } from "@/components/mode-kit/status-tag"; import { cn } from "@/components/ui-primitives"; import { useResultSort } from "@/components/use-result-sort"; @@ -101,6 +102,10 @@ function serviceChipLabel(chip: ServiceStatusChip) { : label; } +function serviceRowId(slug: string) { + return `service-row-${slug}`; +} + function ServiceCard({ service, index, @@ -143,10 +148,12 @@ function ServiceCard({ return (
sortResultItems(facetedMatches, sortValue, (service) => service.title), [facetedMatches, sortValue], ); + const alphabetEntries = useMemo( + () => + alphabetStripEntries( + displayedMatches, + (service) => service.title, + (service) => serviceRowId(service.slug), + ), + [displayedMatches], + ); // Both scope segments show the current facet selection applied — "counts // on both" — differing only in whether the query/group scoping applies. const resultsScopedCount = useMemo( @@ -1088,7 +1104,7 @@ export function ServicesNavigatorPage() { {showComparison && selected.length >= 2 ? ( setShowComparison(false)} /> ) : null} -
+
{displayedMatches.map((service, index) => ( ))}
+ {/* Phone A to Z strip (audit item 13): drawn only when the list is + in A to Z order, never over a ranked list. */} + {query ? : null} )} From a705563003d325ffac23a57190901305f5d762bc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:26:23 +0000 Subject: [PATCH 06/36] Service record: hide raw catalogue tags and drop repeated phone numbers Tags & catchments printed machine tokens (digital_phone, crisis_high, aboriginal_torres_strait_islander). They now show the existing facet label where exactly one facet map names the token, and are hidden otherwise; no new wording is made up. The referral band's number also reappeared as the Contact line, the Route fact card and the Primary route and Phone rows; exact repeats are dropped, distinct values and the Call button stay. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../services/service-detail-page.tsx | 80 +++++++++++++++---- src/lib/service-facets.ts | 21 +++++ tests/service-detail-page.dom.test.tsx | 64 ++++++++++++++- 3 files changed, 149 insertions(+), 16 deletions(-) diff --git a/src/components/services/service-detail-page.tsx b/src/components/services/service-detail-page.tsx index 10912afe42..11b5a0d191 100644 --- a/src/components/services/service-detail-page.tsx +++ b/src/components/services/service-detail-page.tsx @@ -46,6 +46,7 @@ import { ServiceReferralFlow } from "@/components/services/service-referral-flow import { appModeHomeHref } from "@/lib/app-modes"; import { compactCatalogField } from "@/lib/compact-best-use-title"; import { extractFirstDialablePhone } from "@/lib/service-call"; +import { serviceTagDisplayLabel } from "@/lib/service-facets"; import { serviceCoreGroupIds, serviceCoreGroups } from "@/lib/service-core-groups"; import { serviceNavigatorQuery, @@ -276,6 +277,19 @@ function normalizeTagForList(tag: string) { return tag.trim().replace(/\s+/g, " ").toLowerCase(); } +/** + * Identity for "is this the same value shown again". A phone number compares by its + * digits so "13 92 76" and "139276" count as one; anything else compares as trimmed, + * case-folded text. Only exact repeats match: a row holding two numbers, or a number + * plus words, stays distinct. + */ +function repeatedValueKey(value: string | null | undefined) { + const text = value?.trim(); + if (!text) return undefined; + if (/^[\d\s()+\-.]+$/.test(text) && /\d/.test(text)) return `tel:${text.replace(/\D/g, "")}`; + return `text:${normalizeTagForList(text)}`; +} + function dedupeTagItems(items: string[]) { const seen = new Set(); return items @@ -409,7 +423,7 @@ function renderPhoneLines(value: string) { } /** Compact scan-first referral facts. Direct actions live in the referral band above. */ -function ReferralTable({ rows }: { rows: ServiceInfoRow[] }) { +function ReferralTable({ rows, firstIsPrimary = true }: { rows: ServiceInfoRow[]; firstIsPrimary?: boolean }) { if (!rows.length) { return (
@@ -422,7 +436,7 @@ function ReferralTable({ rows }: { rows: ServiceInfoRow[] }) { return (
{rows.map((row, index) => { - const isPrimary = index === 0; + const isPrimary = firstIsPrimary && index === 0; const isCost = row.label.toLowerCase().includes("cost"); return ( @@ -553,9 +567,31 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { const primaryContact = hasText(service.primaryContact?.value) ? service.primaryContact : (service.contacts?.find((contact) => hasText(contact.value)) ?? null); - const summaryCards = summaryCardsFor(service); - const referralRows = referralRowsFor(service, primaryContact); const callHref = contactHref(primaryContact); + const bandRouteValue = hasText(service.referral) ? service.referral : hasText(service.route) ? service.route : ""; + const contactLabel = contactDisplayValue(primaryContact); + // The referral band shows the route and, for a phone contact, the number on its Call + // button. The same number then reappeared as the "Contact" line, the Route fact card and + // the Primary route and Phone rows. Exact repeats of a value the band already shows are + // dropped below; every distinct value stays. + const bandValueKeys = new Set( + [ + repeatedValueKey(bandRouteValue), + callHref && primaryContact?.kind === "phone" ? repeatedValueKey(contactLabel) : undefined, + ].filter((key): key is string => Boolean(key)), + ); + const repeatsBand = (value: string | null | undefined) => { + const key = repeatedValueKey(value); + return Boolean(key && bandValueKeys.has(key)); + }; + const summaryCards = summaryCardsFor(service); + const allReferralRows = referralRowsFor(service, primaryContact); + const referralRows = allReferralRows.filter( + (row) => !(/route|phone|contact/i.test(row.label) && repeatsBand(row.value)), + ); + // The first row is styled and announced as the primary access route; once that row is + // dropped as a repeat of the band above, the next row is not a route and must not inherit it. + const firstReferralRowIsPrimary = referralRows.length > 0 && referralRows[0] === allReferralRows[0]; const actionableContact = Boolean( primaryContact && ["phone", "email", "web"].includes(primaryContact.kind) && callHref, ); @@ -564,7 +600,6 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { const ContactActionIcon = primaryContact?.kind === "email" ? Mail : primaryContact?.kind === "web" ? Globe2 : Phone; const contactActionLabel = primaryContact?.kind === "email" ? "Email" : primaryContact?.kind === "web" ? "Open website" : "Call"; - const contactLabel = contactDisplayValue(primaryContact); const verified = service.verification?.locallyVerified === true; const readinessActionHref = callHref ?? sourceHref; const readinessActionLabel = callHref @@ -573,6 +608,12 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { : contactActionLabel : "Confirm at source"; const ReadinessActionIcon = callHref ? ContactActionIcon : Globe2; + // "Contact: 13 92 76" under a "13 92 76" heading, above a "Call 13 92 76" button. + const contactLineRepeats = Boolean( + actionableContact && + contactLabel && + (repeatsBand(contactLabel) || (primaryContact?.kind === "phone" && readinessActionLabel.includes(contactLabel))), + ); const ReadinessStatusIcon = verified ? BadgeCheck : TriangleAlert; const showSourceAction = Boolean( sourceHref && actionDestinationIdentity(sourceHref) !== actionDestinationIdentity(callHref), @@ -588,9 +629,16 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { const visibleSummaryCards = dedupeSummaryCardDetails([ ...compactSummaryCards, ...leftoverCards.filter(summaryCardIsRenderable), - ]); + ]).filter( + (card) => !(card.id === "route" && repeatsBand(card.title) && (!hasText(card.detail) || repeatsBand(card.detail))), + ); const compactedSubtitle = hasText(service.subtitle) ? compactCatalogField(service.subtitle, 160) : ""; - const tagItems = dedupeTagItems([...(service.catchments ?? []), ...(service.tags ?? [])]); + // Raw catalogue tokens ("digital_phone", "crisis_high") are shown in plain words only where + // the existing facet label map already names them; any other machine token is hidden. + const tagItems = dedupeTagItems([ + ...(service.catchments ?? []), + ...(service.tags ?? []).map(serviceTagDisplayLabel).filter((tag): tag is string => Boolean(tag)), + ]); const criteria = service.criteria ?? []; const showReferralSection = referralRows.length > 0; const showCriteriaSection = criteria.length > 0; @@ -791,13 +839,15 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { ? compactCatalogField(hasText(service.referral) ? service.referral : (service.route ?? ""), 150) : "Confirm referral route"} -

- {actionableContact && contactLabel - ? `${primaryContact?.label ?? "Contact"}: ${contactLabel}` - : sourceHref - ? "Confirm the current contact at the source." - : "Service-specific contact details require local confirmation."} -

+ {contactLineRepeats ? null : ( +

+ {actionableContact && contactLabel + ? `${primaryContact?.label ?? "Contact"}: ${contactLabel}` + : sourceHref + ? "Confirm the current contact at the source." + : "Service-specific contact details require local confirmation."} +

+ )}
{readinessActionHref ? ( @@ -875,7 +925,7 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { > {showReferralSection ? (
- +
) : null} diff --git a/src/lib/service-facets.ts b/src/lib/service-facets.ts index e4226783e2..563e67058a 100644 --- a/src/lib/service-facets.ts +++ b/src/lib/service-facets.ts @@ -96,6 +96,27 @@ export function serviceFacetValueLabel(dimension: ServiceFacetDimension, value: return dimensionValueLabels[dimension][value] ?? humanizeToken(value); } +/** + * Plain words for a raw catalogue tag shown on its own, outside any facet + * dimension (the service record's tag list). Returns the existing label only + * when exactly one dimension map above carries the token; returns `null` for + * any other machine token (snake_case, slug-style or bare lower-case words + * such as `general`, whose meaning depends on a dimension + * the flat list no longer carries) so the caller hides it instead of printing + * computer wording. Human-written tags ("Regional WA") pass through unchanged. + * No new wording is made up here — unmapped tokens such as specialist-group + * ids are hidden, never humanised. + */ +export function serviceTagDisplayLabel(tag: string): string | null { + const value = tag.trim(); + if (!value) return null; + if (!/^[a-z0-9]+(?:[_-][a-z0-9]+)*$/.test(value)) return value; + const labels = Object.values(dimensionValueLabels) + .map((map) => map[value]) + .filter((label): label is string => Boolean(label)); + return labels.length === 1 ? labels[0]! : null; +} + export function serviceSubstanceLensValueLabel(value: string): string { if (value === "all") return "All programs"; return substanceLensValueLabels[value] ?? humanizeToken(value); diff --git a/tests/service-detail-page.dom.test.tsx b/tests/service-detail-page.dom.test.tsx index ed1661ec3d..17b579fda5 100644 --- a/tests/service-detail-page.dom.test.tsx +++ b/tests/service-detail-page.dom.test.tsx @@ -252,10 +252,72 @@ describe("ServiceDetailPage content cleanup", () => { }); it("keeps the referral facts a well-formed description list (#Q80S8B)", () => { - const { container } = render(); + // The base fixture's two rows repeat the referral band (and are dropped), so add a + // distinct row to keep a list on the page for the shape check. + const { container } = render( + , + ); expectWellFormedDefinitionLists(container); }); + it("shows the band's phone number once, with its Call button, and drops exact repeats", () => { + render( + , + ); + + const band = within(screen.getByLabelText("Referral readiness")); + expect(band.getByRole("link", { name: "Call 13 92 76" })).toHaveAttribute("href", "tel:139276"); + expect(band.queryByText("Phone: 13 92 76")).not.toBeInTheDocument(); + expect(within(screen.getByLabelText("Priority facts")).queryByText("Route")).not.toBeInTheDocument(); + const referral = screen.getByRole("heading", { name: "Referral information" }).closest("section")!; + expect(within(referral).queryByText("Primary route")).not.toBeInTheDocument(); + expect(within(referral).queryByText("Phone")).not.toBeInTheDocument(); + // Distinct values stay, and the next row does not inherit the primary-route label. + expect(within(referral).getByText("08 9224 8888")).toBeInTheDocument(); + expect(within(referral).getByText("24/7")).toBeInTheDocument(); + expect(within(referral).queryByText(", primary access route")).not.toBeInTheDocument(); + }); + + it("shows catalogue tags in plain words where a label exists and hides other machine tokens", () => { + render( + , + ); + + const tags = screen.getByRole("heading", { name: "Tags & catchments" }).closest("section")!; + expect(within(tags).getByText("Digital / phone")).toBeInTheDocument(); + expect(within(tags).getByText("Crisis / urgent")).toBeInTheDocument(); + expect(within(tags).getByText("13YARN")).toBeInTheDocument(); + expect(within(tags).queryByText(/_/)).not.toBeInTheDocument(); + expect(within(tags).queryByText("general")).not.toBeInTheDocument(); + }); + it("extracts the first valid dialable phone URI from composite strings without concatenating digits", () => { const compositePhone = "1300 555 788 (metro); 1800 676 822 (Peel)"; render( From e029e71b05a3bda78088952f8964969fa60a12f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:29:45 +0000 Subject: [PATCH 07/36] Therapy: show cautions in full and lift Safety & cautions under the summary The Avoid / modify cell on the older result card is no longer clamped. On a therapy record the Safety & cautions card now sits directly after the key-fact tiles and is not clamped behind Show more, and the Cautions tile shows its whole first sentence (full row on phones) instead of a 64-character cut. No wording changed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../therapy-compass/record/key-fact-cards.ts | 10 +++++++++- src/components/therapy-compass/record/key-facts.tsx | 9 +++++++-- .../therapy-compass/record/record-sections.tsx | 8 +++++--- .../therapy-compass/screens/detail-screen.tsx | 3 ++- src/components/therapy-compass/therapy-card.tsx | 3 ++- tests/therapy-key-facts.test.ts | 10 +++++++++- 6 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/components/therapy-compass/record/key-fact-cards.ts b/src/components/therapy-compass/record/key-fact-cards.ts index 6ff6c66681..212759c346 100644 --- a/src/components/therapy-compass/record/key-fact-cards.ts +++ b/src/components/therapy-compass/record/key-fact-cards.ts @@ -66,6 +66,11 @@ function cautionBody(therapy: Therapy): string { return cautions; } +function cautionFace(body: string): string { + const stripped = extractCitations(body).text; + return summarise(stripped, 1) || stripped; +} + function hasExtraDetail(face: string, body: string): boolean { const faceText = face.trim(); const bodyText = body.trim(); @@ -82,7 +87,10 @@ function hasExtraDetail(face: string, body: string): boolean { */ export function therapyKeyFactCards(therapy: Therapy): TherapyKeyFactCard[] { const cautionsBody = cautionBody(therapy) || "Cautions not recorded"; - const cautionsFace = cautionBody(therapy) ? glanceLine(cautionsBody) : "Cautions not recorded"; + // Cautions is a safety cue, so its face is never cut to the glance budget: it shows the + // whole first sentence (citation markers lifted off), the same wording as the Avoid / + // modify line on search and Recommend. The full field sits directly under the tiles. + const cautionsFace = cautionBody(therapy) ? cautionFace(cautionsBody) : "Cautions not recorded"; const formatText = therapy.sessionLength?.trim() || therapy.timeRequired?.trim() || ""; const formatFace = formatText ? glanceLine(formatText) : "Format not recorded"; diff --git a/src/components/therapy-compass/record/key-facts.tsx b/src/components/therapy-compass/record/key-facts.tsx index 0848bc7d71..667213d9c8 100644 --- a/src/components/therapy-compass/record/key-facts.tsx +++ b/src/components/therapy-compass/record/key-facts.tsx @@ -21,8 +21,13 @@ const ICONS: Record = { const cardSurface = "flex min-h-[5.75rem] flex-col rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-lux)] p-1.5 shadow-[var(--shadow-inset)] sm:min-h-[7rem] sm:p-3"; +// Phone: Cautions carries a whole sentence, so it takes the full row; Suits then takes the +// last row so the three glance tiles do not leave a hole. Wider screens keep one row of four. +const PHONE_FULL_ROW: ReadonlySet = new Set(["cautions", "suits"]); + function FactCard({ card, onOpen }: { card: TherapyKeyFactCard; onOpen?: () => void }) { const Icon = ICONS[card.id]; + const surface = cn(cardSurface, PHONE_FULL_ROW.has(card.id) && "col-span-2 sm:col-span-1"); const warning = card.id === "cautions"; const isInteractive = Boolean(card.hasDetail && onOpen); @@ -64,7 +69,7 @@ function FactCard({ card, onOpen }: { card: TherapyKeyFactCard; onOpen?: () => v if (!isInteractive) { return ( -
+
{header} {body} @@ -73,7 +78,7 @@ function FactCard({ card, onOpen }: { card: TherapyKeyFactCard; onOpen?: () => v } return ( -
+
-
- - {rest.map(({ therapy, reasons }, index) => ( - + {ranked.map(({ therapy, reasons }, index) => ( + ))} -
+
@@ -158,25 +159,3 @@ export function SpecifierRecordPage({ record }: { record: SpecifierRecord }) { ); } - -/** A plain bulleted list in the shared record style. */ -function BulletList({ items, tone = "default" }: { items: string[]; tone?: "default" | "warning" }) { - return ( -
    - {items.map((item) => ( -
  • - - - {item} - -
  • - ))} -
- ); -} From 33e03cc6adeead21caf1af08e8891e43753ce9fc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:46:39 +0000 Subject: [PATCH 15/36] Medication record: shared record layout, no green side bar Name, class line, indication and badges as the record intro; the key figures sit in the At a glance card with each figure's flag unchanged (a high-risk figure stays red and spoken); the record's avoid/caution quick-reference rows become the cautions block straight after, in full; the tab sections are plain headings with hairline rows and keep their fold, with the green left bar on the sections panel removed; quick reference and access are plain sections and From this page closes the record. Access values now wrap instead of clipping. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../medication-record-page.tsx | 264 ++++++++---------- 1 file changed, 120 insertions(+), 144 deletions(-) diff --git a/src/components/clinical-dashboard/medication-record-page.tsx b/src/components/clinical-dashboard/medication-record-page.tsx index 4cf8a9f33e..1b994acafc 100644 --- a/src/components/clinical-dashboard/medication-record-page.tsx +++ b/src/components/clinical-dashboard/medication-record-page.tsx @@ -1,22 +1,15 @@ "use client"; import { - Activity, - Ban, TriangleAlert, - BadgeCheck, BookOpen, - CalendarDays, ChevronDown, ChevronRight, FileSearch, ClipboardList, - FlaskConical, History, - Lock, Pill, ShieldAlert, - ShieldCheck, type LucideIcon, } from "lucide-react"; import Link from "next/link"; @@ -53,6 +46,7 @@ import { type MedicationRecord, type MedicationSection, } from "@/lib/medications"; +import { RecordBody, RecordCautions, RecordIntro, RecordSection } from "@/components/clinical-record-panels"; import { cn, EmptyState, LoadingPanel } from "@/components/ui-primitives"; import { InformationPageFooter, InformationPageShell } from "@/components/information-page-shell"; import { RouteNotFoundPanel } from "@/components/route-not-found-panel"; @@ -65,31 +59,6 @@ import { Button } from "@/components/ui/button"; import { ExternalTextLink } from "@/components/ui/link"; import { Sheet } from "@/components/ui/sheet"; -const sectionIcons: Record = { - dose: CalendarDays, - risk: TriangleAlert, - contra: Ban, - safe: ShieldCheck, - mon: Activity, - inter: FlaskConical, - src: BadgeCheck, -}; - -// Per-section toned icon tiles. Semantic tones stay reserved (red = contra/safety, -// amber = risk/caution, green = safe/verified); other sections use the neutral -// categorical --type-* hues so the list reads with colour without misusing meaning. -const sectionToneClass: Record = { - contra: "border-[color:var(--danger)]/25 bg-[color:var(--danger-soft)] text-[color:var(--danger)]", - risk: "border-[color:var(--warning)]/30 bg-[color:var(--warning-soft)] text-[color:var(--warning)]", - safe: "border-[color:var(--success)]/25 bg-[color:var(--success-soft)] text-[color:var(--success)]", - src: "border-[color:var(--success)]/25 bg-[color:var(--success-soft)] text-[color:var(--success)]", - mon: "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]", - dose: "border-[color:var(--type-document-border)] bg-[color:var(--type-document-soft)] text-[color:var(--type-document)]", - inter: "border-[color:var(--type-source-border)] bg-[color:var(--type-source-soft)] text-[color:var(--type-source)]", -}; -const defaultSectionTone = - "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"; - // Decorative per-medication identity accent (record.accent is keyed to drug // class). Exposed as CSS custom properties and softened with color-mix so it // drives rails/borders only — never text — keeping contrast safe in light + dark @@ -173,40 +142,31 @@ function DetailTile({ metric }: { metric: MedicationHeroMetric }) { } function SectionCard({ section }: { section: MedicationSection }) { - const Icon = sectionIcons[section.type] || ClipboardList; - const toneClass = sectionToneClass[section.type] || defaultSectionTone; - return (
- - - - - {section.title} - + +

+ {section.title} +

-
+
{section.rows.map((row) => { const rowBadges = medicationRowBadges(row, section.type); return ( -
-

{row.key}

+
+

{row.key}

-

+

{row.val.replace(/\*\*/g, "")}

@@ -217,18 +177,6 @@ function SectionCard({ section }: { section: MedicationSection }) { ); } -function SidebarCard({ title, icon: Icon, children }: { title: string; icon: LucideIcon; children: React.ReactNode }) { - return ( -
-
-
- {children} -
- ); -} - // Quick-reference values are the verbose ones (≈164 chars median, up to ~560) and // would otherwise make the fixed-width sidebar run very tall. Long values collapse // to two lines with a chevron affordance and expand in place on tap — the same @@ -243,7 +191,7 @@ function QuickRefRow({ row }: { row: MedicationQuickRow }) { if (value.length <= 110) { return ( -
+
{label}

{value}

@@ -252,7 +200,7 @@ function QuickRefRow({ row }: { row: MedicationQuickRow }) { return (
- + {label} -
+ +
{fields.length ? (
@@ -287,14 +235,16 @@ function MedicationAccessPanel({ record }: { record: MedicationRecord }) { index < fields.length - 1 && "border-b border-[color:var(--border)] pb-2", )} > -
{field.label}
-
{field.value}
+
{field.label}
+
+ {field.value} +
))} ) : null}
- + ); } @@ -306,7 +256,7 @@ function MedicationAccessPanel({ record }: { record: MedicationRecord }) { function MedicationSourceLinks({ links }: { links: readonly MedicationSourceLink[] }) { if (!links.length) return null; return ( -
+

Sources

    {links.map((link) => ( @@ -320,6 +270,9 @@ function MedicationSourceLinks({ links }: { links: readonly MedicationSourceLink ); } +/** Quick-reference rows that are the record's cautions ("Avoid / cautions", "Contraindications"). */ +const QUICK_CAUTION_LABEL = /\b(?:avoid|cautions?|contraindicat\w*)\b/i; + function MedicationRecordDetail({ record, governance, @@ -359,63 +312,83 @@ function MedicationRecordDetail({ }, [metrics, record]); const activeSections = sectionsByTab[activeTab]; const activeTabLabel = medicationNavSections.find((section) => section.id === activeTab)?.label ?? "Medication"; + const cautionRows = record.quick.filter((row) => QUICK_CAUTION_LABEL.test(row.label)); + const referenceRows = record.quick.filter((row) => !QUICK_CAUTION_LABEL.test(row.label)); return ( -
    -
    -
    -
    -
    - - -
    -

    - {record.name} -

    -

    - {record.subclass || record.class} - {record.category ? ( - <> - · - {record.category} - - ) : null} -

    - {indication ? ( -

    - {indication} -

    - ) : null} - - {sourceLinks.length === 0 ? ( -

    - {/* Three records carry no written source notes, so Additional is named only when it has them. */} - {record.sections.some((section) => section.type === "src") - ? "No source link confirmed for this record yet. Its own source notes are under Additional." - : "No source link confirmed for this record yet, and no source notes are recorded for it."} -

    +
    +
    + + + {record.subclass || record.class} + {record.category ? ( + <> + · + {record.category} + ) : null} + + } + title={record.name} + line={indication || undefined} + tags={} + > + {sourceLinks.length === 0 ? ( +

    + {/* Three records carry no written source notes, so Additional is named only when it has them. */} + {record.sections.some((section) => section.type === "src") + ? "No source link confirmed for this record yet. Its own source notes are under Additional." + : "No source link confirmed for this record yet, and no source notes are recorded for it."} +

    + ) : null} +
    + + {/* The key figures as the shared "At a glance" card. Each figure keeps + its own flag: a curator's `hi` figure stays red and spoken ("High + risk"), amber is neutral with a warning icon and a spoken "Caution". + Hairlines without stray edges at any count: every tile draws its + right and bottom rule and the grid is pulled 1px past the clip. */} + {metrics.length ? ( +
    +

    At a glance

    +
    + {metrics.map((metric, index) => ( + // Some records repeat a stat label (e.g. adrenaline has two "Route" + // stats), so the label alone is not a unique key — include the index. + + ))}
    -
    -
    + + ) : null} - {/* Hairlines without stray edges at any count: every tile draws its - right and bottom rule, the grid is pulled 1px past the clip so the - outer column's and last row's rules fall outside it, and the - section draws the top and bottom lines. */} -
    -
    - {metrics.map((metric, index) => ( - // Some records repeat a stat label (e.g. adrenaline has two "Route" - // stats), so the label alone is not a unique key — include the index. - - ))} -
    -
    + {/* Cautions straight after, in full: the record's own avoid/caution + quick-reference rows, lifted out of the clamped list below. */} + {cautionRows.length ? ( + +
    + {cautionRows.map((row) => ( +
    +

    {row.label}

    +

    {row.value.replace(/\*\*/g, "")}

    +
    + ))} +
    +
    + ) : null} @@ -430,44 +403,47 @@ function MedicationRecordDetail({ than a tablist, so claiming the role would name a `tab` that no longer exists. The id stays per-tab — it is the rendered evidence that a declared section resolves to a real panel, which is what - `tests/in-page-nav-route-sections.dom.test.tsx` asserts. */} + `tests/in-page-nav-route-sections.dom.test.tsx` asserts. Plain + sections on the page, no coloured side bar. */}
    {activeSections.length ? ( activeSections.map((section) => ( )) ) : ( -
    - -
    + )} {/* The record's provenance ("src") sections live on this tab, so its confirmed source links do too. */} {activeTab === "more" ? : null}
    - - -
    - -
    ); From 548c1d8a5766f7820ae8c3adb7db468ebaec6b33 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:51:59 +0000 Subject: [PATCH 16/36] Shorten bottom-box prompts so each fits at 390 px Measured against the composer's text width (194 px on mode homes, 241 px on result pages, Geist 16 px): ten mode placeholders were cut with an ellipsis. Only the placeholder words change; every mode keeps its fuller accessible name, and the composer component is untouched. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/app-modes.ts | 20 ++++++++++---------- tests/app-modes.test.ts | 2 +- tests/ui-accessibility.spec.ts | 4 ++-- tests/ui-tools.spec.ts | 18 ++++++------------ 4 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index 797479cf5b..2c2e0354d7 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -140,7 +140,7 @@ export const appModeDefinitions = [ href: "/documents", search: { kind: "documents", - placeholder: "Search source documents...", + placeholder: "Search documents...", inputAriaLabel: "Search indexed source documents", submitIdleLabel: "Docs", submitBusyLabel: "Docs", @@ -234,7 +234,7 @@ export const appModeDefinitions = [ href: "/differentials", search: { kind: "differentials", - placeholder: "Ask or search a presentation...", + placeholder: "Search a presentation...", inputAriaLabel: "Search differential presentations, symptoms, and scenarios", submitIdleLabel: "Diffs", submitBusyLabel: "Diffs", @@ -281,7 +281,7 @@ export const appModeDefinitions = [ href: "/specifiers", search: { kind: "specifiers", - placeholder: "Describe the presentation or search a specifier...", + placeholder: "Search specifiers...", inputAriaLabel: "Search psychiatric specifiers by presentation or diagnosis", submitIdleLabel: "Find", submitBusyLabel: "Find", @@ -304,7 +304,7 @@ export const appModeDefinitions = [ href: "/formulation", search: { kind: "formulation", - placeholder: "Describe a pattern or clinical clue...", + placeholder: "Describe a pattern...", inputAriaLabel: "Search formulation mechanisms by pattern or patient language", submitIdleLabel: "Find", submitBusyLabel: "Find", @@ -385,7 +385,7 @@ export const appModeDefinitions = [ href: "/calculators", search: { kind: "calculators", - placeholder: "Search calculators by scale, symptom, or indication...", + placeholder: "Search calculators...", inputAriaLabel: "Search clinical calculators by scale, symptom, or indication", submitIdleLabel: "Calculate", submitBusyLabel: "Calculate", @@ -444,7 +444,7 @@ export const appModeDefinitions = [ // library (not the document corpus), so it borrows the benign "tools" search // kind — like Therapy Compass — while keeping the shared composer visible. kind: "tools", - placeholder: "Search a medicine, condition, therapy or test...", + placeholder: "Search factsheets...", inputAriaLabel: "Search patient information factsheets", submitIdleLabel: "Sheets", submitBusyLabel: "Sheets", @@ -469,7 +469,7 @@ export const appModeDefinitions = [ // Dictionary owns a local static catalogue. The shared composer uses the // benign tools command kind, then appModeHomeHref routes into its results. kind: "tools", - placeholder: "Search a term or abbreviation...", + placeholder: "Term or abbreviation...", inputAriaLabel: "Search clinical terms, abbreviations, and topics", submitIdleLabel: "Terms", submitBusyLabel: "Terms", @@ -492,7 +492,7 @@ export const appModeDefinitions = [ href: "/sources", search: { kind: "tools", - placeholder: "Search sources, publishers, or topics...", + placeholder: "Search sources...", inputAriaLabel: "Search sources, publishers, or topics", submitIdleLabel: "Sources", submitBusyLabel: "Sources", @@ -609,7 +609,7 @@ export const appModeDefinitions = [ // catalogue of its own, so it borrows the benign "tools" command kind, // as On Call and CME do. kind: "tools", - placeholder: "Open a psychiatry section...", + placeholder: "Open a section...", inputAriaLabel: "Open a psychiatry section", submitIdleLabel: "Psychiatry", submitBusyLabel: "Psychiatry", @@ -636,7 +636,7 @@ export const appModeDefinitions = [ // Like Psychiatry, a landing page that gathers existing modes, each of // which keeps its own search; it borrows the benign "tools" kind. kind: "tools", - placeholder: "Open a medicines or tools section...", + placeholder: "Open a section...", inputAriaLabel: "Open a medicines or tools section", submitIdleLabel: "Medicines", submitBusyLabel: "Medicines", diff --git a/tests/app-modes.test.ts b/tests/app-modes.test.ts index 26c595590d..1b9e26d56f 100644 --- a/tests/app-modes.test.ts +++ b/tests/app-modes.test.ts @@ -228,7 +228,7 @@ describe("app mode search contract", () => { }); expect(config).toMatchObject({ kind: "tools", - placeholder: "Search sources, publishers, or topics...", + placeholder: "Search sources...", resultHeading: "Sources", nextStep: "Filter by quality, location, publisher, topic, or usage", }); diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index a70f2d9f07..c76723d7fd 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -481,7 +481,7 @@ test.describe("PsychSift accessibility coverage", () => { await mockDifferentialSearch(page); await gotoApp(page, "/differentials"); - const presentationInput = page.locator('input[placeholder="Ask or search a presentation..."]:visible').first(); + const presentationInput = page.locator('input[placeholder="Search a presentation..."]:visible').first(); const differentialSubmit = page.locator('button[aria-label="Search differential presentations"]:visible'); await expect(async () => { await presentationInput.fill("acute confusion"); @@ -535,7 +535,7 @@ test.describe("PsychSift accessibility coverage", () => { // Retry fill-then-enabled together: the server-rendered composer is visible // before React controls it, and a fill landing in that gap is discarded by // hydration, leaving the search button disabled and the click a no-op. - const presentationInput = page.locator('input[placeholder="Ask or search a presentation..."]:visible').first(); + const presentationInput = page.locator('input[placeholder="Search a presentation..."]:visible').first(); const differentialSubmit = page.locator('button[aria-label="Search differential presentations"]:visible'); await expect(async () => { await presentationInput.fill("acute confusion"); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 396ff203e4..3afbe86dee 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -87,7 +87,7 @@ function waitForDifferentialCatalogQuery(page: Page, query: string) { } async function submitDifferentialSearch(page: Page, query: string) { - const input = page.locator('input[placeholder="Ask or search a presentation..."]:visible').first(); + const input = page.locator('input[placeholder="Search a presentation..."]:visible').first(); const submit = page.locator('button[aria-label="Search differential presentations"]:visible'); // Own the fill here rather than leaving it to callers. The server-rendered @@ -938,7 +938,7 @@ test.describe("PsychSift tools directory and legacy launcher", () => { const heroSearch = sharedHome.getByTestId("global-search-input"); await expect(visibleGlobalSearchInput(page)).toHaveCount(1); await expect(heroSearch).toBeVisible(); - await expect(heroSearch).toHaveAttribute("placeholder", "Describe the presentation or search a specifier..."); + await expect(heroSearch).toHaveAttribute("placeholder", "Search specifiers..."); await expect(page.getByTestId("specifiers-home")).toHaveCount(0); const expectSoundPhoneGeometry = async () => { @@ -1027,10 +1027,7 @@ test.describe("PsychSift tools directory and legacy launcher", () => { sharedHome.getByText("Review criteria and exclusions before documenting", { exact: true }), ).toHaveCount(0); await expect(sharedHome.locator(".mode-home-icon svg")).toHaveClass(/\blucide-tags\b/); - await expect(visibleGlobalSearchInput(page)).toHaveAttribute( - "placeholder", - "Describe the presentation or search a specifier...", - ); + await expect(visibleGlobalSearchInput(page)).toHaveAttribute("placeholder", "Search specifiers..."); await expect(page.getByTestId("specifiers-home")).toHaveCount(0); const specifiersHeadingBox = await sharedHome @@ -1062,10 +1059,7 @@ test.describe("PsychSift tools directory and legacy launcher", () => { ).toBe(true); await expect(sharedHome.getByRole("heading", { level: 2, name: "Clinical Formulation" })).toBeVisible(); await expect(sharedHome.locator(".mode-home-icon svg")).toHaveClass(/\blucide-network\b/); - await expect(visibleGlobalSearchInput(page)).toHaveAttribute( - "placeholder", - "Describe a pattern or clinical clue...", - ); + await expect(visibleGlobalSearchInput(page)).toHaveAttribute("placeholder", "Describe a pattern..."); await expect(page.getByTestId("formulation-home")).toHaveCount(0); await expect(visibleGlobalSearchInput(page)).toHaveCount(1); expect(await page.evaluate(() => window.history.length)).toBe(historyLengthBefore); @@ -2065,7 +2059,7 @@ test.describe("PsychSift tools directory and legacy launcher", () => { await expect(visibleGlobalSearchInput(page)).toHaveCount(1); const heroSearch = sharedHome.getByTestId("global-search-input"); await expect(heroSearch).toBeVisible(); - await expect(heroSearch).toHaveAttribute("placeholder", "Ask or search a presentation..."); + await expect(heroSearch).toHaveAttribute("placeholder", "Search a presentation..."); // The hero heading sits above the composer, and the composer stays in the // upper two thirds — the same geometry contract every mode home is held to. @@ -2513,7 +2507,7 @@ test.describe("PsychSift tools directory and legacy launcher", () => { await page.setViewportSize({ width: 390, height: 844 }); await gotoLauncher(page, "/differentials"); - const input = page.locator('input[placeholder="Ask or search a presentation..."]:visible'); + const input = page.locator('input[placeholder="Search a presentation..."]:visible'); const submit = page.locator('button[aria-label="Search differential presentations"]:visible'); await expect(input).toHaveCount(1, { timeout: 15_000 }); await expect(submit).toHaveCount(1, { timeout: 15_000 }); From 5fa2ba6d873886392447bc504c83986d0d1ab981 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:54:03 +0000 Subject: [PATCH 17/36] Calculators: plain words in the About these tools notice "3 candidate calculators remain governance-gated pending version, rights and workflow review" now reads "Three more calculators are waiting for version, rights and workflow checks before they appear." The telemetry sentence now says the app's usage logs, and anything documented in the clinical record, are covered separately. Same meaning. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/calculators/search-detail.tsx | 2 +- src/components/calculators/search-page.tsx | 17 +++++++++++++---- tests/calculators-clinical-safety.dom.test.tsx | 6 +++++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/components/calculators/search-detail.tsx b/src/components/calculators/search-detail.tsx index 68864cd1db..6d04b973dc 100644 --- a/src/components/calculators/search-detail.tsx +++ b/src/components/calculators/search-detail.tsx @@ -344,7 +344,7 @@ export function CalculatorSearchHome({

    Calculator answers remain in this browser session and are not intentionally submitted by this calculator - interface. Application telemetry and clinical-record documentation are governed separately. + interface. The app’s usage logs, and anything you document in the clinical record, are covered separately.

    ); diff --git a/src/components/calculators/search-page.tsx b/src/components/calculators/search-page.tsx index 004b6e99bc..8e84eae274 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -273,6 +273,16 @@ function ContinuePanel({ ); } +const countWords = ["No", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine"]; + +/** "Three more calculators are waiting for …" — plain words for the calculators still in review. */ +function pendingCalculatorsNotice(count: number) { + const amount = countWords[count] ?? String(count); + return count === 1 + ? `${amount} more calculator is waiting for version, rights and workflow checks before it appears.` + : `${amount} more calculators are waiting for version, rights and workflow checks before they appear.`; +} + function AboutPanel() { return (
    @@ -282,12 +292,11 @@ function AboutPanel() {

    Scores support clinical judgement — they never replace a full assessment. Calculator answers remain in this - browser session and are not intentionally submitted by this calculator interface. Application telemetry and - clinical-record documentation are governed separately. + browser session and are not intentionally submitted by this calculator interface. The app’s usage logs, and + anything you document in the clinical record, are covered separately.

    - {plannedCalculators.length} candidate calculators remain governance-gated pending version, rights and workflow - review. + {pendingCalculatorsNotice(plannedCalculators.length)}

    ); diff --git a/tests/calculators-clinical-safety.dom.test.tsx b/tests/calculators-clinical-safety.dom.test.tsx index ef763b14aa..09fd0bd736 100644 --- a/tests/calculators-clinical-safety.dom.test.tsx +++ b/tests/calculators-clinical-safety.dom.test.tsx @@ -267,8 +267,12 @@ describe("calculator mode copy", () => { for (const notice of screen.getAllByText(/Calculator answers remain in this browser session/)) { expect(notice).toHaveTextContent( - "Calculator answers remain in this browser session and are not intentionally submitted by this calculator interface. Application telemetry and clinical-record documentation are governed separately.", + "Calculator answers remain in this browser session and are not intentionally submitted by this calculator interface. The app’s usage logs, and anything you document in the clinical record, are covered separately.", ); } + // The calculators still in review, in plain words rather than "governance-gated". + expect( + screen.getAllByText(/more calculators? (?:is|are) waiting for version, rights and workflow checks/)[0], + ).toBeInTheDocument(); }); }); From 2657d7593b24c6ba393f5d31a864738155dedbbf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:54:30 +0000 Subject: [PATCH 18/36] Tests: follow the compact call button, single First Nations entry and All tools heading Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/favourites-auth-gate.dom.test.tsx | 2 +- tests/psychiatry-mha-clock.dom.test.tsx | 6 ++++-- tests/services-call-button.dom.test.tsx | 10 +++++++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/favourites-auth-gate.dom.test.tsx b/tests/favourites-auth-gate.dom.test.tsx index 8789bf5bd5..b9ffd2845e 100644 --- a/tests/favourites-auth-gate.dom.test.tsx +++ b/tests/favourites-auth-gate.dom.test.tsx @@ -500,7 +500,7 @@ describe("favourites auth gate DOM", () => { it("applies the same Favourites access gate to the all-tools results directory", () => { const { rerender } = render(); - expect(screen.getByRole("heading", { level: 1, name: "Tools" })).toBeVisible(); + expect(screen.getByRole("heading", { level: 1, name: "All tools" })).toBeVisible(); expect(within(screen.getByTestId("tool-row-clinical-kb-search")).getByRole("link")).toHaveAttribute( "href", "/?mode=answer", diff --git a/tests/psychiatry-mha-clock.dom.test.tsx b/tests/psychiatry-mha-clock.dom.test.tsx index 365a7121bc..bd33e175e9 100644 --- a/tests/psychiatry-mha-clock.dom.test.tsx +++ b/tests/psychiatry-mha-clock.dom.test.tsx @@ -568,7 +568,7 @@ describe("Psychiatry hub · For a shift", () => { ); }); - it("keeps Safety plan and MHA forms out of the builders, and First Nations in them", () => { + it("keeps Safety plan, MHA forms and First Nations out of the builders", () => { render(); const actions = within(screen.getByRole("list", { name: "Builders" })); expect(actions.queryByText("Medication")).toBeNull(); @@ -576,6 +576,8 @@ describe("Psychiatry hub · For a shift", () => { expect(actions.queryByText("Safety plan")).toBeNull(); expect(actions.queryByText("MHA forms")).toBeNull(); expect(screen.getByTestId("psychiatry-shift-safety-plan").getAttribute("href")).toBe("/safety-plan"); - expect(actions.getByText("First Nations")).toBeTruthy(); + // First Nations is listed once, in Sections (Clinical audit, 13 Oct 2026, item 15). + expect(actions.queryByText("First Nations")).toBeNull(); + expect(screen.getByTestId("psychiatry-section-first-nations")).toBeTruthy(); }); }); diff --git a/tests/services-call-button.dom.test.tsx b/tests/services-call-button.dom.test.tsx index 8dc2e3a7ed..fbfdc89a46 100644 --- a/tests/services-call-button.dom.test.tsx +++ b/tests/services-call-button.dom.test.tsx @@ -129,12 +129,16 @@ describe("serviceCallTarget", () => { }); describe("services result card call button", () => { - it("puts a tel: call link with the visible number on a card whose record has a phone", () => { + it("puts a compact tel: phone button, named for the service, on a card whose record has a phone", () => { + // Clinical audit, 13 Oct 2026, item 8: the full-width blue Call bar became a + // 48px phone button on the row. Same tel: action; the number is its tooltip. render(); const card = screen.getByTestId(`service-search-result-${withPhone.slug}`); - const call = within(card).getByRole("link", { name: /^Call 1800 199 008/ }); + const call = within(card).getByRole("link", { name: `Call ${withPhone.title}` }); expect(call).toHaveAttribute("href", "tel:1800199008"); - expect(call).toHaveTextContent("Call 1800 199 008"); + expect(call).toHaveAttribute("title", "Call 1800 199 008"); + expect(call.className).toContain("min-h-12"); + expect(call.className).toContain("min-w-12"); }); it("shows no call link when the record has no dialable phone", () => { From 38d530a6b60a421e90d19c2af3a47925ecbae279 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:55:15 +0000 Subject: [PATCH 19/36] Forms record: shared record layout, facts shown in full At a glance card holds every priority fact in full (no Tap for detail), the legal boundary follows as the cautions card, and the cultural notes move below the attachment row unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/forms/form-detail-page.tsx | 140 ++++---- .../forms/form-priority-facts-section.tsx | 309 +++++------------- tests/form-priority-facts.dom.test.tsx | 50 +-- 3 files changed, 182 insertions(+), 317 deletions(-) diff --git a/src/components/forms/form-detail-page.tsx b/src/components/forms/form-detail-page.tsx index 7f8c620589..ae5a2e184e 100644 --- a/src/components/forms/form-detail-page.tsx +++ b/src/components/forms/form-detail-page.tsx @@ -45,7 +45,9 @@ import { inPageActionRowClass, inPageAnchor } from "@/components/in-page-nav/in- import type { PageSection } from "@/components/in-page-nav/page-section-index"; import { useInPageSectionNav } from "@/components/in-page-nav/use-in-page-section-nav"; import { FormCodeBadge, splitFormCode } from "@/components/forms/form-code-badge"; -import { PriorityFactsSection } from "@/components/forms/form-priority-facts-section"; +import { CulturalNotesSection, PriorityFactsSection } from "@/components/forms/form-priority-facts-section"; +import { RecordCautions, RecordIntro } from "@/components/clinical-record-panels"; +import { buttonFaceClass } from "@/components/ui/button-face"; import { MhaTimelinePanel } from "@/components/forms/mha-timeline-panel"; import { DisclosureGroup, disclosureBodyText } from "@/components/ui/disclosure"; import { appModeHomeHref } from "@/lib/app-modes"; @@ -792,51 +794,92 @@ export function FormDetailPage({ form }: { form: FormRecord }) {
) : null} -
-
-
-
- -
-

- {form.title} -

-

- {displayText(form.subtitle, "Psychiatry form and workflow details.")} -

- {form.statusChips?.length ? ( -
- {form.statusChips.map((chip, index) => ( +
+
+
+ ( - {displayText(chip.label, "Status")} - ))} -
- ) : null} -
-
+ )) + : undefined + } + actions={ + <> + + + + } + />
+ + + + Legal boundary + + Governance + + + } + > +

+ {displayText( + form.bestUse, + "Use the current approved form, confirm authority, and document the least restrictive safe option before signing.", + )} +

+

+ PsychSift is a reference only. It does not fill in, sign or submit this form for you: complete and lodge + it on the official form through your service's usual process. +

+
+ {/* On a phone the attachment row stacks: the status badges take their own line under the title instead of squeezing the title column down to a few characters ("Ext...", #47Y8XH). The badges decide whether the form can be filled now or must be printed, so neither may truncate. */}
@@ -944,45 +987,10 @@ export function FormDetailPage({ form }: { form: FormRecord }) { )}
- + {details?.form && hasMhaTimeline(details.form) ? : null} - -
{/* One bordered container with divided rows, not thirteen separate cards: no row here is separable or independently actionable, so diff --git a/src/components/forms/form-priority-facts-section.tsx b/src/components/forms/form-priority-facts-section.tsx index 89a7491936..b65898d926 100644 --- a/src/components/forms/form-priority-facts-section.tsx +++ b/src/components/forms/form-priority-facts-section.tsx @@ -1,8 +1,8 @@ "use client"; import Link from "next/link"; -import { BookOpenText, ClipboardList, Clock3, Languages, MapPin, Scale, UserRound } from "lucide-react"; -import { useId, useState, type ReactNode } from "react"; +import { BookOpenText, Languages, UserRound } from "lucide-react"; +import { useId, useState } from "react"; import { inPageAnchor } from "@/components/in-page-nav/in-page-nav-classes"; import { cn, textMuted } from "@/components/ui-primitives"; @@ -27,38 +27,6 @@ function hasText(value: string | null | undefined): value is string { return Boolean(value && value.trim().length > 0); } -const PRIORITY_FACT_ICONS = { - clock: Clock3, - authority: UserRound, - criteria: Scale, - "act-sections": BookOpenText, -} as const; - -function summaryIcon(card: ServiceSummaryCard) { - // Match the card id before the prose. The old heuristic searched the title too, so - // Form 4C's authority card — whose title reads "Check the official form signature block - // and Act sections." — drew the Act-sections book instead of the person. - const known = PRIORITY_FACT_ICONS[card.id as keyof typeof PRIORITY_FACT_ICONS]; - if (known) { - const KnownIcon = known; - return ; - } - const label = `${card.id} ${card.label} ${card.title}`.toLowerCase(); - const Icon = - label.includes("act-section") || label.includes("act section") - ? BookOpenText - : label.includes("clock") - ? Clock3 - : label.includes("destination") || label.includes("place") || label.includes("route") - ? MapPin - : label.includes("authority") || label.includes("maker") - ? UserRound - : label.includes("criteria") || label.includes("threshold") - ? Scale - : ClipboardList; - return ; -} - /** * The longer wording behind a Priority-facts card. * @@ -102,14 +70,11 @@ export function priorityFactBody( } /** - * Whether a card should offer "Tap for detail". + * Whether the longer wording says anything the fact's title does not. * - * This used to be gated on the form having a curated `priorityFacts` block, which is - * why only Form 1A had working popups. The honest test is whether the sheet would show - * anything the card face does not already say: on a form whose catalogue row is - * boilerplate, the clock card's title IS its whole body, so it stays inert rather than - * promising detail that does not exist. The authority card composes maker + authorises - * + does-not-authorise, which is strictly more than its title on every form. + * On a form whose catalogue row is boilerplate, the clock fact's title IS its whole + * body, so the body is not printed a second time. The authority fact composes maker + + * authorises + does-not-authorise, which is strictly more than its title on every form. */ function hasExtraDetail(card: ServiceSummaryCard, detail: { body: string } | null) { const body = detail?.body?.trim(); @@ -118,114 +83,29 @@ function hasExtraDetail(card: ServiceSummaryCard, detail: { body: string } | nul } /** - * The four cards sit in one stretched grid row, so the card with the most to say sets the - * height for all of them. Left alone that reads as three ragged boxes beside a full one: - * a two-line clock card printed its text at the top and left 150px of dead space below, - * while an interactive card's text floated to the middle because a ` - +
+
{label}
+
+

{title}

+ {/* Rendered only when there is a sub-line. The 34 forms with no indexed clock cue + used to print "Not listed" here, which reads as a fact about the form rather + than an absence of one, and there is no honest substitute in the catalogue. */} + {hasText(card.detail) ? ( +

{card.detail.trim()}

+ ) : null} + {body ?

{body}

: null} +
+
); } @@ -273,46 +153,48 @@ function ActSectionsCard({ const awaitingReview = sections.some((entry) => entry.reviewStatus === "drafted"); return ( - -

- {hasText(card.title) ? card.title.trim() : "Authority under the Act"} -

-
0 ? 1 : 0)))} - role="group" - aria-labelledby={groupLabelId} - > - {visible.map((entry) => ( - - ))} - {overflow > 0 ? ( - - ) : null} -
-
+
+
+ {hasText(card.label) ? card.label.trim() : "Act sections"} +
+
+

+ {hasText(card.title) ? card.title.trim() : "Authority under the Act"} +

+
0 ? 1 : 0)))} + role="group" + aria-labelledby={groupLabelId} + > + {visible.map((entry) => ( + + ))} + {overflow > 0 ? ( + + ) : null} +
+

+ {awaitingReview ? "Tap a section — awaiting clinical review" : "Tap a section for authority detail"} +

+
+
); } @@ -403,7 +285,7 @@ function CulturalNoteText({ text }: { text: string }) { * all of them; once any is signed, each note says for itself whether it was * reviewed (and by whom) or is still awaiting review. */ -function CulturalNotesSection({ formCode }: { formCode: string | undefined }) { +export function CulturalNotesSection({ formCode }: { formCode: string | undefined }) { const notes = culturalNotesForForm(formCode); if (!notes.length) return null; const reviewers = notes.map(culturalNoteReviewer); @@ -466,10 +348,8 @@ export function PriorityFactsSection({ form, cards }: { form: FormRecord; cards: // catalogPayload arrives from the owner-record path as an unchecked cast, bypassing // the actSections() coercer, so a malformed stored entry must not render a bare chip. const actSections = (details?.actSections ?? []).filter((entry) => hasText(entry?.section) && hasText(entry?.title)); - const [activeFactId, setActiveFactId] = useState(null); const [actSheet, setActSheet] = useState(null); - const activeFact = activeFactId ? priorityFactBody(form, activeFactId) : null; const activeSection = actSheet?.mode === "section" ? (actSections.find((entry) => entry.section === actSheet.section) ?? null) : null; const pdfHref = details?.officialPdfUrl ?? details?.officialRegisterUrl; @@ -482,17 +362,18 @@ export function PriorityFactsSection({ form, cards }: { form: FormRecord; cards:
-

- Priority facts -

+

At a glance

{contentAwaitingReview ? ( -

+

Drafted from the Act and the approved form, awaiting clinical review.

) : null} -
+
{cards.map((card) => { if (card.id === "act-sections" && actSections.length) { return ( @@ -506,41 +387,17 @@ export function PriorityFactsSection({ form, cards }: { form: FormRecord; cards: ); } const detail = priorityFactBody(form, card.id); - const hasDetail = hasExtraDetail(card, detail); - return ( - setActiveFactId(card.id) : undefined} - /> - ); + return ; })} -
-
- - - - setActiveFactId(null)} - title={activeFact?.title?.trim() || "Priority fact"} - description={activeFact?.detail} - testId="form-priority-fact-sheet" - mobilePlacement="bottom" - > - {activeFact ? ( -
-

{activeFact.body}

- {contentAwaitingReview ? ( -

- Drafted from the Act text and the approved form, and awaiting clinical review. Confirm against the - current Act and approved form before clinical or legal use. -

- ) : null} -
+ + {contentAwaitingReview ? ( + // The full wording used to carry this caveat inside its detail sheet; it now + // sits with the full wording on the card. +

+ Confirm against the current Act and approved form before clinical or legal use. +

) : null} -
+
({ })); import { FormDetailPage } from "@/components/forms/form-detail-page"; +import { priorityFactBody } from "@/components/forms/form-priority-facts-section"; import { getFormRecord } from "@/lib/forms"; vi.mock("@/components/account-data-provider", () => ({ @@ -18,7 +19,7 @@ vi.mock("@/components/account-data-provider", () => ({ })); describe("Form 1A priority facts", () => { - it("opens condensed fact detail and per-section Act detail sheets", async () => { + it("shows the fact wording in full on the card and opens per-section Act detail sheets", async () => { const user = userEvent.setup(); const form = getFormRecord("form-1a"); if (!form) throw new Error("Expected Form 1A"); @@ -33,11 +34,12 @@ describe("Form 1A priority facts", () => { expect(within(priorityFacts).getByText("Act sections")).toBeInTheDocument(); expect(within(priorityFacts).getByText(/Authority under the Act/i)).toBeInTheDocument(); - await user.click(within(priorityFacts).getByRole("button", { name: /Clock \/ review.*Open detail/i })); - const factSheet = await screen.findByTestId("form-priority-fact-sheet"); - expect(factSheet).toHaveTextContent(/72 hours from referral/i); - - await user.click(within(factSheet).getByRole("button", { name: /close/i })); + // The longer wording is on the card itself now: no "Tap for detail", no fact sheet. + expect(within(priorityFacts).queryByText("Tap for detail")).not.toBeInTheDocument(); + expect(within(priorityFacts).queryByRole("button", { name: /Clock \/ review.*Open detail/i })).toBeNull(); + expect(screen.queryByTestId("form-priority-fact-sheet")).not.toBeInTheDocument(); + const clockBody = priorityFactBody(form, "clock")!.body; + expect(within(priorityFacts).getByText(clockBody)).toBeInTheDocument(); await user.click(within(priorityFacts).getByRole("button", { name: /Section 26:/i })); const sectionSheet = await screen.findByTestId("form-act-section-sheet"); @@ -66,8 +68,7 @@ describe("Operational guidance review state", () => { expect(within(priorityFacts).queryByText(/Official form source/i)).not.toBeInTheDocument(); }); - it("repeats the caveat beside the full text in the detail sheet", async () => { - const user = userEvent.setup(); + it("keeps the caveat beside the full text on the card", () => { const form = getFormRecord("form-3c"); if (!form) throw new Error("Expected Form 3C"); @@ -77,17 +78,18 @@ describe("Operational guidance review state", () => { />, ); const priorityFacts = screen.getByLabelText("Priority facts"); - await user.click(within(priorityFacts).getByRole("button", { name: /Clock \/ review.*Open detail/i })); - - const factSheet = await screen.findByTestId("form-priority-fact-sheet"); - expect(factSheet).toHaveTextContent(/72 hours/i); - expect(factSheet).toHaveTextContent(/awaiting clinical review/i); + expect(priorityFacts).toHaveTextContent(/72 hours/i); + expect(priorityFacts).toHaveTextContent(/awaiting clinical review/i); + expect( + within(priorityFacts).getByText( + "Confirm against the current Act and approved form before clinical or legal use.", + ), + ).toBeInTheDocument(); }); }); describe("Priority facts on forms without curated copy", () => { - it("drops the empty sub-line and only offers detail where there is more to show", async () => { - const user = userEvent.setup(); + it("drops the empty sub-line and only prints longer wording where there is more to show", () => { // Form 4C's catalogue row is boilerplate: no indexedClock, and its clock prose is // the generic sentence. It is the shape 34 of the 47 archived forms have. const form = getFormRecord("transfer-order"); @@ -99,16 +101,14 @@ describe("Priority facts on forms without curated copy", () => { // "Not listed" read as a fact about the form rather than an absence of one. expect(within(priorityFacts).queryByText("Not listed")).not.toBeInTheDocument(); - // The clock card's title is its whole body, so it must not promise detail. - expect( - within(priorityFacts).queryByRole("button", { name: /Clock \/ review.*Open detail/i }), - ).not.toBeInTheDocument(); - - // The authority card composes maker + authorises + does-not-authorise, which is - // strictly more than its title — so every form gets a working popup here. - await user.click(within(priorityFacts).getByRole("button", { name: /Made by \/ authority.*Open detail/i })); - const factSheet = await screen.findByTestId("form-priority-fact-sheet"); - expect(factSheet).toHaveTextContent(/Does not replace linked forms/i); + // The clock fact's title is its whole body, so the body is not printed twice. + const clockTitle = (form.summaryCards ?? []).find((card) => card.id === "clock")?.title?.trim(); + expect(clockTitle).toBeTruthy(); + expect(within(priorityFacts).getAllByText(clockTitle!)).toHaveLength(1); + + // The authority fact composes maker + authorises + does-not-authorise, which is + // strictly more than its title — so every form shows that wording in full. + expect(priorityFacts).toHaveTextContent(/Does not replace linked forms/i); }); }); From c824a8b5b8596b826435c6e410351d20a08155c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:56:06 +0000 Subject: [PATCH 20/36] Tests: find the services phone button by its test id in the tap-floor check Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/ui-overlap.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ui-overlap.spec.ts b/tests/ui-overlap.spec.ts index 2a595e36d5..5a65ff530e 100644 --- a/tests/ui-overlap.spec.ts +++ b/tests/ui-overlap.spec.ts @@ -737,7 +737,8 @@ test.describe("Tablet usability regressions", () => { .click(); const controls = [ - page.getByRole("link", { name: /^Call .+, / }).first(), + // The compact phone button (Clinical audit, 13 Oct 2026, item 8), named "Call ". + page.locator('[data-testid^="service-call-"]').first(), page.getByRole("button", { name: /^More actions for / }).first(), page.getByRole("link", { name: /^Review referral for / }).first(), page.getByRole("button", { name: /^Add .+ to shortlist$/ }).first(), From 1f30ba05adc449462cf8e526ec03cb59c3a01f44 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:59:15 +0000 Subject: [PATCH 21/36] Services record: shared record layout, no blue side bar The header card loses its blue left bar and icon tile and becomes the shared record intro; priority facts become the At a glance card (the region keeps the name Priority facts); sections are plain headings. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/clinical-record-panels.tsx | 5 +- .../services/service-detail-page.tsx | 218 ++++++------------ tests/service-detail-page.dom.test.tsx | 4 +- tests/ui-tools.spec.ts | 5 +- 4 files changed, 81 insertions(+), 151 deletions(-) diff --git a/src/components/clinical-record-panels.tsx b/src/components/clinical-record-panels.tsx index c4bb64fcb2..978a73729b 100644 --- a/src/components/clinical-record-panels.tsx +++ b/src/components/clinical-record-panels.tsx @@ -175,10 +175,13 @@ export function RecordGlance({ testId, valueTestId, titleId, + ariaLabel, footer, }: { facts: RecordGlanceFact[]; title?: string; + /** Accessible name when it must differ from the visible title (a record's established section name). */ + ariaLabel?: string; id?: string; testId?: string; titleId?: string; @@ -192,7 +195,7 @@ export function RecordGlance({
; - if (card.id === "eligibility") return ; - if (card.id === "cost") return ; - if (card.id === "confidence" || card.id === "best-use") return ; - return ; -} - function renderRowIcon(label: string) { const normalized = label.toLowerCase(); const className = "h-4 w-4 shrink-0 text-[color:var(--clinical-accent)]"; @@ -304,81 +293,41 @@ export const serviceNavSections: readonly PageSection[] = [ function Section({ id, - icon: Icon, title, description, action, children, }: { id?: string; - icon: LucideIcon; title: string; description?: string; action?: ReactNode; children: ReactNode; }) { return ( -
-
-
- - - -
-

{title}

- {description ?

{description}

: null} -
- {action ?
{action}
: null} -
-
-
{children}
-
+ + {description ?

{description}

: null} + {children} +
); } -function SummaryCard({ card }: { card: ServiceSummaryCard }) { - const isCost = card.id === "cost"; - - return ( - // The cards sit in one stretched grid row, so the card with the most to say sets - // the height for all of them, and a short card printed its text hard against the - // top of a tall box. The block is centred in whatever height the tallest sibling - // imposes instead. Title and detail stay together: unlike the form priority-fact - // cards there is no separate footnote zone here, so there is nothing to pin to - // the bottom. -
-
- - {renderSummaryIcon(card)} - -
-

- {displayText(card.label, "Detail")} -

-

- {displayText(card.title)} -

-
-
- {hasText(card.detail) ?

{card.detail.trim()}

: null} -
- ); +/** A priority-fact card as one At a glance pair: the title, then its supporting line. */ +function summaryCardFact(card: ServiceSummaryCard): RecordGlanceFact { + const title = displayText(card.title); + return { + key: card.id, + label: displayText(card.label, "Detail"), + wide: title.length > 40 || (hasText(card.detail) && card.detail.trim().length > 40), + value: ( + <> + {title} + {hasText(card.detail) ? ( + {card.detail.trim()} + ) : null} + + ), + }; } function renderPhoneLines(value: string) { @@ -718,64 +667,55 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { ) : null}
-
-
-
- - - -
-

- {service.title} -

- {compactedSubtitle ? ( -

- {compactedSubtitle} -

- ) : null} - {service.statusChips?.length ? ( -
- {service.statusChips.map((chip, index) => ( - - - {displayText(chip.label, "Status")} - - ))} -
- ) : null} -
- {recordGroups.map((groupId) => { - const group = serviceCoreGroups.find((item) => item.id === groupId); - return group ? ( - - {group.label} - - ) : null; - })} -
-
-
+
+
+ + {service.statusChips?.map((chip, index) => ( + + + {displayText(chip.label, "Status")} + + ))} + + {recordGroups.map((groupId) => { + const group = serviceCoreGroups.find((item) => item.id === groupId); + return group ? ( + + {group.label} + + ) : null; + })} + + + } + />
+ {showPriorityFacts ? ( + + ) : null} +
@@ -846,44 +786,26 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) {
- {showPriorityFacts ? ( -
-

- Priority facts -

-
- {visibleSummaryCards.map((card) => ( - - ))} -
-
- ) : null} -
{showReferralSection ? ( -
+
) : null} -
+
{showCriteriaSection ? (
@@ -904,7 +826,7 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { id="service-verification" className={cn( inPageAnchor, - "rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-3 shadow-[var(--shadow-inset)]", + "rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4", )} >
@@ -953,7 +875,7 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) {
{showTagsSection ? ( -
+
diff --git a/tests/service-detail-page.dom.test.tsx b/tests/service-detail-page.dom.test.tsx index ed1661ec3d..1fec2c9bbf 100644 --- a/tests/service-detail-page.dom.test.tsx +++ b/tests/service-detail-page.dom.test.tsx @@ -209,7 +209,9 @@ describe("ServiceDetailPage content cleanup", () => { it("keeps referral details compact and exposes no copy controls", () => { render(); - expect(screen.getByRole("heading", { name: "Priority facts" })).toBeInTheDocument(); + // The priority facts are the shared At a glance card; the region keeps its established name. + expect(screen.getByLabelText("Priority facts")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "At a glance" })).toBeInTheDocument(); expect(screen.getByRole("link", { name: "Call 13 92 76" })).toHaveAttribute("href", "tel:139276"); expect(screen.queryByRole("button", { name: /copy/i })).not.toBeInTheDocument(); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 15cc41ed5f..aa3a8d4430 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -3129,7 +3129,10 @@ test.describe("PsychSift service detail page", () => { await expect(actions.getByRole("link", { name: "Call" })).toHaveAttribute("href", "tel:139276"); await expect(actions.getByRole("button", { name: "Use in navigator" })).toBeVisible(); await expect(servicePage.getByRole("link", { name: "Call 13 92 76" })).toHaveAttribute("href", "tel:139276"); - await expect(servicePage.getByRole("heading", { name: "Priority facts" })).toBeVisible(); + // The priority facts are the shared At a glance card; the region keeps its established name. + await expect( + servicePage.getByLabel("Priority facts").getByRole("heading", { name: "At a glance" }), + ).toBeVisible(); await expect(servicePage.getByRole("button", { name: /copy/i })).toHaveCount(0); await page.keyboard.press("Escape"); await expect(actions).toBeHidden(); From 241f37593e7773a9499f4d9fa1e133253bc3f676 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 07:04:56 +0000 Subject: [PATCH 22/36] Compare sheet and comparison buttons: Clinical blue main buttons The compare picker Done, the compare empty-state and slot-strip actions, and the DSM Add to comparison button move from black to the Clinical blue primary button. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/compare/compare-catalog-picker.tsx | 7 ++----- src/components/compare/compare-empty-state.tsx | 7 ++----- src/components/compare/compare-slot-strip.tsx | 3 ++- .../dsm/dsm-differential-considerations-page.tsx | 6 ++---- 4 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/components/compare/compare-catalog-picker.tsx b/src/components/compare/compare-catalog-picker.tsx index 47a6d94157..2b7e015ca2 100644 --- a/src/components/compare/compare-catalog-picker.tsx +++ b/src/components/compare/compare-catalog-picker.tsx @@ -6,6 +6,7 @@ import { ArrowRight, Search } from "lucide-react"; import { filterCompareCatalog, padCompareIds, slotLetters } from "@/components/compare/filter-catalog"; import type { CompareCatalogItem, CompareStarterChip } from "@/components/compare/types"; +import { buttonFaceClass } from "@/components/ui/button-face"; import { cn, searchShell, searchShellInput } from "@/components/ui-primitives"; export function CompareCatalogPicker({ @@ -242,11 +243,7 @@ export function CompareCatalogPicker({ ) : null} {onDone ? ( - ) : null} diff --git a/src/components/compare/compare-empty-state.tsx b/src/components/compare/compare-empty-state.tsx index b723fdf3a3..7aedf63e6c 100644 --- a/src/components/compare/compare-empty-state.tsx +++ b/src/components/compare/compare-empty-state.tsx @@ -1,5 +1,6 @@ "use client"; +import { buttonFaceClass } from "@/components/ui/button-face"; import Link from "next/link"; import type { LucideIcon } from "lucide-react"; import { Search } from "lucide-react"; @@ -27,11 +28,7 @@ export function CompareEmptyState({