diff --git a/src/app/work-mode.css b/src/app/work-mode.css index 9696af400f..8af6af16d3 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: var(--radius-pill); + 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"] { + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + background: color-mix(in srgb, currentColor 14%, 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..016d992de4 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}

); } @@ -356,7 +370,7 @@ export function CmeYearSummary(props: CmeYearSummaryProps) {

{`Your target is ${formatHours(targetHours)} h for ${year}. Log your first activity and it shows here.`}

- {firstLogAction} + {firstLogAction ?? 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 var(--work-hero-action-fill, var(--surface-raised)); + outline-offset: 2px; +} + +.work-hero .cpd-hero-log > .work-button[data-variant="primary"]:focus-visible::before { + box-shadow: none; +} + .cpd-hero-kicker { font-size: var(--text-3xs); font-weight: 700; @@ -954,6 +979,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..eb7543f550 100644 --- a/src/components/differentials/differential-detail-page.tsx +++ b/src/components/differentials/differential-detail-page.tsx @@ -257,8 +257,15 @@ 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() || + // Items that repeat the hinge are hidden from the list, so point to where they are shown. + ((section.items.length > 0 || (section.tone === "test" && record.investigations.length > 0)) && + cleanDifferentialItem(record.clinicalHinge) + ? `See the ${record.clinicalHingeScope === "presentation" ? "presentation" : "clinical"} hinge above` + : "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..e4479a8ac1 100644 --- a/src/components/dsm/dsm-search-page.tsx +++ b/src/components/dsm/dsm-search-page.tsx @@ -386,6 +386,8 @@ export function DsmSearchPage({ return (
+ {/* Phone dock clearance comes from the shell's own reserve, which also + collapses when the pill hides, so this page adds no bottom pad. */}
- + {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..4f8f676477 100644 --- a/src/components/on-call/now/emergency-pin.tsx +++ b/src/components/on-call/now/emergency-pin.tsx @@ -88,11 +88,21 @@ 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..04112c43fb 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,21 @@ 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/phone-focus-clearance.tsx b/src/components/phone-focus-clearance.tsx index 33feee9fa4..bdbe741bc6 100644 --- a/src/components/phone-focus-clearance.tsx +++ b/src/components/phone-focus-clearance.tsx @@ -44,14 +44,36 @@ function revealIfCovered(target: HTMLElement) { * check one frame later catches an engine focus scroll that lands after `focusin`. Engines that * already cleared the dock never reach `scrollIntoView`. Pointer focus (not `:focus-visible`) is * left alone so a tap or click is never scrolled away between press and release. + * + * Layout can still move after that frame: the reveal scroll hides the dock, and the shell's + * bottom reserve then animates to zero, or a late section above the results finishes loading. + * Either can carry an already-revealed control back under the dock or off screen. So for a short + * settle window the content box is watched, and the control is re-revealed whenever it resizes. */ +const settleWindowMs = 1200; + export function PhoneFocusClearance() { useEffect(() => { const phone = window.matchMedia(phoneMediaQuery); + let settle: { observer: ResizeObserver; timer: number } | null = null; + // A reader who scrolls, taps or presses a key during the settle window has moved on (Enter on + // an in-page jump link scrolls away from the still-focused link), so stop pulling the page + // back to the focused control. + const userScrollEvents = ["wheel", "touchstart", "pointerdown", "keydown"] as const; + const stopSettling = () => { + if (!settle) return; + for (const type of userScrollEvents) window.removeEventListener(type, stopSettling, true); + settle.observer.disconnect(); + window.clearTimeout(settle.timer); + settle = null; + }; const onFocusIn = (event: FocusEvent) => { + stopSettling(); if (!phone.matches) return; const target = event.target; - if (!(target instanceof HTMLElement) || !target.closest(".phone-scroll-surface")) return; + if (!(target instanceof HTMLElement)) return; + const surface = target.closest(".phone-scroll-surface"); + if (!surface) return; // Keyboard focus only. A press focuses its control on pointerdown, before the release that // makes the click; scrolling there moves the control out from under the pointer, so the // release lands elsewhere and the click (a form submit, say) never happens. @@ -61,9 +83,27 @@ export function PhoneFocusClearance() { if (document.activeElement !== target) return; revealIfCovered(target); }); + if (typeof ResizeObserver === "undefined") return; + const content = surface.querySelector('[data-testid="mobile-composer-reserve-pad"]') ?? surface; + const observer = new ResizeObserver(() => { + if (document.activeElement !== target) { + stopSettling(); + return; + } + revealIfCovered(target); + }); + // Border box, so the reserve padding collapsing counts as a resize. + observer.observe(content, { box: "border-box" }); + settle = { observer, timer: window.setTimeout(stopSettling, settleWindowMs) }; + for (const type of userScrollEvents) { + window.addEventListener(type, stopSettling, { capture: true, passive: true }); + } }; document.addEventListener("focusin", onFocusIn); - return () => document.removeEventListener("focusin", onFocusIn); + return () => { + document.removeEventListener("focusin", onFocusIn); + stopSettling(); + }; }, []); return null; diff --git a/src/components/roster/roster-month-page.tsx b/src/components/roster/roster-month-page.tsx index 0dc121b997..db25462905 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,55 @@ 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)}`; + // Search every loaded shift, not the three-row Coming up list, so shifts that overlap the + // lead cannot fill the list and hide a later one. + const next = nextShifts(monthShifts, now, monthShifts.length, leadShown).find( + (shift) => Date.parse(shift.startsAt) > now.getTime(), + ); + 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 +441,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 ? ( ) : (