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({
    {shown.map(([kind, label]) => (
  • diff --git a/src/components/on-call/kit/hospital-line.tsx b/src/components/on-call/kit/hospital-line.tsx index a8bc61b6b6..2e42cf4ae8 100644 --- a/src/components/on-call/kit/hospital-line.tsx +++ b/src/components/on-call/kit/hospital-line.tsx @@ -22,35 +22,60 @@ export function OnCallHospitalLine({ readonly handbook: HospitalHandbookState; readonly testId?: string; }) { - const [open, setOpen] = useState(false); const name = handbook.siteName ?? handbook.serviceName; if (!name) return null; - const canChange = handbook.hospitals.length > 1; return (

    {name}

    - {canChange ? ( - <> - - setOpen(false)} title="Choose hospital" testId={`${testId}-sheet`}> -
    - setOpen(false)} /> -
    -
    - - ) : null} +
    ); } + +/** + * 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 ?
    {action}
    : null} + footer={ + action || hospitalChange ? ( +
    + {action} + {hospitalChange} +
    + ) : 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 === 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.

    - {reasons.length} + {reasons.length ? (
      diff --git a/src/components/roster/roster-month-grid.tsx b/src/components/roster/roster-month-grid.tsx index 0c29830502..fec8aad80a 100644 --- a/src/components/roster/roster-month-grid.tsx +++ b/src/components/roster/roster-month-grid.tsx @@ -137,7 +137,7 @@ export function RosterMonthLegend({ }) { return (
        {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(); + }); +});