From 9c68ddcfd688285cd1843e3effe0ac91147b90fc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:19:36 +0000 Subject: [PATCH 01/11] feat(nav): open where the doctor is, and a Clinical button on the work rail A cold open of a bare "/" (the installed app's start address or a typed visit) now goes to the last Work or Clinical page used, or to My Day while a real rostered shift is running. The page is remembered on this device only, as a bare path (no query string, no hash). Any "/" with a query, a reload, Back, an arrival from inside the app, or a set Default landing view keeps today's behaviour. The move uses replace, and it gives up the moment the doctor taps or types. The desktop and tablet work rail gains a Clinical button in the place the Clinical rail keeps My Day, opening the Clinical mode used last. The manifest keeps start_url "/" (its id is "/", so identity would not change either way); the decision is made on the client. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- src/app/(search-app)/layout.tsx | 3 + src/components/last-place/last-place.tsx | 192 +++++++++++++++++++++++ src/components/work-frame/work-rail.tsx | 29 +++- src/lib/last-place.ts | 97 ++++++++++++ tests/last-place.dom.test.tsx | 146 +++++++++++++++++ tests/last-place.test.ts | 99 ++++++++++++ tests/work-rail-clinical.dom.test.tsx | 34 ++++ 7 files changed, 599 insertions(+), 1 deletion(-) create mode 100644 src/components/last-place/last-place.tsx create mode 100644 src/lib/last-place.ts create mode 100644 tests/last-place.dom.test.tsx create mode 100644 tests/last-place.test.ts create mode 100644 tests/work-rail-clinical.dom.test.tsx diff --git a/src/app/(search-app)/layout.tsx b/src/app/(search-app)/layout.tsx index 25b993f48e..f69562587d 100644 --- a/src/app/(search-app)/layout.tsx +++ b/src/app/(search-app)/layout.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { SharedSearchAppShell } from "@/components/clinical-dashboard/shared-search-app-shell"; +import { LastPlace } from "@/components/last-place/last-place"; import { PsychiatryVisitRecorder } from "@/components/psychiatry/psychiatry-visit-recorder"; import { WorkModeLaunchProvider } from "@/components/work-mode-launch/work-mode-launch-provider"; import { clinicalAskModeEnabled } from "@/lib/clinical-ask/authority-registry"; @@ -28,6 +29,8 @@ export default async function SearchAppLayout({ children }: { children: ReactNod {children} + {/* Outside the shell: on `/` the shell draws the dashboard in place of its children. */} + ); } diff --git a/src/components/last-place/last-place.tsx b/src/components/last-place/last-place.tsx new file mode 100644 index 0000000000..f2981b57b1 --- /dev/null +++ b/src/components/last-place/last-place.tsx @@ -0,0 +1,192 @@ +"use client"; + +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 { kindOf } from "@/components/roster/roster-format"; +import { useRosterShifts } from "@/components/roster/use-roster-shifts"; +import { useWorkModeRouteVisible } from "@/components/work-mode-launch/work-mode-launch-provider"; +import { + LAST_PLACE_STORAGE_KEY, + coldLandingTarget, + isColdHomeOpen, + lastPlaceToRemember, + readLastPlace, +} from "@/lib/last-place"; +import { summariseToday } from "@/lib/roster/today"; + +/** 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>(); + +function bootDecision() { + boot ??= { lastPlace: readStoredLastPlace(), done: !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, + }); +} + +/** + * 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. + */ +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; +} + +/** + * 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(); + if (window.location.pathname !== "/") 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/work-frame/work-rail.tsx b/src/components/work-frame/work-rail.tsx index ac45490c4b..0a53e30117 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 { 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,31 @@ 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 [lastMode] = useLastAppMode(); + const Icon = appModeIcons[lastMode]; + return ( + + + + + ); +} + /** * 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 +102,7 @@ export function WorkRail({ > +
    {WORK_SIDE_AREAS.filter((entry) => routeVisible(entry.href)).map((entry) => { const current = entry.id === currentArea; diff --git a/src/lib/last-place.ts b/src/lib/last-place.ts new file mode 100644 index 0000000000..e222e69775 --- /dev/null +++ b/src/lib/last-place.ts @@ -0,0 +1,97 @@ +/** + * "Open where the doctor is" (site recommendations, 12 Oct 2026, item 1). + * + * The app remembers the last Work or Clinical page the doctor used, on this + * device only, as a bare path: never the query string (which can carry search + * text) and never the hash. A cold open of `/` then lands there instead of on + * Clinical Answer, or on My Day while a rostered shift is running. + * + * Everything here is pure so the rules can be pinned by unit tests; the client + * component in `src/components/last-place/` only feeds it browser facts. + */ + +/** localStorage key holding the last page path. Device only; never synced. */ +export const LAST_PLACE_STORAGE_KEY = "clinical-kb-last-place"; + +/** Where a running shift sends a cold open. */ +export const ON_SHIFT_LANDING_PATH = "/my-day"; + +const MAX_PATH_LENGTH = 160; + +/** Plain path segments only: letters, digits and the usual URL-safe marks. */ +const SAFE_PATH = /^(?:\/[A-Za-z0-9._~-]+)+$/; + +/** + * Pages that make a poor first screen: a form part-way through, a results page + * that needs its query (which is never stored), set-up flows, and the patient + * safety plan, which belongs to one conversation rather than to "where I was". + */ +const SKIPPED_LAST_SEGMENTS = new Set(["new", "edit", "search", "setup"]); +const SKIPPED_PREFIXES = ["/safety-plan", "/mockups", "/api", "/auth"]; + +/** + * The path to remember for a page, or null when the page should not replace + * the remembered one. `/` (the Clinical home) is remembered as `/`: the mode + * itself is already kept by `use-last-app-mode`. + */ +export function lastPlaceToRemember(pathname: string | null | undefined): string | null { + if (!pathname) return null; + if (pathname === "/") return "/"; + if (pathname.length > MAX_PATH_LENGTH || !SAFE_PATH.test(pathname)) return null; + if (SKIPPED_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))) return null; + const last = pathname.slice(pathname.lastIndexOf("/") + 1); + if (SKIPPED_LAST_SEGMENTS.has(last)) return null; + return pathname; +} + +/** A stored value, checked again on the way out: storage outlives code changes. */ +export function readLastPlace(stored: string | null | undefined): string | null { + return lastPlaceToRemember(stored ?? null); +} + +export type ColdLandingFacts = { + /** The address the document was first loaded at (not the current one). */ + readonly entry: { readonly pathname: string; readonly search: string; readonly hash: string }; + /** PerformanceNavigationTiming.type for this document ("navigate", "reload", "back_forward", ...). */ + readonly navigationType: string | null; + /** The page was opened from another page of this app (a link, or in-app history). */ + readonly cameFromApp: boolean; + /** Settings "Default landing view" is set: that explicit choice wins. */ + readonly landingPreferenceSet: boolean; +}; + +/** + * Whether this load of `/` is a cold open that may be sent elsewhere. Any query + * (`?mode=`, `?q=`, `?focus=`), any hash, a reload, Back/Forward, or arriving + * from inside the app keeps today's behaviour exactly. + */ +export function isColdHomeOpen(facts: ColdLandingFacts): boolean { + if (facts.entry.pathname !== "/") return false; + if (facts.entry.search !== "" && facts.entry.search !== "?") return false; + if (facts.entry.hash !== "" && facts.entry.hash !== "#") return false; + if (facts.navigationType !== null && facts.navigationType !== "navigate") return false; + if (facts.cameFromApp) return false; + if (facts.landingPreferenceSet) return false; + return true; +} + +/** + * Where a cold open of `/` goes, or null to stay on `/` (Clinical, with the + * remembered Clinical mode seeded as it is today). + * + * A running shift wins, then the last page. `routeVisible` filters out a Work + * page this reader cannot open (the classic work mode hides new-only screens). + */ +export function coldLandingTarget({ + onShiftNow, + lastPlace, + routeVisible, +}: { + readonly onShiftNow: boolean; + readonly lastPlace: string | null; + readonly routeVisible: (href: string) => boolean; +}): string | null { + if (onShiftNow && routeVisible(ON_SHIFT_LANDING_PATH)) return ON_SHIFT_LANDING_PATH; + if (!lastPlace || lastPlace === "/") return null; + return routeVisible(lastPlace) ? lastPlace : null; +} diff --git a/tests/last-place.dom.test.tsx b/tests/last-place.dom.test.tsx new file mode 100644 index 0000000000..17af62f8da --- /dev/null +++ b/tests/last-place.dom.test.tsx @@ -0,0 +1,146 @@ +/** @vitest-environment jsdom */ + +import { act, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const replace = vi.fn(); +let pathname = "/"; +let roster: { + status: "loading" | "ready" | "signed-out" | "error"; + shifts: { id: string; startsAt: string; endsAt: string; title: string; kind?: string }[]; + teamLoading: boolean; + sample: boolean; + demoMode: boolean; +}; + +vi.mock("next/navigation", () => ({ + usePathname: () => pathname, + useRouter: () => ({ replace, push: vi.fn(), prefetch: vi.fn() }), +})); +vi.mock("@/components/roster/use-roster-shifts", () => ({ + useRosterShifts: () => roster, +})); + +import { LastPlace, resetLastPlaceForTests } from "@/components/last-place/last-place"; +import { LAST_PLACE_STORAGE_KEY } from "@/lib/last-place"; + +function setEntry(url: string, type = "navigate") { + vi.spyOn(performance, "getEntriesByType").mockReturnValue([ + { name: `http://localhost${url}`, type } as unknown as PerformanceEntry, + ]); +} + +describe("LastPlace", () => { + beforeEach(() => { + resetLastPlaceForTests(); + replace.mockReset(); + pathname = "/"; + window.localStorage.clear(); + roster = { status: "signed-out", shifts: [], teamLoading: false, sample: false, demoMode: false }; + Object.defineProperty(document, "referrer", { value: "", configurable: true }); + }); + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("opens the last page on a cold open of a bare /", async () => { + window.localStorage.setItem(LAST_PLACE_STORAGE_KEY, "/teaching"); + setEntry("/"); + render(); + await waitFor(() => expect(replace).toHaveBeenCalledWith("/teaching", { scroll: false })); + // The page now shown is remembered only after the old one was read. + expect(window.localStorage.getItem(LAST_PLACE_STORAGE_KEY)).toBe("/"); + }); + + it("opens My Day while a real shift is running", async () => { + window.localStorage.setItem(LAST_PLACE_STORAGE_KEY, "/teaching"); + const now = Date.now(); + roster = { + status: "ready", + shifts: [ + { + id: "s1", + title: "On call", + kind: "on_call", + startsAt: new Date(now - 3_600_000).toISOString(), + endsAt: new Date(now + 3_600_000).toISOString(), + }, + ], + teamLoading: false, + sample: false, + demoMode: false, + }; + setEntry("/"); + render(); + await waitFor(() => expect(replace).toHaveBeenCalledWith("/my-day", { scroll: false })); + }); + + it("ignores the example roster", async () => { + const now = Date.now(); + roster = { + status: "ready", + shifts: [ + { + id: "s1", + title: "Day", + startsAt: new Date(now - 3_600_000).toISOString(), + endsAt: new Date(now + 3_600_000).toISOString(), + }, + ], + teamLoading: false, + sample: true, + demoMode: false, + }; + setEntry("/"); + render(); + await act(async () => {}); + expect(replace).not.toHaveBeenCalled(); + }); + + it("never moves a / with a query, a reload, or an arrival from inside the app", async () => { + window.localStorage.setItem(LAST_PLACE_STORAGE_KEY, "/teaching"); + setEntry("/?mode=dsm"); + render(); + await act(async () => {}); + expect(replace).not.toHaveBeenCalled(); + + resetLastPlaceForTests(); + window.localStorage.setItem(LAST_PLACE_STORAGE_KEY, "/teaching"); + setEntry("/", "reload"); + render(); + await act(async () => {}); + expect(replace).not.toHaveBeenCalled(); + + resetLastPlaceForTests(); + window.localStorage.setItem(LAST_PLACE_STORAGE_KEY, "/teaching"); + Object.defineProperty(document, "referrer", { value: `${window.location.origin}/on-call`, configurable: true }); + setEntry("/"); + render(); + await act(async () => {}); + expect(replace).not.toHaveBeenCalled(); + }); + + it("leaves the doctor where they are once they start using the page", async () => { + window.localStorage.setItem(LAST_PLACE_STORAGE_KEY, "/teaching"); + roster = { ...roster, status: "loading" }; + setEntry("/"); + const view = render(); + await act(async () => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" })); + }); + roster = { ...roster, status: "signed-out" }; + view.rerender(); + await act(async () => {}); + expect(replace).not.toHaveBeenCalled(); + }); + + it("remembers each page without its query string", async () => { + pathname = "/on-call/whos-on"; + setEntry("/on-call/whos-on?x=1"); + render(); + await act(async () => {}); + expect(window.localStorage.getItem(LAST_PLACE_STORAGE_KEY)).toBe("/on-call/whos-on"); + expect(replace).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/last-place.test.ts b/tests/last-place.test.ts new file mode 100644 index 0000000000..99b18cc7f2 --- /dev/null +++ b/tests/last-place.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { + ON_SHIFT_LANDING_PATH, + coldLandingTarget, + isColdHomeOpen, + lastPlaceToRemember, + readLastPlace, + type ColdLandingFacts, +} from "@/lib/last-place"; + +const cold: ColdLandingFacts = { + entry: { pathname: "/", search: "", hash: "" }, + navigationType: "navigate", + cameFromApp: false, + landingPreferenceSet: false, +}; + +describe("lastPlaceToRemember", () => { + it("keeps plain Work and Clinical page paths", () => { + expect(lastPlaceToRemember("/on-call")).toBe("/on-call"); + expect(lastPlaceToRemember("/my-day/week")).toBe("/my-day/week"); + expect(lastPlaceToRemember("/dsm/major-depressive-disorder")).toBe("/dsm/major-depressive-disorder"); + expect(lastPlaceToRemember("/")).toBe("/"); + }); + + it("never keeps forms part-way through, results pages, set-up or the safety plan", () => { + expect(lastPlaceToRemember("/cme/new")).toBeNull(); + expect(lastPlaceToRemember("/documents/search")).toBeNull(); + expect(lastPlaceToRemember("/my-day/setup")).toBeNull(); + expect(lastPlaceToRemember("/safety-plan")).toBeNull(); + expect(lastPlaceToRemember("/safety-plan/print")).toBeNull(); + expect(lastPlaceToRemember("/mockups/anything")).toBeNull(); + }); + + it("refuses anything that is not a bare same-site path", () => { + expect(lastPlaceToRemember("/on-call?q=lithium")).toBeNull(); + expect(lastPlaceToRemember("/on-call#top")).toBeNull(); + expect(lastPlaceToRemember("//evil.example/x")).toBeNull(); + expect(lastPlaceToRemember("https://evil.example")).toBeNull(); + expect(lastPlaceToRemember("/a b")).toBeNull(); + expect(lastPlaceToRemember(`/${"a".repeat(200)}`)).toBeNull(); + expect(lastPlaceToRemember("")).toBeNull(); + expect(lastPlaceToRemember(null)).toBeNull(); + }); + + it("checks a stored value again on the way out", () => { + expect(readLastPlace("/teaching")).toBe("/teaching"); + expect(readLastPlace("javascript:alert(1)")).toBeNull(); + expect(readLastPlace(null)).toBeNull(); + }); +}); + +describe("isColdHomeOpen", () => { + it("is true only for a fresh open of a bare /", () => { + expect(isColdHomeOpen(cold)).toBe(true); + expect(isColdHomeOpen({ ...cold, navigationType: null })).toBe(true); + }); + + it("never moves a / that carries a query or a hash", () => { + expect(isColdHomeOpen({ ...cold, entry: { ...cold.entry, search: "?mode=dsm" } })).toBe(false); + expect(isColdHomeOpen({ ...cold, entry: { ...cold.entry, search: "?q=lithium" } })).toBe(false); + expect(isColdHomeOpen({ ...cold, entry: { ...cold.entry, search: "?focus=1" } })).toBe(false); + expect(isColdHomeOpen({ ...cold, entry: { ...cold.entry, hash: "#main" } })).toBe(false); + }); + + it("keeps today's behaviour for other pages, reloads, Back, in-app arrivals and a set landing view", () => { + expect(isColdHomeOpen({ ...cold, entry: { ...cold.entry, pathname: "/on-call" } })).toBe(false); + expect(isColdHomeOpen({ ...cold, navigationType: "reload" })).toBe(false); + expect(isColdHomeOpen({ ...cold, navigationType: "back_forward" })).toBe(false); + expect(isColdHomeOpen({ ...cold, cameFromApp: true })).toBe(false); + expect(isColdHomeOpen({ ...cold, landingPreferenceSet: true })).toBe(false); + }); +}); + +describe("coldLandingTarget", () => { + const all = () => true; + + it("opens My Day while a shift is running", () => { + expect(coldLandingTarget({ onShiftNow: true, lastPlace: "/teaching", routeVisible: all })).toBe( + ON_SHIFT_LANDING_PATH, + ); + }); + + it("otherwise opens the last page used", () => { + expect(coldLandingTarget({ onShiftNow: false, lastPlace: "/teaching", routeVisible: all })).toBe("/teaching"); + }); + + it("stays on Clinical when the last place was Clinical home or nothing is remembered", () => { + expect(coldLandingTarget({ onShiftNow: false, lastPlace: "/", routeVisible: all })).toBeNull(); + expect(coldLandingTarget({ onShiftNow: false, lastPlace: null, routeVisible: all })).toBeNull(); + }); + + it("skips a page this reader cannot open", () => { + const hidden = (href: string) => href !== "/teaching" && href !== ON_SHIFT_LANDING_PATH; + expect(coldLandingTarget({ onShiftNow: false, lastPlace: "/teaching", routeVisible: hidden })).toBeNull(); + expect(coldLandingTarget({ onShiftNow: true, lastPlace: "/on-call", routeVisible: hidden })).toBe("/on-call"); + }); +}); diff --git a/tests/work-rail-clinical.dom.test.tsx b/tests/work-rail-clinical.dom.test.tsx new file mode 100644 index 0000000000..85265fbb3b --- /dev/null +++ b/tests/work-rail-clinical.dom.test.tsx @@ -0,0 +1,34 @@ +/** @vitest-environment jsdom */ + +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next/navigation", () => ({ + usePathname: () => "/my-day", + useRouter: () => ({ replace: vi.fn(), push: vi.fn(), prefetch: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + +import { WorkRail } from "@/components/work-frame/work-rail"; +import { LAST_APP_MODE_STORAGE_KEY } from "@/components/clinical-dashboard/use-last-app-mode"; + +const identity = { signedIn: false, displayName: "", initials: "G" } as Parameters[0]["identity"]; + +describe("WorkRail Clinical button", () => { + afterEach(() => window.localStorage.clear()); + + it("goes back to Clinical Answer by default", () => { + render( {}} />); + const link = screen.getByTestId("work-rail-clinical"); + expect(link).toHaveAccessibleName("Clinical, Answer"); + expect(link.getAttribute("href")).toContain("mode=answer"); + }); + + it("goes back to the Clinical mode used last", () => { + window.localStorage.setItem(LAST_APP_MODE_STORAGE_KEY, "dsm"); + render( {}} />); + const link = screen.getByTestId("work-rail-clinical"); + expect(link.getAttribute("href")).toContain("mode=dsm"); + expect(link).toHaveTextContent("Clinical"); + }); +}); From 92bdd0e6bb0bec5f409fe0d96823ed229ee2f441 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:26:11 +0000 Subject: [PATCH 02/11] feat(keys): fill the keyboard shortcut gaps and add a shortcuts list On a computer (a mouse or trackpad screen), never while typing in a field and never over an open dialog: - "/" and Ctrl K (Command K on a Mac) now reach the page's own search box on the Psychiatry and Medicines homes, where the shared composer is not on screen. Elsewhere the composer's existing shortcut and Work's AI Search shortcut keep doing this; a key a page has handled is left alone. - "?" on a Clinical page shows a short "Keyboard shortcuts" list. Work pages keep their AI Search list. - The Work Help page gains an "On a computer" row that opens the list. Esc already closes every sheet and dialog (the shared Sheet owns it), so nothing changed there. The composer's position and behaviour are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- docs/design-system/COMPONENTS.md | 2 +- docs/design-system/adoption-manifest.json | 2 + src/app/(search-app)/layout.tsx | 2 + .../keyboard-shortcuts-sheet.tsx | 51 ++++++++ .../keyboard-shortcuts/keyboard-shortcuts.tsx | 81 ++++++++++++ src/components/work-help/work-help-page.tsx | 23 ++++ src/lib/keyboard-shortcuts.ts | 41 +++++++ tests/keyboard-shortcuts.dom.test.tsx | 115 ++++++++++++++++++ 8 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 src/components/keyboard-shortcuts/keyboard-shortcuts-sheet.tsx create mode 100644 src/components/keyboard-shortcuts/keyboard-shortcuts.tsx create mode 100644 src/lib/keyboard-shortcuts.ts create mode 100644 tests/keyboard-shortcuts.dom.test.tsx diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index ff20513ef4..e6644fd876 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -1094,7 +1094,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `SearchField` | controls | yes | yes | inherited-global-root | yes | no | 5 | | `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 33 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 16 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 136 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 137 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 8 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 4 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index ae498bc793..0c086542b5 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -2311,6 +2311,7 @@ "src/components/first-nations/where-is-home.tsx", "src/components/forms/form-priority-facts-section.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", + "src/components/keyboard-shortcuts/keyboard-shortcuts-sheet.tsx", "src/components/mode-kit/dial-sheet.tsx", "src/components/mode-nav/mode-nav.tsx", "src/components/my-day/my-day-calendar-page.tsx", @@ -2453,6 +2454,7 @@ "src/components/first-nations/where-is-home.tsx", "src/components/forms/form-priority-facts-section.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", + "src/components/keyboard-shortcuts/keyboard-shortcuts-sheet.tsx", "src/components/mode-kit/dial-sheet.tsx", "src/components/mode-nav/mode-nav.tsx", "src/components/my-day/my-day-calendar-page.tsx", diff --git a/src/app/(search-app)/layout.tsx b/src/app/(search-app)/layout.tsx index f69562587d..d431e2a5b5 100644 --- a/src/app/(search-app)/layout.tsx +++ b/src/app/(search-app)/layout.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { SharedSearchAppShell } from "@/components/clinical-dashboard/shared-search-app-shell"; +import { KeyboardShortcuts } from "@/components/keyboard-shortcuts/keyboard-shortcuts"; import { LastPlace } from "@/components/last-place/last-place"; import { PsychiatryVisitRecorder } from "@/components/psychiatry/psychiatry-visit-recorder"; import { WorkModeLaunchProvider } from "@/components/work-mode-launch/work-mode-launch-provider"; @@ -31,6 +32,7 @@ export default async function SearchAppLayout({ children }: { children: ReactNod {/* Outside the shell: on `/` the shell draws the dashboard in place of its children. */} + ); } diff --git a/src/components/keyboard-shortcuts/keyboard-shortcuts-sheet.tsx b/src/components/keyboard-shortcuts/keyboard-shortcuts-sheet.tsx new file mode 100644 index 0000000000..d0a12a0fe5 --- /dev/null +++ b/src/components/keyboard-shortcuts/keyboard-shortcuts-sheet.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Sheet } from "@/components/ui/sheet"; +import { shortcutRows } from "@/lib/keyboard-shortcuts"; + +function isApple(): boolean { + if (typeof navigator === "undefined") return false; + return /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent); +} + +const keyCap = + "inline-flex min-w-7 items-center justify-center rounded-md border border-[color:var(--border-strong)] bg-[color:var(--surface-raised)] px-1.5 font-sans text-xs font-semibold leading-6 text-[color:var(--text-heading)]"; + +/** + * The short "Keyboard shortcuts" list for the whole app, opened with "?" on a + * Clinical page or from the Help page. Work pages keep their AI Search list, + * which adds the keys that work inside AI Search. Loaded only when first asked for. + */ +export function KeyboardShortcutsSheet({ open, onClose }: { readonly open: boolean; readonly onClose: () => void }) { + return ( + +
    + {shortcutRows(isApple()).map((row) => ( +
    +
    {row.label}
    +
    + {row.keys.map((combo, index) => ( + + {index > 0 ? or : null} + {combo.map((key) => ( + + {key} + + ))} + + ))} +
    +
    + ))} +
    +
    + ); +} 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/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 + +
  • + window.dispatchEvent(new Event(OPEN_KEYBOARD_SHORTCUTS_EVENT))} + testId="work-help-keyboard-shortcuts" + /> +
  • +
    +
    + ); +} + function HelpHome() { const router = useRouter(); const searchParams = useSearchParams(); @@ -345,6 +367,7 @@ function HelpHome() { ) : ( <> + 0 ? "Your areas" : "Areas"} topics={yours} /> diff --git a/src/lib/keyboard-shortcuts.ts b/src/lib/keyboard-shortcuts.ts new file mode 100644 index 0000000000..b1f83ad058 --- /dev/null +++ b/src/lib/keyboard-shortcuts.ts @@ -0,0 +1,41 @@ +/** + * Keyboard shortcuts on a computer (site recommendations, 12 Oct 2026, item 5). + * + * Pure rules for which key does what, so they can be pinned by unit tests. + * Work pages already own "/", Ctrl K and "?" (AI Search, see + * `work-search-button.tsx`), and the Clinical composer already owns "/" and + * Ctrl K where it is on the page (`universal-search-command-surface.tsx`). The + * global listener only fills the gaps those leave. + */ + +/** Ask the global shortcuts list to open (the Help page's row dispatches it). */ +export const OPEN_KEYBOARD_SHORTCUTS_EVENT = "psychsift:open-keyboard-shortcuts"; + +export type ShortcutKey = Pick; + +export type ShortcutAction = "search" | "help" | null; + +/** What a key press asks for, before any page has had its say. */ +export function shortcutFor(event: ShortcutKey): ShortcutAction { + if (event.altKey || event.isComposing) return null; + if (event.key === "/" && !event.metaKey && !event.ctrlKey) return "search"; + if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey) && !event.shiftKey) return "search"; + if (event.key === "?" && !event.metaKey && !event.ctrlKey) return "help"; + return null; +} + +/** Typing in a field (or a rich-text box): the keys are the reader's letters there, not ours. */ +export function isTypingTarget(target: EventTarget | null): boolean { + if (typeof HTMLElement === "undefined" || !(target instanceof HTMLElement)) return false; + return target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName); +} + +/** The rows of the list, worded for the platform's command key. */ +export function shortcutRows(apple: boolean): readonly { readonly keys: string[][]; readonly label: string }[] { + const command = apple ? "⌘" : "Ctrl"; + return [ + { keys: [["/"], [command, "K"]], label: "Search: the search box in Clinical, AI Search in Work" }, + { keys: [["Esc"]], label: "Close a sheet or dialog" }, + { keys: [["?"]], label: "Show this list" }, + ]; +} diff --git a/tests/keyboard-shortcuts.dom.test.tsx b/tests/keyboard-shortcuts.dom.test.tsx new file mode 100644 index 0000000000..ff2b2a3c6b --- /dev/null +++ b/tests/keyboard-shortcuts.dom.test.tsx @@ -0,0 +1,115 @@ +/** @vitest-environment jsdom */ + +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { KeyboardShortcuts } from "@/components/keyboard-shortcuts/keyboard-shortcuts"; +import { OPEN_KEYBOARD_SHORTCUTS_EVENT, isTypingTarget, shortcutFor, shortcutRows } from "@/lib/keyboard-shortcuts"; + +function key(init: Partial & { key: string }) { + return { metaKey: false, ctrlKey: false, altKey: false, shiftKey: false, isComposing: false, ...init }; +} + +describe("shortcutFor", () => { + it("reads / and Ctrl or Command K as search, ? as the list", () => { + expect(shortcutFor(key({ key: "/" }))).toBe("search"); + expect(shortcutFor(key({ key: "k", ctrlKey: true }))).toBe("search"); + expect(shortcutFor(key({ key: "K", metaKey: true }))).toBe("search"); + expect(shortcutFor(key({ key: "?", shiftKey: true }))).toBe("help"); + }); + + it("ignores other keys and chords", () => { + expect(shortcutFor(key({ key: "k" }))).toBeNull(); + expect(shortcutFor(key({ key: "/", ctrlKey: true }))).toBeNull(); + expect(shortcutFor(key({ key: "/", altKey: true }))).toBeNull(); + expect(shortcutFor(key({ key: "k", ctrlKey: true, shiftKey: true }))).toBeNull(); + expect(shortcutFor(key({ key: "/", isComposing: true }))).toBeNull(); + }); + + it("treats fields and rich-text boxes as typing", () => { + expect(isTypingTarget(document.createElement("input"))).toBe(true); + expect(isTypingTarget(document.createElement("textarea"))).toBe(true); + expect(isTypingTarget(document.createElement("button"))).toBe(false); + expect(isTypingTarget(null)).toBe(false); + }); + + it("names the platform's command key", () => { + expect(shortcutRows(true)[0].keys[1]).toEqual(["⌘", "K"]); + expect(shortcutRows(false)[0].keys[1]).toEqual(["Ctrl", "K"]); + }); +}); + +describe("KeyboardShortcuts", () => { + let fine = true; + beforeEach(() => { + fine = true; + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: fine)" ? fine : false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })) as unknown as typeof window.matchMedia; + document.body.innerHTML = ""; + }); + afterEach(() => vi.restoreAllMocks()); + + function mountSearchPage() { + const main = document.createElement("div"); + main.id = "main-content"; + const box = document.createElement("input"); + box.type = "search"; + // jsdom has no layout: report the box as on screen. + box.getClientRects = () => [{}] as unknown as DOMRectList; + main.append(box); + document.body.append(main); + return box; + } + + it("puts the cursor in a page's own search box on /", () => { + const box = mountSearchPage(); + render(); + const event = new KeyboardEvent("keydown", { key: "/", cancelable: true }); + act(() => { + window.dispatchEvent(event); + }); + expect(document.activeElement).toBe(box); + expect(event.defaultPrevented).toBe(true); + }); + + it("leaves a key a page owner already handled", () => { + const box = mountSearchPage(); + render(); + const event = new KeyboardEvent("keydown", { key: "/", cancelable: true }); + event.preventDefault(); + act(() => { + window.dispatchEvent(event); + }); + expect(document.activeElement).not.toBe(box); + }); + + it("does nothing on a touch-only screen", () => { + fine = false; + const box = mountSearchPage(); + render(); + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "/", cancelable: true })); + }); + expect(document.activeElement).not.toBe(box); + }); + + it("opens the list on ?", async () => { + render(); + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "?", shiftKey: true, cancelable: true })); + }); + expect(await screen.findByRole("dialog", { name: "Keyboard shortcuts" })).toBeInTheDocument(); + }); + + it("opens the list when the Help page asks", async () => { + render(); + act(() => { + window.dispatchEvent(new Event(OPEN_KEYBOARD_SHORTCUTS_EVENT)); + }); + expect(await screen.findByRole("dialog", { name: "Keyboard shortcuts" })).toBeInTheDocument(); + }); +}); From ba3ae328cc511b894bd445353fd2be5f4c4d4dbb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:35:25 +0000 Subject: [PATCH 03/11] feat(motion): quiet, consistent tab fades and row press release One small motion set in src/app/motion.css, on the design system's motion tokens, all off under prefers-reduced-motion and the in-app "reduced" Motion setting (the in-app "full" opts back in): - Tab content fades in over --duration-quick (150 ms) when a role="tab" control switches, and when moving between two Work pages (Work's tab row is pages). The Work band (title and tabs) stays put; only opacity moves, so nothing shifts position. Clinical routes and anything holding the search composer are never faded. - A tapped Work list row lights at once and lets go over --duration-deliberate, so the highlight is still visible as the next page opens. Bottom sheets already rise with a soft settle (the shared Sheet's sheet-up), so nothing is added there. The number count-up was skipped. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- src/app/(search-app)/layout.tsx | 4 + src/app/motion.css | 60 +++++++++++++ src/components/motion/motion-enter.tsx | 118 +++++++++++++++++++++++++ tests/motion-enter.dom.test.tsx | 93 +++++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 src/app/motion.css create mode 100644 src/components/motion/motion-enter.tsx create mode 100644 tests/motion-enter.dom.test.tsx diff --git a/src/app/(search-app)/layout.tsx b/src/app/(search-app)/layout.tsx index d431e2a5b5..6c51dac247 100644 --- a/src/app/(search-app)/layout.tsx +++ b/src/app/(search-app)/layout.tsx @@ -1,8 +1,11 @@ +import "@/app/motion.css"; + import type { ReactNode } from "react"; import { SharedSearchAppShell } from "@/components/clinical-dashboard/shared-search-app-shell"; import { KeyboardShortcuts } from "@/components/keyboard-shortcuts/keyboard-shortcuts"; import { LastPlace } from "@/components/last-place/last-place"; +import { MotionEnter } from "@/components/motion/motion-enter"; import { PsychiatryVisitRecorder } from "@/components/psychiatry/psychiatry-visit-recorder"; import { WorkModeLaunchProvider } from "@/components/work-mode-launch/work-mode-launch-provider"; import { clinicalAskModeEnabled } from "@/lib/clinical-ask/authority-registry"; @@ -33,6 +36,7 @@ export default async function SearchAppLayout({ children }: { children: ReactNod {/* Outside the shell: on `/` the shell draws the dashboard in place of its children. */} + ); } diff --git a/src/app/motion.css b/src/app/motion.css new file mode 100644 index 0000000000..57a5d77d75 --- /dev/null +++ b/src/app/motion.css @@ -0,0 +1,60 @@ +/* + * Quiet, consistent motion (site recommendations, 12 Oct 2026, item 9). + * + * One small set, on the design system's motion tokens: + * - Tab content cross-fades in (--duration-quick, 150 ms) when a tab changes: + * a role="tab" switch, or a move between two Work pages (Work's tabs are + * pages). `src/components/motion/motion-enter.tsx` marks the new content with + * `data-motion-enter`; only opacity moves, so nothing shifts position and no + * layout or composer timing changes. + * - A tapped list row lights at once and lets go slowly (--duration-deliberate), + * so the highlight is still visible as the next page opens. + * - Bottom sheets already rise with a soft settle: the shared Sheet's + * `sheet-up` (250 ms on --ease-standard's curve). Nothing is added here. + * + * Everything is off under prefers-reduced-motion, and under the in-app Motion + * setting's "reduced"; the in-app "full" opts back in, matching the + * motion-safe variant in globals.css. The search composer is never targeted. + */ + +@keyframes motion-enter-fade { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion: no-preference) { + html:not([data-motion="reduced"]) [data-motion-enter] { + animation: motion-enter-fade var(--duration-quick) var(--ease-standard) both; + } + + html:not([data-motion="reduced"]) a.work-row, + html:not([data-motion="reduced"]) button.work-row { + transition: background-color var(--duration-deliberate) var(--ease-standard); + } + + html:not([data-motion="reduced"]) a.work-row:active, + html:not([data-motion="reduced"]) button.work-row:active { + transition: none; + } +} + +@media (prefers-reduced-motion: reduce) { + html[data-motion="full"] [data-motion-enter] { + animation: motion-enter-fade var(--duration-quick) var(--ease-standard) both; + } + + html[data-motion="full"] a.work-row, + html[data-motion="full"] button.work-row { + transition: background-color var(--duration-deliberate) var(--ease-standard); + } + + html[data-motion="full"] a.work-row:active, + html[data-motion="full"] button.work-row:active { + transition: none; + } +} 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/tests/motion-enter.dom.test.tsx b/tests/motion-enter.dom.test.tsx new file mode 100644 index 0000000000..47f466560c --- /dev/null +++ b/tests/motion-enter.dom.test.tsx @@ -0,0 +1,93 @@ +/** @vitest-environment jsdom */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { act, render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +let pathname = "/my-day"; +vi.mock("next/navigation", () => ({ usePathname: () => pathname })); + +import { MotionEnter, markMotionEnter } from "@/components/motion/motion-enter"; + +// Rules only: the file's notes may name what it leaves alone. +const css = readFileSync(join(process.cwd(), "src/app/motion.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); + +function withAnimation(name: string) { + const real = window.getComputedStyle; + return vi.spyOn(window, "getComputedStyle").mockImplementation((element: Element) => { + const style = real(element); + return new Proxy(style, { + get: (target, key) => (key === "animationName" ? name : Reflect.get(target, key)), + }); + }); +} + +describe("motion.css", () => { + it("only animates when motion is allowed", () => { + // Every rule sits inside a motion media query: nothing runs under reduced motion by default. + const outside = css + .replace(/@media[^{]+\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, "") + .replace(/@keyframes[^{]+\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, ""); + expect(outside).not.toMatch(/animation:|transition:/); + expect(css).toContain("@media (prefers-reduced-motion: no-preference)"); + expect(css).toContain('html:not([data-motion="reduced"])'); + }); + + it("uses the design system's motion tokens and never names the composer", () => { + expect(css).toMatch(/var\(--duration-quick\)/); + expect(css).not.toMatch(/\d+ms/); + expect(css).not.toMatch(/composer|global-search/); + }); +}); + +describe("markMotionEnter", () => { + afterEach(() => vi.restoreAllMocks()); + + it("marks an element and clears the mark when the fade ends", () => { + withAnimation("motion-enter-fade"); + const element = document.createElement("div"); + markMotionEnter([element]); + expect(element.hasAttribute("data-motion-enter")).toBe(true); + element.dispatchEvent(new Event("animationend")); + expect(element.hasAttribute("data-motion-enter")).toBe(false); + }); + + it("leaves nothing behind under reduced motion", () => { + withAnimation("none"); + const element = document.createElement("div"); + markMotionEnter([element]); + expect(element.hasAttribute("data-motion-enter")).toBe(false); + }); +}); + +describe("MotionEnter", () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("fades a Work page's content, not its band, on a move between Work pages", () => { + withAnimation("motion-enter-fade"); + document.body.innerHTML = + '
    '; + pathname = "/my-day"; + const view = render(); + pathname = "/my-day/week"; + act(() => view.rerender()); + expect(document.getElementById("page")?.hasAttribute("data-motion-enter")).toBe(true); + expect(document.querySelector(".work-band")?.hasAttribute("data-motion-enter")).toBe(false); + }); + + it("leaves Clinical pages alone", () => { + withAnimation("motion-enter-fade"); + document.body.innerHTML = + '
    '; + pathname = "/"; + const view = render(); + pathname = "/dsm"; + act(() => view.rerender()); + expect(document.getElementById("page")?.hasAttribute("data-motion-enter")).toBe(false); + }); +}); From 7bf92eccebe2bbb93fd6961e4e748642058a1144 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:44:45 +0000 Subject: [PATCH 04/11] feat(offline): one "Offline, showing what was saved at HH:MM" line The app-wide offline notice now reads "Offline, showing what was saved at 19:02": the work-clock (Perth by default) time the page on screen was last opened online, which can understate how fresh the page is but never overstates it. A copy from an earlier day names the day; an unknown time says "saved earlier". It is the same card in Work and Clinical: Work's squeezed one-row variant broke once the line named a time, so it now draws the Clinical card (keeping Work's glass ground). The "needs a connection" sentence stays for screen readers. On Call's saved-copy banner uses the same wording (a server that could not be reached reads "Couldn't refresh, showing what was saved at ...", since that is not lost signal), with the same offline icon. public/sw.js is untouched, and no new offline caching was added. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- .../on-call/on-call-offline-banner.tsx | 17 ++++----- src/components/pwa-lifecycle.tsx | 21 +++++++++-- src/components/pwa-offline-line.css | 37 +++++++++++++++++++ src/lib/offline-line.ts | 37 +++++++++++++++++++ tests/offline-line.test.ts | 32 ++++++++++++++++ tests/on-call-compliance.test.ts | 2 +- tests/on-call-contacts.dom.test.tsx | 8 ++-- tests/pwa-lifecycle.dom.test.tsx | 29 ++++++++++----- 8 files changed, 157 insertions(+), 26 deletions(-) create mode 100644 src/components/pwa-offline-line.css create mode 100644 src/lib/offline-line.ts create mode 100644 tests/offline-line.test.ts 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..424458d50d 100644 --- a/src/components/pwa-lifecycle.tsx +++ b/src/components/pwa-lifecycle.tsx @@ -13,10 +13,14 @@ import { Zap, type LucideIcon, } from "lucide-react"; +import { usePathname } from "next/navigation"; import { 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 { savedLine } from "@/lib/offline-line"; const INSTALL_DISMISSAL_KEY = "clinical-kb-pwa-install-dismissed-at"; const IOS_INSTALL_DISMISSAL_KEY = "clinical-kb-pwa-ios-install-dismissed-at"; @@ -316,6 +320,12 @@ export function PwaLifecycle() { 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(); + const shownOnlineAtRef = useRef(null); + const [offlineSavedAt, setOfflineSavedAt] = useState(null); const [showIosHint, setShowIosHint] = useState(false); const [waitingWorker, setWaitingWorker] = useState(null); const [activatedUpdateReady, setActivatedUpdateReady] = useState(false); @@ -335,6 +345,7 @@ export function PwaLifecycle() { const handleOffline = () => { if (restoredTimer) window.clearTimeout(restoredTimer); setConnectionRestored(false); + setOfflineSavedAt(shownOnlineAtRef.current); }; const handleOnline = () => { if (restoredTimer) window.clearTimeout(restoredTimer); @@ -354,6 +365,10 @@ export function PwaLifecycle() { }; }, []); + useEffect(() => { + if (navigator.onLine) shownOnlineAtRef.current = Date.now(); + }, [pathname]); + useEffect(() => { const displayMode = window.matchMedia("(display-mode: standalone)"); const syncDisplayMode = () => { @@ -571,11 +586,9 @@ export function PwaLifecycle() {

    - You’re offline -

    -

    - Clinical search and private features need a connection. + {savedLine({ reason: "offline", savedAt: offlineSavedAt })}

    +

    Clinical search and private features need a connection.