From 44841c65fcd2c2f239a8ee97a3514c80c792017c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 16:03:54 +0200 Subject: [PATCH] feat(shell): add global keyboard shortcuts and their help dialog g then d/m/l/t/i/c/s opens a page through the same visible-destination lists the navigation renders, so a switched-off module, a Coach without a provider or a door a shared record does not cover stays shut. n opens the capture picker, [ and ] step the open day, ? lists everything. Keys typed into a field, held with Cmd/Ctrl/Alt, or pressed while a dialog or menu is open are left alone; [ and ] still act over the day sheet and when the layout needs Alt or AltGr to type them. The list is also reachable from the sidebar account menu. --- CHANGELOG.md | 3 + e2e/keyboard-shortcuts.spec.ts | 214 +++++++++++++ messages/de.json | 16 + messages/en.json | 16 + messages/es.json | 16 + messages/fr.json | 16 + messages/it.json | 16 + messages/ko.json | 16 + messages/pl.json | 16 + .../keyboard-shortcuts-dialog.test.tsx | 114 +++++++ src/components/layout/auth-shell.tsx | 5 + src/components/layout/global-shortcuts.tsx | 185 ++++++++++++ .../layout/keyboard-shortcuts-dialog.tsx | 158 ++++++++++ src/components/layout/sidebar-nav.tsx | 16 + .../__tests__/global-shortcuts.test.ts | 285 ++++++++++++++++++ src/lib/keyboard/global-shortcuts.ts | 239 +++++++++++++++ 16 files changed, 1331 insertions(+) create mode 100644 e2e/keyboard-shortcuts.spec.ts create mode 100644 src/components/layout/__tests__/keyboard-shortcuts-dialog.test.tsx create mode 100644 src/components/layout/global-shortcuts.tsx create mode 100644 src/components/layout/keyboard-shortcuts-dialog.tsx create mode 100644 src/lib/keyboard/__tests__/global-shortcuts.test.ts create mode 100644 src/lib/keyboard/global-shortcuts.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f942fb915..c62bae56b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,9 @@ an earlier fold computed from part of a day. `REINDEX INDEX CONCURRENTLY` per index, largest first. - **Query statistics.** The bundled database loads `pg_stat_statements`; `CREATE EXTENSION` once to read it (`docs/ops/query-statistics.md`). +- **Keyboard shortcuts.** `g` then `d`, `m`, `l`, `t`, `i`, `c` or `s` opens + a page, `n` the add menu, `[` and `]` step the open day, and `?` (or the + account menu) lists them; ignored while typing in a field. ### Changed diff --git a/e2e/keyboard-shortcuts.spec.ts b/e2e/keyboard-shortcuts.spec.ts new file mode 100644 index 000000000..b58b4823e --- /dev/null +++ b/e2e/keyboard-shortcuts.spec.ts @@ -0,0 +1,214 @@ +import AxeBuilder from "@axe-core/playwright"; +import type { Page } from "@playwright/test"; + +import { mockDay } from "./setup/day-mock"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expect, test } from "./setup/test"; +import { mockTimeline } from "./utils/mock-timeline"; + +/** + * v1.42 — the keyboard shortcuts. + * + * - `g t` opens the timeline while its module is on, and does nothing while + * it is off; + * - keys typed into a field stay in the field; + * - `?` opens the list: a titled dialog that keeps focus inside and closes + * on Escape; the account menu opens the same list; + * - `n` opens the add menu; + * - `[` and `]` step the open day, never past today. + * + * The module map is the account's real `/api/auth/me` with only `timeline` + * rewritten, so the switch is never moved on a shared account. Desktop only: + * a phone has no keyboard to press these on. + */ + +test.use({ storageState: STORAGE_STATE_PATH }); + +const TODAY = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", +}).format(new Date()); + +function shiftDay(key: string, delta: number): string { + const d = new Date(`${key}T12:00:00Z`); + d.setUTCDate(d.getUTCDate() + delta); + return d.toISOString().slice(0, 10); +} + +async function withTimelineModule(page: Page, on: boolean): Promise { + await page.route("**/api/auth/me", async (route) => { + const res = await route.fetch(); + const json = await res.json().catch(() => null); + if (!json?.data?.modules) return route.fulfill({ response: res }); + json.data.modules.timeline = on; + return route.fulfill({ + response: res, + body: JSON.stringify(json), + headers: { ...res.headers(), "content-type": "application/json" }, + }); + }); +} + +/** The shell is up and its listener registered. */ +async function shellReady(page: Page): Promise { + await expect(page.locator("#main-content")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator('[data-slot="top-bar"]')).toBeVisible(); +} + +const helpDialog = (page: Page) => + page.getByRole("dialog", { name: "Keyboard shortcuts" }); + +test.describe("keyboard shortcuts", () => { + test.beforeEach(async ({ context }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "keyboard journeys run on the desktop project", + ); + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "en", url: baseURL }, + ]); + }); + + test("g t opens the timeline while its module is on", async ({ page }) => { + await withTimelineModule(page, true); + await mockTimeline(page, "ready", TODAY); + await page.goto("/"); + await shellReady(page); + await page.keyboard.press("g"); + await page.keyboard.press("t"); + await expect(page).toHaveURL(/\/timeline$/); + }); + + test("g t does nothing while the timeline module is off", async ({ + page, + }) => { + await withTimelineModule(page, false); + // Every client navigation, in order, so "nothing happened" is a fact + // rather than a wait. + await page.addInitScript(() => { + const log: string[] = []; + (window as unknown as { __pushed: string[] }).__pushed = log; + const push = history.pushState.bind(history); + history.pushState = (state, unused, url) => { + if (url != null) log.push(String(url)); + push(state, unused, url); + }; + }); + await page.goto("/insights"); + await shellReady(page); + await page.keyboard.press("g"); + await page.keyboard.press("t"); + // The positive control: the same reader opens a page that is on. + await page.keyboard.press("g"); + await page.keyboard.press("d"); + await expect(page).toHaveURL(/\/$/); + const pushed = await page.evaluate( + () => (window as unknown as { __pushed: string[] }).__pushed, + ); + expect(pushed.some((url) => url.includes("/timeline"))).toBe(false); + }); + + test("keys typed into a field stay in the field", async ({ page }) => { + await withTimelineModule(page, true); + await page.goto("/"); + await shellReady(page); + // A plain field in the page: the rule is about the focused element, not + // about which page carries it. + await page.evaluate(() => { + const field = document.createElement("input"); + field.setAttribute("data-testid", "probe-field"); + field.setAttribute("aria-label", "Probe"); + document.getElementById("main-content")?.prepend(field); + }); + const field = page.getByTestId("probe-field"); + await field.focus(); + await page.keyboard.type("gt n ? ["); + await expect(field).toHaveValue("gt n ? ["); + await expect(page).toHaveURL(/\/$/); + await expect(helpDialog(page)).toHaveCount(0); + await expect(page.getByTestId("capture-picker-options")).toHaveCount(0); + }); + + test("? opens a titled list that keeps focus and closes on Escape", async ({ + page, + }) => { + await page.goto("/"); + await shellReady(page); + await page.keyboard.press("Shift+?"); + const dialog = helpDialog(page); + await expect(dialog).toBeVisible(); + await expect( + dialog.locator('[data-slot="keyboard-shortcuts"] [data-group]'), + ).toHaveCount(3); + await expect(dialog.locator('[data-shortcut="go-d"] kbd')).toHaveText([ + "g", + "d", + ]); + + // Focus trap: Tab and Shift+Tab never leave the dialog. + for (const step of ["Tab", "Tab", "Tab", "Shift+Tab", "Shift+Tab"]) { + await page.keyboard.press(step); + expect( + await dialog.evaluate((el) => el.contains(document.activeElement)), + ).toBe(true); + } + + // While the list is open, the page's shortcuts are off. + await page.keyboard.press("g"); + await page.keyboard.press("i"); + await expect(page).toHaveURL(/\/$/); + + const axe = await new AxeBuilder({ page }) + .include('[role="dialog"]') + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(axe.violations).toEqual([]); + + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + }); + + test("the account menu opens the same list", async ({ page }) => { + await page.goto("/"); + await shellReady(page); + await page + .getByRole("button", { name: "User menu" }) + .locator("visible=true") + .first() + .click(); + await page.locator('[data-slot="open-keyboard-shortcuts"]').click(); + await expect(helpDialog(page)).toBeVisible(); + }); + + test("n opens the add menu", async ({ page }) => { + await page.goto("/insights"); + await shellReady(page); + await page.keyboard.press("n"); + await expect(page.getByTestId("capture-picker-options")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("capture-picker-options")).toHaveCount(0); + }); + + test("[ and ] step the open day, never past today", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockDay(page); + const yesterday = shiftDay(TODAY, -1); + await page.goto(`/insights?day=${yesterday}`); + await shellReady(page); + const panel = page.locator('[data-slot="day-panel"]'); + await expect(panel).toHaveAttribute("data-shell", "docked"); + // Focus on the page, not in the panel: docked, the keys still reach it. + await page.locator("#main-content").focus(); + + await page.keyboard.press("["); + await expect(page).toHaveURL(new RegExp(`[?&]day=${shiftDay(TODAY, -2)}`)); + await page.keyboard.press("]"); + await page.keyboard.press("]"); + await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`)); + await page.keyboard.press("]"); + await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`)); + }); +}); diff --git a/messages/de.json b/messages/de.json index bebe091e0..463244410 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12554,6 +12554,22 @@ "body": "Titel und Notiz sind danach nicht mehr da." } }, + "shortcuts": { + "title": "Tastenkürzel", + "description": "Tasten, die überall in der App funktionieren, außer während du in ein Feld tippst.", + "menuEntry": "Tastenkürzel", + "then": "dann", + "help": "Diese Liste zeigen", + "close": "Dialog oder Bereich schließen", + "previousDay": "Vorheriger Tag", + "nextDay": "Nächster Tag", + "dayHint": "Während ein Tag geöffnet ist.", + "groups": { + "navigation": "Navigation", + "actions": "Aktionen", + "day": "Tag" + } + }, "aboutPage": { "eyebrow": "Info", "title": "Über HealthLog", diff --git a/messages/en.json b/messages/en.json index a966393a1..da9d0a874 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12554,6 +12554,22 @@ "body": "Its title and note will be gone." } }, + "shortcuts": { + "title": "Keyboard shortcuts", + "description": "Keys that work anywhere in the app, except while you type in a field.", + "menuEntry": "Keyboard shortcuts", + "then": "then", + "help": "Show this list", + "close": "Close a dialog or panel", + "previousDay": "Previous day", + "nextDay": "Next day", + "dayHint": "While a day is open.", + "groups": { + "navigation": "Navigation", + "actions": "Actions", + "day": "Day" + } + }, "aboutPage": { "eyebrow": "About", "title": "About HealthLog", diff --git a/messages/es.json b/messages/es.json index 4b5b3de6c..b87def957 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12554,6 +12554,22 @@ "body": "Su título y su nota desaparecerán." } }, + "shortcuts": { + "title": "Atajos de teclado", + "description": "Teclas que funcionan en toda la aplicación, salvo mientras escribes en un campo.", + "menuEntry": "Atajos de teclado", + "then": "luego", + "help": "Mostrar esta lista", + "close": "Cerrar un diálogo o panel", + "previousDay": "Día anterior", + "nextDay": "Día siguiente", + "dayHint": "Mientras hay un día abierto.", + "groups": { + "navigation": "Navegación", + "actions": "Acciones", + "day": "Día" + } + }, "aboutPage": { "eyebrow": "Información", "title": "Acerca de HealthLog", diff --git a/messages/fr.json b/messages/fr.json index a876ff161..29cc36106 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12554,6 +12554,22 @@ "body": "Son titre et sa note disparaîtront." } }, + "shortcuts": { + "title": "Raccourcis clavier", + "description": "Des touches qui fonctionnent partout dans l’application, sauf pendant la saisie dans un champ.", + "menuEntry": "Raccourcis clavier", + "then": "puis", + "help": "Afficher cette liste", + "close": "Fermer une boîte de dialogue ou un panneau", + "previousDay": "Jour précédent", + "nextDay": "Jour suivant", + "dayHint": "Lorsqu’un jour est ouvert.", + "groups": { + "navigation": "Navigation", + "actions": "Actions", + "day": "Jour" + } + }, "aboutPage": { "eyebrow": "À propos", "title": "À propos de HealthLog", diff --git a/messages/it.json b/messages/it.json index dfbd9bd6a..41809edae 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12554,6 +12554,22 @@ "body": "Titolo e nota non ci saranno più." } }, + "shortcuts": { + "title": "Scorciatoie da tastiera", + "description": "Tasti che funzionano ovunque nell’app, tranne mentre scrivi in un campo.", + "menuEntry": "Scorciatoie da tastiera", + "then": "poi", + "help": "Mostra questo elenco", + "close": "Chiudi una finestra o un pannello", + "previousDay": "Giorno precedente", + "nextDay": "Giorno successivo", + "dayHint": "Quando un giorno è aperto.", + "groups": { + "navigation": "Navigazione", + "actions": "Azioni", + "day": "Giorno" + } + }, "aboutPage": { "eyebrow": "Informazioni", "title": "Informazioni su HealthLog", diff --git a/messages/ko.json b/messages/ko.json index 3b0fa4cff..b75ea597c 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12554,6 +12554,22 @@ "body": "제목과 메모가 사라져요." } }, + "shortcuts": { + "title": "키보드 단축키", + "description": "입력란에 입력하는 중이 아니면 앱 어디에서나 작동하는 키입니다.", + "menuEntry": "키보드 단축키", + "then": "다음", + "help": "이 목록 보기", + "close": "대화 상자나 패널 닫기", + "previousDay": "이전 날", + "nextDay": "다음 날", + "dayHint": "하루가 열려 있을 때.", + "groups": { + "navigation": "탐색", + "actions": "작업", + "day": "하루" + } + }, "aboutPage": { "eyebrow": "정보", "title": "HealthLog 정보", diff --git a/messages/pl.json b/messages/pl.json index 71b857b83..97b87bad8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12554,6 +12554,22 @@ "body": "Tytuł i notatka znikną." } }, + "shortcuts": { + "title": "Skróty klawiszowe", + "description": "Klawisze działające w całej aplikacji, z wyjątkiem pisania w polu.", + "menuEntry": "Skróty klawiszowe", + "then": "potem", + "help": "Pokaż tę listę", + "close": "Zamknij okno lub panel", + "previousDay": "Poprzedni dzień", + "nextDay": "Następny dzień", + "dayHint": "Gdy otwarty jest dzień.", + "groups": { + "navigation": "Nawigacja", + "actions": "Akcje", + "day": "Dzień" + } + }, "aboutPage": { "eyebrow": "Informacje", "title": "O HealthLog", diff --git a/src/components/layout/__tests__/keyboard-shortcuts-dialog.test.tsx b/src/components/layout/__tests__/keyboard-shortcuts-dialog.test.tsx new file mode 100644 index 000000000..002edbe8d --- /dev/null +++ b/src/components/layout/__tests__/keyboard-shortcuts-dialog.test.tsx @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +// The sheet primitive portals through Radix, which renders nothing on the +// server; this stand-in keeps its contract (title + description name the +// surface) so the list itself can be read. Focus trap and Escape are the +// primitive's and are proven in `e2e/keyboard-shortcuts.spec.ts`. +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: ({ + open, + title, + description, + children, + }: { + open: boolean; + title: React.ReactNode; + description?: React.ReactNode; + children: React.ReactNode; + }) => + open ? ( +
+

