-
{section.title}
+
{section.title}
{scopeNote ?
: null}
{section.summary}
@@ -294,7 +263,7 @@ function SectionRow({
{
// Native toggle also fires for prop-driven and browser-initiated flips
@@ -304,10 +273,9 @@ function SectionRow({
if (next !== open) onOpenChange(section.id, next);
}}
>
-
- {iconTile}
+
-
{section.title}
+
{section.title}
{scopeNote ?
: null}
-
+
);
}
-type SnapshotTheme = {
- Icon: LucideIcon;
- container: string;
- iconTile: string;
- heading: string;
- divider: string;
- chip: string;
- accentText: string;
-};
-
-const snapshotThemes: Record
= {
- emergent: {
- Icon: ShieldAlert,
- // Full-opacity soft tokens: an /NN modifier compiles to a color-mix toward
- // transparent, which renders near-invisible on the near-white soft values
- // in light mode (see PR #468).
- container: "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)]",
- iconTile: "border-[color:var(--danger)]/20 bg-[color:var(--surface)] text-[color:var(--danger)]",
- heading: "text-[color:var(--danger)]",
- divider: "border-[color:var(--danger)]/14",
- chip: "border-[color:var(--danger-border)]/60 bg-[color:var(--danger-soft)] text-[color:var(--danger)]",
- accentText: "text-[color:var(--danger)]",
- },
- urgent: {
- Icon: TriangleAlert,
- container: "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)]",
- iconTile: "border-[color:var(--warning)]/25 bg-[color:var(--surface)] text-[color:var(--warning)]",
- heading: "text-[color:var(--warning)]",
- divider: "border-[color:var(--warning)]/20",
- chip: "border-[color:var(--warning-border)]/60 bg-[color:var(--warning-soft)] text-[color:var(--warning)]",
- accentText: "text-[color:var(--warning)]",
- },
- routine: {
- Icon: Info,
- container: "border-[color:var(--border)] bg-[color:var(--surface-subtle)]",
- iconTile: "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)]",
- heading: "text-[color:var(--text-heading)]",
- divider: "border-[color:var(--border)]",
- chip: "border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]",
- accentText: "text-[color:var(--text-muted)]",
- },
-};
-
-const factIcons: Record = {
- "high-risk": ShieldAlert,
- onset: Clock3,
- course: Activity,
- treatable: Plus,
- causes: TriangleAlert,
- tests: FlaskConical,
- actions: Activity,
- related: GitBranch,
-};
-
-function safetyFactGridClass(count: number): string {
- if (count <= 1) return "grid-cols-1";
- if (count >= 4) return "grid-cols-4";
- if (count === 3) return "grid-cols-3";
- return "grid-cols-2";
+/**
+ * The record's summary facts (High-risk, Actions, Related and the rest of
+ * `resolveSafetyFacts`) in the shared "At a glance" card, labels in full.
+ */
+function SafetyGlance({ record, curated }: { record: DifferentialRecord; curated: DifferentialCuratedEntry | null }) {
+ const facts = resolveSafetyFacts(record, curated);
+ if (facts.length === 0) return null;
+ return (
+ ({ key: fact.id, label: fact.label, value: fact.value, wide: false }))}
+ />
+ );
}
-function SafetySnapshot({
- record,
- termLinks,
- curated,
-}: {
- record: DifferentialRecord;
- termLinks: Record;
- curated: DifferentialCuratedEntry | null;
-}) {
- const theme = snapshotThemes[record.status];
- const facts = resolveSafetyFacts(record, curated);
+/**
+ * Cautions, directly after At a glance and in full: the safety summary and
+ * every "Watch for" item. The items wrap rather than scroll sideways, so none
+ * is cut off at the edge of a phone.
+ */
+function SafetySnapshot({ record, termLinks }: { record: DifferentialRecord; termLinks: Record }) {
const tags = record.safetySnapshot.tags;
const summary = record.safetySnapshot.summary.trim();
const showSummary = summary.length > 0 && !isRedundantSafetySummary(summary, tags);
+ if (!showSummary && tags.length === 0) return null;
return (
-
-
-
-
-
-
Safety snapshot
-
-
- {showSummary ? (
- {summary}
- ) : null}
+
+ {showSummary ? {summary}
: null}
{tags.length > 0 ? (
-
- Watch for
-
+ Watch for
{tags.map((tag) => {
const cleaned = cleanDifferentialItem(tag);
return (
@@ -460,49 +362,17 @@ function SafetySnapshot({
// chip only when linked would jump size mid-row. TOKENS.md §2's
// compact-meta role list ("filter chips") is the closest
// documented fit (TOKENS.md requires this comment).
- className={cn(!termLinks[cleaned] && theme.chip, "min-h-compact-meta shrink-0 px-2 text-2xs")}
+ className={cn(
+ !termLinks[cleaned] &&
+ "border-[color:var(--warning-border)] bg-[color:var(--surface)] text-[color:var(--warning-text)]",
+ "min-h-compact-meta px-2 text-xs",
+ )}
/>
);
})}
) : null}
-
- {facts.length > 0 ? (
-
- {facts.map((fact) => {
- const Icon = factIcons[fact.id];
- const compactLabel = safetyFactCompactLabel[fact.id] ?? fact.label;
- return (
-
-
- {fact.value}
-
-
-
-
- {compactLabel}
-
- {fact.label}
-
-
- );
- })}
-
- ) : null}
-
+
);
}
@@ -525,17 +395,8 @@ function ClinicalHinge({ record }: { record: DifferentialRecord }) {
const group = differentialGroupLabel(record);
return (
-
-
-
- {groupScoped ? "Presentation hinge" : "Clinical hinge"}
-
-
- {hinge}
-
+
+ {hinge}
{groupScoped ? (
) : null}
-
+
);
}
@@ -592,15 +453,12 @@ function PhoneDoNow({ record, curated }: { record: DifferentialRecord; curated:
const authored = doNowStepsAreCurated(curated);
return (
-
-
-
- Do now
-
{authored ? null : (
{differentialGroupScopeNote(record)}
@@ -633,7 +491,7 @@ function PhoneDoNow({ record, curated }: { record: DifferentialRecord; curated:
{authored ? (
{curatedProvenanceFor(curated)}
) : null}
-
+
);
}
@@ -944,53 +802,16 @@ function FooterStatus({
}
/**
- * Renders desktop actions for comparing, copying, and saving a diagnosis.
+ * The record's one action row, under the title at every width: Compare (into
+ * the prefilled compare queue), Copy after review, and Save.
*
- * @param record - The diagnosis record whose content is copied.
+ * @param record - The diagnosis record being shown.
* @param saved - Whether the diagnosis is currently saved.
* @param onToggleSaved - Called when the saved state is toggled.
* @param compareHref - Where the Compare control navigates.
+ * @param compareCount - How many diagnoses the compare queue will hold.
*/
-function TopActions({
- record,
- saved,
- onToggleSaved,
- compareHref,
-}: {
- record: DifferentialRecord;
- saved: boolean;
- onToggleSaved: () => void;
- compareHref: string;
-}) {
- return (
-
-
-
- Compare
-
-
-
- {saved ? : }
-
-
- );
-}
-
-function MobilePrimaryActions({
+function RecordActionRow({
record,
saved,
onToggleSaved,
@@ -1004,18 +825,15 @@ function MobilePrimaryActions({
compareCount: number;
}) {
return (
-
-
+ <>
+
Compare ({compareCount})
{saved ? : }
-
+ >
);
}
@@ -1058,17 +876,8 @@ function DiagnosisDiscoveryActions({
];
return (
-
-
-
- Explore diagnosis
-
-
Quick access
-
+
+ Explore diagnosis
{actions.map((action) => {
const Icon = action.icon;
@@ -1100,10 +909,6 @@ function DiagnosisDiscoveryActions({
);
}
-function IconForDiagnosis(record: DifferentialRecord): LucideIcon {
- return record.slug === "delirium" ? BrainCircuit : Stethoscope;
-}
-
/**
* Body of the header's ellipsis sheet. This is where the page actions live now
* that the header's right slot is a single actions control — including "New
@@ -1409,15 +1214,11 @@ export function DifferentialDetailPage({
/>
)}
/>
-
-
+
>
}
- actions={ }
+ actions={
+
+ }
/>
-
-
{saveNotice ? (
{saveNotice}
@@ -1468,17 +1275,16 @@ export function DifferentialDetailPage({
no ellipsis, scrollbar or any other cue that content was missing.
`repeat(1, minmax(0, 1fr))` caps the track at the column; measured
identical at 639px and above. */}
-
+
+
+
-
-
-
-
- Clinical review
-
- {expandableSectionIds.length > 0 ? (
+
0 ? (
- ) : null}
+ ) : null
+ }
+ >
+
+ {bodyWithheld ? (
+ changeTab("source")} />
+ ) : (
+ record.sections.map((section) => (
+
+ ))
+ )}
- {bodyWithheld ? (
- changeTab("source")} />
- ) : (
- record.sections.map((section) => (
-
- ))
- )}
-
-
+
+ {/* Map and Related, the record's onward journeys, close the
+ phone record (the strip is phone-only; tabs carry them from sm). */}
+
+
-
Clinical reference — not validated decision support. Review before use.
diff --git a/src/lib/differential-detail.ts b/src/lib/differential-detail.ts
index 3528a4e813..39f1883fbb 100644
--- a/src/lib/differential-detail.ts
+++ b/src/lib/differential-detail.ts
@@ -194,17 +194,6 @@ export function resolveSafetyFacts(
return facts.slice(0, 4);
}
-/** Compact phone labels for the Safety Snapshot metric strip. Longer labels
- * wrap awkwardly in equal 3-column cells; the full label remains the
- * accessible name via the fact's `label` field. */
-export const safetyFactCompactLabel: Partial> = {
- causes: "High-risk",
- tests: "Core tests",
- actions: "Actions",
- related: "Related",
- "high-risk": "High risk",
-};
-
function normalizeSafetyToken(value: string): string {
return value
.replace(/\s+/g, " ")
diff --git a/tests/differential-detail.test.ts b/tests/differential-detail.test.ts
index f3fde9215f..9d65bcbd75 100644
--- a/tests/differential-detail.test.ts
+++ b/tests/differential-detail.test.ts
@@ -345,22 +345,25 @@ describe("getDifferentialDetailContext", () => {
});
});
-describe("Safety Snapshot compact layout", () => {
- it("keeps the full fact label available to assistive tech when the compact label is shown", () => {
+describe("Safety facts in the shared record layout", () => {
+ it("shows every fact under its full label in the At a glance card, never a compact label", () => {
const source = readFileSync(
new URL("../src/components/differentials/differential-detail-page.tsx", import.meta.url),
"utf8",
);
- expect(source).toContain("aria-label={fact.label}");
- expect(source).toMatch(/sm:hidden[^>]*>\s*\{[\s\S]*compactLabel/);
+ expect(source).toContain(" {
+ it("keeps the safety snapshot as the full cautions block and omits the old review action", () => {
const source = readFileSync(
new URL("../src/components/differentials/differential-detail-page.tsx", import.meta.url),
"utf8",
);
- expect(source).toContain('if (count >= 4) return "grid-cols-4"');
+ expect(source).toContain('');
+ // Watch-for items wrap, so none is cut off at the edge of a phone.
+ expect(source).toMatch(/flex-wrap[^"]*"[^>]*\n?[^>]*data-testid="differential-safety-watchlist"/);
expect(source).not.toContain("Review must-not-miss causes");
expect(source).not.toContain('data-testid="differential-safety-cta"');
});
diff --git a/tests/therapy-compass-responsive-contract.test.ts b/tests/therapy-compass-responsive-contract.test.ts
index c14f8083ae..b0c3a5daf3 100644
--- a/tests/therapy-compass-responsive-contract.test.ts
+++ b/tests/therapy-compass-responsive-contract.test.ts
@@ -166,11 +166,11 @@ describe("Therapy Compass responsive contract", () => {
// 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 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
+ // (clinical-record-panels): label/value pairs in full, two up once the card is
+ // 15rem wide (a container query, so rem follows the root font size), 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(recordPanelsSource).toContain("@[15rem]:grid-cols-2");
expect(detailSource).not.toContain("sticky");
expect(responsiveStackCount(compareSource)).toBeGreaterThanOrEqual(1);
expect(compareSource).toContain(" {
await expect(safetySnapshot).toBeVisible();
await expect(safetySnapshot.getByTestId("differential-safety-cta")).toHaveCount(0);
- const safetyMetricItems = safetySnapshot.getByRole("list", { name: "Safety metrics" }).getByRole("listitem");
+ // The summary facts sit in the shared record layout's "At a glance" card,
+ // under their full labels: two up at 320px, one column with large text.
+ const atAGlance = detailPage.getByTestId("differential-at-a-glance");
+ await expect(atAGlance).toBeVisible();
+ const safetyMetricItems = atAGlance.locator("dl > div");
await expect(safetyMetricItems).toHaveCount(4);
const metricRows = await safetyMetricItems.evaluateAll((items) => {
return new Set(items.map((item) => Math.round(item.getBoundingClientRect().top))).size;
});
- expect(metricRows).toBe(1);
+ expect(metricRows).toBeLessThanOrEqual(2);
const metricValuesStayWithinCells = await safetyMetricItems.evaluateAll((items) => {
return items.every((item) => {
const value = item.querySelector('[data-testid="differential-safety-value"]');
@@ -2718,18 +2722,18 @@ test.describe("PsychSift tools directory and legacy launcher", () => {
});
expect(metricValuesStayWithinCells).toBe(true);
- const watchRowCenterSpread = await safetySnapshot
+ // Cautions are shown in full: every "Watch for" item wraps inside the
+ // snapshot rather than scrolling off its edge, so none is cut off.
+ const watchItemsFullyInside = await safetySnapshot
.getByTestId("differential-safety-watchlist")
.evaluate((watchlist) => {
- const centers = Array.from(watchlist.children).map((item) => {
+ const box = watchlist.getBoundingClientRect();
+ return Array.from(watchlist.children).every((item) => {
const bounds = item.getBoundingClientRect();
- return bounds.top + bounds.height / 2;
+ return bounds.left >= box.left - 0.5 && bounds.right <= box.right + 0.5;
});
- return Math.max(...centers) - Math.min(...centers);
});
- expect(watchRowCenterSpread).toBeLessThan(1);
- const safetySnapshotBox = await safetySnapshot.boundingBox();
- expect(safetySnapshotBox?.height ?? Number.POSITIVE_INFINITY).toBeLessThan(250);
+ expect(watchItemsFullyInside).toBe(true);
await expect(detailPage.getByRole("tab", { name: "Overview" })).toBeHidden();
// The header is portaled into the universal collapse row on phones, so it
// lives outside the page root that `detailPage` scopes to.
@@ -2844,7 +2848,7 @@ test.describe("PsychSift tools directory and legacy launcher", () => {
const forcedColorsMetricRows = await safetyMetricItems.evaluateAll((items) => {
return new Set(items.map((item) => Math.round(item.getBoundingClientRect().top))).size;
});
- expect(forcedColorsMetricRows).toBe(1);
+ expect(forcedColorsMetricRows).toBeLessThanOrEqual(2);
});
test("diagnosis map keeps labels contained and the selected inspector out of the canvas", async ({ page }) => {
From a1260142779c91b9b501f158d798093cf91e4a15 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 13 Oct 2026 06:35:40 +0000
Subject: [PATCH 10/36] Psychiatry, Medicines and Tools: less overlap below the
band (audit item 15)
Psychiatry moves Continue to the top when it has items and lists First
Nations once (in Sections). Psychiatry and Medicines each link once to
All tools. Tools becomes a plain All tools list: flat hairline groups,
no amber card around Safety, no coloured group dots. No tool removed.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
src/components/medicines/medicines-home.tsx | 15 +++++++-
src/components/psychiatry/psychiatry-home.tsx | 24 ++++++++-----
.../tools/tools-search-results-page.tsx | 36 +++++++++----------
tests/medicines-home.dom.test.tsx | 6 ++++
tests/psychiatry-home.dom.test.tsx | 16 ++++++++-
tests/ui-tools-show-all.spec.ts | 2 +-
tests/ui-tools.spec.ts | 14 ++++----
7 files changed, 76 insertions(+), 37 deletions(-)
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/psychiatry/psychiatry-home.tsx b/src/components/psychiatry/psychiatry-home.tsx
index bb88ce8542..ff63c2fdd0 100644
--- a/src/components/psychiatry/psychiatry-home.tsx
+++ b/src/components/psychiatry/psychiatry-home.tsx
@@ -136,12 +136,8 @@ const BUILDERS: readonly DashQuickAction[] = [
},
{ label: "Mechanism map", href: "/formulation/map", icon: MapIcon, testId: "psychiatry-qa-mechanism-map" },
{ label: "Problem pathways", href: "/therapy-compass/pathways", icon: Workflow, testId: "psychiatry-qa-pathways" },
- {
- label: "First Nations",
- href: appModeHomeHref("first-nations"),
- icon: appModeIcons["first-nations"],
- testId: "psychiatry-qa-first-nations",
- },
+ // First Nations is not repeated here: it is one of the Sections above
+ // (Clinical audit, 13 Oct 2026, item 15), so the page lists it once.
];
const PsychiatrySavedCard = dynamic(
@@ -554,6 +550,9 @@ function AskPage({
{latest ?
: null}
+ {/* Continue leads the content once it holds something (Clinical audit,
+ 13 Oct 2026, item 15); empty, it keeps its quiet place at the end. */}
+ {latest ?
: null}
@@ -567,10 +566,19 @@ function AskPage({
className="grid gap-2"
data-testid="psychiatry-card-quick-actions"
>
-
+
+ All tools
+
+ }
+ />
-
+ {latest ? null :
}
);
diff --git a/src/components/tools/tools-search-results-page.tsx b/src/components/tools/tools-search-results-page.tsx
index d2134fdcc0..a0f627943c 100644
--- a/src/components/tools/tools-search-results-page.tsx
+++ b/src/components/tools/tools-search-results-page.tsx
@@ -18,7 +18,7 @@ import { modeHomeComposerReservePendingValue } from "@/lib/mode-home-composer";
import { cn, controlBase, floatingControl } from "@/components/ui-primitives";
import { pageHeadingDescriptionClass, pageHeadingTitleClass } from "@/components/ui/page-header";
import { Sheet } from "@/components/ui/sheet";
-import { TOOL_AREA_ACCENT, toolIdentity } from "@/lib/category-identity";
+import { toolIdentity } from "@/lib/category-identity";
import { isLocalNoAuthMode, resolveClientDemoMode } from "@/lib/client-env";
import { interpretSmartSearch, smartSearchExpansions } from "@/lib/smart-search-intent";
import { useAuthSession } from "@/lib/supabase/client";
@@ -138,14 +138,8 @@ function ToolGroupSection({
const headingId = `tools-group-${group.id}`;
return (
-
- {group.accentArea ? (
-
- ) : null}
+ {/* Plain heading, no coloured group dot (Clinical audit, 13 Oct 2026, item 15). */}
+
{group.label}
+
{tools.map((tool) => (
-
+
))}
@@ -431,7 +427,9 @@ export function ToolsSearchResultsPage({
{searching ? null : (
)}
diff --git a/tests/medicines-home.dom.test.tsx b/tests/medicines-home.dom.test.tsx
index 67933dd780..bee5c33ca3 100644
--- a/tests/medicines-home.dom.test.tsx
+++ b/tests/medicines-home.dom.test.tsx
@@ -44,6 +44,12 @@ describe("MedicinesHome", () => {
expect(links[1]).toHaveTextContent("1 factsheet");
});
+ it("links once to the plain list of every tool", () => {
+ render(
);
+ expect(screen.getAllByRole("link", { name: "All tools" })).toHaveLength(1);
+ expect(screen.getByTestId("medicines-all-tools")).toHaveAttribute("href", "/tools");
+ });
+
it("sends a typed medicine to the medication search, and ignores an empty box", () => {
render(
);
const input = screen.getByTestId("medicines-find-input");
diff --git a/tests/psychiatry-home.dom.test.tsx b/tests/psychiatry-home.dom.test.tsx
index 935f65f524..6ae73eec60 100644
--- a/tests/psychiatry-home.dom.test.tsx
+++ b/tests/psychiatry-home.dom.test.tsx
@@ -121,6 +121,11 @@ describe("PsychiatryHome", () => {
expect(within(screen.getByTestId("psychiatry-mha-links")).getByText("Form 1A")).toBeTruthy();
expect(within(screen.getByTestId("psychiatry-mha-links")).getByText("Opened once on this phone")).toBeTruthy();
+ // With something in it, Continue sits at the top of the content, before "For a shift".
+ const continueCard = screen.getByTestId("psychiatry-card-continue");
+ const shiftCard = screen.getByTestId("psychiatry-card-shift");
+ expect(continueCard.compareDocumentPosition(shiftCard) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+
fireEvent.click(screen.getByTestId("psychiatry-continue-clear"));
expect(screen.queryByTestId("psychiatry-resume")).toBeNull();
});
@@ -139,10 +144,19 @@ describe("PsychiatryHome", () => {
"/therapy-compass/recommend",
"/differentials/presentations",
"/dsm/compare",
- appModeHomeHref("first-nations"),
]) {
expect(hrefs).toContain(href);
}
+ // First Nations is listed once, in Sections, not again in Builders.
+ expect(hrefs).not.toContain(appModeHomeHref("first-nations"));
+ expect(
+ within(screen.getByRole("list", { name: "Psychiatry sections" }))
+ .getAllByRole("link")
+ .filter((link) => link.getAttribute("href") === appModeHomeHref("first-nations")),
+ ).toHaveLength(1);
+ // One link to the plain list of every tool.
+ expect(screen.getAllByRole("link", { name: "All tools" })).toHaveLength(1);
+ expect(screen.getByTestId("psychiatry-all-tools")).toHaveAttribute("href", "/tools");
// The Act, the MHA clock and Safety plan each sit once, in their own sections.
expect(screen.getByTestId("psychiatry-mha-act")).toHaveAttribute("href", "/forms/act");
expect(screen.getByTestId("psychiatry-shift-mha-clock")).toHaveAttribute("href", "/psychiatry/mha-clock");
diff --git a/tests/ui-tools-show-all.spec.ts b/tests/ui-tools-show-all.spec.ts
index b8604c2470..78cdc6bcae 100644
--- a/tests/ui-tools-show-all.spec.ts
+++ b/tests/ui-tools-show-all.spec.ts
@@ -28,7 +28,7 @@ test("the legacy tools alias lands on the single tools directory", async ({ page
// "lands on the single tools directory", so this asserts it rather than working around it.
await expect(results).toHaveCount(1);
await expect(results).toBeVisible();
- await expect(results.getByRole("heading", { level: 1, name: "Tools", exact: true })).toBeVisible();
+ await expect(results.getByRole("heading", { level: 1, name: "All tools", exact: true })).toBeVisible();
await expect(page.getByTestId("tools-pinned")).toBeVisible();
await expect(results.getByTestId("tool-row-clinical-kb-search").getByRole("link")).toHaveAttribute(
"href",
diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts
index 396ff203e4..78b7e471a7 100644
--- a/tests/ui-tools.spec.ts
+++ b/tests/ui-tools.spec.ts
@@ -480,7 +480,7 @@ test.describe("PsychSift tools directory and legacy launcher", () => {
await gotoLauncher(page, "/tools");
await expect(visibleByTestId(page, "tools-search-results-page")).toBeVisible();
- await expect(page.getByRole("heading", { level: 1, name: "Tools", exact: true })).toBeVisible();
+ await expect(page.getByRole("heading", { level: 1, name: "All tools", exact: true })).toBeVisible();
await expect(visibleGlobalSearchInput(page)).toHaveCount(0);
await expect(page.locator("form.answer-footer-search-dock")).toHaveCount(0);
await expectNoPageHorizontalOverflow(page);
@@ -503,7 +503,7 @@ test.describe("PsychSift tools directory and legacy launcher", () => {
await gotoLauncher(page, "/tools");
const results = visibleByTestId(page, "tools-search-results-page");
- await expect(results.getByRole("heading", { level: 1, name: "Tools", exact: true })).toBeVisible();
+ await expect(results.getByRole("heading", { level: 1, name: "All tools", exact: true })).toBeVisible();
await expect(results.getByRole("region", { name: "Pinned", exact: true })).toBeVisible();
// Safety has its own band ahead of the task groups, so nothing can filter it away.
const safety = results.getByRole("region", { name: "Safety", exact: true });
@@ -540,7 +540,7 @@ test.describe("PsychSift tools directory and legacy launcher", () => {
const results = visibleByTestId(page, "tools-search-results-page");
await expect(results).toBeVisible();
- await expect(results.getByRole("heading", { level: 1, name: "Tools", exact: true })).toBeVisible();
+ await expect(results.getByRole("heading", { level: 1, name: "All tools", exact: true })).toBeVisible();
await expect(results.getByRole("heading", { level: 3, name: "PsychSift Search" })).toBeVisible();
await expect(results.getByRole("heading", { level: 3, name: "Medication Prescribing" })).toBeVisible();
await expect(toolRowLink(results, "clinical-kb-search")).toHaveAttribute("href", "/?mode=answer");
@@ -597,7 +597,7 @@ test.describe("PsychSift tools directory and legacy launcher", () => {
const showAll = results.getByRole("link", { name: "Show all tools" });
await expect(showAll).toHaveAttribute("href", "/tools");
await Promise.all([page.waitForURL(/\/tools$/), showAll.click()]);
- await expect(results.getByRole("heading", { level: 1, name: "Tools", exact: true })).toBeVisible();
+ await expect(results.getByRole("heading", { level: 1, name: "All tools", exact: true })).toBeVisible();
await expect(visibleGlobalSearchInput(page)).toHaveCount(0);
await expectNoPageHorizontalOverflow(page);
});
@@ -655,7 +655,7 @@ test.describe("PsychSift tools directory and legacy launcher", () => {
for (const width of [320, 390, 639, 768, 1440, 1920]) {
await page.setViewportSize({ width, height: 900 });
- await expect(page.getByRole("heading", { level: 1, name: "Tools", exact: true })).toBeVisible();
+ await expect(page.getByRole("heading", { level: 1, name: "All tools", exact: true })).toBeVisible();
await expect(page.getByRole("region", { name: "Safety", exact: true })).toBeVisible();
await expect(page.getByRole("heading", { level: 3, name: "PsychSift Search" })).toBeVisible();
await expect(visibleGlobalSearchInput(page)).toHaveCount(0);
@@ -664,7 +664,7 @@ test.describe("PsychSift tools directory and legacy launcher", () => {
}
await page.emulateMedia({ reducedMotion: "reduce", forcedColors: "active" });
- await expect(page.getByRole("heading", { level: 1, name: "Tools", exact: true })).toBeVisible();
+ await expect(page.getByRole("heading", { level: 1, name: "All tools", exact: true })).toBeVisible();
await expect(visibleGlobalSearchInput(page)).toHaveCount(0);
await expectNoPageHorizontalOverflow(page);
});
@@ -1334,7 +1334,7 @@ test.describe("PsychSift tools directory and legacy launcher", () => {
await gotoLauncher(page, "/?mode=tools");
await page.waitForURL(/\/tools$/);
- await expect(page.getByRole("heading", { level: 1, name: "Tools", exact: true })).toBeVisible();
+ await expect(page.getByRole("heading", { level: 1, name: "All tools", exact: true })).toBeVisible();
await expect(visibleGlobalSearchInput(page)).toHaveCount(0);
await expect(page.locator("form.answer-footer-search-dock")).toHaveCount(0);
await expect(page.getByTestId("tools-search-results-page").getByTestId("tools-local-search-input")).toBeVisible();
From d70535ddd48e01acfbb9f4aaaa6d86539e0226d1 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 13 Oct 2026 06:37:03 +0000
Subject: [PATCH 11/36] Documents: one calm message when search cannot run
A failed documents search stacked a red dashed Search unavailable box on top
of the results' own degraded state. The documents results now own the
message: the red box is not shown once a documents search was submitted, the
degraded zero-result card loses its double side bar, keeps the honest copy,
adds a Retry wired to the dashboard's own search runner, and shows the
specific failure as a quiet Reason line. The band's partial count line stays
(a test pins it as the only announced status above the failure).
Co-Authored-By: Claude Opus 5.5 (1M context)
---
src/components/ClinicalDashboard.tsx | 20 +++++-
.../document-search-results.tsx | 8 +++
.../search-results-header-band.tsx | 72 ++++++++++++++++---
...ent-search-scope-zero-results.dom.test.tsx | 19 ++++-
4 files changed, 105 insertions(+), 14 deletions(-)
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index f47fa789ab..73d2399617 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -2746,6 +2746,13 @@ function ClinicalDashboardContent({
],
);
const answerProgressCompleted = answerProgressEvents.at(-1)?.stage === "complete";
+ // A failed documents search. The documents results own that message (one calm
+ // "not a reliable nothing found" state with Retry and Browse, or the band's partial
+ // line when earlier matches are still listed), so the generic red "Search unavailable"
+ // box above them is not stacked on top once a search has actually been submitted.
+ const documentsSearchFailed = searchMode === "documents" && Boolean(error) && errorKind !== "no-results";
+ const documentsResultsOwnError =
+ documentsSearchFailed && activeModeResultKind === "documents" && modeSearchSubmitted && Boolean(query.trim());
const differentialsCompareHasSelection = useDifferentialsCompareHasSelection();
const {
showAuthPanel,
@@ -3285,7 +3292,7 @@ function ClinicalDashboardContent({
>
}
/>
- ) : error ? (
+ ) : error && !documentsResultsOwnError ? (
{
+ setError(null);
+ void ask(lastFailedQuery ?? query);
+ }
+ : undefined
+ }
/>
>
)
diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx
index 1ff33edbd0..42cc35b3f0 100644
--- a/src/components/clinical-dashboard/document-search-results.tsx
+++ b/src/components/clinical-dashboard/document-search-results.tsx
@@ -818,6 +818,8 @@ function DocumentSearchResultsPanelImpl({
showHome = false,
desktopComposerSlotId,
searchFailed = false,
+ searchFailureReason = null,
+ onRetrySearch,
}: {
matches: ClientDocumentMatch[];
recordMatches?: SearchRecordMatch[];
@@ -837,6 +839,10 @@ function DocumentSearchResultsPanelImpl({
setupWarning: string | null;
/** The last documents search request failed, so an empty list is not a real "no matches". */
searchFailed?: boolean;
+ /** The failure message for that request, kept as the reason line in the one degraded message. */
+ searchFailureReason?: string | null;
+ /** Re-runs the failed documents search (the dashboard's own search runner). */
+ onRetrySearch?: () => 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/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 ? (
+ "forced-colors:border-l-[color:CanvasText]",
+ )}
+ />
+ )}
{/* Visible copy is never inside the live region: a region that mounts
already populated is silent in most screen readers, and wrapping the
visible tree would flash empty for a frame. The sr-only live region
@@ -1099,6 +1146,9 @@ export function SearchResultsEmptyState({
{emptyTitle}
{emptyBody}
+ {degraded && degradedReason?.trim() ? (
+
Reason: {degradedReason.trim()}
+ ) : null}
{!filtered ? (
diff --git a/tests/document-search-scope-zero-results.dom.test.tsx b/tests/document-search-scope-zero-results.dom.test.tsx
index 2252e7ba2a..e7bb057529 100644
--- a/tests/document-search-scope-zero-results.dom.test.tsx
+++ b/tests/document-search-scope-zero-results.dom.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -188,6 +188,23 @@ describe("documents zero-result state when retrieval degraded", () => {
expect(screen.queryByText(/no matches for/i)).not.toBeInTheDocument();
});
+ it("offers Retry and keeps the failure reason inside the one degraded message", () => {
+ const onRetrySearch = vi.fn();
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Reason: Search setup not ready.")).toBeInTheDocument();
+ fireEvent.click(screen.getByTestId("search-results-empty-retry"));
+ expect(onRetrySearch).toHaveBeenCalledTimes(1);
+ });
+
it("outranks the filtered-to-zero copy, which also asserts a real zero", () => {
render(
Date: Tue, 13 Oct 2026 06:38:20 +0000
Subject: [PATCH 12/36] Specifiers record: shared record layout and a Clinical
blue main button
Name, one line and tags; the existing Compare in one action row; What matters
now becomes the At a glance card (values in full, same anchor id); When this
may not fit becomes the cautions block straight after; fit, checks, patient
language, plan, quick reference, example wording and compare next as plain
sections. Use in builder now uses the Clinical blue primary button instead
of the black one.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../specifiers/specifier-record-page.tsx | 248 +++++++++---------
1 file changed, 126 insertions(+), 122 deletions(-)
diff --git a/src/components/specifiers/specifier-record-page.tsx b/src/components/specifiers/specifier-record-page.tsx
index 82c23d33ac..482a259715 100644
--- a/src/components/specifiers/specifier-record-page.tsx
+++ b/src/components/specifiers/specifier-record-page.tsx
@@ -1,17 +1,5 @@
import Link from "next/link";
-import {
- ArrowRight,
- Check,
- CircleHelp,
- FileCheck2,
- GitCompareArrows,
- ListChecks,
- MessageSquareQuote,
- Route,
- ShieldAlert,
- Tags,
- Target,
-} from "lucide-react";
+import { ArrowRight, GitCompareArrows, ListChecks } from "lucide-react";
import {
DiagnosisChips,
@@ -19,13 +7,19 @@ import {
SpecifierFamilyBadge,
SpecifierPageShell,
SpecifierSafetyNote,
- specifierCard,
} from "@/components/specifiers/specifier-ui";
-import { compareRecordsHref, GuidanceSection, RecordFact } from "@/components/clinical-record-panels";
-import { InformationPageHeader } from "@/components/information-page-shell";
+import {
+ compareRecordsHref,
+ RecordBody,
+ RecordCautions,
+ RecordGlance,
+ RecordIntro,
+ RecordSection,
+} from "@/components/clinical-record-panels";
import { inPageActionRowClass, inPageAnchor } from "@/components/in-page-nav/in-page-nav-classes";
import { SpecifierNavHeader } from "@/components/specifiers/specifier-nav-header";
-import { cn, eyebrowText, primaryControl } from "@/components/ui-primitives";
+import { buttonFaceClass } from "@/components/ui/button-face";
+import { cn } from "@/components/ui-primitives";
import { relatedSpecifiers, type SpecifierRecord } from "@/lib/specifiers";
export function SpecifierRecordPage({ record }: { record: SpecifierRecord }) {
@@ -53,126 +47,136 @@ export function SpecifierRecordPage({ record }: { record: SpecifierRecord }) {
}
/>
-
-
-
-
- >
- }
+
+
+
+
+
+ >
+ }
+ actions={
+
+
+ Compare
+
+ }
+ />
+
+
+
-
-
+ {record.notFit.length ? (
+
+
+
+ ) : null}
-
-
+
+
+
+
+
+
+
+
+ `“${item}”`)} />
+
+
-
-
-
-
- `“${item}”`)}
- />
-
+
+ {record.treatmentLens}
+
-
-
-
-
-
-
-
Clinical implication
-
- How this changes the plan
-
+
+
+ {[
+ ["Focus", record.comparison.focus],
+ ["Time course", record.comparison.timeCourse],
+ ["Look for", record.comparison.lookFor],
+ ["Avoid", record.comparison.caution],
+ ].map(([label, body]) => (
+
+
{label}
+ {body}
-
-
{record.treatmentLens}
-
-
-
-
-
-
-
- {[
- ["Focus", record.comparison.focus],
- ["Time course", record.comparison.timeCourse],
- ["Look for", record.comparison.lookFor],
- ["Avoid", record.comparison.caution],
- ].map(([label, body]) => (
-
-
{label}
- {body}
-
- ))}
-
-
+ ))}
+
+
-
- Example wording
- {record.wording}
-
- Use in builder
-
-
-
+
+ {record.wording}
+
+ Use in builder
+
+
+
- {related.length ? (
-
-
-
- {related.map((item) => (
-
- {item.shortName}
-
-
- ))}
-
-
- ) : null}
-
-
+ {related.length ? (
+
+
+ {related.map((item) => (
+
+ {item.shortName}
+
+
+ ))}
+
+
+ ) : null}
-
+
+
>
);
}
+
+/** 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 601b60324f5cc1496f02a6c430e57027962dd2f8 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 13 Oct 2026 06:39:08 +0000
Subject: [PATCH 13/36] Mode homes: Browse links under the box on an empty home
(audit item 17)
With nothing to continue and no favourites, a Clinical mode home shows
three or four Browse links to that mode's existing pages, as a hairline
group under the unchanged box. Adds Formulation, Specifiers, Forms,
Factsheets, Dictionary and Services. Medication has no medicine catalogue
page, so it links Factsheets and the Medicines home instead.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
src/components/mode-home-start-list.tsx | 151 +++++++++++++++++++++---
tests/mode-home-start-list.dom.test.tsx | 55 ++++++++-
2 files changed, 182 insertions(+), 24 deletions(-)
diff --git a/src/components/mode-home-start-list.tsx b/src/components/mode-home-start-list.tsx
index b1eb2fabf8..ec359bb175 100644
--- a/src/components/mode-home-start-list.tsx
+++ b/src/components/mode-home-start-list.tsx
@@ -21,35 +21,129 @@ 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 three or four 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. 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 };
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 +156,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 +167,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 +192,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.map((link) => (
+
+ ))}
+
+ ) : firstBrowse ? (
- {config.browse.label}
+ {firstBrowse.label}
) : null}
diff --git a/tests/mode-home-start-list.dom.test.tsx b/tests/mode-home-start-list.dom.test.tsx
index 2a8b1f16b0..0c5a2c9293 100644
--- a/tests/mode-home-start-list.dom.test.tsx
+++ b/tests/mode-home-start-list.dom.test.tsx
@@ -26,23 +26,66 @@ afterEach(() => {
});
describe("mode home start list", () => {
- it("is offered on DSM, Medication, Therapy, Documents and Differentials only", () => {
- for (const modeId of ["dsm", "prescribing", "therapy-compass", "documents", "differentials"] as const) {
+ it("is offered on the Clinical mode homes, and not on Answer, Psychiatry or On Call", () => {
+ for (const modeId of [
+ "dsm",
+ "prescribing",
+ "therapy-compass",
+ "documents",
+ "differentials",
+ "formulation",
+ "specifiers",
+ "forms",
+ "factsheets",
+ "dictionary",
+ "services",
+ ] as const) {
expect(modeHomeStartListConfig(modeId), modeId).not.toBeNull();
}
- for (const modeId of ["answer", "psychiatry", "services", "forms", "on-call"] as const) {
+ for (const modeId of ["answer", "psychiatry", "on-call", "calculators", "sources"] as const) {
expect(modeHomeStartListConfig(modeId), modeId).toBeNull();
}
});
- it("shows a first visit only the Browse link, and nothing at all where there is no catalogue page", () => {
+ it("shows an empty home three or four Browse links to the mode's existing pages", () => {
render(
);
expect(screen.queryByTestId("mode-home-continue")).toBeNull();
- expect(screen.getByTestId("mode-home-browse").getAttribute("href")).toBe("/dsm/search");
+ const browse = screen.getByTestId("mode-home-browse-list");
+ expect(
+ within(browse)
+ .getAllByRole("link")
+ .map((link) => [link.textContent, link.getAttribute("href")]),
+ ).toEqual([
+ ["Browse all diagnoses", "/dsm/search"],
+ ["Compare diagnoses", "/dsm/compare"],
+ ["Browse all specifiers", "/specifiers/search"],
+ ]);
cleanup();
+ // Medication has no medicine catalogue page: its links go to the Medicines pages around it.
render(
);
- expect(screen.queryByRole("link")).toBeNull();
+ expect(
+ within(screen.getByTestId("mode-home-browse-list"))
+ .getAllByRole("link")
+ .map((link) => link.getAttribute("href")),
+ ).toEqual(["/factsheets/search", "/medicines"]);
+ cleanup();
+
+ for (const modeId of ["therapy-compass", "differentials", "formulation", "specifiers", "forms"] as const) {
+ render(
);
+ const count = within(screen.getByTestId("mode-home-browse-list")).getAllByRole("link").length;
+ expect(count, modeId).toBeGreaterThanOrEqual(3);
+ expect(count, modeId).toBeLessThanOrEqual(4);
+ cleanup();
+ }
+ });
+
+ it("keeps only the first Browse link once Continue has something", () => {
+ recordRecentPage("/dsm/diagnoses/a", "Disorder A");
+ render(
);
+ expect(screen.getByTestId("mode-home-continue")).toBeTruthy();
+ expect(screen.queryByTestId("mode-home-browse-list")).toBeNull();
+ expect(screen.getByTestId("mode-home-browse").getAttribute("href")).toBe("/dsm/search");
});
it("continues with the last three records of this mode, newest first", () => {
From f0bd61c35ff864f6bd19e96f66f1925b6ab866bb Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 13 Oct 2026 06:41:35 +0000
Subject: [PATCH 14/36] Formulation record: shared record layout and a Clinical
blue main button
Name, definition and tags; the existing Compare in one action row; What
matters now becomes the At a glance card (same anchor and label ids); the
record's own caveats become the cautions block straight after, in full; fit,
clues, patient language, the three Ps, case example, formulation language,
treatment target, quick reference, add to a draft, compare next and evidence
notes as plain sections. Use in formulation now uses the Clinical blue
primary button instead of the black one. The shared list moves into
clinical-record-panels; the icon-tile fact and accordion blocks, now unused,
are removed.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
src/components/clinical-record-panels.tsx | 103 ++---
.../formulation-mechanism-page.tsx | 419 ++++++++----------
.../specifiers/specifier-record-page.tsx | 31 +-
3 files changed, 219 insertions(+), 334 deletions(-)
diff --git a/src/components/clinical-record-panels.tsx b/src/components/clinical-record-panels.tsx
index 8633df49eb..c4bb64fcb2 100644
--- a/src/components/clinical-record-panels.tsx
+++ b/src/components/clinical-record-panels.tsx
@@ -1,75 +1,15 @@
-import { ChevronDown, TriangleAlert, 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({
@@ -234,12 +174,14 @@ export function RecordGlance({
id,
testId,
valueTestId,
+ titleId,
footer,
}: {
facts: RecordGlanceFact[];
title?: string;
id?: string;
testId?: string;
+ titleId?: string;
/** Optional test id on every value, for records whose browser tests pin them. */
valueTestId?: string;
footer?: ReactNode;
@@ -250,13 +192,16 @@ export function RecordGlance({
- {title}
+
+ {title}
+
{shown.length ? (
{shown.map((fact) => {
@@ -358,3 +303,25 @@ export function RecordSection({
);
}
+
+/** 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/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
}
/>
-
-
+ {/* 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}
-
-
+
-
+
+
+
+
+
+
+
-
+
+ {mechanism.caseExample}
+
-
-
-
-
-
-
-
Short case example
-
- {mechanism.caseExample}
-
-
-
-
-
-
-
-
-
Formulation language
-
- {mechanism.exampleSentence}
-
-
-
-
+
+
+ {mechanism.exampleSentence}
+
+
-
+
+
+ {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/specifiers/specifier-record-page.tsx b/src/components/specifiers/specifier-record-page.tsx
index 482a259715..47ef88bc65 100644
--- a/src/components/specifiers/specifier-record-page.tsx
+++ b/src/components/specifiers/specifier-record-page.tsx
@@ -11,6 +11,7 @@ import {
import {
compareRecordsHref,
RecordBody,
+ RecordBulletList,
RecordCautions,
RecordGlance,
RecordIntro,
@@ -83,7 +84,7 @@ export function SpecifierRecordPage({ record }: { record: SpecifierRecord }) {
{record.notFit.length ? (
-
+
) : null}
@@ -94,13 +95,13 @@ export function SpecifierRecordPage({ record }: { record: SpecifierRecord }) {
body="Start with the deciding signal, then test chronology, competing explanations, and the effect on diagnostic wording."
/>
-
+
-
+
- `“${item}”`)} />
+ `“${item}”`)} />
@@ -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 (
-
-
-
-
{title}
-
- {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 (
-
+
@@ -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}
-