From 7060b06368f41ea6c5ea2bc939e9d58b3bee2185 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:24:36 +0000 Subject: [PATCH 01/17] Design: fix layout bugs across Clinical and Work, add On Call and Roster summary cards CPD Log an activity moves into the summary card, Log page keeps room for the bar, Customise stays in the gutter, today segment no longer splits. DSM Compare stacks on phones, DSM results leave room for the composer, empty differential sections say None listed, Services uses Browse rows, Formulation builder drops its repeat eyebrow. On Call timeline labels no longer collide, roster label uses Geist, emergency number keeps together, Teaching name wraps, My Day ring shows percent done, Favourites names hyphenate. On Call and Roster open with the approved summary card. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TexgR3MMbRJjXTT2C5PoPw --- src/app/work-mode.css | 55 +++++++++ src/components/cme/cme-dashboard-catch-up.tsx | 27 +++-- src/components/cme/cme-dashboard.tsx | 40 ++++--- src/components/cme/cme-log-page.tsx | 5 +- src/components/cme/cme-work.css | 31 ++++++ .../differential-detail-page.tsx | 4 +- src/components/dsm/dsm-compare-chrome.tsx | 4 +- src/components/dsm/dsm-search-page.tsx | 5 +- .../favourites/favourites-shelf.tsx | 2 +- .../formulation/formulation-builder-page.tsx | 1 - src/components/mode-home-start-list.tsx | 5 +- src/components/my-day/my-day-today-cards.tsx | 9 +- src/components/on-call/now/emergency-pin.tsx | 9 +- src/components/on-call/now/on-call-hero.tsx | 104 ++++++++++++++++++ src/components/on-call/now/right-now.tsx | 6 +- src/components/on-call/now/shift-clock.tsx | 2 +- src/components/on-call/on-call-home.tsx | 16 ++- src/components/roster/roster-month-page.tsx | 85 +++++++------- src/components/teaching/teaching-modules.tsx | 2 +- tests/cme-dashboard-today.dom.test.tsx | 10 +- tests/cme-dashboard.dom.test.tsx | 7 +- tests/dsm-compare-chrome.dom.test.tsx | 6 +- tests/dsm-ui-smoke.spec.ts | 10 +- tests/mode-home-start-list.dom.test.tsx | 10 ++ tests/on-call-now-hero.dom.test.tsx | 87 +++++++++++++++ tests/on-call-now.dom.test.tsx | 7 ++ tests/roster-month-page.dom.test.tsx | 22 ++++ tests/ui-route-coverage.spec.ts | 4 +- 28 files changed, 475 insertions(+), 100 deletions(-) create mode 100644 src/components/on-call/now/on-call-hero.tsx create mode 100644 tests/on-call-now-hero.dom.test.tsx diff --git a/src/app/work-mode.css b/src/app/work-mode.css index 9696af400f..520cffcb27 100644 --- a/src/app/work-mode.css +++ b/src/app/work-mode.css @@ -1718,6 +1718,61 @@ a.work-hero:focus-visible { margin-top: calc(var(--spacing) * 2.5); } +/* The hero's actions (Work standard, owner 15 Oct 2026): the main action is a + white rounded pill in the mode colour, any second one a quiet tint of the + hero's own ink. 48px tall, the production tap target. In dark themes the pill + takes the heading ink as its fill (see --work-hero-action-fill). */ +.work-hero__actions { + display: flex; + flex-wrap: wrap; + gap: calc(var(--spacing) * 2); +} + +.work-hero__action { + display: inline-flex; + min-height: 3rem; + min-width: 0; + align-items: center; + justify-content: center; + gap: calc(var(--spacing) * 1.5); + padding: 0 calc(var(--spacing) * 4.5); + border: 0; + border-radius: 999px; + background: var(--work-hero-action-fill, var(--surface-raised)); + color: var(--work-hero-action-ink, var(--mode-identity)); + font: inherit; + font-size: var(--text-sm, 0.875rem); + font-weight: 700; + text-decoration: none; + white-space: nowrap; + cursor: pointer; +} + +.work-hero__action[data-variant="quiet"] { + background: color-mix(in srgb, currentColor 14%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 22%, transparent); + color: inherit; +} + +.work-hero__action:focus-visible { + outline: 2px solid var(--work-hero-action-fill, var(--surface-raised)); + outline-offset: 2px; +} + +/* Holds the pill's place while its number loads, so nothing moves under the thumb. */ +.work-hero__action[data-placeholder] { + width: 10rem; + background: color-mix(in srgb, currentColor 14%, transparent); +} + +@media (forced-colors: active) { + .work-hero__action { + border: 1px solid ButtonText; + background: ButtonFace; + color: ButtonText; + } +} + @media (forced-colors: active) { .work-hero { border: 1px solid CanvasText; diff --git a/src/components/cme/cme-dashboard-catch-up.tsx b/src/components/cme/cme-dashboard-catch-up.tsx index 3df0c539d6..6badc0d26d 100644 --- a/src/components/cme/cme-dashboard-catch-up.tsx +++ b/src/components/cme/cme-dashboard-catch-up.tsx @@ -259,6 +259,19 @@ export function CmeCategoryLegend({ ); } +/** + * "· 1 h today" after the year label. It wraps as one unit (nowrap), so a narrow + * phone never leaves "1 H" on one line and "TODAY" on the next. + */ +function TodayNote({ hours }: { readonly hours: number }) { + return ( + <> + {" "} + {`· ${formatHours(hours)} h today`} + + ); +} + export type CmeYearSummaryProps = { readonly year: number; /** Perth calendar date, `YYYY-MM-DD`. */ @@ -278,6 +291,8 @@ export type CmeYearSummaryProps = { readonly onOpenGap: () => void; /** The empty variant's one action ("Log your first activity"). */ readonly firstLogAction?: ReactNode; + /** "Log an activity", drawn as a full-width white pill at the foot of the card once something is logged. */ + readonly logAction?: ReactNode; /** Hours logged today, added to the kicker when above zero ("· 1 h today"): it replaced "About this year" (12 Oct 2026). */ readonly todayHours?: number; /** Customise's "Year dates": off leaves the kicker as the bare year (a closed year still says so). */ @@ -299,7 +314,7 @@ export type CmeYearSummaryProps = { */ export function CmeYearSummary(props: CmeYearSummaryProps) { const { year, today, loggedHours, targetHours, categoryHours, plan, weeklyHours, entries, closed, onOpenGap } = props; - const { firstLogAction, todayHours = 0, showTimeLeft = true } = props; + const { firstLogAction, logAction, todayHours = 0, showTimeLeft = true } = props; const label = showTimeLeft || closed ? cmeYearLabel({ year, today, closed }) : String(year); @@ -308,9 +323,7 @@ export function CmeYearSummary(props: CmeYearSummaryProps) {
{label} - {todayHours > 0 ? ( - {` · ${formatHours(todayHours)} h today`} - ) : null} + {todayHours > 0 ? : null}

{loggedHours > 0 ? ( @@ -332,6 +345,7 @@ export function CmeYearSummary(props: CmeYearSummaryProps) { > Set up your year + {logAction}

); } @@ -373,9 +387,7 @@ export function CmeYearSummary(props: CmeYearSummaryProps) {
{label} - {todayHours > 0 ? ( - {` · ${formatHours(todayHours)} h today`} - ) : null} + {todayHours > 0 ? : null}
{/* Read as one sentence: "32.5 of 50 hours logged, 65 percent". */}

@@ -388,6 +400,7 @@ export function CmeYearSummary(props: CmeYearSummaryProps) { + {logAction ?

{logAction}
: null} ); } diff --git a/src/components/cme/cme-dashboard.tsx b/src/components/cme/cme-dashboard.tsx index d99ba64051..41697a9478 100644 --- a/src/components/cme/cme-dashboard.tsx +++ b/src/components/cme/cme-dashboard.tsx @@ -83,7 +83,9 @@ import { * Simplify plan (12 Oct 2026): "What's left" shows its top three, then "See all in End of year"; the * copy and reflection chips and "About this year" went (they repeated What's left and the hero), and * today's hours moved into the hero's kicker. - * 6. the floating dock: "Log an activity" (it opens the quick-log sheet). + * 6. "Log an activity" (it opens the quick-log sheet), a white pill at the foot of the summary card. It + * was a floating dock until 15 Oct 2026, when the owner moved it into the card: at 390px the dock + * covered list rows ("See all in End of year"). * * What follows is the earlier (5 Oct) description, which still holds for the * data and the honest variants: @@ -344,15 +346,24 @@ export function CmeDashboard({ : { kind: "loaded", at: now } } /> - {underBand ? ( - - ) : ( -
- -
- )} + {/* The status sits outside the page columns, so this row takes their width and side padding itself: + Customise stays inside the page gutter at 390px rather than running off the right edge. */} +
+ {underBand ? ( + + ) : ( +
+ +
+ )} +
); @@ -383,6 +394,7 @@ export function CmeDashboard({ closed={Boolean(set.closedAt)} onOpenGap={() => setDetail("gap")} firstLogAction={nothingLogged ? logLink("Log your first activity", "work-button min-h-tap mt-2") : undefined} + logAction={nothingLogged ? undefined : logLink("Log an activity", "work-button min-h-tap w-full")} todayHours={shows("audited-today") ? loggedTodayHours : 0} showTimeLeft={shows("year-dates")} /> @@ -622,13 +634,6 @@ export function CmeDashboard({ ) : null; - // The dock: the one filled button. Before anything is logged it lives in the empty card instead. - const dock = nothingLogged ? null : ( -
-
{logLink("Log an activity", "work-button min-h-tap")}
-
- ); - return (
{status}
@@ -653,7 +658,6 @@ export function CmeDashboard({ {trainingLink} {provenance}
- {dock} diff --git a/src/components/cme/cme-log-page.tsx b/src/components/cme/cme-log-page.tsx index d3888611e8..78d0db6e1c 100644 --- a/src/components/cme/cme-log-page.tsx +++ b/src/components/cme/cme-log-page.tsx @@ -670,7 +670,10 @@ export function CmeLogPage({

) : null} - {showFinish ? finish : activities} + {/* The rows end with a reserve the floating dock's height plus 16px, so the last ones never sit under it. */} +
+ {showFinish ? finish : activities} +
.work-button[data-variant="primary"] { + display: flex; + width: 100%; + color: var(--work-hero-action-ink, var(--mode-identity)); +} + +.work-hero .cpd-hero-log > .work-button[data-variant="primary"]::before { + background: var(--work-hero-action-fill, var(--surface-raised)); +} + +.work-hero .cpd-hero-log > .work-button:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} + .cpd-hero-kicker { font-size: var(--text-3xs); font-weight: 700; @@ -954,6 +975,16 @@ a.cpd-months__col:focus-visible { content: attr(data-short) / ""; } +/* The Log keeps its floating dock, so the rows above it end with a reserve the + dock's height plus 16px: the last rows are never left under it. The height is + the dock's own box (work-mode.css .work-dock): its fade, the 48px button in + the capsule, and the home-bar padding. */ +.cpd-dock-reserve { + padding-bottom: calc( + 1.5rem + var(--spacing-tap, 3rem) + var(--spacing) + max(0.875rem, var(--safe-area-bottom, 0px)) + 1rem + ); +} + /* The dock's filled button keeps the 48px floor CPD holds to (min-h-12). */ html [data-mode-identity="cme"] .work-dock__capsule > .work-button, html[data-mode-identity="cme"] .work-dock__capsule > .work-button { diff --git a/src/components/differentials/differential-detail-page.tsx b/src/components/differentials/differential-detail-page.tsx index a9ae3b6117..5b10fccd9c 100644 --- a/src/components/differentials/differential-detail-page.tsx +++ b/src/components/differentials/differential-detail-page.tsx @@ -257,8 +257,10 @@ function SectionRow({

{section.title}

{scopeNote ? : null} + {/* A section with neither items nor a summary would render as a bare + heading with no chevron and read as broken; say so plainly. */}

- {section.summary} + {section.summary.trim() || "None listed"}

diff --git a/src/components/dsm/dsm-compare-chrome.tsx b/src/components/dsm/dsm-compare-chrome.tsx index 41111cbe04..bf9db6ed12 100644 --- a/src/components/dsm/dsm-compare-chrome.tsx +++ b/src/components/dsm/dsm-compare-chrome.tsx @@ -42,7 +42,9 @@ export function DsmCompareChrome({ slotPlaceholder="Choose diagnosis" icon={BookOpenCheck} showEmptyState={false} - slotLayout="compact" + // Default slot layout, not the `compact` snap rail: at 390px the rail + // cut slot B off and pushed C off screen. Default stacks the slots full + // width on a phone and goes side by side once the strip is wide enough. onCommit={(ids) => router.push( idsCompareHref( diff --git a/src/components/dsm/dsm-search-page.tsx b/src/components/dsm/dsm-search-page.tsx index 747d0bc441..d6e7f6b179 100644 --- a/src/components/dsm/dsm-search-page.tsx +++ b/src/components/dsm/dsm-search-page.tsx @@ -386,7 +386,10 @@ export function DsmSearchPage({ return (
-
+ {/* Phone: the bottom pad is the floating search pill's height (3.25rem, + 52px) plus 16px, so the last result card can scroll clear of the pill + even when the shell's own dock reserve has collapsed on scroll. */} +
- + {label} diff --git a/src/components/mode-home-start-list.tsx b/src/components/mode-home-start-list.tsx index 82e0a89973..78935506fc 100644 --- a/src/components/mode-home-start-list.tsx +++ b/src/components/mode-home-start-list.tsx @@ -212,7 +212,10 @@ function ModeHomeStartListBody({ modeId, config }: { modeId: StartListMode; conf {config.favouriteType ? ( ) : null} - {emptyHome && config.browse.length > 1 ? ( + {/* An empty home always gets the Browse card list, even with one link: + Services and Documents have a single catalogue link, and as a bare + text link it read as unlike every other Clinical home. */} + {emptyHome && config.browse.length > 0 ? ( {config.browse.slice(0, EMPTY_HOME_BROWSE_SHOWN).map((link) => ( diff --git a/src/components/my-day/my-day-today-cards.tsx b/src/components/my-day/my-day-today-cards.tsx index 3c565caae2..71427f923a 100644 --- a/src/components/my-day/my-day-today-cards.tsx +++ b/src/components/my-day/my-day-today-cards.tsx @@ -488,16 +488,13 @@ export function HeroCard({ const end = Date.parse(shift.endsAt); const at = now.getTime(); if (running) { - const left = ringParts(end - at); const until = words.sub.split(" · ")[0] ?? words.sub; + const done = Math.min(1, Math.max(0, (at - start) / Math.max(1, end - start))); top = ( + // The title already says the time left, so the ring shows how much of the shift is done. + } eyebrow={`${words.eyebrow}${asOf}`} title={words.big} diff --git a/src/components/on-call/now/emergency-pin.tsx b/src/components/on-call/now/emergency-pin.tsx index e020127c1b..6c16d7bcf3 100644 --- a/src/components/on-call/now/emergency-pin.tsx +++ b/src/components/on-call/now/emergency-pin.tsx @@ -88,11 +88,14 @@ function EmergencyRoute({ onClick={recordCall} aria-label={`Call ${title}${primaryWhere ? ` ${primaryWhere}` : ""}, emergency, ${spokenOnCallNumber(primary.display)}`} data-testid={`${testId}-call`} - className={cn(onCallFilledButton, focusRing, "justify-start")} + className={cn(onCallFilledButton, focusRing, "justify-start py-1.5")} >

{cover.from} - {/* "Now" sits under the dot, kept clear of both ends. */} + {/* "Now" sits under the dot, kept clear of both ends. The clamp is in ch so it scales with the + text: an end label is at most 5ch ("17:00"), "Now 19:12" is about 9ch wide (4.5ch each side + of its centre), so an 11ch centre leaves at least 1.5ch between them. */} {`Now ${cover.nowTime}`} diff --git a/src/components/on-call/now/shift-clock.tsx b/src/components/on-call/now/shift-clock.tsx index 5cb96fd481..efba2a3351 100644 --- a/src/components/on-call/now/shift-clock.tsx +++ b/src/components/on-call/now/shift-clock.tsx @@ -80,7 +80,7 @@ export function NowShiftClock({ context, now }: { readonly context: OnCallShiftC return (

- + From your roster}> {`Your shift · ${clock.periodLabel}`}
diff --git a/src/components/on-call/on-call-home.tsx b/src/components/on-call/on-call-home.tsx index d58d16943e..5fa69c8a2e 100644 --- a/src/components/on-call/on-call-home.tsx +++ b/src/components/on-call/on-call-home.tsx @@ -16,6 +16,7 @@ import { OnCallHandbookState } from "@/components/on-call/kit/handbook-state"; import { OnCallHospitalLine } 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"; import { NowNeedsYou, onCallNeedsYouAnswered, useOnCallCallMarks } from "@/components/on-call/now/needs-you"; import { NowRightNow } from "@/components/on-call/now/right-now"; import { NowShiftClock } from "@/components/on-call/now/shift-clock"; @@ -59,7 +60,8 @@ import { useWorkTimeZone } from "@/components/work-time/use-work-time-zone"; * Now, the On Call mode home (v6 figure "Now"): who to ring, at this hospital, * this hour. Top to bottom, in the safety order: * - * 0. the offline banner and the load-failed notice, as before; + * 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 * are not on screen (signed out, expired, no service, unavailable); * 2. the hospital's pinned emergency route, above everything else; @@ -329,6 +331,18 @@ function OnCallHomeLive({ now: pinnedNow }: { now?: Date } = {}) { columns="two" status={ <> + {/* 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. */} + {!ready && !handbookLoading ? : null} diff --git a/src/components/roster/roster-month-page.tsx b/src/components/roster/roster-month-page.tsx index 0dc121b997..2b68836e66 100644 --- a/src/components/roster/roster-month-page.tsx +++ b/src/components/roster/roster-month-page.tsx @@ -1,7 +1,7 @@ "use client"; import dynamic from "next/dynamic"; -import { ChevronRight, Clock, FileUp, Plus, TriangleAlert, Users } from "lucide-react"; +import { CalendarDays, ChevronRight, Clock, FileUp, Plus, TriangleAlert, Users } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMemo, useState, type ReactNode } from "react"; @@ -16,6 +16,7 @@ import { WorkCard, WorkDateRow, WorkEmpty, + WorkHero, WorkIconRow, WorkSectionLabel, } from "@/components/mode-kit/work"; @@ -376,43 +377,47 @@ export function RosterMonthPage({ now: pinnedNow }: { readonly now?: Date } = {} : date === addDaysToDate(today, 1) ? `${SHIFT_KIND_LABEL[kind]} tomorrow` : `${SHIFT_KIND_LABEL[kind]} ${dayWords(date)}`; - const tag = onNow - ? `${formatSpanUntil(Date.parse(leadDisplay.endsAt) - now.getTime())} left` - : `in ${formatSpanUntil(Date.parse(leadDisplay.startsAt) - now.getTime())}`; - // One slim line: My Day and On Call already show this shift in full (Work mode simplify plan, - // 12 Oct 2026). Tapping it opens the day; Who is on sits beside it, one tap from Team. + const left = `${formatSpanUntil(Date.parse(leadDisplay.endsAt) - now.getTime())} left`; + const startsIn = `in ${formatSpanUntil(Date.parse(leadDisplay.startsAt) - now.getTime())}`; + const times = `${perthTimeOf(leadDisplay.startsAt)} to ${perthTimeOf(leadDisplay.endsAt)}`; + const next = upcoming[0]; + const nextWords = next + ? `Next: ${SHIFT_KIND_LABEL[next.kind]} ${dayWords(perthDateOf(next.startsAt))} ${perthTimeOf(next.startsAt)}` + : null; + // The summary hero that leads every Work page (Work standard, owner 15 Oct 2026), in place + // of the slim white line: on now, the time left is the headline; otherwise the shift's own + // words ("On call tonight") are, with how soon it starts. Who's on is the white pill, and + // the day's sheet stays one tap away beside it. return ( -
- - -
+ } + footer={ +
+ +
+ } + /> ); })() : null; @@ -428,12 +433,8 @@ export function RosterMonthPage({ now: pinnedNow }: { readonly now?: Date } = {} ) : null; return ( <> - {leadRow || restRow ? ( - - {leadRow} - {restRow} - - ) : null} + {leadRow} + {restRow ? {restRow} : null} {waitingSwap ? (
diff --git a/src/components/teaching/teaching-modules.tsx b/src/components/teaching/teaching-modules.tsx index 0b12eb5a82..319a7bcbde 100644 --- a/src/components/teaching/teaching-modules.tsx +++ b/src/components/teaching/teaching-modules.tsx @@ -43,7 +43,7 @@ export function TeachingContextBar({ {teams.length === 1 ? ( ) : (