{title}

+

{description}

+ {children} +
+ ) : null, +})); + +import { KeyboardShortcutsDialog } from "../keyboard-shortcuts-dialog"; + +const EVERYTHING = { + navHrefs: ["/", "/medications", "/labs", "/timeline", "/insights", "/coach"], + settingsHref: "/settings/account", +}; + +function render( + props: Partial> = {}, + locale = "en", +) { + return renderToStaticMarkup( + + undefined} + offer={EVERYTHING} + canCapture + {...props} + /> + , + ); +} + +describe("", () => { + it("renders nothing while closed", () => { + expect(render({ open: false })).toBe(""); + }); + + it("is titled and lists three groups", () => { + const html = render(); + expect(html).toContain('aria-label="Keyboard shortcuts"'); + expect(html).toContain('data-group="navigation"'); + expect(html).toContain('data-group="actions"'); + expect(html).toContain('data-group="day"'); + }); + + it("shows every destination as g then its key, in kbd", () => { + const html = render(); + for (const k of ["d", "m", "l", "t", "i", "c", "s"]) { + expect(html).toContain(`data-shortcut="go-${k}"`); + } + expect(html).toMatch(/]*>g<\/kbd>/); + expect(html).toContain(">then<"); + expect(html).toMatch(/]*>\?<\/kbd>/); + expect(html).toMatch(/]*>\[<\/kbd>/); + expect(html).toMatch(/]*>\]<\/kbd>/); + expect(html).toMatch(/]*>Esc<\/kbd>/); + }); + + it("drops a destination the session does not offer", () => { + const html = render({ + offer: { navHrefs: ["/", "/insights"], settingsHref: null }, + }); + expect(html).toContain('data-shortcut="go-d"'); + expect(html).toContain('data-shortcut="go-i"'); + for (const k of ["m", "l", "t", "c", "s"]) { + expect(html).not.toContain(`data-shortcut="go-${k}"`); + } + }); + + it("drops n where there is nothing to log", () => { + expect(render({ canCapture: false })).not.toContain( + 'data-shortcut="capture"', + ); + expect(render()).toContain('data-shortcut="capture"'); + }); + + it("uses token classes for the keys, no raw colour", () => { + const kbd = render().match(/ { + const html = render({}, locale); + expect(html).not.toContain('aria-label="Keyboard shortcuts"'); + expect(html).not.toContain("shortcuts."); + }, + ); +}); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 913e34cc2..dec92bdc5 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -24,6 +24,7 @@ import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; import { isManageDelegateSettingsDestination } from "@/lib/record-settings/classification"; import { isDestinationInSharedRecord } from "./nav-model"; import { BottomNav } from "./bottom-nav"; +import { GlobalShortcuts } from "./global-shortcuts"; import { DemoBanner } from "./demo-banner"; import { OfflineBanner } from "./offline-banner"; import { SharedRecordBanner } from "./shared-record-banner"; @@ -579,6 +580,10 @@ export function AuthShell({ got a raw 403. Demo is read-only by design; the FAB has no job here, so it's mounted only outside demo mode. */} {!demoMode && !inSharedRecord && } + {/* v1.42 — the keyboard shortcuts (`g d`, `n`, `?` …) and their list. + Mounted here and only here, so they never run on the sign-in pages + or in the setup flow, which return above. */} + ); } diff --git a/src/components/layout/global-shortcuts.tsx b/src/components/layout/global-shortcuts.tsx new file mode 100644 index 000000000..42705ef5f --- /dev/null +++ b/src/components/layout/global-shortcuts.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; + +import { stepDay, useOpenDay } from "@/components/day/day-layer-controller"; +import { useTodayKey } from "@/components/day/use-today-key"; +import { + CAPTURE_KIND_ORDER, + CapturePicker, + visibleCaptureKinds, +} from "@/components/layout/capture-picker"; +import { KeyboardShortcutsDialog } from "@/components/layout/keyboard-shortcuts-dialog"; +import { + isSettingsUtilityDestination, + visibleNavDestinations, + visibleUtilityDestinations, +} from "@/components/layout/nav-model"; +import { useAuth } from "@/hooks/use-auth"; +import { useNavModules } from "@/hooks/use-nav-modules"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { + createShortcutReader, + resolveGoTo, + shortcutScope, + type ShortcutOffer, +} from "@/lib/keyboard/global-shortcuts"; + +/** + * The app-wide keyboard shortcuts, mounted once by the authenticated shell + * (never on the sign-in pages or in the setup flow, which the shell renders + * without it). + * + * `g` + a key opens a page, `n` the add menu, `[` / `]` step the open day, + * `?` the list of all of them. Which key does what, and when a key is not + * ours, is decided in `src/lib/keyboard/global-shortcuts.ts`; this component + * reads the page's state and carries the action out. A destination comes from + * the same lists the navigation renders, so `g t` with the timeline module + * off, or `g c` without an AI provider, does nothing at all. + * + * The account menu opens the list too (`openShortcutsHelp`), for anyone who + * does not know there is a `?` to press. + */ + +let helpOpen = false; +const helpListeners = new Set<() => void>(); + +function setShortcutsHelpOpen(open: boolean): void { + if (helpOpen === open) return; + helpOpen = open; + for (const listener of helpListeners) listener(); +} + +function subscribeHelp(listener: () => void): () => void { + helpListeners.add(listener); + return () => helpListeners.delete(listener); +} + +/** Open the shortcut list from anywhere inside the signed-in shell. */ +export function openShortcutsHelp(): void { + setShortcutsHelpOpen(true); +} + +function useShortcutOffer(): ShortcutOffer & { canCapture: boolean } { + const { user } = useAuth(); + const navModules = useNavModules(); + const capabilities = useRecordCapabilities(); + const { inSharedRecord, sections, recordKind, level } = capabilities; + const manageableDomains = user?.accountAccess?.active?.manageableDomains; + const canCapture = + visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER, user?.modules) + .length > 0; + const navHrefs = useMemo( + () => + visibleNavDestinations(navModules, true, inSharedRecord, sections).map( + (d) => d.href, + ), + [navModules, inSharedRecord, sections], + ); + const settingsHref = useMemo( + () => + visibleUtilityDestinations({ + record: inSharedRecord + ? { recordKind, level, manageableDomains: manageableDomains ?? [] } + : null, + }).find(isSettingsUtilityDestination)?.href ?? null, + [inSharedRecord, recordKind, level, manageableDomains], + ); + return { navHrefs, settingsHref, canCapture }; +} + +export function GlobalShortcuts() { + const router = useRouter(); + const open = useSyncExternalStore( + subscribeHelp, + () => helpOpen, + () => false, + ); + const [captureOpen, setCaptureOpen] = useState(false); + const offer = useShortcutOffer(); + const openDay = useOpenDay(); + const today = useTodayKey(); + + // The listener is registered once; it reads the current state from here. + const latest = useRef({ offer, openDay, today, router }); + useEffect(() => { + latest.current = { offer, openDay, today, router }; + }); + + useEffect(() => { + const reader = createShortcutReader(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + const action = reader.read( + { + key: event.key, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + altGraph: event.getModifierState?.("AltGraph") ?? false, + repeat: event.repeat, + isComposing: event.isComposing, + target: event.target, + }, + shortcutScope(document), + event.timeStamp, + ); + if (action === null || action === "pending") return; + const state = latest.current; + switch (action.type) { + case "go": { + const href = resolveGoTo(action.key, state.offer); + if (href === null) return; + event.preventDefault(); + state.router.push(href); + return; + } + case "capture": + if (!state.offer.canCapture) return; + event.preventDefault(); + setCaptureOpen(true); + return; + case "help": + event.preventDefault(); + setShortcutsHelpOpen(true); + return; + case "day": { + const date = state.openDay; + if (date === null) return; + event.preventDefault(); + // No day after today: the layer refuses it, so the step does too. + if (action.delta > 0 && date >= state.today) return; + stepDay(date, action.delta); + return; + } + } + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + // Leaving the shell (signing out) leaves the list closed. + setShortcutsHelpOpen(false); + }; + }, []); + + return ( + <> + + {offer.canCapture ? ( + + ) : null} + + ); +} diff --git a/src/components/layout/keyboard-shortcuts-dialog.tsx b/src/components/layout/keyboard-shortcuts-dialog.tsx new file mode 100644 index 000000000..f2dce5260 --- /dev/null +++ b/src/components/layout/keyboard-shortcuts-dialog.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { Fragment } from "react"; + +import { NAV_DESTINATIONS } from "@/components/layout/nav-model"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useTranslations } from "@/lib/i18n/context"; +import { + GO_TO_SHORTCUTS, + resolveGoTo, + type ShortcutOffer, +} from "@/lib/keyboard/global-shortcuts"; + +/** + * The list of keyboard shortcuts, opened with `?` or from the account menu. + * + * It lists what works for this session: a destination whose module is off, + * or a Coach without an AI provider, is not offered by `g` and is not listed + * either. Key names are literal keys, not translated words. + */ +export interface KeyboardShortcutsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + offer: ShortcutOffer; + /** Whether `n` has anything to open (a read-only record has not). */ + canCapture: boolean; +} + +interface Row { + id: string; + label: string; + /** Keys pressed one after the other. */ + sequence: ReadonlyArray; +} + +function Kbd({ children }: { children: string }) { + // Foreground, not muted: a key name has to be readable at a glance, and + // the muted token sits under the contrast floor on the muted wash. + return ( + + {children} + + ); +} + +export function KeyboardShortcutsDialog({ + open, + onOpenChange, + offer, + canCapture, +}: KeyboardShortcutsDialogProps) { + const { t } = useTranslations(); + + const navigation: Row[] = GO_TO_SHORTCUTS.flatMap((shortcut) => { + if (resolveGoTo(shortcut.key, offer) === null) return []; + const tKey = + shortcut.destination === "settings" + ? "nav.settings" + : NAV_DESTINATIONS.find((d) => d.href === shortcut.destination)?.tKey; + if (!tKey) return []; + return [ + { + id: `go-${shortcut.key}`, + label: t(tKey), + sequence: ["g", shortcut.key], + }, + ]; + }); + + const actions: Row[] = [ + ...(canCapture + ? [ + { + id: "capture", + label: t("nav.capture.title"), + sequence: ["n"], + }, + ] + : []), + { id: "help", label: t("shortcuts.help"), sequence: ["?"] }, + { id: "close", label: t("shortcuts.close"), sequence: ["Esc"] }, + ]; + + const day: Row[] = [ + { id: "day-previous", label: t("shortcuts.previousDay"), sequence: ["["] }, + { id: "day-next", label: t("shortcuts.nextDay"), sequence: ["]"] }, + ]; + + const groups = [ + { + id: "navigation", + title: t("shortcuts.groups.navigation"), + rows: navigation, + }, + { id: "actions", title: t("shortcuts.groups.actions"), rows: actions }, + { + id: "day", + title: t("shortcuts.groups.day"), + hint: t("shortcuts.dayHint"), + rows: day, + }, + ].filter((group) => group.rows.length > 0); + + return ( + +
+ {groups.map((group) => ( +
+
+

+ {group.title} +

+ {"hint" in group && group.hint ? ( +

{group.hint}

+ ) : null} +
+
+ {group.rows.map((row) => ( +
+
{row.label}
+
+ {row.sequence.map((key, index) => ( + + {index > 0 ? ( + + {t("shortcuts.then")} + + ) : null} + {key} + + ))} +
+
+ ))} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 68ea65357..2e6f75f5a 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Bell, + Keyboard, LogOut, Monitor, Moon, @@ -17,6 +18,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { AccountSwitcherMenuItems } from "@/components/layout/account-switcher-menu"; +import { openShortcutsHelp } from "@/components/layout/global-shortcuts"; import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications"; import { isNavDestinationActive, @@ -46,6 +48,7 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, @@ -200,6 +203,19 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { )} + {/* v1.42 — the keyboard shortcuts, findable without knowing that `?` + lists them. Desktop only, like this menu: the phone's account menu + has no keyboard to describe. */} + openShortcutsHelp()} + className="cursor-pointer" + > + + {t("shortcuts.menuEntry")} + + {/* The about section lives at the end of the settings shell nav; the avatar menu stays focused on account-level actions. */} diff --git a/src/lib/keyboard/__tests__/global-shortcuts.test.ts b/src/lib/keyboard/__tests__/global-shortcuts.test.ts new file mode 100644 index 000000000..508a5dfd4 --- /dev/null +++ b/src/lib/keyboard/__tests__/global-shortcuts.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from "vitest"; + +import { visibleNavDestinations } from "@/components/layout/nav-model"; +import { + SEQUENCE_TIMEOUT_MS, + blockedByModifier, + createShortcutReader, + isEditableTarget, + resolveGoTo, + shortcutScope, + type ShortcutKeyEvent, +} from "@/lib/keyboard/global-shortcuts"; + +function key( + k: string, + over: Partial = {}, +): ShortcutKeyEvent { + return { + key: k, + altKey: false, + ctrlKey: false, + metaKey: false, + target: null, + ...over, + }; +} + +/** An element whose `closest` answers for the selectors it matches. */ +function element(matches: (selector: string) => boolean) { + return { + closest: (selector: string) => + selector.split(", ").some((s) => matches(s)) ? {} : null, + }; +} + +const input = element((s) => s === "input"); +const textarea = element((s) => s === "textarea"); +const select = element((s) => s === "select"); +const combobox = element((s) => s === '[role="combobox"]'); +const editable = { isContentEditable: true, closest: () => null }; +const button = element(() => false); + +describe("isEditableTarget", () => { + it("treats fields, comboboxes and contenteditable as the field's", () => { + for (const target of [input, textarea, select, combobox, editable]) { + expect(isEditableTarget(target)).toBe(true); + } + }); + + it("leaves buttons, the body and non-elements to the shortcuts", () => { + expect(isEditableTarget(button)).toBe(false); + expect(isEditableTarget(null)).toBe(false); + expect(isEditableTarget(undefined)).toBe(false); + expect(isEditableTarget({})).toBe(false); + }); +}); + +describe("the reader ignores keys typed into a field", () => { + it.each([ + ["input", input], + ["textarea", textarea], + ["select", select], + ["combobox", combobox], + ["contenteditable", editable], + ])("%s: no action for g d, n, ? or [", (_name, target) => { + const reader = createShortcutReader(); + expect(reader.read(key("g", { target }), "page", 0)).toBeNull(); + expect(reader.read(key("d", { target }), "page", 10)).toBeNull(); + expect(reader.read(key("n", { target }), "page", 20)).toBeNull(); + expect(reader.read(key("?", { target }), "page", 30)).toBeNull(); + expect(reader.read(key("[", { target }), "page", 40)).toBeNull(); + }); + + it("a field between g and d breaks the sequence", () => { + const reader = createShortcutReader(); + expect(reader.read(key("g"), "page", 0)).toBe("pending"); + expect(reader.read(key("d", { target: input }), "page", 10)).toBeNull(); + expect(reader.read(key("d"), "page", 20)).toBeNull(); + }); +}); + +describe("modifiers", () => { + it("Cmd, Ctrl and Alt make a letter somebody else's", () => { + const reader = createShortcutReader(); + for (const over of [ + { metaKey: true }, + { ctrlKey: true }, + { altKey: true }, + ]) { + expect(reader.read(key("n", over), "page", 0)).toBeNull(); + expect(reader.read(key("g", over), "page", 0)).toBeNull(); + } + expect(reader.read(key("?", { metaKey: true }), "page", 0)).toBeNull(); + expect(reader.read(key("?", { ctrlKey: true }), "page", 0)).toBeNull(); + }); + + it("a modified second key does not complete g", () => { + const reader = createShortcutReader(); + expect(reader.read(key("g"), "page", 0)).toBe("pending"); + expect(reader.read(key("d", { metaKey: true }), "page", 10)).toBeNull(); + expect(reader.read(key("d"), "page", 20)).toBeNull(); + }); + + it("Shift alone, pressed to type ?, neither acts nor breaks a sequence", () => { + const reader = createShortcutReader(); + expect(reader.read(key("Shift"), "page", 0)).toBeNull(); + expect(reader.read(key("?"), "page", 5)).toEqual({ type: "help" }); + expect(reader.read(key("g"), "page", 10)).toBe("pending"); + expect(reader.read(key("Shift"), "page", 20)).toBeNull(); + expect(reader.read(key("D"), "page", 30)).toEqual({ type: "go", key: "d" }); + }); + + it("[ typed with Option (German Mac) or AltGr (German PC) still steps the day", () => { + expect(blockedByModifier(key("[", { altKey: true }))).toBe(false); + expect( + blockedByModifier( + key("]", { altKey: true, ctrlKey: true, altGraph: true }), + ), + ).toBe(false); + // Ctrl+Alt without AltGr is a command, not a layout key. + expect(blockedByModifier(key("[", { altKey: true, ctrlKey: true }))).toBe( + true, + ); + expect(blockedByModifier(key("[", { metaKey: true }))).toBe(true); + // An Alt+arrow is the day layer's own step and stays its own. + expect(blockedByModifier(key("ArrowLeft", { altKey: true }))).toBe(true); + }); +}); + +describe("sequence timing", () => { + it("g then a destination within the window goes there", () => { + const reader = createShortcutReader(); + expect(reader.read(key("g"), "page", 1000)).toBe("pending"); + expect(reader.read(key("t"), "page", 1000 + SEQUENCE_TIMEOUT_MS)).toEqual({ + type: "go", + key: "t", + }); + }); + + it("a second key after the window does nothing", () => { + const reader = createShortcutReader(); + reader.read(key("g"), "page", 0); + expect(reader.read(key("t"), "page", SEQUENCE_TIMEOUT_MS + 1)).toBeNull(); + }); + + it("a destination key without g does nothing", () => { + const reader = createShortcutReader(); + for (const k of ["d", "t", "c", "m", "l", "i", "s"]) { + expect(reader.read(key(k), "page", 0)).toBeNull(); + } + }); + + it("an unknown second key cancels; it does not fall through to n", () => { + const reader = createShortcutReader(); + reader.read(key("g"), "page", 0); + expect(reader.read(key("n"), "page", 10)).toBeNull(); + expect(reader.read(key("d"), "page", 20)).toBeNull(); + }); + + it("g g restarts the window", () => { + const reader = createShortcutReader(); + reader.read(key("g"), "page", 0); + expect(reader.read(key("g"), "page", 900)).toBe("pending"); + expect(reader.read(key("s"), "page", 1800)).toEqual({ + type: "go", + key: "s", + }); + }); + + it("a held key does not repeat the action", () => { + const reader = createShortcutReader(); + expect(reader.read(key("n", { repeat: true }), "page", 0)).toBeNull(); + }); + + it("reset forgets a pending g", () => { + const reader = createShortcutReader(); + reader.read(key("g"), "page", 0); + reader.reset(); + expect(reader.read(key("d"), "page", 10)).toBeNull(); + }); +}); + +describe("scope", () => { + it("an open dialog, menu or popover takes every key", () => { + const reader = createShortcutReader(); + expect(reader.read(key("n"), "off", 0)).toBeNull(); + expect(reader.read(key("?"), "off", 0)).toBeNull(); + expect(reader.read(key("["), "off", 0)).toBeNull(); + reader.read(key("g"), "page", 0); + expect(reader.read(key("d"), "off", 10)).toBeNull(); + }); + + it("over the day sheet only [ and ] act", () => { + const reader = createShortcutReader(); + expect(reader.read(key("["), "day-sheet", 0)).toEqual({ + type: "day", + delta: -1, + }); + expect(reader.read(key("]"), "day-sheet", 0)).toEqual({ + type: "day", + delta: 1, + }); + expect(reader.read(key("n"), "day-sheet", 0)).toBeNull(); + expect(reader.read(key("g"), "day-sheet", 0)).toBeNull(); + }); + + it("Escape is never ours", () => { + const reader = createShortcutReader(); + expect(reader.read(key("Escape"), "page", 0)).toBeNull(); + }); + + it("reads the scope from the open surfaces", () => { + const none = { querySelectorAll: () => [] }; + expect(shortcutScope(none)).toBe("page"); + const daySheet = { + querySelectorAll: () => [ + { + closest: (s: string) => (s === '[data-slot="day-panel"]' ? {} : null), + }, + ], + }; + expect(shortcutScope(daySheet)).toBe("day-sheet"); + const other = { querySelectorAll: () => [{ closest: () => null }] }; + expect(shortcutScope(other)).toBe("off"); + const both = { + querySelectorAll: () => [ + { + closest: (s: string) => (s === '[data-slot="day-panel"]' ? {} : null), + }, + { closest: () => null }, + ], + }; + expect(shortcutScope(both)).toBe("off"); + }); +}); + +describe("module gating", () => { + function offer(modules: Record | undefined) { + return { + navHrefs: visibleNavDestinations(modules).map((d) => d.href), + settingsHref: "/settings/account", + }; + } + + it("g t goes nowhere while the timeline module is off", () => { + expect(resolveGoTo("t", offer({ timeline: false }))).toBeNull(); + expect(resolveGoTo("t", offer({ timeline: true }))).toBe("/timeline"); + }); + + it("g m and g l follow the medications and labs modules", () => { + expect(resolveGoTo("m", offer({ medications: false }))).toBeNull(); + expect(resolveGoTo("l", offer({ labs: false }))).toBeNull(); + expect(resolveGoTo("m", offer({ medications: true }))).toBe("/medications"); + expect(resolveGoTo("l", offer({ labs: true }))).toBe("/labs"); + }); + + it("g c follows the coach entry, which carries the AI capability", () => { + // `useNavModules` folds the capability into `coach`. + expect(resolveGoTo("c", offer({ coach: false }))).toBeNull(); + expect(resolveGoTo("c", offer({ coach: true }))).toBe("/coach"); + }); + + it("g d and g i belong to no module", () => { + const off = offer({ timeline: false, labs: false, medications: false }); + expect(resolveGoTo("d", off)).toBe("/"); + expect(resolveGoTo("i", off)).toBe("/insights"); + }); + + it("inside a shared record the doors sharing does not cover stay shut", () => { + const shared = { + navHrefs: visibleNavDestinations({}, true, true).map((d) => d.href), + settingsHref: null, + }; + expect(resolveGoTo("i", shared)).toBeNull(); + expect(resolveGoTo("c", shared)).toBeNull(); + expect(resolveGoTo("s", shared)).toBeNull(); + expect(resolveGoTo("d", shared)).toBe("/"); + }); + + it("g s lands where the Settings entry lands", () => { + expect( + resolveGoTo("s", { navHrefs: [], settingsHref: "/settings/profile" }), + ).toBe("/settings/profile"); + }); +}); diff --git a/src/lib/keyboard/global-shortcuts.ts b/src/lib/keyboard/global-shortcuts.ts new file mode 100644 index 000000000..56ac65970 --- /dev/null +++ b/src/lib/keyboard/global-shortcuts.ts @@ -0,0 +1,239 @@ +/** + * The app-wide keyboard shortcuts, as pure logic. + * + * The listener in `src/components/layout/global-shortcuts.tsx` hands every + * keydown to `createShortcutReader().read()` together with the scope the page + * is in, and gets back an action, a "wait for the second key", or nothing. + * Everything that decides whether a key is ours lives here, so it can be + * pinned without a DOM: + * + * - a key typed into a field is the field's (`isEditableTarget`); + * - a key held with Cmd, Ctrl or Alt belongs to the browser, the operating + * system or a screen reader (`blockedByModifier`), with one exception: a + * punctuation key the layout itself needs Alt or AltGr to type (`[` is + * Option+5 on a German Mac, AltGr+8 on a German PC) still counts, because + * for that person there is no other way to press it; + * - while a dialog, sheet, menu or popover is open the keys are its own, + * except over the day sheet, where `[` and `]` step the day it shows. + * + * `g` starts a two-key sequence; the second key has to follow within + * `SEQUENCE_TIMEOUT_MS`. Escape is never handled here: every surface closes + * itself on it, as before. + */ + +/** How long `g` waits for its second key. */ +export const SEQUENCE_TIMEOUT_MS = 1000; + +/** + * `g` + key: where it goes. The destination is a nav href, or `settings`, + * which lands wherever the Settings entry of the account menu lands (it + * differs inside a shared record). Listed in navigation order, which is the + * order the help dialog shows them in. + */ +export const GO_TO_SHORTCUTS = [ + { key: "d", destination: "/" }, + { key: "m", destination: "/medications" }, + { key: "l", destination: "/labs" }, + { key: "t", destination: "/timeline" }, + { key: "i", destination: "/insights" }, + { key: "c", destination: "/coach" }, + { key: "s", destination: "settings" }, +] as const; + +export type GoToKey = (typeof GO_TO_SHORTCUTS)[number]["key"]; + +export type ShortcutAction = + | { type: "go"; key: GoToKey } + | { type: "capture" } + | { type: "help" } + | { type: "day"; delta: -1 | 1 }; + +/** + * Where the keyboard is: + * - `page`: nothing modal is open, every shortcut applies; + * - `day-sheet`: the day sheet is the only modal surface, `[` / `]` apply; + * - `off`: another dialog, sheet, menu or popover has the keys. + */ +export type ShortcutScope = "page" | "day-sheet" | "off"; + +/** What the reader needs from a `KeyboardEvent`. */ +export interface ShortcutKeyEvent { + key: string; + altKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + /** `event.getModifierState("AltGraph")`. */ + altGraph?: boolean; + repeat?: boolean; + isComposing?: boolean; + target?: unknown; +} + +/** Keys a layout may need Alt or AltGr for, and that still count then. */ +const LAYOUT_CHARACTERS: ReadonlySet = new Set(["[", "]", "?"]); + +/** Pressing one of these alone never starts, ends or breaks a shortcut. */ +const MODIFIER_KEYS: ReadonlySet = new Set([ + "Shift", + "Control", + "Alt", + "AltGraph", + "Meta", + "CapsLock", + "Fn", + "OS", +]); + +/** Something a person types into, by element or by role. */ +const EDITABLE_SELECTOR = [ + "input", + "textarea", + "select", + '[contenteditable]:not([contenteditable="false"])', + '[role="combobox"]', + '[role="textbox"]', + '[role="searchbox"]', + '[role="spinbutton"]', +].join(", "); + +/** Open surfaces that own the keyboard while they are up. */ +export const MODAL_SURFACE_SELECTOR = [ + '[role="dialog"][data-state="open"]', + '[role="alertdialog"][data-state="open"]', + '[aria-modal="true"]', + '[role="menu"][data-state="open"]', + '[role="listbox"][data-state="open"]', +].join(", "); + +/** The day layer's frame, docked or as a sheet (`day-layer.tsx`). */ +const DAY_PANEL_SELECTOR = '[data-slot="day-panel"]'; + +/** True when the key went to a field, which keeps every key it gets. */ +export function isEditableTarget(target: unknown): boolean { + if (target === null || typeof target !== "object") return false; + const el = target as { + isContentEditable?: boolean; + closest?: (selector: string) => unknown; + }; + if (el.isContentEditable === true) return true; + if (typeof el.closest !== "function") return false; + return el.closest(EDITABLE_SELECTOR) != null; +} + +/** + * True when a modifier makes the key somebody else's: Cmd and Ctrl always + * (browser and screen-reader commands), Alt unless the layout needs it to + * type one of our punctuation keys. Shift is free: it is how `?` is typed. + */ +export function blockedByModifier(event: ShortcutKeyEvent): boolean { + if (event.metaKey) return true; + const altGraph = event.altGraph === true; + // AltGr reports as Ctrl+Alt on Windows; it is a layout key, not a command. + if (event.ctrlKey && !altGraph) return true; + if ((event.altKey || altGraph) && !LAYOUT_CHARACTERS.has(event.key)) { + return true; + } + return false; +} + +/** The scope the page is in, read from the open surfaces. */ +export function shortcutScope(root: { + querySelectorAll: (selector: string) => ArrayLike; +}): ShortcutScope { + const open = Array.from(root.querySelectorAll(MODAL_SURFACE_SELECTOR)); + if (open.length === 0) return "page"; + const onlyDay = open.every((el) => { + const node = el as { closest?: (selector: string) => unknown }; + return ( + typeof node.closest === "function" && + node.closest(DAY_PANEL_SELECTOR) != null + ); + }); + return onlyDay ? "day-sheet" : "off"; +} + +function normalise(key: string): string { + return key.length === 1 ? key.toLowerCase() : key; +} + +function isGoToKey(key: string): key is GoToKey { + return GO_TO_SHORTCUTS.some((shortcut) => shortcut.key === key); +} + +/** + * A reader with the one piece of state a sequence needs: when `g` was + * pressed. `read` returns the action a key completes, `"pending"` after `g`, + * or `null` for a key that is not ours (which also drops a pending `g`). + */ +export function createShortcutReader(timeoutMs = SEQUENCE_TIMEOUT_MS) { + let pendingSince: number | null = null; + + function read( + event: ShortcutKeyEvent, + scope: ShortcutScope, + now: number, + ): ShortcutAction | "pending" | null { + if (event.isComposing) return null; + if (MODIFIER_KEYS.has(event.key)) return null; + if (event.repeat) return null; + + const pending = pendingSince; + pendingSince = null; + + if (scope === "off") return null; + if (isEditableTarget(event.target)) return null; + if (blockedByModifier(event)) return null; + + const key = normalise(event.key); + + if (key === "[" || key === "]") { + return { type: "day", delta: key === "[" ? -1 : 1 }; + } + if (scope !== "page") return null; + + if (pending !== null && now - pending <= timeoutMs) { + // The second key of a sequence: a destination, or nothing at all, so + // a mistyped `g n` does not open the add menu. + if (isGoToKey(key)) return { type: "go", key }; + if (key !== "g") return null; + } + if (key === "g") { + pendingSince = now; + return "pending"; + } + if (key === "n") return { type: "capture" }; + if (key === "?") return { type: "help" }; + return null; + } + + return { + read, + /** Forget a pending `g`. */ + reset() { + pendingSince = null; + }, + }; +} + +/** What this session may open, as the navigation offers it. */ +export interface ShortcutOffer { + /** The hrefs of the visible nav destinations. */ + navHrefs: ReadonlyArray; + /** Where the Settings entry lands, or null when there is none. */ + settingsHref: string | null; +} + +/** + * The href `g` + `key` opens, or null when the destination is not offered: a + * switched-off module, a Coach without an AI provider, a page a shared record + * does not cover. Read from the same lists the navigation renders, so a + * shortcut never opens a door the sidebar does not show. + */ +export function resolveGoTo(key: GoToKey, offer: ShortcutOffer): string | null { + const shortcut = GO_TO_SHORTCUTS.find((s) => s.key === key); + if (!shortcut) return null; + if (shortcut.destination === "settings") return offer.settingsHref; + return offer.navHrefs.includes(shortcut.destination) + ? shortcut.destination + : null; +}