+
+ );
+}
diff --git a/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx b/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx
new file mode 100644
index 0000000000..6468b7ddc2
--- /dev/null
+++ b/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import { useEffect, useState } from "react";
+
+import { OPEN_KEYBOARD_SHORTCUTS_EVENT, isTypingTarget, shortcutFor } from "@/lib/keyboard-shortcuts";
+
+const KeyboardShortcutsSheet = dynamic(
+ () => import("@/components/keyboard-shortcuts/keyboard-shortcuts-sheet").then((mod) => mod.KeyboardShortcutsSheet),
+ { ssr: false },
+);
+
+/** Another dialog is open over the page: its keys belong to it. */
+function dialogOpen(): boolean {
+ return document.querySelector('[role="dialog"][aria-modal="true"]') !== null;
+}
+
+function shown(element: Element): boolean {
+ return element.getClientRects().length > 0;
+}
+
+/**
+ * The page's own search box when the shared composer is not on screen (the
+ * Psychiatry and Medicines homes draw their own). Null when there is none.
+ */
+function pageSearchBox(): HTMLInputElement | null {
+ const composer = document.getElementById("global-search-input");
+ if (composer && shown(composer)) return null;
+ const boxes = document.querySelectorAll('#main-content input[type="search"]:not([disabled])');
+ for (const box of boxes) if (shown(box)) return box;
+ return null;
+}
+
+/**
+ * Keyboard shortcuts on a computer (site recommendations, 12 Oct 2026, item 5).
+ *
+ * Fills the gaps the page owners leave, and only on a screen with a mouse or
+ * trackpad, never while typing in a field and never over an open dialog:
+ * - "/" and Ctrl K (Command K on a Mac) go to a page's own search box where
+ * the shared composer is not on screen. Where it is, its own shortcut
+ * already does this; on Work pages AI Search owns both keys.
+ * - "?" shows the "Keyboard shortcuts" list on Clinical pages (Work pages show
+ * their AI Search list). The Help page opens the same list.
+ * Esc already closes every sheet and dialog (the shared Sheet owns it).
+ *
+ * Page owners that handle a key mark it handled (`preventDefault`), so a key
+ * is never acted on twice. The composer's position and behaviour are untouched.
+ */
+export function KeyboardShortcuts() {
+ const [open, setOpen] = useState(false);
+
+ useEffect(() => {
+ const finePointer = typeof window.matchMedia === "function" ? window.matchMedia("(pointer: fine)") : null;
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.defaultPrevented || !finePointer?.matches) return;
+ const action = shortcutFor(event);
+ if (!action || isTypingTarget(event.target) || dialogOpen()) return;
+ if (action === "help") {
+ event.preventDefault();
+ setOpen(true);
+ return;
+ }
+ const box = pageSearchBox();
+ if (!box) return;
+ event.preventDefault();
+ box.focus();
+ };
+ const onOpen = () => setOpen(true);
+ window.addEventListener("keydown", onKeyDown);
+ window.addEventListener(OPEN_KEYBOARD_SHORTCUTS_EVENT, onOpen);
+ return () => {
+ window.removeEventListener("keydown", onKeyDown);
+ window.removeEventListener(OPEN_KEYBOARD_SHORTCUTS_EVENT, onOpen);
+ };
+ }, []);
+
+ // Loaded on first ask, then kept, so closing can play the sheet's own exit.
+ const [asked, setAsked] = useState(false);
+ if (open && !asked) setAsked(true);
+ return asked ? setOpen(false)} /> : null;
+}
diff --git a/src/components/last-place/last-place.tsx b/src/components/last-place/last-place.tsx
new file mode 100644
index 0000000000..2284a576ba
--- /dev/null
+++ b/src/components/last-place/last-place.tsx
@@ -0,0 +1,184 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import { usePathname, useRouter } from "next/navigation";
+import { useCallback, useEffect, useEffectEvent, useRef, useState, useSyncExternalStore } from "react";
+
+import { landingModeForPreference, readAppPreferences } from "@/components/clinical-dashboard/use-app-preferences";
+import { useWorkModeRouteVisible } from "@/components/work-mode-launch/work-mode-launch-provider";
+import {
+ LAST_PLACE_STORAGE_KEY,
+ coldLandingTarget,
+ isColdHomeOpen,
+ lastPlaceToRemember,
+ readLastPlace,
+} from "@/lib/last-place";
+
+/**
+ * The roster read is only needed on a cold open of the home, so it loads then and never
+ * weighs on every other page of the app.
+ */
+const ShiftNowProbe = dynamic(() => import("@/components/last-place/shift-now-probe").then((m) => m.ShiftNowProbe), {
+ ssr: false,
+});
+
+/** How long a cold open waits for the roster before going to the last page instead. */
+const SHIFT_CHECK_WAIT_MS = 2_500;
+
+function readStoredLastPlace(): string | null {
+ try {
+ return readLastPlace(window.localStorage.getItem(LAST_PLACE_STORAGE_KEY));
+ } catch {
+ return null;
+ }
+}
+
+function storeLastPlace(path: string) {
+ try {
+ window.localStorage.setItem(LAST_PLACE_STORAGE_KEY, path);
+ } catch {
+ // Storage blocked (private window): the app simply opens on Clinical as before.
+ }
+}
+
+/**
+ * Read once per document, at the first mount. Module scope rather than a ref, so
+ * a remount (React's development double-run, or a layout change) can neither
+ * re-read storage after this page was remembered over it nor decide twice.
+ */
+let boot: { readonly lastPlace: string | null; done: boolean } | null = null;
+const bootListeners = new Set<() => void>();
+
+/**
+ * True once the doctor has tapped or typed on this document. This component loads after
+ * the page, so its own listeners can miss the first touches; the browser keeps the record.
+ */
+function doctorHasInteracted(): boolean {
+ return (
+ (navigator as Navigator & { userActivation?: { hasBeenActive?: boolean } }).userActivation?.hasBeenActive === true
+ );
+}
+
+function bootDecision() {
+ boot ??= { lastPlace: readStoredLastPlace(), done: doctorHasInteracted() || !coldHomeOpen() };
+ return boot;
+}
+
+/** The decision is over (made, or given up): nothing moves the page after this. */
+function endDecision() {
+ const state = bootDecision();
+ if (state.done) return;
+ state.done = true;
+ for (const listener of bootListeners) listener();
+}
+
+function subscribeBoot(listener: () => void) {
+ bootListeners.add(listener);
+ return () => bootListeners.delete(listener);
+}
+
+function bootChecking(): boolean {
+ return !bootDecision().done;
+}
+
+/** Test seam: forget the per-document decision. */
+export function resetLastPlaceForTests() {
+ boot = null;
+}
+
+/** Browser facts about how this document was opened. */
+function coldHomeOpen(): boolean {
+ let entryUrl: URL | null = null;
+ let navigationType: string | null = null;
+ try {
+ const entry = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined;
+ if (entry) {
+ entryUrl = new URL(entry.name);
+ navigationType = entry.type;
+ }
+ } catch {
+ // An older browser without navigation timing: fall back to the current address.
+ }
+ const url = entryUrl ?? new URL(window.location.href);
+ let cameFromApp = false;
+ try {
+ cameFromApp = document.referrer !== "" && new URL(document.referrer).origin === window.location.origin;
+ } catch {
+ cameFromApp = false;
+ }
+ return isColdHomeOpen({
+ entry: { pathname: url.pathname, search: url.search, hash: url.hash },
+ navigationType,
+ cameFromApp,
+ landingPreferenceSet: landingModeForPreference(readAppPreferences().landing) !== null,
+ standalone:
+ (typeof window.matchMedia === "function" && window.matchMedia("(display-mode: standalone)").matches) ||
+ (navigator as Navigator & { standalone?: boolean }).standalone === true,
+ historyLength: window.history.length,
+ });
+}
+
+/**
+ * Open where the doctor is (site recommendations, 12 Oct 2026, item 1).
+ *
+ * Remembers the last page used, on this device only and without its query
+ * string, and on a cold open of a bare `/` (the installed app's start address,
+ * or a typed visit) goes there instead; while a rostered shift is running it
+ * opens My Day. Anything else, including every `/?mode=` or `/?q=` link, a
+ * reload, Back and in-app navigation, keeps today's behaviour. The move uses
+ * `replace`, so Back never returns to a page the doctor did not choose.
+ *
+ * It gives up the moment the doctor starts using the page (a tap or a key), so
+ * a slow roster read can never pull the screen from under them.
+ */
+export function LastPlace() {
+ const pathname = usePathname();
+ const router = useRouter();
+ const routeVisible = useWorkModeRouteVisible();
+ const firstPathname = useRef(pathname);
+ // False on the server and through hydration; then true only while a cold open is being decided.
+ const checking = useSyncExternalStore(subscribeBoot, bootChecking, () => false);
+ const [shiftNow, setShiftNow] = useState(null);
+
+ const finish = useEffectEvent((onShiftNow: boolean) => {
+ const state = bootDecision();
+ if (state.done) return;
+ endDecision();
+ // Only a bare, untouched home moves: a search or mode change on `/` keeps the page.
+ if (window.location.pathname !== "/" || window.location.search || window.location.hash) return;
+ if (doctorHasInteracted()) return;
+ const target = coldLandingTarget({ onShiftNow, lastPlace: state.lastPlace, routeVisible });
+ if (target) router.replace(target, { scroll: false });
+ });
+
+ // While deciding: stop waiting for the roster after a short while, and give up
+ // the moment the doctor starts using the page.
+ useEffect(() => {
+ if (!checking) return undefined;
+ const timer = window.setTimeout(() => finish(false), SHIFT_CHECK_WAIT_MS);
+ window.addEventListener("pointerdown", endDecision, { capture: true, once: true });
+ window.addEventListener("keydown", endDecision, { capture: true, once: true });
+ return () => {
+ window.clearTimeout(timer);
+ window.removeEventListener("pointerdown", endDecision, { capture: true });
+ window.removeEventListener("keydown", endDecision, { capture: true });
+ };
+ }, [checking]);
+
+ // Remember each page. The remembered one is read first (bootDecision is read
+ // once per document), so the page on screen never replaces it before the
+ // decision. A page change before the decision (a tapped link) also ends it.
+ useEffect(() => {
+ bootDecision();
+ if (pathname !== firstPathname.current) endDecision();
+ const remembered = lastPlaceToRemember(pathname);
+ if (remembered) storeLastPlace(remembered);
+ }, [pathname]);
+
+ useEffect(() => {
+ if (shiftNow !== null) finish(shiftNow);
+ }, [shiftNow]);
+
+ const reportShift = useCallback((onShiftNow: boolean) => setShiftNow(onShiftNow), []);
+ return checking ? : null;
+}
diff --git a/src/components/last-place/shift-now-probe.tsx b/src/components/last-place/shift-now-probe.tsx
new file mode 100644
index 0000000000..3d3ebe8df3
--- /dev/null
+++ b/src/components/last-place/shift-now-probe.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+
+import { kindOf } from "@/components/roster/roster-format";
+import { useRosterShifts } from "@/components/roster/use-roster-shifts";
+import { summariseToday } from "@/lib/roster/today";
+
+/**
+ * Reads the doctor's own roster once, and reports whether a real shift (not
+ * the example roster) is running right now. The same roster read and the same
+ * "on now" rule My Day uses for its hero.
+ */
+export function ShiftNowProbe({ onResult }: { readonly onResult: (onShiftNow: boolean) => void }) {
+ const roster = useRosterShifts();
+ const reported = useRef(false);
+ useEffect(() => {
+ if (reported.current) return;
+ if (roster.status === "loading" || (roster.status === "ready" && roster.teamLoading)) return;
+ reported.current = true;
+ if (roster.status !== "ready" || roster.sample || roster.demoMode) {
+ onResult(false);
+ return;
+ }
+ const today = summariseToday(
+ roster.shifts.map((shift) => ({
+ id: shift.id,
+ startsAt: shift.startsAt,
+ endsAt: shift.endsAt,
+ kind: kindOf(shift),
+ })),
+ new Date(),
+ );
+ onResult(today.lead.state === "on_now");
+ }, [roster.status, roster.teamLoading, roster.sample, roster.demoMode, roster.shifts, onResult]);
+ return null;
+}
diff --git a/src/components/motion/motion-enter.tsx b/src/components/motion/motion-enter.tsx
new file mode 100644
index 0000000000..06227186b4
--- /dev/null
+++ b/src/components/motion/motion-enter.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import { useEffect, useLayoutEffect, useRef } from "react";
+
+import { workAreaIdForPath } from "@/lib/work-frame/areas";
+
+const ATTRIBUTE = "data-motion-enter";
+
+/**
+ * Marks elements so `motion.css` fades them in once. The attribute is removed
+ * and re-added (with a style read between) so a second change replays it, and
+ * cleared when the fade ends so it never lingers on the element.
+ */
+export function markMotionEnter(elements: Iterable) {
+ for (const element of elements) {
+ if (!(element instanceof HTMLElement)) continue;
+ element.removeAttribute(ATTRIBUTE);
+ // Reading layout restarts the animation when the attribute comes straight back.
+ void element.offsetWidth;
+ element.setAttribute(ATTRIBUTE, "");
+ // Reduced motion (or no animation at all): nothing to wait for, so nothing is left behind.
+ if (getComputedStyle(element).animationName === "none") {
+ element.removeAttribute(ATTRIBUTE);
+ continue;
+ }
+ element.addEventListener("animationend", () => element.removeAttribute(ATTRIBUTE), { once: true });
+ }
+}
+
+/** Elements that can carry an animation: `display: contents` wrappers pass it to their children. */
+function animatable(elements: Iterable): Element[] {
+ const out: Element[] = [];
+ for (const element of elements) {
+ if (getComputedStyle(element).display === "contents") out.push(...animatable(element.children));
+ else out.push(element);
+ }
+ return out;
+}
+
+/** A Work page's content: everything after its band (title and tabs), which stays put. */
+function workPageContent(): Element[] {
+ const band = document.querySelector("#main-content .work-band");
+ if (!band?.parentElement) return [];
+ const after: Element[] = [];
+ let next = band.nextElementSibling;
+ while (next) {
+ after.push(next);
+ next = next.nextElementSibling;
+ }
+ return animatable(after);
+}
+
+/** The panel a tab shows: by `aria-controls`, else the tab list's neighbouring panel. */
+function panelFor(tab: Element): Element | null {
+ const controls = tab.getAttribute("aria-controls");
+ const byId = controls ? document.getElementById(controls) : null;
+ if (byId) return byId;
+ const list = tab.closest('[role="tablist"]');
+ let scope = list?.parentElement ?? null;
+ while (scope && scope !== document.body) {
+ const panel = scope.querySelector('[role="tabpanel"]');
+ if (panel) return panel;
+ scope = scope.parentElement;
+ }
+ return null;
+}
+
+/**
+ * Tab content cross-fades in (site recommendations, 12 Oct 2026, item 9).
+ *
+ * Two kinds of tab: `role="tab"` controls (the content swaps in place), and
+ * Work's tab row, whose tabs are pages. The search composer is never touched,
+ * and only opacity moves; `motion.css` switches it all off under reduced motion.
+ */
+export function MotionEnter() {
+ const pathname = usePathname();
+ const previous = useRef(pathname);
+
+ // A move between two Work pages: fade the new page's content in before it paints.
+ useLayoutEffect(() => {
+ const from = previous.current;
+ previous.current = pathname;
+ if (from === pathname) return;
+ if (workAreaIdForPath(from) === null || workAreaIdForPath(pathname) === null) return;
+ markMotionEnter(workPageContent());
+ }, [pathname]);
+
+ // A role="tab" switch: fade the shown panel in on the frame after React has swapped it.
+ useEffect(() => {
+ let frame = 0;
+ const onTab = (event: Event) => {
+ if (event instanceof KeyboardEvent && !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
+ const target = event.target instanceof Element ? event.target.closest('[role="tab"]') : null;
+ if (!target) return;
+ const list = target.closest('[role="tablist"]');
+ const before = list?.querySelector('[role="tab"][aria-selected="true"]') ?? null;
+ window.cancelAnimationFrame(frame);
+ frame = window.requestAnimationFrame(() => {
+ const selected = list?.querySelector('[role="tab"][aria-selected="true"]') ?? target;
+ if (selected === before) return;
+ const panel = panelFor(selected);
+ // Never fade anything holding the search composer.
+ if (panel && !panel.querySelector("#global-search-input")) markMotionEnter([panel]);
+ });
+ };
+ // Capture: read the selected tab before React swaps it.
+ document.addEventListener("click", onTab, true);
+ document.addEventListener("keydown", onTab, true);
+ return () => {
+ window.cancelAnimationFrame(frame);
+ document.removeEventListener("click", onTab, true);
+ document.removeEventListener("keydown", onTab, true);
+ };
+ }, []);
+
+ return null;
+}
diff --git a/src/components/on-call/on-call-offline-banner.tsx b/src/components/on-call/on-call-offline-banner.tsx
index 7198840ee1..2e32c161ce 100644
--- a/src/components/on-call/on-call-offline-banner.tsx
+++ b/src/components/on-call/on-call-offline-banner.tsx
@@ -1,9 +1,9 @@
"use client";
-import { CloudOff } from "lucide-react";
+import { CloudOff, WifiOff } from "lucide-react";
import { cn } from "@/components/ui-primitives";
-import { formatClinicalDate } from "@/lib/source-metadata";
+import { savedLine } from "@/lib/offline-line";
export interface OnCallOfflineBannerProps {
/** ISO timestamp of when the shown copy was cached, or `null` if unknown. */
@@ -16,7 +16,9 @@ export interface OnCallOfflineBannerProps {
/**
* Shown when contacts render from the offline cache rather than a live fetch
- * (spec §6, §8.5). Names the date the saved copy was taken.
+ * (spec §6, §8.5). Names when the saved copy was taken, in the one wording the
+ * whole app uses for a saved copy ("Offline, showing what was saved at 19:02";
+ * an earlier day is named too).
*
* The visible banner is deliberately NOT itself a live region — no `role`,
* no `aria-live` on this element — because the page can mount it already
@@ -31,11 +33,8 @@ export function OnCallOfflineBanner({
reason = "offline",
testId = "on-call-offline-banner",
}: OnCallOfflineBannerProps) {
- const savedLabel = savedAt ? formatClinicalDate(savedAt) : "an earlier date";
- const message =
- reason === "failed"
- ? `Couldn't reach the server. Showing a saved copy from ${savedLabel}.`
- : `You are offline. Showing a saved copy from ${savedLabel}.`;
+ const message = savedLine({ reason, savedAt });
+ const Icon = reason === "failed" ? CloudOff : WifiOff;
return (
<>
@@ -45,7 +44,7 @@ export function OnCallOfflineBanner({
"flex min-h-tap items-center gap-2 rounded-lg border border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] px-3 py-2 text-sm font-semibold text-[color:var(--warning)]",
)}
>
-
+ {message}
diff --git a/src/components/pwa-lifecycle.tsx b/src/components/pwa-lifecycle.tsx
index 68dbc6b35e..8e7ea540c9 100644
--- a/src/components/pwa-lifecycle.tsx
+++ b/src/components/pwa-lifecycle.tsx
@@ -13,10 +13,17 @@ import {
Zap,
type LucideIcon,
} from "lucide-react";
-import { useEffect, useRef, useState, useSyncExternalStore } from "react";
+import { usePathname, useSearchParams } from "next/navigation";
+import { Suspense, useEffect, useRef, useState, useSyncExternalStore } from "react";
+import "@/components/pwa-offline-line.css";
+
import { BrandMark } from "@/components/clinical-dashboard/brand";
import { BRAND_INSTALL_TAGLINE, BRAND_NAME } from "@/lib/brand";
import { createBrowserStore } from "@/lib/client-store-factory";
+import type { savedLine as SavedLine } from "@/lib/offline-line";
+
+/** savedLine's own wording when the save time is unknown. */
+const OFFLINE_LINE_FALLBACK = "Offline, showing what was saved earlier";
const INSTALL_DISMISSAL_KEY = "clinical-kb-pwa-install-dismissed-at";
const IOS_INSTALL_DISMISSAL_KEY = "clinical-kb-pwa-ios-install-dismissed-at";
@@ -311,11 +318,69 @@ function useSettledNoticeSignature(signature: string | null): string | null {
* `?pwa-dev=0` (local hosts only) unregisters that worker and deletes the
* owned caches again.
*/
+/**
+ * Reports the query string, so a page whose views switch by query alone
+ * (Roster's ?view=hours) counts as a different page for the offline time. Its
+ * own Suspense boundary keeps the query read from making the app tree client-only.
+ */
+function QueryWatcher({ onQuery }: { readonly onQuery: (query: string) => void }) {
+ const query = useSearchParams().toString();
+ useEffect(() => onQuery(query), [query, onQuery]);
+ return null;
+}
+
export function PwaLifecycle() {
const isOnline = useConnectivityStore();
const [connectionRestored, setConnectionRestored] = useState(false);
const [installPrompt, setInstallPrompt] = useState(null);
const [offlineNoticeDismissed, setOfflineNoticeDismissed] = useState(false);
+ // When the page on screen was last opened while online. What an offline page
+ // still shows was loaded then or later, so naming this time can understate how
+ // fresh it is but never overstate it.
+ const pathname = usePathname();
+ // Null until the query has been read, which never matches a saved page.
+ const [query, setQuery] = useState(null);
+ const page = query === null ? null : query ? `${pathname}?${query}` : pathname;
+ const shownOnlineRef = useRef<{ at: number; page: string | null } | null>(null);
+ const [offlineSaved, setOfflineSaved] = useState<{ at: number; page: string | null } | null>(null);
+ // A page reached after the signal dropped came from a saved copy of unknown
+ // age, so the time only holds for the page (path and query) that was on screen
+ // when it dropped.
+ // The Clinical home "/" switches what it shows by query (?mode=…) without a new
+ // path, so there a matching path proves nothing and the line says "saved earlier".
+ const offlineSavedAt =
+ offlineSaved && page !== null && pathname !== "/" && offlineSaved.page === page ? offlineSaved.at : null;
+ // The offline line's wording helper pulls in the work clock and account
+ // preferences, which every page would otherwise download up front for a
+ // notice most visits never show. It loads just after mount, while online, so
+ // it is ready before the signal drops; until then the line says "saved earlier".
+ const [savedLineFn, setSavedLineFn] = useState(null);
+ // A load that fails (opened offline, flaky signal) is tried again when the
+ // signal returns, so a later offline notice can still name its time.
+ useEffect(() => {
+ let live = true;
+ let loading = false;
+ let loaded = false;
+ const load = () => {
+ if (loading || loaded) return;
+ loading = true;
+ import("@/lib/offline-line")
+ .then((module) => {
+ loaded = true;
+ if (live) setSavedLineFn(() => module.savedLine);
+ })
+ .catch(() => {})
+ .finally(() => {
+ loading = false;
+ });
+ };
+ load();
+ window.addEventListener("online", load);
+ return () => {
+ live = false;
+ window.removeEventListener("online", load);
+ };
+ }, []);
const [showIosHint, setShowIosHint] = useState(false);
const [waitingWorker, setWaitingWorker] = useState(null);
const [activatedUpdateReady, setActivatedUpdateReady] = useState(false);
@@ -335,6 +400,7 @@ export function PwaLifecycle() {
const handleOffline = () => {
if (restoredTimer) window.clearTimeout(restoredTimer);
setConnectionRestored(false);
+ setOfflineSaved(shownOnlineRef.current);
};
const handleOnline = () => {
if (restoredTimer) window.clearTimeout(restoredTimer);
@@ -354,6 +420,10 @@ export function PwaLifecycle() {
};
}, []);
+ useEffect(() => {
+ shownOnlineRef.current = navigator.onLine && page !== null ? { at: Date.now(), page } : null;
+ }, [page]);
+
useEffect(() => {
const displayMode = window.matchMedia("(display-mode: standalone)");
const syncDisplayMode = () => {
@@ -544,8 +614,14 @@ export function PwaLifecycle() {
// while that element is absent — including the hydration gap where it briefly
// stops existing — means being restyled a moment later. See useAppShellReady.
const appShellReady = useAppShellReady();
- if (!appShellReady) return null;
- if (settledSignature === null || settledSignature !== desiredSignature) return null;
+ // Always mounted, notices or not, so the page is known before the signal drops.
+ const queryWatcher = (
+
+
+
+ );
+ if (!appShellReady) return queryWatcher;
+ if (settledSignature === null || settledSignature !== desiredSignature) return queryWatcher;
const showOffline = wantsOffline;
const showUpdate = wantsUpdate;
const showInstall = wantsInstall;
@@ -553,6 +629,7 @@ export function PwaLifecycle() {
return (
Clinical search and private features need a connection.
diff --git a/src/components/pwa-offline-line.css b/src/components/pwa-offline-line.css
new file mode 100644
index 0000000000..88ff53e5fd
--- /dev/null
+++ b/src/components/pwa-offline-line.css
@@ -0,0 +1,37 @@
+/*
+ * The offline line looks the same in Work and Clinical (site recommendations,
+ * 12 Oct 2026, item 10). Work used to squeeze this card onto one row, which
+ * broke awkwardly once the line named the saved time. `revert-layer` hands
+ * each property back to the card's own utility classes, so Work draws exactly
+ * the card Clinical draws; Work's glass card ground (globals.css) is kept. The
+ * card's padding is not a utility, so it is restated: the lifecycle card's.
+ */
+html[data-work-frame] .pwa-notice-stack .pwa-offline-card {
+ padding: var(--pwa-inset-xl);
+}
+
+html[data-work-frame] .pwa-notice-stack .pwa-offline-card .pwa-notice-row {
+ align-items: revert-layer;
+}
+
+html[data-work-frame] .pwa-notice-stack .pwa-offline-card .pwa-notice-body {
+ display: revert-layer;
+ flex: revert-layer;
+ flex-wrap: revert-layer;
+ align-items: revert-layer;
+ justify-content: revert-layer;
+ column-gap: revert-layer;
+}
+
+html[data-work-frame] .pwa-notice-stack .pwa-offline-card .pwa-notice-copy {
+ display: revert-layer;
+}
+
+html[data-work-frame] .pwa-notice-stack .pwa-offline-card .pwa-notice-retry {
+ margin-top: revert-layer;
+}
+
+html[data-work-frame] .pwa-notice-stack .pwa-offline-card .pwa-notice-dismiss {
+ top: revert-layer;
+ translate: revert-layer;
+}
diff --git a/src/components/work-frame/work-rail.tsx b/src/components/work-frame/work-rail.tsx
index ac45490c4b..f883ec7b99 100644
--- a/src/components/work-frame/work-rail.tsx
+++ b/src/components/work-frame/work-rail.tsx
@@ -8,7 +8,8 @@ import type { SidebarIdentity } from "@/components/clinical-dashboard/ClinicalSi
import { rememberedWorkAreaPage, useWorkSideCounts } from "@/components/work-frame/work-frame-store";
import { useNewWorkMode, useWorkModeRouteVisible } from "@/components/work-mode-launch/work-mode-launch-provider";
import { appModeIcons } from "@/lib/app-mode-icons";
-import type { AppModeId } from "@/lib/app-modes";
+import { DEFAULT_APP_MODE, useLastAppMode } from "@/components/clinical-dashboard/use-last-app-mode";
+import { appModeDefinition, appModeHomeHref, type AppModeId } from "@/lib/app-modes";
import { modeMenuSideForMode } from "@/lib/phone-mode-groups";
import { WORK_AREAS, workAreaFor, type WorkAreaId } from "@/lib/work-frame/areas";
import { WORK_SIDE_AREAS, workSideAreaId, workSideBadgeText, workSideCountLabel } from "@/lib/work-frame/side-nav";
@@ -47,6 +48,34 @@ export function useWorkRailShown(): boolean {
);
}
+/**
+ * The way back to Clinical (site recommendations, 12 Oct 2026, item 1): the
+ * Clinical rail has My Day near its top, so the work rail has Clinical in the
+ * same place. It opens the Clinical mode used last, with that mode's icon.
+ */
+function WorkRailClinical() {
+ const [storedMode] = useLastAppMode();
+ // The mode menu also remembers Work modes (My Day, Roster), which must never
+ // turn this button into a link to another Work page.
+ const lastMode = modeMenuSideForMode(storedMode) === "clinical" ? storedMode : DEFAULT_APP_MODE;
+ const Icon = appModeIcons[lastMode];
+ return (
+
+
+
+
+
+ Clinical
+
+
+ );
+}
+
/**
* The tablet rail (owner pick "Burger and rail", 7 Oct 2026): from 768 px a
* narrow column down the left edge with your initial, the seven work areas
@@ -76,6 +105,7 @@ export function WorkRail({
>
{identity.initials}
+
{WORK_SIDE_AREAS.filter((entry) => routeVisible(entry.href)).map((entry) => {
const current = entry.id === currentArea;
diff --git a/src/components/work-help/work-help-page.tsx b/src/components/work-help/work-help-page.tsx
index aa7b0f5605..6681980fb9 100644
--- a/src/components/work-help/work-help-page.tsx
+++ b/src/components/work-help/work-help-page.tsx
@@ -8,6 +8,7 @@ import {
ChevronRight,
Clock3,
Compass,
+ Keyboard,
Search,
SearchX,
ShieldCheck,
@@ -42,6 +43,7 @@ import {
} from "@/lib/work-help";
import { workSetupCount, workSetupCountLabel } from "@/lib/work-setup/progress";
import { looksLikePatientDetail } from "@/lib/work-text/patient-detail-check";
+import { OPEN_KEYBOARD_SHORTCUTS_EVENT } from "@/lib/keyboard-shortcuts";
const subscribeNothing = () => () => undefined;
function useHydrated(): boolean {
@@ -266,6 +268,26 @@ function GetStarted() {
);
}
+/** Keyboard shortcuts, only where there is a mouse or trackpad (a phone has no use for them). */
+function OnAComputer() {
+ return (
+
+ On a computer
+
+