: 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")}
>
- {primary.display}
- {primaryWhere ? {primaryWhere} : null}
+ {/* Keep each dialable segment intact while allowing a long route to wrap. */}
+
+
+ {primary.display.split(", then ext ").map((segment, index) => (
+
+ {index > 0 ? ", then ext " : null}
+ {segment}
+
+ ))}
+
+ {primaryWhere ? {primaryWhere} : null}
+
) : null}
{dial && (deskOnly || !primary?.tel) && !extension ? (
diff --git a/src/components/on-call/now/on-call-hero.tsx b/src/components/on-call/now/on-call-hero.tsx
new file mode 100644
index 0000000000..b4d5b78304
--- /dev/null
+++ b/src/components/on-call/now/on-call-hero.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { Phone } from "lucide-react";
+
+import { WorkHero } from "@/components/mode-kit/work";
+import { onCallShiftClock } from "@/components/on-call/now/shift-clock";
+import { onCallCallRoute } from "@/components/on-call/kit/dial-sheet";
+import { useOnCallHospitalPhone } from "@/components/on-call/call/call-device-stores";
+import { rememberOnCallYouCalled } from "@/lib/on-call/call-marks";
+import { ON_CALL_YOU_CALLED_ENABLED } from "@/lib/on-call/feature-flags";
+import type { HandbookItem } from "@/lib/on-call/handbook-items";
+import { spokenOnCallNumber, type OnCallPeriod } from "@/lib/on-call/number-resolver";
+import type { OnCallHospitalHours } from "@/lib/on-call/now-rows";
+import { onCallCoverWindow, onCallDurationWords } from "@/lib/on-call/period-window";
+import { recordOnCallRecent } from "@/lib/on-call/recent-storage";
+import type { OnCallShiftContext } from "@/lib/on-call/shift-context";
+
+/**
+ * Now's summary card (Work standard, owner 15 Oct 2026): every Work page opens
+ * with one filled mode-colour hero, its main action a white pill. On Call's
+ * answers "how long have I got, and how do I reach switchboard?".
+ *
+ * Nothing here is new data: the time left is the reader's rostered shift (the
+ * same clock as "Your shift"), the fallback is the hospital's own after-hours
+ * 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.
+ */
+export function NowOnCallHero({
+ context,
+ answer,
+ hours,
+ hospitalPeriod,
+ hospitalName,
+ handbookLoading,
+ rosterLoading = false,
+ rosterError = false,
+ rosterSignedOut = false,
+ now,
+}: {
+ readonly context: OnCallShiftContext;
+ /** The hospital's switchboard row, or null when none is recorded. */
+ readonly answer: HandbookItem | null;
+ readonly hours: OnCallHospitalHours | null;
+ readonly hospitalPeriod: OnCallPeriod | null;
+ readonly hospitalName: string | null;
+ readonly handbookLoading: boolean;
+ readonly rosterLoading?: boolean;
+ /** The roster read failed, so an empty shift list proves nothing. */
+ readonly rosterError?: boolean;
+ /** Signed out, the roster read returns nothing, which is not the same as no shift. */
+ readonly rosterSignedOut?: boolean;
+ readonly now: Date;
+}) {
+ const hospitalPhone = useOnCallHospitalPhone();
+ const clock = onCallShiftClock(context, now);
+ const cover = clock ? null : onCallCoverWindow(hours, now);
+ const periodLabel = hospitalPeriod ? (hospitalPeriod === "after-hours" ? "After hours" : "In hours") : null;
+ const dial = answer && answer.dial.kind !== "none" ? answer.dial : null;
+ const route = dial ? onCallCallRoute(dial, answer?.mobileDial, hospitalPhone) : null;
+ // The line names the number the pill actually rings. A bare extension with no
+ // mobile route can only be keyed from a hospital phone, so it says so.
+ const switchboardLine = route
+ ? `Switchboard ${route.display}`
+ : dial?.kind === "extension"
+ ? `Switchboard ${dial.display} from a hospital phone`
+ : dial
+ ? `Switchboard ${dial.display}`
+ : null;
+
+ const eyebrow = [clock ? "On call now" : "Right now", hospitalName].filter(Boolean).join(" · ");
+ const title = clock
+ ? `${onCallDurationWords(clock.minutesLeft)} left`
+ : cover && periodLabel
+ ? `${periodLabel} until ${cover.until}`
+ : rosterLoading || handbookLoading
+ ? "Checking your shift…"
+ : rosterError
+ ? "Couldn't load your roster"
+ : rosterSignedOut
+ ? "Sign in to see your shift"
+ : "No rostered shift on now";
+ const sub = [
+ clock
+ ? `${clock.periodLabel} shift ends ${clock.until}`
+ : cover
+ ? `${onCallDurationWords(cover.minutesLeft)} to go`
+ : null,
+ switchboardLine,
+ ]
+ .filter(Boolean)
+ .join(" · ");
+
+ const recordCall = () => {
+ if (!answer) return;
+ recordOnCallRecent({ id: answer.id, source: "handbook" });
+ if (ON_CALL_YOU_CALLED_ENABLED) rememberOnCallYouCalled(answer.id);
+ };
+
+ const action =
+ answer && route?.tel ? (
+
+
+ Call switchboard
+
+ ) : handbookLoading ? (
+
+ ) : null;
+
+ return (
+ {title}}
+ sub={sub ? {sub} : null}
+ footer={action ?
{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 (
-
-
-
-
- {/* Icon only on the narrowest phones, so the shift line keeps to one line. */}
- Who is on
-
-