diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 024d23e1c8..8af049e6bb 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -1083,7 +1083,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `LoadingPanel` | feedback | yes | yes | inherited-global-root | yes | no | 10 | | `MissingValue` | feedback | yes | yes | inherited-global-root | yes | no | 11 | | `OverlayRoot` | layout | yes | yes | inherited-global-root | yes | no | 1 | -| `PageHeader` | layout | yes | yes | inherited-global-root | yes | no | 16 | +| `PageHeader` | layout | yes | yes | inherited-global-root | yes | no | 14 | | `Pagination` | controls | yes | yes | inherited-global-root | yes | no | 1 | | `PanelHeading` | layout | yes | yes | inherited-global-root | yes | no | 2 | | `Progress` | feedback | yes | yes | inherited-global-root | yes | no | 1 | @@ -1094,7 +1094,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `SearchField` | controls | yes | yes | inherited-global-root | yes | no | 5 | | `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 33 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 16 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 137 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 136 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 9 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 4 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 4f7fddc1ea..82f65f4052 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1710,7 +1710,6 @@ "directImportFiles": [ "src/components/dictionary/dictionary-catalogue-pages.tsx", "src/components/differentials/differential-compare-queue-page.tsx", - "src/components/differentials/differential-detail-page.tsx", "src/components/dsm/dsm-page-header.tsx", "src/components/factsheets/factsheets-topics-page.tsx", "src/components/information-page-shell.tsx", @@ -1718,7 +1717,6 @@ "src/components/specifiers/specifier-map-page.tsx", "src/components/therapy-compass/screens/brief-screen.tsx", "src/components/therapy-compass/screens/compare-screen.tsx", - "src/components/therapy-compass/screens/detail-screen.tsx", "src/components/therapy-compass/screens/other-screen.tsx", "src/components/therapy-compass/screens/pathways-screen.tsx", "src/components/therapy-compass/screens/recommend-screen.tsx", @@ -1728,7 +1726,6 @@ "productImportFiles": [ "src/components/dictionary/dictionary-catalogue-pages.tsx", "src/components/differentials/differential-compare-queue-page.tsx", - "src/components/differentials/differential-detail-page.tsx", "src/components/dsm/dsm-page-header.tsx", "src/components/factsheets/factsheets-topics-page.tsx", "src/components/information-page-shell.tsx", @@ -1736,7 +1733,6 @@ "src/components/specifiers/specifier-map-page.tsx", "src/components/therapy-compass/screens/brief-screen.tsx", "src/components/therapy-compass/screens/compare-screen.tsx", - "src/components/therapy-compass/screens/detail-screen.tsx", "src/components/therapy-compass/screens/other-screen.tsx", "src/components/therapy-compass/screens/pathways-screen.tsx", "src/components/therapy-compass/screens/recommend-screen.tsx", @@ -2375,7 +2371,6 @@ "src/components/teaching/term-folder/term-folder-export-sheet.tsx", "src/components/therapy-compass/pathway-picker-sheet.tsx", "src/components/therapy-compass/recommend-scenario-control.tsx", - "src/components/therapy-compass/record/key-facts.tsx", "src/components/tools/tools-search-results-page.tsx", "src/components/ui/confirm-dialog.tsx", "src/components/work-frame/two-pane-side-menu.tsx", @@ -2516,7 +2511,6 @@ "src/components/teaching/term-folder/term-folder-export-sheet.tsx", "src/components/therapy-compass/pathway-picker-sheet.tsx", "src/components/therapy-compass/recommend-scenario-control.tsx", - "src/components/therapy-compass/record/key-facts.tsx", "src/components/tools/tools-search-results-page.tsx", "src/components/ui/confirm-dialog.tsx", "src/components/work-frame/two-pane-side-menu.tsx", diff --git a/src/app/globals.css b/src/app/globals.css index 3a7888c755..489d94750c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2937,16 +2937,25 @@ html[data-motion="reduced"] .shared-compare-tray-layer[data-scroll-hidden="true" mode-action popover only renders ≥1024px, so this holds no .mode-action-* overrides. */ @media (max-width: 430px) { + /* Large text (browser or OS text zoom raising the root size): every rem here + grew with it while the field's own 16px text did not, so at 320px with a + 200% root three 88px buttons left the field 0-24px wide and the prompt read + "As". Each size in this block is its normal rem value capped in px. The caps + sit above what any density reaches (the largest root is 17px: 2.75rem is + 46.75px against a 48px cap, 1.25rem 21.25px against 24px, 0.375rem 6.4px + against 8px, 0.35rem 5.95px against 6px, 3.25rem 55.25px against 56px), so + the composer is unchanged at 15, 16 and 17px roots and only stops growing + past them. 48px keeps the full tap floor at large text. */ .chat-composer-input { - min-height: 2.75rem; + min-height: min(2.75rem, 48px); } .chat-composer-icon-button, .chat-send-button { - height: 2.75rem !important; - width: 2.75rem !important; - min-height: 2.75rem; - min-width: 2.75rem; + height: min(2.75rem, 48px) !important; + width: min(2.75rem, 48px) !important; + min-height: min(2.75rem, 48px); + min-width: min(2.75rem, 48px); } /* 1.1rem was 17.6px: lucide draws on a 24-unit grid with a 2px stroke, so a @@ -2957,8 +2966,34 @@ html[data-motion="reduced"] .shared-compare-tray-layer[data-scroll-hidden="true" height this block exists to protect untouched. */ .chat-composer-icon-button svg, .chat-send-button svg { - height: var(--spacing-icon-lg); - width: var(--spacing-icon-lg); + height: min(var(--spacing-icon-lg), 24px); + width: min(var(--spacing-icon-lg), 24px); + } + + /* Large-text caps for the pill and the field's inset. Unlayered (beats the + layered surface rule) and scoped one class deeper than the base + `.answer-footer-search-input` rule further down, which would otherwise win on + source order; at normal sizes both compute to the values those rules set. */ + .answer-footer-search-pill { + min-height: min(3.25rem, 56px); + gap: min(0.375rem, 8px); + padding-inline: min(0.375rem, 8px); + } + + .answer-footer-search-pill .answer-footer-search-input { + padding-inline: min(0.35rem, 6px); + } + + /* The in-field Clear button sizes from the tap utilities (3rem, 51px at the + 17px root) and a 1rem glyph; cap them past that the same way. */ + .answer-footer-search-pill .min-w-tap { + min-height: min(var(--spacing-tap), 52px); + min-width: min(var(--spacing-tap), 52px); + } + + .answer-footer-search-pill .min-w-tap svg { + height: min(var(--spacing-icon-md), 20px); + width: min(var(--spacing-icon-md), 20px); } } /* Mirrors the `motion-safe` variant: animate when the OS allows it, and also when @@ -3150,10 +3185,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/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index f47fa789ab..284d46356c 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -2746,6 +2746,17 @@ function ClinicalDashboardContent({ ], ); const answerProgressCompleted = answerProgressEvents.at(-1)?.stage === "complete"; + // A failed documents search with nothing listed. The documents results own that + // message (one calm "not a reliable nothing found" state with Retry and Browse), so the + // generic red "Search unavailable" box is not stacked on top. When earlier matches are + // still listed they belong to the previous query, so the red box stays and says so. + const documentsSearchFailed = searchMode === "documents" && Boolean(error) && errorKind !== "no-results"; + const documentsResultsOwnError = + documentsSearchFailed && + activeModeResultKind === "documents" && + modeSearchSubmitted && + Boolean(query.trim()) && + documentMatches.length === 0; const differentialsCompareHasSelection = useDifferentialsCompareHasSelection(); const { showAuthPanel, @@ -3285,7 +3296,7 @@ function ClinicalDashboardContent({ } /> - ) : error ? ( + ) : error && !documentsResultsOwnError ? ( { + setError(null); + void ask(documentsSearchFailed ? (lastFailedQuery ?? query) : query); + } + : undefined + } /> ) 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..2ad61b01d3 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -139,18 +139,20 @@ function CalculatorTile({ // finds the tile again by calculator id rather than holding a node ref. data-calculator-open={calc.id} aria-label={`Open ${calc.abbrev} — ${calc.name}`} + // A compact row of the list's one hairline card (Clinical audit, 13 Oct + // 2026, item 12), the same row Therapy search uses: name, one line, + // tags, and the whole row opens the calculator. className={cn( - "group grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-left shadow-[var(--e2)] transition hover:-translate-y-0.5 hover:border-[color:var(--clinical-accent-border)] hover:bg-[color:var(--surface-raised)] hover:shadow-[var(--shadow-hover)] motion-reduce:transition-none motion-reduce:hover:translate-y-0", + "group grid min-h-tap w-full grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-[color:var(--surface-subtle)] motion-reduce:transition-none sm:px-5", focusRing, )} > - @@ -167,11 +169,11 @@ function CalculatorTile({ {calc.name} {compact ? null : ( - + {calc.indication} )} - + {compact ? null : } @@ -273,6 +275,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 +294,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)}

); @@ -587,18 +598,22 @@ export function CalculatorsSearchPage({ } > {results.length ? ( -
+
    {results.map(({ calc, derived, context }) => ( - openCalculator(calc.id)} - /> +
  • + openCalculator(calc.id)} + /> +
  • ))} -
+ ) : ( void | Promise; facets?: SearchFacets | null; searchScope?: SearchScopeSummary | null; onScopeDocument: (documentId: string) => void; @@ -1571,6 +1577,8 @@ function DocumentSearchResultsPanelImpl({ onClearFilters={activeFilterCount > 0 ? clearAllFilters : undefined} // A retrieval layer errored, so this zero is not evidence of absence. degraded={retrievalDegraded} + degradedReason={searchFailed ? searchFailureReason : null} + onRetry={retrievalDegraded ? onRetrySearch : undefined} onBrowseAll={onOpenLibrary} browseAllLabel={ documentCount > 0 ? `Browse all ${documentCount.toLocaleString()} sources` : "Browse all sources" 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/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}
    - - -
    - -
    ); diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index 2d0a3d0831..643c271f6b 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -1,6 +1,17 @@ "use client"; -import { Bookmark, CircleAlert, CircleMinus, Funnel, LayoutList, LoaderCircle, Search, Table2, X } from "lucide-react"; +import { + Bookmark, + CircleAlert, + CircleMinus, + Funnel, + LayoutList, + LoaderCircle, + RefreshCw, + Search, + Table2, + X, +} from "lucide-react"; import { useCallback, useEffect, useState, type ReactNode } from "react"; import { searchCommandSurfaceConfig } from "@/lib/search-command-surface"; @@ -895,6 +906,8 @@ export function SearchResultsEmptyState({ onBrowseAll, browseAllLabel = "Browse all sources", degraded = false, + degradedReason, + onRetry, }: { modeId: AppModeId; query: string; @@ -935,6 +948,11 @@ export function SearchResultsEmptyState({ * be available when the search never actually ran. */ degraded?: boolean; + /** Degraded only: the specific failure the page was given ("Search setup not ready."), + shown quietly under the fixed copy so the one message keeps the actual reason. */ + degradedReason?: string | null; + /** Degraded only: re-run the same search. Offered first, ahead of browsing. */ + onRetry?: () => void | Promise; }) { const config = searchCommandSurfaceConfig(modeId); const crossModes = (config?.crossModes ?? []).filter((target) => canAccessFavourites || target !== "favourites"); @@ -998,7 +1016,33 @@ export function SearchResultsEmptyState({ const frame = requestAnimationFrame(() => setLiveMessage(`${emptyTitle}. ${emptyBody}`)); return () => cancelAnimationFrame(frame); }, [filtered, emptyTitle, emptyBody]); + const [retrying, setRetrying] = useState(false); + const retry = async () => { + if (!onRetry) return; + setRetrying(true); + try { + await onRetry(); + } catch { + // This degraded state stays the failure surface; a rejected retry must not escape. + } finally { + setRetrying(false); + } + }; const secondary = [ + degraded && onRetry ? ( + } + className={cn(emptyStateAction, focusRing)} + > + Retry + + ) : null, config?.examples[0] && onTryExample ? (
{!filtered ? ( diff --git a/src/components/clinical-record-panels.tsx b/src/components/clinical-record-panels.tsx index 4fb7d13481..978a73729b 100644 --- a/src/components/clinical-record-panels.tsx +++ b/src/components/clinical-record-panels.tsx @@ -1,73 +1,15 @@ -import { ChevronDown, Waypoints, type LucideIcon } from "lucide-react"; +import { TriangleAlert, Waypoints } from "lucide-react"; +import type { ReactNode } from "react"; +import { inPageAnchor } from "@/components/in-page-nav/in-page-nav-classes"; import { cn, eyebrowText } from "@/components/ui-primitives"; -/** - * Presentation blocks shared by the clinical record families (formulation - * mechanisms, specifiers). The families own their own data, copy, and shells, - * but their record facts, guidance accordions, pathway strips, and compare links - * are the same design-system surface, so they live here rather than being - * re-typed per family and drifting apart. +/* + * Presentation blocks shared by the clinical record families. The families own + * their own data, copy, and shells, but their record layout, pathway strips and + * compare links are the same design-system surface, so they live here rather + * than being re-typed per family and drifting apart. */ -export function RecordFact({ icon: Icon, label, body }: { icon: LucideIcon; label: string; body: string }) { - return ( -
- - - - - {label} - {body} - -
- ); -} - -export function GuidanceSection({ - icon: Icon, - title, - items, - tone = "default", - open = false, -}: { - icon: LucideIcon; - title: string; - items: string[]; - tone?: "default" | "success" | "warning"; - open?: boolean; -}) { - const toneClass = - tone === "success" - ? "text-[color:var(--success)] bg-[color:var(--success-soft)]" - : tone === "warning" - ? "text-[color:var(--warning)] bg-[color:var(--warning-soft)]" - : "text-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)]"; - - return ( -
- - - - - {title} - - -
-
    - {items.map((item) => ( -
  • - - {item} -
  • - ))} -
-
-
- ); -} /** Numbered "work through it in this order" strip on a mode home page. */ export function ClinicalPathwayStrip({ @@ -129,3 +71,260 @@ export function compareRecordsHref(basePath: string, left: string, right?: strin if (right) params.set("b", right); return `${basePath}?${params.toString()}`; } + +/* + * Record body primitives: the one layout every Clinical record body shares. + * + * Modelled on the Factsheet record (the cleanest page in Clinical): the name, + * one plain line and key tags; one row of the record's own actions; one calm + * "At a glance" card with every value in full; cautions straight after, in + * full; then sections as plain headings with the content below; sources, + * related and compare-next at the end. Record families keep their own data, + * copy and behaviour and only compose these blocks, so the record types stop + * drifting into separate page designs. + * + * None of these own the record top bar, record nav bar, tabs or Back button. + */ + +/** The record body column: one even rhythm between every block. */ +export function RecordBody({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
; +} + +/** + * Name, one plain line, key tags, then the record's action row. + * + * `eyebrow` is the record family or category, in the record's own words. + * `meta` carries anything the record already showed beside its tags (aliases, + * review status). `actions` is the record's existing Favourite / Compare / Copy + * controls, in one row under the title — never new actions. + */ +export function RecordIntro({ + eyebrow, + title, + line, + meta, + tags, + actions, + titleId, + testId, + children, +}: { + eyebrow?: ReactNode; + title: ReactNode; + line?: ReactNode; + meta?: ReactNode; + tags?: ReactNode; + actions?: ReactNode; + titleId?: string; + testId?: string; + children?: ReactNode; +}) { + return ( +
+
+ {eyebrow ?

{eyebrow}

: null} +

+ {title} +

+ {line ? ( +

{line}

+ ) : null} +
+ {meta ?
{meta}
: null} + {tags ?
{tags}
: null} + {children} + {actions ? {actions} : null} +
+ ); +} + +/** One row for the record's Favourite / Compare / Copy actions. */ +export function RecordActions({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +export type RecordGlanceFact = { + key?: string; + label: string; + value: ReactNode; + /** Span the full card width. Long string values do this automatically. */ + wide?: boolean; +}; + +const GLANCE_WIDE_CHARS = 40; + +/** + * The one "At a glance" card: a calm tinted hairline card of label/value + * pairs. Values are always shown in full — no "Tap for detail" truncation. + * Two columns once the card is 15rem wide. It is a container query, and rem + * there follows the root font size, so the large-text setting on a 320px + * phone falls back to one column instead of squeezing two. + */ +export function RecordGlance({ + facts, + title = "At a glance", + id, + 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; + /** Optional test id on every value, for records whose browser tests pin them. */ + valueTestId?: string; + footer?: ReactNode; +}) { + const shown = facts.filter((fact) => fact.value !== null && fact.value !== undefined && fact.value !== ""); + if (!shown.length && !footer) return null; + return ( +
+

+ {title} +

+ {shown.length ? ( +
+ {shown.map((fact) => { + const wide = fact.wide ?? (typeof fact.value === "string" && fact.value.length > GLANCE_WIDE_CHARS); + return ( +
+
{fact.label}
+
+ {fact.value} +
+
+ ); + })} +
+ ) : null} + {footer ?
{footer}
: null} +
+ ); +} + +/** + * Cautions, directly after At a glance, in full. A flat warning-tinted hairline + * card — no coloured side bar — carrying the record's existing caution content. + */ +export function RecordCautions({ + title = "Cautions", + id, + testId, + children, +}: { + title?: ReactNode; + id?: string; + testId?: string; + children: ReactNode; +}) { + return ( +
+

+ + {title} +

+
{children}
+
+ ); +} + +/** + * One record section: a plain heading with the content below, the Factsheet + * style. Longer sections fold with the record's existing expand pattern inside + * `children`; this owns only the heading and the spacing. + */ +export function RecordSection({ + title, + id, + testId, + action, + className, + children, + headingLevel = "h2", + headingId, +}: { + title: ReactNode; + id?: string; + testId?: string; + action?: ReactNode; + className?: string; + children: ReactNode; + headingLevel?: "h2" | "h3"; + headingId?: string; +}) { + const Heading = headingLevel; + return ( +
+
+ + {title} + + {action} +
+
{children}
+
+ ); +} + +/** A plain bulleted list in the shared record style. */ +export function RecordBulletList({ items, tone = "default" }: { items: string[]; tone?: "default" | "warning" }) { + return ( +
    + {items.map((item) => ( +
  • + + + {item} + +
  • + ))} +
+ ); +} diff --git a/src/components/compare/compare-catalog-picker.tsx b/src/components/compare/compare-catalog-picker.tsx index 47a6d94157..cceebc3b32 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({ @@ -26,6 +27,7 @@ export function CompareCatalogPicker({ title, titleId, starters, + hideActions = false, }: { items: readonly CompareCatalogItem[]; query: string; @@ -44,6 +46,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 +238,33 @@ 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-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({
- ); -} - -function MobilePrimaryActions({ +function RecordActionRow({ record, saved, onToggleSaved, @@ -1004,18 +825,15 @@ function MobilePrimaryActions({ compareCount: number; }) { return ( -
- + <> + Compare ({compareCount}) -
+ ); } @@ -1058,17 +876,8 @@ function DiagnosisDiscoveryActions({ ]; return ( -
+
- {/* 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 +
+ } diff --git a/src/components/dsm/dsm-differential-considerations-page.tsx b/src/components/dsm/dsm-differential-considerations-page.tsx index 9d916074a1..3bb722ecbf 100644 --- a/src/components/dsm/dsm-differential-considerations-page.tsx +++ b/src/components/dsm/dsm-differential-considerations-page.tsx @@ -1,5 +1,6 @@ "use client"; +import { buttonFaceClass } from "@/components/ui/button-face"; import Link from "next/link"; import { useMemo, useState } from "react"; import { @@ -326,10 +327,7 @@ export function DsmDifferentialConsiderationsPage({ ) : null} - + Add to comparison 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} -
+
) : results.length === 0 ? null : ( -
- {results.map(({ mechanism }, index) => ( -
-
-
-
-
-

+
+ {/* Compact rows (Clinical audit, 13 Oct 2026, item 12), the same row + Therapy search uses: one hairline card with divided rows, the whole + row opens the mechanism, and Use and Open stay as small 48px + buttons. Patient language, clues and formulation use are on the + mechanism's own page, where they are shown in full. */} +
    + {results.map(({ mechanism }, index) => { + const topMatch = index === 0 && hasUniqueTopMatch; + return ( +
  • + {topMatch ? ( + // Ranking order is not clinical fit: the caution travels with the tag. +
    + + + Top match + + + Review fit and alternatives + +
    + ) : null} +
    +
    +

    + + {mechanism.name} + +

    +

    + {mechanism.summary} +

    +
    + +
    +
    +
    + + + - {mechanism.name} + -

- {index === 0 && hasUniqueTopMatch ? ( - <> - {/* The banner this replaces spent a whole row saying what a - chip says on the title line. The caution beside it is the - part worth keeping: ranking order is not clinical fit. */} - - - Top match - - - Review fit and alternatives - - - ) : null} -
-

- {mechanism.summary} -

-
- - {/* Both actions sit on the title row rather than in a footer band of - their own: the old strip cost a full card row per result to hold - one button the heading already linked to. */} -
- - - Use - - - Open - - -
-
- - {/* One band instead of three: the clue keeps its tinted panel, and the - supporting lines sit beside it rather than under two more rules. */} -
-
- -
- - - -
-

Patient language

-

- “{mechanism.patientPhrases[0]}” -

-
-
-
- -
-
-
- -

Look for

-
-

- {mechanism.clinicalClues[0]} -

-
-
-
- -

Formulation use

-

- {mechanism.formulationUse} -

-
-
-
- ))} + + ); + })} +
)} diff --git a/src/components/formulation/formulation-mechanism-page.tsx b/src/components/formulation/formulation-mechanism-page.tsx index 6a1d40f800..a08f1a0e15 100644 --- a/src/components/formulation/formulation-mechanism-page.tsx +++ b/src/components/formulation/formulation-mechanism-page.tsx @@ -1,49 +1,40 @@ import Link from "next/link"; -import { - ArrowRight, - BookOpenCheck, - Check, - ChevronDown, - CircleHelp, - GitCompareArrows, - ListChecks, - MessageSquareQuote, - Network, - Route, - ShieldAlert, - Sparkles, - Target, - Waypoints, -} from "lucide-react"; +import { ArrowRight, Check, ChevronDown, GitCompareArrows, ListChecks } from "lucide-react"; import { EvidenceList, FormulationPageShell, FormulationSafetyNote, MechanismBadge, - MechanismCaveats, MechanismDomainChips, RecordReviewBadge, RecordReviewNote, SectionHeading, - formulationCard, } from "@/components/formulation/formulation-ui"; -import { compareRecordsHref, GuidanceSection, RecordFact } from "@/components/clinical-record-panels"; +import { + compareRecordsHref, + RecordBody, + RecordBulletList, + RecordCautions, + RecordGlance, + RecordIntro, + RecordSection, +} from "@/components/clinical-record-panels"; import { FormulationNavHeader } from "@/components/formulation/formulation-nav-header"; import { inPageActionRowClass, inPageAnchor } from "@/components/in-page-nav/in-page-nav-classes"; -import { InformationPageHeader } from "@/components/information-page-shell"; -import { cn, eyebrowText } from "@/components/ui-primitives"; +import { buttonFaceClass } from "@/components/ui/button-face"; +import { cn } from "@/components/ui-primitives"; import { formulationSourceLibrary, relatedFormulationMechanisms, type FormulationMechanism } from "@/lib/formulation"; import { mechanismReviewState } from "@/lib/formulation-review-status"; function FactorColumn({ title, items }: { title: string; items: string[] }) { return ( -
-

{title}

-
    +
    +

    {title}

    +
      {items.map((item) => (
    • - + {item}
    • ))} @@ -81,239 +72,187 @@ export function FormulationMechanismPage({ mechanism }: { mechanism: Formulation } /> -
      - - - - - - } + +
      + + + + + + } + actions={ + + + Compare + + } + /> +
      + + -
      -
      -
      -

      - What matters now -

      -
      -
      - - - - -
      -
      + {/* The record's own caveats, directly after At a glance and in full: + the limits that belong to this mechanism, not a generic note. */} + {mechanism.caveats.length ? ( + Caveats for this record}> + + + ) : null} -
      -
      +
      + + + + + + + + + + + `“${item}”`)} /> + +
      -
      - - - - `“${item}”`)} - /> -
      + +
      + + + +
      +
      -
      -
      -

      Across the formulation

      -

      - Predisposing, precipitating, and perpetuating factors -

      -
      -
      - - - -
      -
      + +

      {mechanism.caseExample}

      +
      -
      -
      - - - -
      -

      Short case example

      -

      - {mechanism.caseExample} -

      -
      -
      -
      - - - -
      -

      Formulation language

      -

      - {mechanism.exampleSentence} -

      -
      -
      -
      + +

      + {mechanism.exampleSentence} +

      +
      -
      -
      - - - -
      -

      Treatment implications

      -

      - Translate the hypothesis into a testable target -

      -
      -
      -
        - {mechanism.treatmentImplications.map((item) => ( -
      • - - {item} -
      • - ))} -
      -

      - {mechanism.treatmentTargetExample} -

      -
      + +
        + {mechanism.treatmentImplications.map((item) => ( +
      • + + {item} +
      • + ))} +
      +

      + {mechanism.treatmentTargetExample} +

      +
      - {/* The record's own caveats were held in the data and rendered - nowhere: every mechanism showed the same generic footer note - instead of the limits that belong to it. The record's review - state was held and dropped the same way — the caveats say what - the mechanism cannot tell you, this says whether anyone has - checked what it does. */} - - -
      + {/* Whether anyone has checked what the mechanism does. */} + - -
      + {sources.length ? ( +
      + + + Evidence notes + + + +
      + {sources.map((source) => ( + + {source.title} + + ))} + {/* The citations the 2026-09-16 patch set attached to this + record's definition and candidate process, rendered where + the claim is rather than as Markdown inside a text field. */} + +

      + Teaching references only. Check current local guidance and clinical applicability. +

      +
      +
      + ) : null} - + +
      ); diff --git a/src/components/medicines/medicines-home.tsx b/src/components/medicines/medicines-home.tsx index 923096075b..37d6329dac 100644 --- a/src/components/medicines/medicines-home.tsx +++ b/src/components/medicines/medicines-home.tsx @@ -302,7 +302,20 @@ const NO_VISITS: readonly MedicineVisit[] = []; /** One calm list: grey icon, the section's name, and its real count on the right. */ function SectionsGroup({ counts }: { readonly counts: MedicinesSectionCounts }) { return ( - + + All tools + + } + >
        {SECTION_MODE_IDS.map((modeId) => { const mode = appModeDefinition(modeId); diff --git a/src/components/mode-home-start-list.tsx b/src/components/mode-home-start-list.tsx index b1eb2fabf8..82e0a89973 100644 --- a/src/components/mode-home-start-list.tsx +++ b/src/components/mode-home-start-list.tsx @@ -21,35 +21,132 @@ import { RECENT_PAGES_CONTINUE_SHOWN, type RecentPageSection } from "@/lib/recen * Look and navigation only: the rows are the records' own titles, read from * the device's Recent pages list and the account's favourites, never ranked * or reworded here. + * + * A home with nothing to continue and no favourites (signed out, a new device) + * shows two Browse links instead of one (Clinical audit, 13 Oct 2026, item 17), + * each an existing page of that mode, so the screen under the box is not empty. + * Two is the most that keeps the idle phone home on one screen at 390 x 844. Once Continue or Favourites has something, only the first Browse + * link stays, as before. */ -type StartListMode = Extract; +type StartListMode = Extract< + AppModeId, + | "dsm" + | "prescribing" + | "therapy-compass" + | "documents" + | "differentials" + | "formulation" + | "specifiers" + | "forms" + | "factsheets" + | "dictionary" + | "services" +>; + +type BrowseLink = { readonly href: string; readonly label: string }; + +/** Browse links an empty home shows; more pushes the idle phone home past one screen. */ +const EMPTY_HOME_BROWSE_SHOWN = 2; type StartListConfig = { /** The Recent pages section whose records Continue lists. */ readonly section: RecentPageSection; /** The favourites registry type saved for this mode, when the account can save one. */ readonly favouriteType?: "differentials" | "therapies"; - /** The mode's own whole-catalogue page, when it has one. */ - readonly browse?: { readonly href: string; readonly label: string }; + /** + * Existing pages to browse from, most useful first. The first is the mode's + * own whole catalogue when it has one; the rest show only on an empty home. + */ + readonly browse: readonly BrowseLink[]; }; const START_LIST_CONFIG: Readonly> = { - dsm: { section: "dsm", browse: { href: "/dsm/search", label: "Browse all diagnoses" } }, + dsm: { + section: "dsm", + browse: [ + { href: "/dsm/search", label: "Browse all diagnoses" }, + { href: "/dsm/compare", label: "Compare diagnoses" }, + { href: "/specifiers/search", label: "Browse all specifiers" }, + ], + }, differentials: { section: "differentials", favouriteType: "differentials", - browse: { href: "/differentials/diagnoses", label: "Browse all differentials" }, + browse: [ + { href: "/differentials/diagnoses", label: "Browse all differentials" }, + { href: "/differentials/presentations", label: "Start from a presentation" }, + { href: "/differentials/compare", label: "Compare differentials" }, + ], }, "therapy-compass": { section: "therapy-compass", favouriteType: "therapies", - browse: { href: "/therapy-compass/search", label: "Browse all therapies" }, + browse: [ + { href: "/therapy-compass/search", label: "Browse all therapies" }, + { href: "/therapy-compass/recommend", label: "Recommend a therapy" }, + { href: "/therapy-compass/pathways", label: "Problem pathways" }, + { href: "/therapy-compass/compare", label: "Compare therapies" }, + ], + }, + formulation: { + section: "formulation", + browse: [ + { href: "/formulation/search", label: "Browse all mechanisms" }, + { href: "/formulation/builder", label: "Formulation builder" }, + { href: "/formulation/map", label: "Mechanism map" }, + ], + }, + specifiers: { + section: "specifiers", + browse: [ + { href: "/specifiers/search", label: "Browse all specifiers" }, + { href: "/specifiers/builder", label: "Specifier builder" }, + { href: "/specifiers/map", label: "Specifier map" }, + { href: "/specifiers/compare", label: "Compare specifiers" }, + ], + }, + forms: { + section: "forms", + browse: [ + { href: "/forms/search", label: "Browse all forms" }, + { href: "/forms/act", label: "The Act and Standards" }, + { href: "/psychiatry/mha-clock", label: "MHA clock" }, + ], + }, + factsheets: { + section: "factsheets", + browse: [ + { href: "/factsheets/search", label: "Browse all factsheets" }, + { href: "/factsheets/topics", label: "Browse by topic" }, + ], + }, + dictionary: { + section: "dictionary", + browse: [ + { href: "/dictionary/search", label: "Browse all terms" }, + { href: "/dictionary/topics", label: "Browse by topic" }, + { href: "/dictionary/compare", label: "Compare terms" }, + ], + }, + services: { + section: "services", + browse: [{ href: "/services/search", label: "Browse all services" }], + }, + // Medication has no medicine catalogue page of its own (the medicine list is a search + // filter), so its Browse links go to the existing Medicines pages around it; they show + // only on an empty home. Documents' library is a drawer, so it links the source catalogue. + prescribing: { + section: "prescribing", + browse: [ + { href: "/factsheets/search", label: "Browse medicine factsheets" }, + { href: "/medicines", label: "Medicines home" }, + ], + }, + documents: { + section: "documents", + browse: [{ href: "/sources/search", label: "Browse the source catalogue" }], }, - // Medication and Documents have no catalogue page of their own (the medicine list is a - // search filter and the document library a drawer), so they offer no Browse link. - prescribing: { section: "prescribing" }, - documents: { section: "documents" }, }; export function modeHomeStartListConfig(modeId: AppModeId): StartListConfig | null { @@ -62,8 +159,7 @@ const ModeHomeFavouritesGroup = dynamic( { ssr: false }, ); -function ContinueGroup({ modeId, section }: { modeId: StartListMode; section: RecentPageSection }) { - const pages = useRecentPages({ limit: RECENT_PAGES_CONTINUE_SHOWN, section }); +function ContinueGroup({ modeId, pages }: { modeId: StartListMode; pages: ReturnType }) { if (pages.length === 0) return null; return ( @@ -74,6 +170,13 @@ function ContinueGroup({ modeId, section }: { modeId: StartListMode; section: Re ); } +/** True once the account's favourites for this mode are loaded and hold something. */ +function useHasModeFavourites(favouriteType: StartListConfig["favouriteType"]): boolean { + const account = useOptionalAccountData(); + if (!favouriteType || !account?.ready) return false; + return (favouriteType === "therapies" ? account.favourites.therapy : account.favourites.differential).length > 0; +} + function FavouritesGroupWhenAccount({ modeId, favouriteType, @@ -92,22 +195,37 @@ function FavouritesGroupWhenAccount({ export function ModeHomeStartList({ modeId }: { modeId: AppModeId }) { const config = modeHomeStartListConfig(modeId); if (!config) return null; - const mode = modeId as StartListMode; + return ; +} + +function ModeHomeStartListBody({ modeId, config }: { modeId: StartListMode; config: StartListConfig }) { + const pages = useRecentPages({ limit: RECENT_PAGES_CONTINUE_SHOWN, section: config.section }); + const hasFavourites = useHasModeFavourites(config.favouriteType); + const emptyHome = pages.length === 0 && !hasFavourites; + const [firstBrowse] = config.browse; return (
        - - {config.favouriteType ? : null} - {config.browse ? ( + + {config.favouriteType ? ( + + ) : null} + {emptyHome && config.browse.length > 1 ? ( + + {config.browse.slice(0, EMPTY_HOME_BROWSE_SHOWN).map((link) => ( + + ))} + + ) : firstBrowse ? (
        ); diff --git a/src/components/services/service-detail-page.tsx b/src/components/services/service-detail-page.tsx index 10912afe42..7c2e0dfe34 100644 --- a/src/components/services/service-detail-page.tsx +++ b/src/components/services/service-detail-page.tsx @@ -4,14 +4,12 @@ import { useRouter } from "next/navigation"; import { TriangleAlert, BadgeCheck, - BadgeDollarSign, Bookmark, BookmarkCheck, CircleCheck, Clipboard, DollarSign, Globe2, - HeartHandshake, Info, LayoutGrid, Mail, @@ -23,7 +21,6 @@ import { Users, X, CircleX, - type LucideIcon, } from "lucide-react"; import { useState, type ReactNode } from "react"; @@ -37,6 +34,7 @@ import { toneSuccess, toneWarning, } from "@/components/ui-primitives"; +import { RecordGlance, RecordIntro, RecordSection, type RecordGlanceFact } from "@/components/clinical-record-panels"; import { InformationPageShell } from "@/components/information-page-shell"; import { InPageNavHeader } from "@/components/in-page-nav/in-page-nav-header"; import { inPageActionRowClass, inPageAnchor } from "@/components/in-page-nav/in-page-nav-classes"; @@ -46,6 +44,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, @@ -122,15 +121,6 @@ function confidenceToneClass(confidence: ServiceVerification["confidence"]) { return toneNeutral; } -function renderSummaryIcon(card: ServiceSummaryCard) { - const className = "h-4 w-4"; - if (card.id === "route") return ; - 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)]"; @@ -276,6 +266,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 @@ -304,81 +307,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) { @@ -409,7 +372,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 +385,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 +516,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 +549,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 +557,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 +578,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; @@ -718,64 +715,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} +
        @@ -791,13 +779,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 ? ( @@ -846,44 +836,26 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) {
        - {showPriorityFacts ? ( -
        -

        - Priority facts -

        -
        - {visibleSummaryCards.map((card) => ( - - ))} -
        -
        - ) : null} -
        {showReferralSection ? ( -
        - +
        +
        ) : null} -
        +
        {showCriteriaSection ? (
        @@ -904,7 +876,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 +925,7 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) {
        {showTagsSection ? ( -
        +
        diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index 86f89c6a66..3761945d6e 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -33,7 +33,8 @@ import { resultFilterFacetGroup, resultFilterGroup, } from "@/components/clinical-dashboard/result-filter-control"; -import { Chip as DesignChip, type ChipStatusTone } from "@/components/ui/chip"; +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"; import { compactBestUseTitle } from "@/lib/compact-best-use-title"; @@ -79,9 +80,24 @@ 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, and a closed, + * paused, planned or low-confidence service is an amber dot. 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"; + // A closed, paused, not-yet-open or low-confidence service gets the amber caution dot, + // matched by its own label: acuity and confidence tones share colours, so tone alone + // would give "High acuity" the crisis triangle. + if (/^(closed|superseded|temporarily unavailable|planned\b.*|low confidence)$/.test(label.trim())) return "soon"; + return "none"; } function serviceChipLabel(chip: ServiceStatusChip) { @@ -91,6 +107,10 @@ function serviceChipLabel(chip: ServiceStatusChip) { : label; } +function serviceRowId(slug: string) { + return `service-row-${slug}`; +} + function ServiceCard({ service, index, @@ -133,10 +153,12 @@ function ServiceCard({ return (
        @@ -156,7 +180,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 +201,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 +213,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 +268,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} + {call.display} ) : null}
        + -
        -

        Example wording

        -

        {record.wording}

        - - Use in builder - - -
        + +

        {record.wording}

        + + Use in builder + + +
        - {related.length ? ( -
        -
        -

        Compare next

        -
        -
        - {related.map((item) => ( - - {item.shortName} - - - ))} -
        -
        - ) : null} - -
        + {related.length ? ( + +
        + {related.map((item) => ( + + {item.shortName} + + + ))} +
        +
        + ) : null} - + + ); diff --git a/src/components/therapy-compass/record/compare-action.tsx b/src/components/therapy-compass/record/compare-action.tsx index 43899d35fd..d472475ad8 100644 --- a/src/components/therapy-compass/record/compare-action.tsx +++ b/src/components/therapy-compass/record/compare-action.tsx @@ -43,8 +43,6 @@ export function TherapyCompareAction({ therapy }: { therapy: Therapy }) { - - ); -} - -/** - * 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 ??  } -

        - ); -} +import { therapyKeyFactCards } from "./key-fact-cards"; /** - * 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.id === "format" ? formatValue(therapy, card.body) : card.body, + })); + + return ; +} - setActiveId(null)} - title={active?.label ?? "Key fact"} - testId="therapy-key-fact-sheet" - mobilePlacement="bottom" - > - {active ? : null} - - - ); +/** Format keeps the session length beside the time required, so neither is lost. */ +function formatValue(therapy: Therapy, body: string): string { + const sessionLength = therapy.sessionLength?.trim(); + const timeRequired = therapy.timeRequired?.trim(); + if (!sessionLength || !timeRequired) return body; + if (timeRequired.toLowerCase().includes(sessionLength.toLowerCase())) return timeRequired; + return `${sessionLength}, ${timeRequired}`; } 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/src/components/therapy-compass/screens/recommend-screen.tsx b/src/components/therapy-compass/screens/recommend-screen.tsx index 164c00f18e..4acd983897 100644 --- a/src/components/therapy-compass/screens/recommend-screen.tsx +++ b/src/components/therapy-compass/screens/recommend-screen.tsx @@ -12,7 +12,7 @@ import { useTcBindings } from "../bindings"; import { RECOMMEND_CONSTRAINTS } from "../data/select"; import { RecommendScenarioControl } from "../recommend-scenario-control"; import { RecommendScenarioFields } from "../recommend-scenario-fields"; -import { ResultCard } from "../therapy-card"; +import { CompactResultRow } from "../therapy-card"; import { EmptyState, LoadingState } from "../ui"; import { useClipboard } from "../use-clipboard"; @@ -21,7 +21,6 @@ export function RecommendScreen() { const { copied, copy } = useClipboard(); const ranked = b.recommendations; const top = ranked[0]; - const rest = ranked.slice(1); const desktopIdPrefix = useId(); const copyShortlist = () => @@ -110,24 +109,25 @@ export function RecommendScreen() {
-
- - {rest.map(({ therapy, reasons }, index) => ( - + {ranked.map(({ therapy, reasons }, index) => ( + ))} -
+
-

{text}

+ {/* The warning cell is a safety cue: never clamped, matching the compact row (October 2026). */} +

{text}

); } @@ -317,12 +318,22 @@ export function CompactResultRow({ featured = false, query, outsideFilter = false, + rank, + whyMatched, }: { therapy: Therapy; featured?: boolean; query?: string; /** Outside the active filters: still listed and tappable, title muted by token. */ outsideFilter?: boolean; + /** + * Recommend's ranked list (Clinical audit, 13 Oct 2026, item 12): the rank + * number leads the title and the review status shows under it, as on the + * large card it replaces. + */ + rank?: number; + /** Recommend's ranking reasons, shown as one quiet line under the summary. */ + whyMatched?: string; }) { const b = useTcBindings(); const baseId = useId(); @@ -372,9 +383,19 @@ export function CompactResultRow({

+ {rank != null ? ( + + Rank + {rank} + + ) : null}

+ {rank != null ? ( +
+ +
+ ) : null} {subtitle ? (

{subtitle}

) : null} + {whyMatched ? ( +

+ Why matched: + {whyMatched} +

+ ) : null}