diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json
index 79151e4f6e..06694b5ce6 100644
--- a/data/repo-awareness-snapshot.json
+++ b/data/repo-awareness-snapshot.json
@@ -1,7 +1,7 @@
{
"version": "repo-awareness-snapshot-v3",
"captured_revision": {
- "committed_at": "2026-10-15"
+ "committed_at": "2026-10-16"
},
"routes": {
"modes": [
@@ -2500,6 +2500,11 @@
"section": "agents",
"catalogued": true
},
+ {
+ "path": "docs/decisions/2026-10-16-admin-shows-the-date-line.md",
+ "section": "decisions",
+ "catalogued": true
+ },
{
"path": "docs/product/clinical-trust-direction.md",
"section": "product",
diff --git a/docs/decisions/2026-10-16-admin-shows-the-date-line.md b/docs/decisions/2026-10-16-admin-shows-the-date-line.md
new file mode 100644
index 0000000000..b5388d17e7
--- /dev/null
+++ b/docs/decisions/2026-10-16-admin-shows-the-date-line.md
@@ -0,0 +1,10 @@
+# Decision: Admin shows the date line over its title, like the other Work pages
+
+- **Status:** decided 2026-10-16
+- **Source:** `src/components/admin/admin-today-page.tsx`, find "The owner brought the date line back on 16 Oct 2026"
+
+The owner approved a before and after picture of Admin with the date over its title, matching My Day,
+CPD, Roster and Teaching. This replaces the earlier wish, from the 13 Oct 2026 design audit, to leave
+the date line off Admin.
+
+If this summary and the source ever differ, the source wins.
diff --git a/src/components/admin/admin-today-page.tsx b/src/components/admin/admin-today-page.tsx
index eb7d5c02ba..24818c0223 100644
--- a/src/components/admin/admin-today-page.tsx
+++ b/src/components/admin/admin-today-page.tsx
@@ -46,8 +46,9 @@ import { useWorkTimeZone } from "@/components/work-time/use-work-time-zone";
*
* Phone is one column in that order. From a laptop width it splits into what
* to act on (left) and what is ahead (right); left then right IS the phone
- * order. The band names the page "Admin" (audit D15, 13 Oct 2026): no greeting,
- * and no date line, which the owner asked to drop from Admin.
+ * order. The band names the page "Admin" (audit D15, 13 Oct 2026) with no
+ * greeting. The owner brought the date line back on 16 Oct 2026, after seeing it
+ * beside the other Work pages, so Admin shows the date over its title like them.
*
* Fail-closed: while loading, signed out or after a failed load nothing from
* the reader's records is drawn, never a saved copy (the device cache holds
@@ -110,7 +111,8 @@ export function AdminTodayPage({ now: nowProp }: { now?: Date } = {}) {
const load = adminLoadState(state);
const bandShown = useModeBandShown();
const dateEcho = formatZonedLongDay(today);
- useModeBandHeading({ title: "Admin" });
+ // The date line over the title, as every other Work page has it (My Day, CPD, Roster, Teaching).
+ useModeBandHeading({ eyebrow: dateEcho, title: "Admin" });
const own = useMemo(() => selectAdminOwnEntries(state), [state]);
const shared = useMemo(() => selectAdminSharedEntries(state), [state]);
diff --git a/src/components/admin/admin-work.module.css b/src/components/admin/admin-work.module.css
index c7bd9aac84..c2e45aacfb 100644
--- a/src/components/admin/admin-work.module.css
+++ b/src/components/admin/admin-work.module.css
@@ -512,9 +512,10 @@
fill: currentColor;
}
+/* Full hero ink, not faded: the labels are small, so they keep their contrast
+ on the slate (design follow-up 15 Oct 2026). */
.onHero .windowLabels {
color: inherit;
- opacity: 0.86;
}
.onHero .windowLabels b {
diff --git a/src/components/clinical-dashboard/answer-suggestion-chips.tsx b/src/components/clinical-dashboard/answer-suggestion-chips.tsx
index f57d06e4b8..7737f82019 100644
--- a/src/components/clinical-dashboard/answer-suggestion-chips.tsx
+++ b/src/components/clinical-dashboard/answer-suggestion-chips.tsx
@@ -115,6 +115,7 @@ export function AnswerSuggestionChips({
layout = "wrap",
className,
icon: Icon,
+ chipShape = "pill",
}: {
suggestions: string[];
onPick: (suggestion: string) => void;
@@ -129,6 +130,12 @@ export function AnswerSuggestionChips({
// Optional leading glyph rendered inside every chip — used to signal a chip's
// kind (e.g. a history icon on recent-search chips) without changing the label.
icon?: LucideIcon;
+ // "control" squares the chip to the Clinical control rung (`rounded-lg`, as
+ // `controlBase`): rounded pills are a Work-mode shape only (owner decision,
+ // 15 Oct 2026). The chip's base rule in globals.css is unlayered, so the
+ // utility needs the important modifier to win. Default stays "pill" so the
+ // answer-thread follow-ups and any Work caller keep their shape.
+ chipShape?: "pill" | "control";
}) {
const scrolls = layout === "scroll";
const { ref: railRef, node: railNode, edges } = useRailScrollEdges();
@@ -166,7 +173,7 @@ export function AnswerSuggestionChips({
type="button"
disabled={disabled}
onClick={() => onPick(suggestion)}
- className={cn("answer-suggestion-chip", focusRing)}
+ className={cn("answer-suggestion-chip", chipShape === "control" && "!rounded-lg", focusRing)}
>
{Icon ? (
<>
diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx
index fc180d110c..e8e48f8213 100644
--- a/src/components/clinical-dashboard/universal-search-command-surface.tsx
+++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx
@@ -33,6 +33,7 @@ import { useSavedRegistryFavourites } from "@/components/clinical-dashboard/use-
import { cn } from "@/components/ui-primitives";
import { appModeDefinition, filterCrossModesForSession, type AppModeId } from "@/lib/app-modes";
import { appModeIcons } from "@/lib/app-mode-icons";
+import { modeMenuSideForMode } from "@/lib/phone-mode-groups";
import {
commandDropdownCanDisplay,
commandDropdownMinimumWidthMediaQuery,
@@ -305,7 +306,15 @@ function SmartIntentCue({ active, modeLabel }: { active: boolean; modeLabel: str
);
}
-function SmartPromptRow({ examples, onPickExample }: { examples: string[]; onPickExample: (example: string) => void }) {
+function SmartPromptRow({
+ modeId,
+ examples,
+ onPickExample,
+}: {
+ modeId: AppModeId;
+ examples: string[];
+ onPickExample: (example: string) => void;
+}) {
return (
);
}
@@ -1264,6 +1275,7 @@ export function UniversalSearchCommandSurface({
{showHomeSuggestions ? (
{
onQueryChange(example);
diff --git a/src/components/cme/cme-work.css b/src/components/cme/cme-work.css
index 178aa12ae3..71b9248d41 100644
--- a/src/components/cme/cme-work.css
+++ b/src/components/cme/cme-work.css
@@ -29,7 +29,10 @@ html[data-mode-identity="cme"] {
--cme-hero-track: color-mix(in srgb, white 14%, transparent);
--cme-hero-line: color-mix(in srgb, white 22%, transparent);
--cme-hero-panel: color-mix(in srgb, white 10%, transparent);
- --cme-hero-soft-text: color-mix(in srgb, white 82%, transparent);
+ /* The hero's quieter words (the kicker, "of 50 h", the legend, the pace line):
+ the hero's own ink, barely lifted, so they stay readable on the copper
+ (design follow-up 15 Oct 2026: at 82% white they read as faint). */
+ --cme-hero-soft-text: color-mix(in srgb, var(--mode-identity-contrast) 94%, transparent);
}
html.dark [data-mode-identity="cme"],
@@ -39,6 +42,8 @@ html.dark[data-mode-identity="cme"] {
--cme-cat-4: color-mix(in oklab, var(--mode-identity) 24%, var(--surface-raised));
--cme-hero-cat-2: color-mix(in oklab, var(--mode-identity) 70%, white);
--cme-hero-cat-3: var(--mode-identity);
+ /* The dark hero is inked with the heading colour, not white. */
+ --cme-hero-soft-text: color-mix(in srgb, var(--text-heading) 92%, transparent);
}
/* --------------------------------------------------------------- page body */
diff --git a/src/components/favourites/favourites-shelf.tsx b/src/components/favourites/favourites-shelf.tsx
index 4675e35010..e2f8516fc5 100644
--- a/src/components/favourites/favourites-shelf.tsx
+++ b/src/components/favourites/favourites-shelf.tsx
@@ -49,7 +49,14 @@ function TileFace({ item }: { item: FavouriteItem }) {
) : null}
-
+ {/* Words wrap only at spaces (no break-words, no hyphens: Chromium splits unknown drug
+ names mid-word, "Acamprosa te"). Two lines, then an ellipsis; a single word too long
+ for the tile ends in an ellipsis rather than being cut in two (15 Oct 2026). At large
+ text the clamp lifts, so there a long word may break rather than spill past the tile. */}
+
{label}
preset.label)}
onPick={(label) => {
diff --git a/src/components/medicines/medicines-home.tsx b/src/components/medicines/medicines-home.tsx
index 37d6329dac..d6bf51415c 100644
--- a/src/components/medicines/medicines-home.tsx
+++ b/src/components/medicines/medicines-home.tsx
@@ -227,9 +227,9 @@ function FindArea() {
-
+
{suggestion}
diff --git a/src/components/my-day/my-day-today-cards.tsx b/src/components/my-day/my-day-today-cards.tsx
index 71427f923a..0436063caf 100644
--- a/src/components/my-day/my-day-today-cards.tsx
+++ b/src/components/my-day/my-day-today-cards.tsx
@@ -866,7 +866,7 @@ export function ShiftLegend({
);
}
+
+/**
+ * The "Change" control on its own, with its chooser sheet, for a surface that
+ * already names the hospital (Now's summary hero). Renders nothing unless the
+ * reader belongs to more than one hospital. `variant="hero"` draws it as the
+ * hero's quiet pill; the default is the hospital line's text button.
+ */
+export function OnCallHospitalChange({
+ handbook,
+ testId,
+ variant = "line",
+}: {
+ readonly handbook: HospitalHandbookState;
+ /** Stem for the control's testids: `${testId}-change` and `${testId}-sheet`. */
+ readonly testId: string;
+ readonly variant?: "line" | "hero";
+}) {
+ const [open, setOpen] = useState(false);
+ const name = handbook.siteName ?? handbook.serviceName;
+ if (!name || handbook.hospitals.length <= 1) return null;
+ return (
+ <>
+
+ setOpen(false)} title="Choose hospital" testId={`${testId}-sheet`}>
+
+ setOpen(false)} />
+
+
+ >
+ );
+}
diff --git a/src/components/on-call/now/on-call-hero.tsx b/src/components/on-call/now/on-call-hero.tsx
index b4d5b78304..b8373ce276 100644
--- a/src/components/on-call/now/on-call-hero.tsx
+++ b/src/components/on-call/now/on-call-hero.tsx
@@ -1,6 +1,7 @@
"use client";
import { Phone } from "lucide-react";
+import type { ReactNode } from "react";
import { WorkHero } from "@/components/mode-kit/work";
import { onCallShiftClock } from "@/components/on-call/now/shift-clock";
@@ -25,6 +26,11 @@ import type { OnCallShiftContext } from "@/lib/on-call/shift-context";
* window (as "Right now" reads it), and the number is the hospital's recorded
* switchboard. With no switchboard recorded there is no pill, never an invented
* number. While the handbook loads the pill's place is held, so nothing moves.
+ *
+ * The hero is also where the page names the hospital (its eyebrow), so the
+ * "Change hospital" control sits here as a quiet second pill, not on a separate
+ * hospital line that would repeat the name. It is passed in as
+ * `hospitalChange`, and is absent for a reader with only one hospital.
*/
export function NowOnCallHero({
context,
@@ -37,6 +43,7 @@ export function NowOnCallHero({
rosterError = false,
rosterSignedOut = false,
now,
+ hospitalChange = null,
}: {
readonly context: OnCallShiftContext;
/** The hospital's switchboard row, or null when none is recorded. */
@@ -51,6 +58,8 @@ export function NowOnCallHero({
/** Signed out, the roster read returns nothing, which is not the same as no shift. */
readonly rosterSignedOut?: boolean;
readonly now: Date;
+ /** The quiet "Change hospital" pill, or null when there is nothing to change. */
+ readonly hospitalChange?: ReactNode;
}) {
const hospitalPhone = useOnCallHospitalPhone();
const clock = onCallShiftClock(context, now);
@@ -68,7 +77,14 @@ export function NowOnCallHero({
? `Switchboard ${dial.display}`
: null;
- const eyebrow = [clock ? "On call now" : "Right now", hospitalName].filter(Boolean).join(" · ");
+ const lead = clock ? "On call now" : "Right now";
+ const eyebrow = hospitalName ? (
+ <>
+ {lead} · {hospitalName}
+ >
+ ) : (
+ lead
+ );
const title = clock
? `${onCallDurationWords(clock.minutesLeft)} left`
: cover && periodLabel
@@ -120,7 +136,14 @@ export function NowOnCallHero({
eyebrow={eyebrow}
title={{title}}
sub={sub ? {sub} : null}
- footer={action ?
+ ) : null
+ }
/>
);
}
diff --git a/src/components/on-call/on-call-home.tsx b/src/components/on-call/on-call-home.tsx
index 04112c43fb..5c010219a8 100644
--- a/src/components/on-call/on-call-home.tsx
+++ b/src/components/on-call/on-call-home.tsx
@@ -13,7 +13,7 @@ import { focusRing } from "@/components/card-recipes";
import { InformationPageShell } from "@/components/information-page-shell";
import { toHandbookDial } from "@/components/on-call/kit/dial-row";
import { OnCallHandbookState } from "@/components/on-call/kit/handbook-state";
-import { OnCallHospitalLine } from "@/components/on-call/kit/hospital-line";
+import { OnCallHospitalChange } from "@/components/on-call/kit/hospital-line";
import { NowCrisisLines } from "@/components/on-call/now/crisis-lines";
import { NowEmergencyPin } from "@/components/on-call/now/emergency-pin";
import { NowOnCallHero } from "@/components/on-call/now/on-call-hero";
@@ -62,7 +62,8 @@ import { useWorkTimeZone } from "@/components/work-time/use-work-time-zone";
*
* 0. the offline banner and the load-failed notice, as before, then the
* summary hero (time left on your shift, switchboard as the white pill);
- * 1. the hospital line (review F3), then the handbook's state when its numbers
+ * 1. the hospital, named once in the hero's eyebrow with a quiet "Change
+ * hospital" pill beside the call pill, then the handbook's state when its numbers
* are not on screen (signed out, expired, no service, unavailable);
* 2. the hospital's pinned emergency route, above everything else;
* 3. the public crisis lines whenever the hospital's own numbers are not on
@@ -85,7 +86,7 @@ import { useWorkTimeZone } from "@/components/work-time/use-work-time-zone";
*
* Nothing moves under the thumb: while the hospital loads, each hospital module
* holds its space with static outlines, and nothing is ever inserted above the
- * hospital line.
+ * summary hero.
*
* The hospital's own after-hours times are a Stage B field. Until a hospital
* sets them, Now does not adapt to after hours: `handbook.hours` is null, the
@@ -296,7 +297,7 @@ function OnCallHomeLive({ now: pinnedNow }: { now?: Date } = {}) {
const hasEntries = entries.length > 0;
// The handbook's own sign-in states (signed out, or a session that ended)
- // already ask the reader to sign in, under the hospital line; the first-run
+ // already ask the reader to sign in, under the summary hero; the first-run
// block then does not ask a second time. Spelled as what it is not, because
// this file is a compliance surface and one status name is a banned word.
const handbookAsksSignIn =
@@ -333,7 +334,8 @@ function OnCallHomeLive({ now: pinnedNow }: { now?: Date } = {}) {
<>
{/* The summary hero leads every Work page (Work standard, owner 15 Oct 2026). It is
drawn from the first paint and holds its shape while the handbook loads, so
- nothing is inserted above the hospital line later. */}
+ nothing is inserted above it later. It names the hospital and carries the
+ "Change hospital" pill, so there is no separate hospital line repeating the name. */}
1 ? (
+
+ ) : null
+ }
/>
-
{!ready && !handbookLoading ? : null}
>
}
diff --git a/src/components/patient-safety-plan.tsx b/src/components/patient-safety-plan.tsx
index eacae45b5d..7858f9eef5 100644
--- a/src/components/patient-safety-plan.tsx
+++ b/src/components/patient-safety-plan.tsx
@@ -36,7 +36,6 @@ import {
fieldLabel,
fieldControlPlain,
ignoreUnavailableActivation,
- metadataPill,
panelSubtle,
primaryControl,
toneNeutral,
@@ -406,6 +405,24 @@ function AddRow({
);
}
+/**
+ * How many entries a step holds. Drawn as a quiet tinted number, not a bordered
+ * box, so it reads as a count rather than a field to type into (design
+ * follow-up 15 Oct 2026). The number alone is hidden from screen readers; they
+ * hear it with its noun ("0 entries").
+ */
+function StepCount({ count, noun }: { count: number; noun: readonly [string, string] }) {
+ return (
+
+ {count}
+ {`${count} ${count === 1 ? noun[0] : noun[1]}`}
+
+ );
+}
+
function EntryChip({ entry, kind, onRemove }: { entry: Entry; kind: StepKind; onRemove: () => void }) {
return (
{def.builderTitle}
{def.helper}
- {entries.length}
+
{entries.length ? (
@@ -1078,7 +1095,7 @@ export function PatientSafetyPlan() {
The people, plans and things that matter — worth coming back to.
{LEGEND_KINDS.map((kind) => (
diff --git a/src/components/show-all-chip.tsx b/src/components/show-all-chip.tsx
index e5cdccb570..a312d0a5a1 100644
--- a/src/components/show-all-chip.tsx
+++ b/src/components/show-all-chip.tsx
@@ -7,7 +7,10 @@ import { cn } from "@/components/ui-primitives";
/**
* Soft-fill well chip used under a mode-home hero (Tools, Calculators).
*
- * Visual capsule is 36px (`h-9` row face). The hit target stays `min-h-tap`
+ * Visual face is 36px (`h-9` row face). Its corners are the Clinical control
+ * rung (`rounded-lg`, the same as `controlBase`), not a pill: every caller is a
+ * Clinical page, and rounded pills are a Work-mode shape only (owner decision,
+ * 15 Oct 2026). The icon well inside steps down one rung to `rounded-md`. The hit target stays `min-h-tap`
* (48px) on phones and may tighten to `lg:min-h-compact-meta` (40px) on
* desktop — catalogue disclosure chrome, not a primary CTA. Never `min-h-11`
* and never `--row-compact` (36px) as the tap.
@@ -36,10 +39,10 @@ export function ShowAllChip({
focusRing,
)}
>
-
+
diff --git a/src/components/work-search/work-search-button.tsx b/src/components/work-search/work-search-button.tsx
index 2023d06e26..41eb2cabef 100644
--- a/src/components/work-search/work-search-button.tsx
+++ b/src/components/work-search/work-search-button.tsx
@@ -14,6 +14,8 @@ import { isWorkSearchArea } from "@/lib/work-search/model";
/** Remembered per device so the one-time "New" note never comes back once read. A convenience only. */
const COACH_KEY = "psychsift:work-search-coach-seen";
+/** How long the one-time note stays before it goes by itself. */
+const COACH_SHOW_MS = 8000;
function coachSeen(): boolean {
try {
@@ -31,6 +33,11 @@ function markCoachSeen() {
}
}
+/** The note itself only shows from 1024px (the `lg` breakpoint); below that only the ring does. */
+function noteVisible(): boolean {
+ return typeof window.matchMedia === "function" && window.matchMedia("(min-width: 64rem)").matches;
+}
+
function subscribeNever() {
return () => {};
}
@@ -78,7 +85,7 @@ function prefetchWhenIdle(): () => void {
* the header names `header-trailing`) has room for it beside the bell: 20.75rem is the bell,
* the gap and the 18rem pill, less the grid gap it may lean into. Measured in rem, so at large
* text sizes the control stays a round icon rather than running under the mode switcher. It opens a full-screen search across Roster, Teaching, CPD, Admin and On Call.
- * The first time it appears, a small "New" note points at it until dismissed.
+ * The first time it appears, a small "New" note points at it for a few seconds, once per device.
*
* Keys (work-mode redesign, ideas list #3): "/" or Ctrl K (Command K on a Mac) opens
* it from anywhere on a work page that is not a text field, and "?" shows the list.
@@ -97,11 +104,29 @@ export function WorkSearchButton({ modeId, className }: { modeId: AppModeId; cla
useEffect(() => {
if (!coach) return;
+ // On a wide screen the note hangs below the header over the top of the page (it covered
+ // CPD's Customise button and the My Day heading, 15 Oct 2026). So it goes by itself after a
+ // few seconds, or at the reader's first tap, key or scroll, whichever comes first. It is
+ // counted as read only when it goes and only if the note was on screen: storing it as it
+ // appears would hide it at the next re-render (the stored value is read on every render),
+ // and a phone shows only the ring, so a phone visit must not use up the note.
+ const setAsideNow = () => {
+ if (noteVisible()) markCoachSeen();
+ setSetAside(true);
+ };
const onPointerDown = (event: PointerEvent) => {
- if (!coachRef.current?.contains(event.target as Node)) setSetAside(true);
+ if (!coachRef.current?.contains(event.target as Node)) setAsideNow();
};
+ const timer = window.setTimeout(setAsideNow, COACH_SHOW_MS);
document.addEventListener("pointerdown", onPointerDown, true);
- return () => document.removeEventListener("pointerdown", onPointerDown, true);
+ document.addEventListener("keydown", setAsideNow, true);
+ window.addEventListener("scroll", setAsideNow, { capture: true, passive: true });
+ return () => {
+ window.clearTimeout(timer);
+ document.removeEventListener("pointerdown", onPointerDown, true);
+ document.removeEventListener("keydown", setAsideNow, true);
+ window.removeEventListener("scroll", setAsideNow, { capture: true });
+ };
}, [coach]);
const buttonRef = useRef(null);
diff --git a/tests/admin-today-page.dom.test.tsx b/tests/admin-today-page.dom.test.tsx
index 6411cb8bfe..5462cfeaa8 100644
--- a/tests/admin-today-page.dom.test.tsx
+++ b/tests/admin-today-page.dom.test.tsx
@@ -45,6 +45,20 @@ vi.mock("@/components/clinical-dashboard/account-setup-dialog", () => ({
AccountSetupDialog: () => null,
}));
+// Records what the page hands the band, so the date line over the title can be checked
+// without a band on screen. The real hook still runs.
+const bandHeadings = vi.hoisted(() => [] as unknown[]);
+vi.mock("@/components/mode-band/mode-band", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useModeBandHeading: (heading: Parameters[0]) => {
+ bandHeadings.push(heading);
+ actual.useModeBandHeading(heading);
+ },
+ };
+});
+
import { AdminTodayPage } from "@/components/admin/admin-today-page";
// 09:00 on Sat 26 Sep 2026 in Perth, the same fixed instant the selector tests use.
@@ -124,6 +138,12 @@ describe("AdminTodayPage", () => {
expect(screen.queryByRole("navigation", { name: "Sections of this page" })).toBeNull();
});
+ it("puts the date line over the title in the band, like every other Work page", () => {
+ bandHeadings.length = 0;
+ render();
+ expect(bandHeadings.at(-1)).toEqual({ eyebrow: expect.stringContaining("Saturday 26 September"), title: "Admin" });
+ });
+
it("shows static skeletons while loading, nothing ready-shaped", () => {
state.loading = true;
render();
diff --git a/tests/clinical-chip-corners.dom.test.tsx b/tests/clinical-chip-corners.dom.test.tsx
new file mode 100644
index 0000000000..104c724463
--- /dev/null
+++ b/tests/clinical-chip-corners.dom.test.tsx
@@ -0,0 +1,131 @@
+/** @vitest-environment jsdom */
+
+// Owner decision (15 Oct 2026): Clinical keeps its square-ish control corners on
+// buttons and chips; rounded pills are a Work-mode shape only. Three Clinical
+// chips had drifted to pills — the mode-home "Prompts" rail (DSM, Differentials
+// and the other Clinical homes), the "Show all" chip (Calculators), and the
+// Medicines "Try" suggestions. Each now wears the Clinical control rung
+// (`rounded-lg`, the same as `controlBase`), and the Prompts rail on a Work
+// mode keeps its pill.
+
+import { cleanup, render, screen, within } from "@testing-library/react";
+import { Calculator } from "lucide-react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const universal = vi.hoisted(() => ({
+ state: { groups: [], loading: false, query: "" } as Record,
+}));
+
+vi.mock("@/components/clinical-dashboard/use-universal-search", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, useUniversalSearch: () => universal.state };
+});
+
+vi.mock("@/lib/supabase/client", () => ({
+ useAuthSession: () => ({ status: "signed_out", session: null, isConfigured: true, authorizationHeader: {} }),
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
+ usePathname: () => "/",
+}));
+
+vi.mock("@/components/clinical-dashboard/use-saved-registry-favourites", () => ({
+ useSavedRegistryFavourites: () => ({ items: [], status: "ready", registryStatus: "ready", refetch: () => undefined }),
+}));
+
+vi.mock("@/components/clinical-dashboard/search-command-context", () => ({
+ useSearchCommand: () => null,
+}));
+
+import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips";
+import { UniversalSearchCommandSurface } from "@/components/clinical-dashboard/universal-search-command-surface";
+import { MedicinesHome } from "@/components/medicines/medicines-home";
+import { ShowAllChip } from "@/components/show-all-chip";
+import type { AppModeId } from "@/lib/app-modes";
+
+function HomeSurface({ modeId }: { modeId: AppModeId }) {
+ return (
+ undefined}
+ onQueryChange={() => undefined}
+ onSearch={() => undefined}
+ onPickRecent={() => undefined}
+ onCrossMode={() => undefined}
+ showHomeSuggestions
+ >
+
+
+ );
+}
+
+function promptChips() {
+ return within(screen.getByTestId("smart-search-prompt-row")).getAllByRole("button");
+}
+
+beforeEach(() => {
+ vi.stubGlobal("matchMedia", () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }));
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+describe("Clinical chips keep the control corner, not a pill", () => {
+ it("leaves the shared suggestion chip a pill unless a caller asks for the control shape", () => {
+ const { unmount } = render( undefined} />);
+ expect(screen.getByRole("button", { name: "One" })).not.toHaveClass("!rounded-lg");
+ unmount();
+
+ render( undefined} chipShape="control" />);
+ expect(screen.getByRole("button", { name: "Two" })).toHaveClass("answer-suggestion-chip", "!rounded-lg");
+ });
+
+ it.each(["dsm", "differentials"])("squares the Prompts chips on the %s home", (modeId) => {
+ render();
+ const chips = promptChips();
+ expect(chips.length).toBeGreaterThan(0);
+ for (const chip of chips) expect(chip).toHaveClass("!rounded-lg");
+ });
+
+ it("keeps the Prompts chips a pill on a Work mode home", () => {
+ render();
+ const chips = promptChips();
+ expect(chips.length).toBeGreaterThan(0);
+ for (const chip of chips) expect(chip).not.toHaveClass("!rounded-lg");
+ });
+
+ it("draws the Show all chip on the control rung, with its icon well one rung smaller", () => {
+ render(
+ ,
+ );
+ const link = screen.getByTestId("calc-all");
+ expect(link).toHaveClass("min-h-tap");
+ const face = link.firstElementChild as HTMLElement;
+ expect(face).toHaveClass("rounded-lg");
+ expect(face).not.toHaveClass("rounded-full");
+ const well = screen.getByTestId("calc-all-well");
+ expect(well).toHaveClass("rounded-md");
+ expect(well).not.toHaveClass("rounded-full");
+ });
+
+ it("draws the Medicines Try suggestions on the control rung at the 48px tap floor", () => {
+ render();
+ const links = within(screen.getByTestId("medicines-find-try")).getAllByRole("link");
+ expect(links.length).toBeGreaterThan(0);
+ for (const link of links) {
+ expect(link).toHaveClass("min-h-12", "rounded-lg");
+ expect(link).not.toHaveClass("rounded-xl");
+ const face = link.firstElementChild as HTMLElement;
+ expect(face).toHaveClass("rounded-lg");
+ expect(face).not.toHaveClass("rounded-xl");
+ }
+ });
+});
diff --git a/tests/my-day-favourites-shelf.dom.test.tsx b/tests/my-day-favourites-shelf.dom.test.tsx
index e14affae52..f088baf3a2 100644
--- a/tests/my-day-favourites-shelf.dom.test.tsx
+++ b/tests/my-day-favourites-shelf.dom.test.tsx
@@ -149,6 +149,17 @@ describe("My Day Favourites shelf", () => {
expect(screen.getByTestId("my-day-favourites-all")).toHaveTextContent("All 1");
});
+ it("wraps a long name only at spaces, so a long drug name ellipsises rather than splitting mid-word", () => {
+ shelfState.current = state({ items: [clinical("acamprosate", "Acamprosate renal dosing")] });
+ render();
+ const name = screen.getByTestId("favourites-shelf-tile-name");
+ expect(name).toHaveTextContent("Acamprosate renal dosing");
+ expect(name).toHaveClass("line-clamp-2", "text-ellipsis", "[overflow-wrap:normal]", "[word-break:normal]");
+ for (const splitting of ["break-words", "break-all", "hyphens-auto", "wrap-anywhere", "wrap-break-word"]) {
+ expect(name).not.toHaveClass(splitting);
+ }
+ });
+
it("signed out with no work pages, draws nothing", () => {
shelfState.current = state({ signedIn: false, items: [clinical("delirium", "Delirium")] });
const { container } = render();
diff --git a/tests/on-call-now-hero.dom.test.tsx b/tests/on-call-now-hero.dom.test.tsx
index cb9512fadb..5247d65b78 100644
--- a/tests/on-call-now-hero.dom.test.tsx
+++ b/tests/on-call-now-hero.dom.test.tsx
@@ -50,6 +50,31 @@ describe("NowOnCallHero", () => {
expect(call).toHaveClass("work-hero__action");
});
+ it("names the hospital in its eyebrow and carries the Change hospital pill beside the call pill", () => {
+ render(
+
+ Change hospital
+
+ }
+ />,
+ );
+ const hero = screen.getByTestId("on-call-now-hero");
+ expect(within(hero).getByTestId("on-call-now-hospital")).toHaveTextContent("Synthetic Hospital");
+ const actions = hero.querySelector(".work-hero__actions");
+ expect(actions).not.toBeNull();
+ expect(within(actions as HTMLElement).getByRole("link", { name: /^Call switchboard, / })).toBeInTheDocument();
+ expect(within(actions as HTMLElement).getByRole("button", { name: "Change hospital" })).toBeInTheDocument();
+ });
+
it("says no rostered shift is on, and offers no number the hospital has not recorded", () => {
render(
{
expect(within(hero).getByRole("link", { name: /^Call switchboard, / })).toHaveAttribute("href", "tel:0890000000");
});
+ it("names the hospital once, in the hero, with Change hospital as the hero's quiet pill", async () => {
+ handbook.state = {
+ ...readyHandbook(handbookItems([{ id: "sw", title: "Switchboard", phone: "9000 0000" }])),
+ hospitals: [
+ { serviceId: "svc", serviceName: "Synthetic Health Service", siteId: "a", siteName: "Synthetic Hospital" },
+ {
+ serviceId: "svc",
+ serviceName: "Synthetic Health Service",
+ siteId: "b",
+ siteName: "Synthetic North Hospital",
+ },
+ ],
+ };
+ render();
+ const hero = screen.getByTestId("on-call-now-hero");
+ // The hospital's name appears in the hero's eyebrow and nowhere else at the top of the page.
+ expect(hero.contains(screen.getByTestId("on-call-now-hospital"))).toBe(true);
+ expect(screen.getAllByText("Synthetic Hospital")).toHaveLength(1);
+ const change = within(hero).getByRole("button", { name: "Change hospital, currently Synthetic Hospital" });
+ expect(change).toHaveTextContent("Change hospital");
+ expect(change).toHaveClass("work-hero__action");
+ expect(change).toHaveAttribute("data-variant", "quiet");
+ await userEvent.click(change);
+ expect(await screen.findByRole("option", { name: /Synthetic North Hospital/ })).toBeInTheDocument();
+ });
+
+ it("offers no Change hospital pill when the reader has only one hospital", () => {
+ handbook.state = {
+ ...readyHandbook(handbookItems([{ id: "sw", title: "Switchboard", phone: "9000 0000" }])),
+ hospitals: [
+ { serviceId: "svc", serviceName: "Synthetic Health Service", siteId: "a", siteName: "Synthetic Hospital" },
+ ],
+ };
+ render();
+ expect(screen.queryByRole("button", { name: /Change hospital/ })).toBeNull();
+ expect(screen.getByTestId("on-call-now-hospital")).toHaveTextContent("Synthetic Hospital");
+ });
+
it("pins only this site's clinical Emergency rows, three at most, with the quiet red dot", async () => {
handbook.state = readyHandbook(
handbookItems([
diff --git a/tests/patient-safety-plan.dom.test.tsx b/tests/patient-safety-plan.dom.test.tsx
index 0bc695aa86..f483eaebec 100644
--- a/tests/patient-safety-plan.dom.test.tsx
+++ b/tests/patient-safety-plan.dom.test.tsx
@@ -45,6 +45,23 @@ describe("PatientSafetyPlan — incomplete-plan draft guard", () => {
expect(screen.getByRole("heading", { name: /Safety plan generator/i })).toBeTruthy();
});
+ it("draws each step's count as a plain tinted number, not an input-like box, and names it for screen readers", () => {
+ render();
+ const counts = screen.getAllByTestId("safety-plan-step-count");
+ // Six steps plus Reasons for living, all empty on a blank plan.
+ expect(counts).toHaveLength(7);
+ for (const count of counts) {
+ const className = count.getAttribute("class") ?? "";
+ expect(className).not.toMatch(/(?:^|\s)border(?:\s|$|-)/);
+ expect(className).not.toContain("shadow");
+ expect(className).toContain("rounded-full");
+ expect(className).toContain("text-xs");
+ }
+ expect(counts[0].textContent).toBe("00 entries");
+ expect(within(counts[0]).getByText("0 entries")).toHaveClass("sr-only");
+ expect(counts.at(-1)?.textContent).toBe("00 reasons");
+ });
+
it("flags the patient copy as a draft until every step is complete", async () => {
render();
diff --git a/tests/work-search-coach.dom.test.tsx b/tests/work-search-coach.dom.test.tsx
new file mode 100644
index 0000000000..dbbdb4c9b3
--- /dev/null
+++ b/tests/work-search-coach.dom.test.tsx
@@ -0,0 +1,92 @@
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/components/work-search/lazy-work-search-sheet", () => ({
+ LazyWorkSearchSheet: () => null,
+ LazyWorkSearchKeys: () => null,
+ prefetchWorkSearchSheet: () => {},
+}));
+
+import { WorkSearchButton } from "@/components/work-search/work-search-button";
+
+const KEY = "psychsift:work-search-coach-seen";
+
+/** The note shows from 1024px; below that only the ring does. */
+function setViewport(desktop: boolean) {
+ window.matchMedia = vi.fn().mockImplementation((query: string) => ({
+ matches: desktop,
+ media: query,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false,
+ })) as unknown as typeof window.matchMedia;
+}
+
+describe("AI Search's one-time New note", () => {
+ const originalMatchMedia = window.matchMedia;
+ beforeEach(() => {
+ window.localStorage.removeItem(KEY);
+ setViewport(true);
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ window.localStorage.removeItem(KEY);
+ window.matchMedia = originalMatchMedia;
+ });
+
+ it("goes by itself after a few seconds, and is counted as read when it goes", () => {
+ render();
+ expect(screen.getByRole("note")).toHaveTextContent("New");
+ expect(window.localStorage.getItem(KEY)).toBeNull();
+ act(() => {
+ vi.advanceTimersByTime(7999);
+ });
+ expect(screen.queryByRole("note")).not.toBeNull();
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(screen.queryByRole("note")).toBeNull();
+ expect(window.localStorage.getItem(KEY)).toBe("1");
+ });
+
+ it("stays through a re-render while it is showing", () => {
+ const { rerender } = render();
+ rerender();
+ expect(screen.getByRole("note")).toHaveTextContent("New");
+ });
+
+ it("is not used up on a phone, where only the ring shows", () => {
+ setViewport(false);
+ render();
+ act(() => {
+ vi.advanceTimersByTime(8000);
+ });
+ expect(window.localStorage.getItem(KEY)).toBeNull();
+ });
+
+ it("goes at the reader's first scroll", () => {
+ render();
+ act(() => {
+ fireEvent.scroll(window);
+ });
+ expect(screen.queryByRole("note")).toBeNull();
+ });
+
+ it("goes at the reader's first tap elsewhere on the page", () => {
+ render();
+ act(() => {
+ fireEvent.pointerDown(document.body);
+ });
+ expect(screen.queryByRole("note")).toBeNull();
+ });
+
+ it("never shows again once read", () => {
+ window.localStorage.setItem(KEY, "1");
+ render();
+ expect(screen.queryByRole("note")).toBeNull();
+ });
+});