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 1/3] 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; +} From 715b733164a00078892e2c87d63927a4ab7a14ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 16:35:21 +0200 Subject: [PATCH 2/3] feat(shell): add a command palette for pages, settings, modules and actions Cmd/Ctrl+K or the search control at the end of the top bar opens a combobox over a client-side index built from the lists the navigation already renders: pages, Insights pages, every Settings section and card (deep-linked by a new anchor prop on SettingsCardHeader), the Appearance sub-pages, module switches and a few actions. Entries follow the module map, the Coach capability, the admin role and a shared record's grant; inside somebody else's record no cards, switches or backup are offered. Ranking is prefix, then word start, then substring, then a small edit distance, then subsequence, with a short multilingual synonym list. The empty state shows recent places (per account, in localStorage) and the main actions. The palette is its own lazily loaded chunk; the top bar shows a search field where it has room and a magnifier otherwise. The module switch mutation moves into a shared hook used by the Modules hub and the palette. --- CHANGELOG.md | 5 + e2e/command-palette.spec.ts | 157 ++++++ e2e/keyboard-shortcuts.spec.ts | 4 +- messages/de.json | 30 ++ messages/en.json | 30 ++ messages/es.json | 30 ++ messages/fr.json | 30 ++ messages/it.json | 30 ++ messages/ko.json | 30 ++ messages/pl.json | 30 ++ .../resolved-field-consumer-guard.test.ts | 6 +- .../success-affordance-guard.test.ts | 1 - .../__tests__/command-palette.test.tsx | 135 +++++ .../command-palette-trigger.tsx | 86 ++++ .../command-palette/command-palette.lazy.tsx | 22 + .../command-palette/command-palette.tsx | 446 +++++++++++++++++ .../command-palette/palette-store.ts | 42 ++ .../command-palette/use-palette-index.ts | 97 ++++ src/components/layout/auth-shell.tsx | 4 + src/components/layout/global-shortcuts.tsx | 41 +- .../layout/keyboard-shortcuts-dialog.tsx | 14 +- src/components/layout/top-bar.tsx | 10 +- src/components/settings/_card-header.tsx | 16 +- .../settings/access/grant-invite-card.tsx | 1 + .../settings/access/grants-given-card.tsx | 1 + .../settings/access/grants-received-card.tsx | 1 + .../settings/access/managed-profile-card.tsx | 1 + .../settings/access/record-activity-card.tsx | 1 + .../account-section/avatar-section.tsx | 6 +- .../settings/account-section/index.tsx | 7 +- src/components/settings/advanced-section.tsx | 2 + .../settings/ai/ai-consent-card.tsx | 1 + .../settings/ai/ai-insights-card.tsx | 1 + src/components/settings/ai/auto-read-card.tsx | 1 + .../settings/ai/disable-coach-card.tsx | 1 + .../settings/ai/fallback-chain-card.tsx | 1 + .../settings/ai/response-timeout-card.tsx | 1 + .../settings/ai/runtime-actions-row.tsx | 1 + src/components/settings/anamnesis-section.tsx | 7 + src/components/settings/api-section.tsx | 9 + .../coach-conversations-memory-card.tsx | 1 + .../settings/coach-prefs-section.tsx | 1 + .../settings/environment-section.tsx | 4 + src/components/settings/export-section.tsx | 11 + .../settings/glucose-reference-card.tsx | 1 + .../import-panel/apple-health-import-card.tsx | 1 + .../settings/import-panel/csv-import-card.tsx | 1 + .../import-panel/dose-history-import-card.tsx | 1 + .../health-connect-import-card.tsx | 1 + .../import-panel/import-card-shell.tsx | 10 +- .../import-panel/json-import-card.tsx | 1 + src/components/settings/mcp-section.tsx | 3 + src/components/settings/module-icons.ts | 57 +++ src/components/settings/modules-section.tsx | 171 +------ .../settings/notification-status-card.tsx | 1 + .../settings/nutrient-intake-card.tsx | 1 + src/components/settings/password-card.tsx | 1 + src/components/settings/privacy-section.tsx | 6 + .../settings/record-anamnesis-section.tsx | 2 + .../report-selection/selection-panel.tsx | 1 + .../settings/security-activity-card.tsx | 1 + .../security-section/passkey-list-section.tsx | 1 + .../security-section/security-keys-card.tsx | 1 + .../settings/security-section/totp-card.tsx | 1 + .../settings/security-sessions-card.tsx | 1 + src/components/settings/sharing-section.tsx | 1 + .../settings/trusted-devices-card.tsx | 1 + src/hooks/use-module-toggle.ts | 128 +++++ .../__tests__/build-index.test.ts | 146 ++++++ .../command-palette/__tests__/rank.test.ts | 104 ++++ .../__tests__/settings-cards.test.ts | 80 +++ src/lib/command-palette/build-index.ts | 297 +++++++++++ src/lib/command-palette/rank.ts | 184 +++++++ src/lib/command-palette/recent.ts | 49 ++ src/lib/command-palette/settings-cards.ts | 468 ++++++++++++++++++ src/lib/command-palette/synonyms.ts | 97 ++++ src/lib/dom/scroll-to-anchor.ts | 80 +++ .../__tests__/global-shortcuts.test.ts | 40 ++ src/lib/keyboard/global-shortcuts.ts | 32 +- 79 files changed, 3147 insertions(+), 180 deletions(-) create mode 100644 e2e/command-palette.spec.ts create mode 100644 src/components/command-palette/__tests__/command-palette.test.tsx create mode 100644 src/components/command-palette/command-palette-trigger.tsx create mode 100644 src/components/command-palette/command-palette.lazy.tsx create mode 100644 src/components/command-palette/command-palette.tsx create mode 100644 src/components/command-palette/palette-store.ts create mode 100644 src/components/command-palette/use-palette-index.ts create mode 100644 src/components/settings/module-icons.ts create mode 100644 src/hooks/use-module-toggle.ts create mode 100644 src/lib/command-palette/__tests__/build-index.test.ts create mode 100644 src/lib/command-palette/__tests__/rank.test.ts create mode 100644 src/lib/command-palette/__tests__/settings-cards.test.ts create mode 100644 src/lib/command-palette/build-index.ts create mode 100644 src/lib/command-palette/rank.ts create mode 100644 src/lib/command-palette/recent.ts create mode 100644 src/lib/command-palette/settings-cards.ts create mode 100644 src/lib/command-palette/synonyms.ts create mode 100644 src/lib/dom/scroll-to-anchor.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c62bae56b..d8228b219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,11 @@ an earlier fold computed from part of a day. - **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. +- **Command palette.** Cmd+K (Ctrl+K) or the search field in the top bar + finds pages, Insights pages, every Settings card by its anchor, module + switches and actions (new entry, the Coach, a backup, today, the + shortcuts), filtered by modules, AI availability, role and a shared + record's grant; typo-tolerant, with recent places when empty. ### Changed diff --git a/e2e/command-palette.spec.ts b/e2e/command-palette.spec.ts new file mode 100644 index 000000000..0f37045a2 --- /dev/null +++ b/e2e/command-palette.spec.ts @@ -0,0 +1,157 @@ +import AxeBuilder from "@axe-core/playwright"; +import type { Page } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expect, test } from "./setup/test"; + +/** + * v1.42 — the command palette. + * + * - Cmd/Ctrl+K, "sleep", Enter lands on the sleep page; + * - a settings card is a link with its anchor, and the page scrolls to it; + * - a module's entry is a switch: Enter sends the module write and the + * palette stays open (the write is answered by a route mock, so the + * shared account's modules never move); + * - the top bar carries the search field on a wide desktop and a + * magnifier on a phone, which opens a full-height sheet with the field + * focused; + * - the open palette passes axe. + * + * Stable hooks only: `command-palette` (+ `data-variant`), + * `command-palette-input`, `command-palette-trigger` (+ `data-variant`), + * options by `data-entry`. + */ + +test.use({ storageState: STORAGE_STATE_PATH }); + +const palette = (page: Page) => page.locator('[data-slot="command-palette"]'); +const input = (page: Page) => + page.locator('[data-slot="command-palette-input"]'); + +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(); +} + +test.describe("command palette", () => { + test.beforeEach(async ({ context }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "palette journeys run on the desktop project (the phone case resizes)", + ); + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "en", url: baseURL }, + ]); + }); + + test("Cmd/Ctrl+K, sleep, Enter opens the sleep page", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + await shellReady(page); + await page.keyboard.press("ControlOrMeta+k"); + await expect(palette(page)).toHaveAttribute("data-variant", "dialog"); + await expect(input(page)).toBeFocused(); + await input(page).fill("sleep"); + // The best hit is the highlighted option. + const active = await input(page).getAttribute("aria-activedescendant"); + await expect(page.locator(`[id="${active}"]`)).toHaveAttribute( + "data-entry", + "insights:sleep", + ); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/insights\/sleep$/); + await expect(palette(page)).toHaveCount(0); + }); + + test("a settings card opens at its anchor", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + await shellReady(page); + await page.keyboard.press("ControlOrMeta+k"); + await input(page).fill("passkeys"); + await page.locator('[data-entry="settings:security#passkeys"]').click(); + await expect(page).toHaveURL(/\/settings\/security#passkeys$/); + await expect(page.locator("#passkeys")).toBeInViewport({ timeout: 10_000 }); + }); + + test("a module is a switch; Enter flips it and the palette stays", async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + const writes: unknown[] = []; + await page.route("**/api/auth/me/modules", async (route) => { + if (route.request().method() !== "PATCH") return route.fallback(); + writes.push(route.request().postDataJSON()); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + modules: { timeline: true }, + updatedAt: new Date().toISOString(), + }, + error: null, + }), + }); + }); + await page.goto("/"); + await shellReady(page); + await page.keyboard.press("ControlOrMeta+k"); + await input(page).fill("timeline"); + const option = page.locator('[data-entry="module:timeline"]'); + await expect(option).toHaveAttribute("aria-checked", "false"); + await option.hover(); + await page.keyboard.press("Enter"); + await expect.poll(() => writes.length).toBe(1); + expect(writes[0]).toMatchObject({ timeline: true }); + await expect(palette(page)).toBeVisible(); + await expect(input(page)).toHaveValue("timeline"); + }); + + test("the top bar: a search field on a wide desktop", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + await shellReady(page); + const field = page.locator( + '[data-slot="command-palette-trigger"][data-variant="field"]', + ); + await expect(field).toBeVisible(); + await expect( + page.locator( + '[data-slot="command-palette-trigger"][data-variant="icon"]', + ), + ).toBeHidden(); + await field.click(); + await expect(palette(page)).toBeVisible(); + await expect(input(page)).toBeFocused(); + + const axe = await new AxeBuilder({ page }) + .include('[data-slot="command-palette"]') + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(axe.violations).toEqual([]); + + await page.keyboard.press("Escape"); + await expect(palette(page)).toHaveCount(0); + }); + + test("on a phone: the magnifier opens a full-height sheet", async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + await shellReady(page); + const magnifier = page.locator( + '[data-slot="command-palette-trigger"][data-variant="icon"]', + ); + await expect(magnifier).toBeVisible(); + await magnifier.click(); + await expect(palette(page)).toHaveAttribute("data-variant", "sheet"); + await expect(input(page)).toBeFocused(); + const box = await palette(page).boundingBox(); + expect(box?.height ?? 0).toBeGreaterThanOrEqual(844 - 1); + await input(page).fill("weight"); + await expect(page.locator('[data-entry="insights:weight"]')).toBeVisible(); + }); +}); diff --git a/e2e/keyboard-shortcuts.spec.ts b/e2e/keyboard-shortcuts.spec.ts index b58b4823e..0c1e64031 100644 --- a/e2e/keyboard-shortcuts.spec.ts +++ b/e2e/keyboard-shortcuts.spec.ts @@ -114,7 +114,8 @@ test.describe("keyboard shortcuts", () => { test("keys typed into a field stay in the field", async ({ page }) => { await withTimelineModule(page, true); await page.goto("/"); - await shellReady(page); + const main = page.locator("#main-content"); + await expect(main).toBeVisible({ timeout: 20_000 }); // A plain field in the page: the rule is about the focused element, not // about which page carries it. await page.evaluate(() => { @@ -124,6 +125,7 @@ test.describe("keyboard shortcuts", () => { document.getElementById("main-content")?.prepend(field); }); const field = page.getByTestId("probe-field"); + await expect(field).toBeVisible(); await field.focus(); await page.keyboard.type("gt n ? ["); await expect(field).toHaveValue("gt n ? ["); diff --git a/messages/de.json b/messages/de.json index 463244410..a019152f5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12554,11 +12554,41 @@ "body": "Titel und Notiz sind danach nicht mehr da." } }, + "palette": { + "open": "Suchen…", + "openLabel": "Seiten, Einstellungen und Aktionen suchen", + "title": "Suche", + "description": "Finde eine Seite, eine Einstellung, ein Modul oder eine Aktion.", + "placeholder": "Seiten, Einstellungen, Module suchen…", + "results": "Treffer", + "noResults": "Nichts gefunden für „{query}“.", + "moduleOn": "An", + "moduleOff": "Aus", + "hintMove": "Wählen", + "hintOpen": "Öffnen", + "hintClose": "Schließen", + "groups": { + "recent": "Zuletzt", + "pages": "Seiten", + "insights": "Insights", + "settings": "Einstellungen", + "modules": "Module", + "actions": "Aktionen" + }, + "actions": { + "capture": "Neuer Eintrag", + "coach": "Coach fragen", + "backup": "Backup erstellen", + "today": "Heute öffnen", + "shortcuts": "Tastenkürzel zeigen" + } + }, "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", + "search": "Suchen", "help": "Diese Liste zeigen", "close": "Dialog oder Bereich schließen", "previousDay": "Vorheriger Tag", diff --git a/messages/en.json b/messages/en.json index da9d0a874..32aea792d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12554,11 +12554,41 @@ "body": "Its title and note will be gone." } }, + "palette": { + "open": "Search…", + "openLabel": "Search pages, settings and actions", + "title": "Search", + "description": "Find a page, a setting, a module or an action.", + "placeholder": "Search pages, settings, modules…", + "results": "Results", + "noResults": "Nothing found for “{query}”.", + "moduleOn": "On", + "moduleOff": "Off", + "hintMove": "Move", + "hintOpen": "Open", + "hintClose": "Close", + "groups": { + "recent": "Recent", + "pages": "Pages", + "insights": "Insights", + "settings": "Settings", + "modules": "Modules", + "actions": "Actions" + }, + "actions": { + "capture": "New entry", + "coach": "Ask the Coach", + "backup": "Create a backup", + "today": "Open today", + "shortcuts": "Show keyboard shortcuts" + } + }, "shortcuts": { "title": "Keyboard shortcuts", "description": "Keys that work anywhere in the app, except while you type in a field.", "menuEntry": "Keyboard shortcuts", "then": "then", + "search": "Search", "help": "Show this list", "close": "Close a dialog or panel", "previousDay": "Previous day", diff --git a/messages/es.json b/messages/es.json index b87def957..0d7c8804f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12554,11 +12554,41 @@ "body": "Su título y su nota desaparecerán." } }, + "palette": { + "open": "Buscar…", + "openLabel": "Buscar páginas, ajustes y acciones", + "title": "Buscar", + "description": "Encuentra una página, un ajuste, un módulo o una acción.", + "placeholder": "Buscar páginas, ajustes, módulos…", + "results": "Resultados", + "noResults": "No hay resultados para «{query}».", + "moduleOn": "Activado", + "moduleOff": "Desactivado", + "hintMove": "Mover", + "hintOpen": "Abrir", + "hintClose": "Cerrar", + "groups": { + "recent": "Recientes", + "pages": "Páginas", + "insights": "Insights", + "settings": "Ajustes", + "modules": "Módulos", + "actions": "Acciones" + }, + "actions": { + "capture": "Nueva entrada", + "coach": "Preguntar al Coach", + "backup": "Crear una copia de seguridad", + "today": "Abrir hoy", + "shortcuts": "Mostrar atajos de teclado" + } + }, "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", + "search": "Buscar", "help": "Mostrar esta lista", "close": "Cerrar un diálogo o panel", "previousDay": "Día anterior", diff --git a/messages/fr.json b/messages/fr.json index 29cc36106..c9cee17c2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12554,11 +12554,41 @@ "body": "Son titre et sa note disparaîtront." } }, + "palette": { + "open": "Rechercher…", + "openLabel": "Rechercher des pages, réglages et actions", + "title": "Recherche", + "description": "Trouver une page, un réglage, un module ou une action.", + "placeholder": "Rechercher des pages, réglages, modules…", + "results": "Résultats", + "noResults": "Aucun résultat pour « {query} ».", + "moduleOn": "Activé", + "moduleOff": "Désactivé", + "hintMove": "Naviguer", + "hintOpen": "Ouvrir", + "hintClose": "Fermer", + "groups": { + "recent": "Récents", + "pages": "Pages", + "insights": "Insights", + "settings": "Paramètres", + "modules": "Modules", + "actions": "Actions" + }, + "actions": { + "capture": "Nouvelle saisie", + "coach": "Demander au Coach", + "backup": "Créer une sauvegarde", + "today": "Ouvrir aujourd’hui", + "shortcuts": "Afficher les raccourcis clavier" + } + }, "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", + "search": "Rechercher", "help": "Afficher cette liste", "close": "Fermer une boîte de dialogue ou un panneau", "previousDay": "Jour précédent", diff --git a/messages/it.json b/messages/it.json index 41809edae..e77412ded 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12554,11 +12554,41 @@ "body": "Titolo e nota non ci saranno più." } }, + "palette": { + "open": "Cerca…", + "openLabel": "Cerca pagine, impostazioni e azioni", + "title": "Cerca", + "description": "Trova una pagina, un’impostazione, un modulo o un’azione.", + "placeholder": "Cerca pagine, impostazioni, moduli…", + "results": "Risultati", + "noResults": "Nessun risultato per «{query}».", + "moduleOn": "Attivo", + "moduleOff": "Disattivo", + "hintMove": "Sposta", + "hintOpen": "Apri", + "hintClose": "Chiudi", + "groups": { + "recent": "Recenti", + "pages": "Pagine", + "insights": "Insights", + "settings": "Impostazioni", + "modules": "Moduli", + "actions": "Azioni" + }, + "actions": { + "capture": "Nuova voce", + "coach": "Chiedi al Coach", + "backup": "Crea un backup", + "today": "Apri oggi", + "shortcuts": "Mostra scorciatoie da tastiera" + } + }, "shortcuts": { "title": "Scorciatoie da tastiera", "description": "Tasti che funzionano ovunque nell’app, tranne mentre scrivi in un campo.", "menuEntry": "Scorciatoie da tastiera", "then": "poi", + "search": "Cerca", "help": "Mostra questo elenco", "close": "Chiudi una finestra o un pannello", "previousDay": "Giorno precedente", diff --git a/messages/ko.json b/messages/ko.json index b75ea597c..71da2dd6c 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12554,11 +12554,41 @@ "body": "제목과 메모가 사라져요." } }, + "palette": { + "open": "검색…", + "openLabel": "페이지, 설정, 작업 검색", + "title": "검색", + "description": "페이지, 설정, 모듈 또는 작업을 찾습니다.", + "placeholder": "페이지, 설정, 모듈 검색…", + "results": "결과", + "noResults": "“{query}”에 대한 결과가 없습니다.", + "moduleOn": "켜짐", + "moduleOff": "꺼짐", + "hintMove": "이동", + "hintOpen": "열기", + "hintClose": "닫기", + "groups": { + "recent": "최근", + "pages": "페이지", + "insights": "인사이트", + "settings": "설정", + "modules": "모듈", + "actions": "작업" + }, + "actions": { + "capture": "새 항목", + "coach": "코치에게 묻기", + "backup": "백업 만들기", + "today": "오늘 열기", + "shortcuts": "키보드 단축키 보기" + } + }, "shortcuts": { "title": "키보드 단축키", "description": "입력란에 입력하는 중이 아니면 앱 어디에서나 작동하는 키입니다.", "menuEntry": "키보드 단축키", "then": "다음", + "search": "검색", "help": "이 목록 보기", "close": "대화 상자나 패널 닫기", "previousDay": "이전 날", diff --git a/messages/pl.json b/messages/pl.json index 97b87bad8..1645f0469 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12554,11 +12554,41 @@ "body": "Tytuł i notatka znikną." } }, + "palette": { + "open": "Szukaj…", + "openLabel": "Szukaj stron, ustawień i akcji", + "title": "Szukaj", + "description": "Znajdź stronę, ustawienie, moduł lub akcję.", + "placeholder": "Szukaj stron, ustawień, modułów…", + "results": "Wyniki", + "noResults": "Brak wyników dla „{query}”.", + "moduleOn": "Wł.", + "moduleOff": "Wył.", + "hintMove": "Wybierz", + "hintOpen": "Otwórz", + "hintClose": "Zamknij", + "groups": { + "recent": "Ostatnie", + "pages": "Strony", + "insights": "Insights", + "settings": "Ustawienia", + "modules": "Moduły", + "actions": "Akcje" + }, + "actions": { + "capture": "Nowy wpis", + "coach": "Zapytaj Coacha", + "backup": "Utwórz kopię zapasową", + "today": "Otwórz dzisiaj", + "shortcuts": "Pokaż skróty klawiszowe" + } + }, "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", + "search": "Szukaj", "help": "Pokaż tę listę", "close": "Zamknij okno lub panel", "previousDay": "Poprzedni dzień", diff --git a/src/__tests__/resolved-field-consumer-guard.test.ts b/src/__tests__/resolved-field-consumer-guard.test.ts index b7461f861..d63da7a57 100644 --- a/src/__tests__/resolved-field-consumer-guard.test.ts +++ b/src/__tests__/resolved-field-consumer-guard.test.ts @@ -144,8 +144,8 @@ const RULES: ResolvedFieldRule[] = [ allow: { "src/components/settings/ai/disable-coach-card.tsx": "the Activate-Coach switch: it edits the stored opt-out, so its checked state is that value", - "src/components/settings/modules-section.tsx": - "the module toggle row for the Coach: its checked state is the stored opt-out it writes", + "src/hooks/use-module-toggle.ts": + "the module switch for the Coach (Settings hub and command palette): its checked state is the stored opt-out it writes", "src/components/settings/managed-record-settings-section.tsx": "the managed record's own settings form: the field's default is the record's stored opt-out", "src/hooks/use-auth.ts": @@ -169,6 +169,8 @@ const RULES: ResolvedFieldRule[] = [ allow: { "src/components/settings/modules-section.tsx": "the Modules hub: it shows the operator-off state of each toggle row, which is what `moduleAvailability` is published for", + "src/components/command-palette/use-palette-index.ts": + "the command palette's module switches: like the Modules hub it leaves out a switch the operator turned off, which is what `moduleAvailability` is published for", "src/hooks/use-auth.ts": "the `/me` payload normaliser: types and coerces the field, decides nothing", }, diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index dffde273e..aba2dbe9e 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -382,7 +382,6 @@ const PINNED_AFFORDANCES: Record< "text-success": 4, "toast.success": 2, }, - "src/components/settings/modules-section.tsx": { "toast.success": 1 }, "src/components/settings/notification-status-card.tsx": { CheckCircle2: 2, "text-success": 1, diff --git a/src/components/command-palette/__tests__/command-palette.test.tsx b/src/components/command-palette/__tests__/command-palette.test.tsx new file mode 100644 index 000000000..cd23ee416 --- /dev/null +++ b/src/components/command-palette/__tests__/command-palette.test.tsx @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Activity, Moon, Plus, Smile } from "lucide-react"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { PaletteEntry } from "@/lib/command-palette/build-index"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: { id: "user-1" } }), +})); +vi.mock("@/hooks/use-module-toggle", () => ({ + useModuleToggle: () => ({ mutate: vi.fn() }), +})); +vi.mock("@/lib/insights/coach-launch-context", () => ({ + useCoachLaunch: () => null, +})); +vi.mock("@/components/day/use-today-key", () => ({ + useTodayKey: () => "2026-10-09", +})); + +const ENTRIES: PaletteEntry[] = [ + { + id: "insights:sleep", + group: "insights", + title: "Sleep", + hint: "Insights", + keywords: ["schlaf"], + icon: Moon, + run: { kind: "href", href: "/insights/sleep" }, + }, + { + id: "nav:/measurements", + group: "pages", + title: "Measurements", + keywords: [], + icon: Activity, + run: { kind: "href", href: "/measurements" }, + }, + { + id: "module:mood", + group: "modules", + title: "Mood", + keywords: [], + icon: Smile, + run: { kind: "module", module: "mood", enabled: true }, + }, + { + id: "action:capture", + group: "actions", + title: "New entry", + keywords: [], + icon: Plus, + run: { kind: "action", action: "capture" }, + }, + { + id: "action:today", + group: "actions", + title: "Open today", + keywords: [], + icon: Plus, + run: { kind: "action", action: "today" }, + }, +]; + +vi.mock("../use-palette-index", () => ({ + usePaletteIndex: () => ENTRIES, +})); + +import { PaletteBody, arrangeResults } from "../command-palette"; + +function render(isMobile = false) { + return renderToStaticMarkup( + + + , + ); +} + +describe("arrangeResults", () => { + it("empty query: recent places first, then the main actions", () => { + const sections = arrangeResults("", ENTRIES, ["insights:sleep", "gone"]); + expect(sections.map((s) => s.group)).toEqual(["recent", "actions"]); + expect(sections[0]?.entries.map((e) => e.id)).toEqual(["insights:sleep"]); + expect(sections[1]?.entries.map((e) => e.id)).toEqual([ + "action:capture", + "action:today", + ]); + }); + + it("a query groups the hits in the order of each group's best hit", () => { + const sections = arrangeResults("schlaf", ENTRIES, []); + expect(sections.map((s) => s.group)).toEqual(["insights"]); + // "mo": Mood is a prefix (modules), Measurements no match; "o" in + // "Open today" is a word start (actions) and ranks below the prefix. + const mixed = arrangeResults("mo", ENTRIES, []); + expect(mixed.map((s) => s.group)).toEqual(["modules"]); + const both = arrangeResults("o", ENTRIES, []); + expect(both[0]?.entries[0]?.title).toBe("Open today"); + }); + + it("no hit, no sections", () => { + expect(arrangeResults("zzzz", ENTRIES, [])).toEqual([]); + }); +}); + +describe("", () => { + it("is a combobox that controls a listbox and points at the first option", () => { + const html = render(); + const controls = html.match(/aria-controls="([^"]+)"/)?.[1]; + expect(html).toContain('role="combobox"'); + expect(html).toContain('aria-autocomplete="list"'); + expect(controls).toBeTruthy(); + expect(html).toContain(`id="${controls}" role="listbox"`); + const active = html.match(/aria-activedescendant="([^"]+)"/)?.[1]; + expect(html).toContain(`id="${active}" role="option" aria-selected="true"`); + }); + + it("labels its groups and the field", () => { + const html = render(); + expect(html).toContain('role="group"'); + expect(html).toContain('aria-label="Search"'); + expect(html).toContain('placeholder="Search pages, settings, modules…"'); + expect(html).toContain(">Actions<"); + }); + + it("shows the key hints on a desktop and a Cancel button on a phone", () => { + expect(render(false)).toContain("Esc"); + const phone = render(true); + expect(phone).not.toContain(">Esc<"); + expect(phone).toContain(">Cancel<"); + }); +}); diff --git a/src/components/command-palette/command-palette-trigger.tsx b/src/components/command-palette/command-palette-trigger.tsx new file mode 100644 index 000000000..dc86cce0e --- /dev/null +++ b/src/components/command-palette/command-palette-trigger.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Search } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useMounted } from "@/hooks/use-mounted"; +import { useTranslations } from "@/lib/i18n/context"; +import { isApplePlatform } from "@/lib/keyboard/global-shortcuts"; + +import { openCommandPalette, preloadCommandPalette } from "./palette-store"; + +/** + * The way into the command palette, at the trailing end of the top bar on + * every signed-in page. Where the bar has room it looks like a search field + * ("Search…" and the key that opens it); where it has not (a phone, or a + * desktop bar narrowed by the sidebar and a docked panel) it is a magnifier + * with a tooltip. The switch reads the bar's own width (`@container/topbar` + * on the header), not the window's, because a docked panel takes the room + * without the window changing. + */ +export function CommandPaletteTrigger() { + const { t } = useTranslations(); + // The key hint depends on the platform, which the server cannot know. + const mounted = useMounted(); + const apple = mounted && isApplePlatform(); + const label = t("palette.openLabel"); + const keyHint = apple ? "⌘K" : "Ctrl K"; + const shortcut = apple ? "Meta+K" : "Control+K"; + + return ( +
+ + + + + + + {label} + + +
+ ); +} diff --git a/src/components/command-palette/command-palette.lazy.tsx b/src/components/command-palette/command-palette.lazy.tsx new file mode 100644 index 000000000..b84afe4c6 --- /dev/null +++ b/src/components/command-palette/command-palette.lazy.tsx @@ -0,0 +1,22 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useState } from "react"; + +import { useCommandPaletteOpen } from "./palette-store"; + +const CommandPalette = dynamic(() => import("./command-palette"), { + ssr: false, +}); + +/** + * Mounted once by the signed-in shell. The palette's own chunk loads the + * first time it opens (or when the pointer reaches the top-bar button), and + * stays mounted afterwards so a second open is instant. + */ +export function CommandPaletteMount() { + const open = useCommandPaletteOpen(); + const [wanted, setWanted] = useState(false); + if (open && !wanted) setWanted(true); + return wanted ? : null; +} diff --git a/src/components/command-palette/command-palette.tsx b/src/components/command-palette/command-palette.tsx new file mode 100644 index 000000000..f3d97849f --- /dev/null +++ b/src/components/command-palette/command-palette.tsx @@ -0,0 +1,446 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { Search } from "lucide-react"; + +import { openDay } from "@/components/day/day-layer-controller"; +import { useTodayKey } from "@/components/day/use-today-key"; +import { + openCapturePicker, + openShortcutsHelp, +} from "@/components/layout/global-shortcuts"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetTitle, +} from "@/components/ui/sheet"; +import { useAuth } from "@/hooks/use-auth"; +import { useIsMobile } from "@/hooks/use-is-mobile"; +import { useModuleToggle } from "@/hooks/use-module-toggle"; +import { + EMPTY_STATE_ACTIONS, + type PaletteEntry, + type PaletteGroup, +} from "@/lib/command-palette/build-index"; +import { rankEntries } from "@/lib/command-palette/rank"; +import { pushRecent, readRecent } from "@/lib/command-palette/recent"; +import { scrollToAnchorWhenReady } from "@/lib/dom/scroll-to-anchor"; +import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +import { setCommandPaletteOpen, useCommandPaletteOpen } from "./palette-store"; +import { usePaletteIndex } from "./use-palette-index"; + +/** + * The command palette: one field, and below it the places, settings, module + * switches and actions that match, grouped. Arrow keys move, Enter opens, + * Escape closes. A module's entry is a switch: Enter flips it and the palette + * stays open. + * + * Accessibility follows the combobox pattern: the field is the + * `role="combobox"` that keeps focus, the results are a `listbox` of grouped + * `option`s, and `aria-activedescendant` names the highlighted one. + * + * A centred dialog from 768 px; on a phone a full-height sheet with the field + * at the top. That one is a raw `Sheet` rather than `ResponsiveSheet` on + * purpose: the responsive primitive's phone branch is a 90 % bottom sheet + * that keeps the keyboard down on open, and a search wants the opposite, the + * whole height and the keyboard up at once. + * + * Loaded on first use (`command-palette.lazy.tsx`), so no page pays for it. + */ + +/** At most this many hits per group, so one group cannot bury the others. */ +const PER_GROUP = 7; + +interface Section { + group: PaletteGroup; + entries: PaletteEntry[]; +} + +function hashOf(href: string): { pathname: string; hash: string } { + const at = href.indexOf("#"); + return at === -1 + ? { pathname: href, hash: "" } + : { pathname: href.slice(0, at), hash: href.slice(at + 1) }; +} + +export function arrangeResults( + query: string, + index: ReadonlyArray, + recentIds: ReadonlyArray, +): Section[] { + if (query.trim() === "") { + const byId = new Map(index.map((entry) => [entry.id, entry])); + const recent = recentIds + .map((id) => byId.get(id)) + .filter((entry): entry is PaletteEntry => entry !== undefined); + const actions = EMPTY_STATE_ACTIONS.map((id) => byId.get(id)).filter( + (entry): entry is PaletteEntry => + entry !== undefined && !recentIds.includes(entry.id), + ); + return [ + { group: "recent" as const, entries: recent }, + { group: "actions" as const, entries: actions }, + ].filter((section) => section.entries.length > 0); + } + const ranked = rankEntries(query, index); + // Groups appear in the order of their best hit, so the best match is + // always the first option. + const order: PaletteGroup[] = []; + const grouped = new Map(); + for (const entry of ranked) { + const list = grouped.get(entry.group); + if (!list) { + order.push(entry.group); + grouped.set(entry.group, [entry]); + } else if (list.length < PER_GROUP) { + list.push(entry); + } + } + return order.map((group) => ({ group, entries: grouped.get(group) ?? [] })); +} + +export default function CommandPalette() { + const open = useCommandPaletteOpen(); + const isMobile = useIsMobile(); + const { t } = useTranslations(); + const title = t("palette.title"); + const description = t("palette.description"); + + const body = open ? ( + + ) : null; + + if (isMobile) { + return ( + + + {title} + {description} + {body} + + + ); + } + return ( + + + {title} + {description} + {body} + + + ); +} + +export function PaletteBody({ isMobile }: { isMobile: boolean }) { + const { t } = useTranslations(); + const router = useRouter(); + const { user } = useAuth(); + const today = useTodayKey(); + const coachLaunch = useCoachLaunch(); + const index = usePaletteIndex(); + const toggle = useModuleToggle(); + const [query, setQuery] = useState(""); + const [active, setActive] = useState(0); + const [recentIds] = useState(() => readRecent(user?.id)); + const listId = useId(); + const inputRef = useRef(null); + + const sections = useMemo( + () => arrangeResults(query, index, recentIds), + [query, index, recentIds], + ); + const flat = useMemo( + () => sections.flatMap((section) => section.entries), + [sections], + ); + const activeIndex = + flat.length === 0 ? -1 : Math.min(active, flat.length - 1); + const optionId = (i: number) => `${listId}-option-${i}`; + + // Keep the highlighted option in view as the arrows move through a list + // taller than the panel. + useEffect(() => { + if (activeIndex < 0) return; + document + .getElementById(optionId(activeIndex)) + ?.scrollIntoView({ block: "nearest" }); + // `optionId` is derived from `listId`, which is stable. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeIndex]); + + function run(entry: PaletteEntry) { + const target = entry.run; + if (target.kind === "module") { + // A switch: flip it where it is, and stay. + toggle.mutate({ key: target.module, enabled: !target.enabled }); + return; + } + setCommandPaletteOpen(false); + if (target.kind === "href") { + pushRecent(user?.id, entry.id); + router.push(target.href); + const { pathname, hash } = hashOf(target.href); + if (hash) scrollToAnchorWhenReady(hash, { pathname }); + return; + } + switch (target.action) { + case "capture": + openCapturePicker(); + return; + case "coach": + if (coachLaunch) coachLaunch.askCoach(); + else router.push("/coach"); + return; + case "backup": { + router.push("/settings/export#full-backup"); + scrollToAnchorWhenReady("full-backup", { + pathname: "/settings/export", + }); + return; + } + case "today": + openDay(today); + return; + case "shortcuts": + openShortcutsHelp(); + return; + } + } + + function onKeyDown(event: React.KeyboardEvent) { + if (event.nativeEvent.isComposing) return; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + if (flat.length === 0) return; + const step = event.key === "ArrowDown" ? 1 : -1; + setActive((activeIndex + step + flat.length) % flat.length); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + const entry = flat[activeIndex]; + if (entry) run(entry); + return; + } + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + setCommandPaletteOpen(false); + } + } + + // Each section's first position in the flat option list. + const starts = sections.reduce( + (acc, section, i) => [ + ...acc, + i === 0 ? 0 : acc[i - 1] + sections[i - 1].entries.length, + ], + [], + ); + + return ( +
+
+
+ +
+ {sections.length === 0 ? ( +

+ {t("palette.noResults", { query: query.trim() })} +

+ ) : ( + sections.map((section, sectionIndex) => { + const headingId = `${listId}-group-${section.group}`; + return ( +
+
+ {t(`palette.groups.${section.group}`)} +
+ {section.entries.map((entry, entryIndex) => { + const i = starts[sectionIndex] + entryIndex; + const selected = i === activeIndex; + const Icon = entry.icon; + const isModule = entry.run.kind === "module"; + const on = entry.run.kind === "module" && entry.run.enabled; + return ( +
{ + if (!selected) setActive(i); + }} + onMouseDown={(event) => { + // Keep focus in the field; the click runs the entry. + event.preventDefault(); + }} + onClick={() => run(entry)} + className={cn( + "flex min-h-11 cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm", + selected && "bg-accent text-accent-foreground", + )} + > +
+ ); + })} +
+ ); + }) + )} +
+ + {isMobile ? null : ( + + )} +
+ ); +} diff --git a/src/components/command-palette/palette-store.ts b/src/components/command-palette/palette-store.ts new file mode 100644 index 000000000..1b451f625 --- /dev/null +++ b/src/components/command-palette/palette-store.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +/** + * Whether the command palette is open, reachable from anywhere: the top-bar + * button, Cmd/Ctrl+K in the shortcut handler, and the palette itself. The + * palette's code is a separate chunk (`command-palette.lazy.tsx`); this file + * is the few lines every page carries. + */ +let open = false; +const listeners = new Set<() => void>(); + +export function setCommandPaletteOpen(next: boolean): void { + if (open === next) return; + open = next; + for (const listener of listeners) listener(); +} + +export function openCommandPalette(): void { + setCommandPaletteOpen(true); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useCommandPaletteOpen(): boolean { + return useSyncExternalStore( + subscribe, + () => open, + () => false, + ); +} + +/** Start loading the palette's chunk ahead of the click (hover, focus). */ +export function preloadCommandPalette(): void { + void import("./command-palette").catch(() => { + // The click loads it again and reports a failure there. + }); +} diff --git a/src/components/command-palette/use-palette-index.ts b/src/components/command-palette/use-palette-index.ts new file mode 100644 index 000000000..3d98e2a0b --- /dev/null +++ b/src/components/command-palette/use-palette-index.ts @@ -0,0 +1,97 @@ +"use client"; + +import { useMemo } from "react"; + +import { + CAPTURE_KIND_ORDER, + visibleCaptureKinds, +} from "@/components/layout/capture-picker"; +import { useAiCapability } from "@/hooks/use-ai-capability"; +import { useAuth } from "@/hooks/use-auth"; +import { useModuleEnabledState } from "@/hooks/use-module-toggle"; +import { useNavModules } from "@/hooks/use-nav-modules"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { + buildPaletteIndex, + type PaletteEntry, +} from "@/lib/command-palette/build-index"; +import { useTranslations } from "@/lib/i18n/context"; +import { + MODULE_REGISTRY, + isCodeDisabledModule, + moduleDelegatesTo, + type ModuleKey, +} from "@/lib/modules/registry"; +import { isSettingsDestinationListedForRecord } from "@/lib/record-settings/classification"; + +/** + * The palette's index for this session, read from the account payload the + * shell already holds. See `buildPaletteIndex` for what is offered when. + */ +export function usePaletteIndex(): PaletteEntry[] { + const { t } = useTranslations(); + const { user } = useAuth(); + const navModules = useNavModules(); + const capabilities = useRecordCapabilities(); + const { inSharedRecord, sections, recordKind, level } = capabilities; + const coach = useAiCapability("coach"); + const isEnabled = useModuleEnabledState(); + const manageableDomains = user?.accountAccess?.active?.manageableDomains; + const moduleAvailability = user?.moduleAvailability; + const canCapture = + visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER, user?.modules) + .length > 0; + + // A module the operator switched off cannot be switched on by the person, + // so it is not offered as a switch (Settings → Modules explains why). + const moduleToggles = inSharedRecord + ? [] + : (Object.keys(MODULE_REGISTRY) as ModuleKey[]) + .filter((key) => !isCodeDisabledModule(key)) + .filter((key) => + moduleDelegatesTo(key) === "coach" + ? coach.reason !== "operator_disabled" + : moduleAvailability?.[key] !== false, + ) + .map((key) => ({ key, enabled: isEnabled(key) })); + const togglesKey = moduleToggles + .map((m) => `${m.key}:${m.enabled ? 1 : 0}`) + .join(","); + + return useMemo( + () => + buildPaletteIndex({ + t, + modules: user?.modules, + navModules, + inSharedRecord, + sections, + isAdmin: user?.role === "ADMIN", + settingsListed: inSharedRecord + ? (slug) => + isSettingsDestinationListedForRecord(slug, { + recordKind, + level, + manageableDomains: manageableDomains ?? [], + }) + : () => true, + canCapture, + moduleToggles, + }), + // `moduleToggles` is rebuilt every render; its content is `togglesKey`. + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + t, + user?.modules, + user?.role, + navModules, + inSharedRecord, + sections, + recordKind, + level, + manageableDomains, + canCapture, + togglesKey, + ], + ); +} diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index dec92bdc5..acb73f00f 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -25,6 +25,7 @@ import { isManageDelegateSettingsDestination } from "@/lib/record-settings/class import { isDestinationInSharedRecord } from "./nav-model"; import { BottomNav } from "./bottom-nav"; import { GlobalShortcuts } from "./global-shortcuts"; +import { CommandPaletteMount } from "@/components/command-palette/command-palette.lazy"; import { DemoBanner } from "./demo-banner"; import { OfflineBanner } from "./offline-banner"; import { SharedRecordBanner } from "./shared-record-banner"; @@ -584,6 +585,9 @@ export function AuthShell({ Mounted here and only here, so they never run on the sign-in pages or in the setup flow, which return above. */} + {/* v1.42 — the command palette (Cmd/Ctrl+K, the top-bar search). Its + code loads on first use. */} + ); } diff --git a/src/components/layout/global-shortcuts.tsx b/src/components/layout/global-shortcuts.tsx index 42705ef5f..0805cd69a 100644 --- a/src/components/layout/global-shortcuts.tsx +++ b/src/components/layout/global-shortcuts.tsx @@ -1,13 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import { - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from "react"; +import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; import { stepDay, useOpenDay } from "@/components/day/day-layer-controller"; import { useTodayKey } from "@/components/day/use-today-key"; @@ -16,6 +10,7 @@ import { CapturePicker, visibleCaptureKinds, } from "@/components/layout/capture-picker"; +import { openCommandPalette } from "@/components/command-palette/palette-store"; import { KeyboardShortcutsDialog } from "@/components/layout/keyboard-shortcuts-dialog"; import { isSettingsUtilityDestination, @@ -67,6 +62,25 @@ export function openShortcutsHelp(): void { setShortcutsHelpOpen(true); } +let captureOpen = false; +const captureListeners = new Set<() => void>(); + +function setCaptureOpen(open: boolean): void { + if (captureOpen === open) return; + captureOpen = open; + for (const listener of captureListeners) listener(); +} + +function subscribeCapture(listener: () => void): () => void { + captureListeners.add(listener); + return () => captureListeners.delete(listener); +} + +/** Open the add menu (the same picker as the bottom bar's button). */ +export function openCapturePicker(): void { + setCaptureOpen(true); +} + function useShortcutOffer(): ShortcutOffer & { canCapture: boolean } { const { user } = useAuth(); const navModules = useNavModules(); @@ -102,7 +116,11 @@ export function GlobalShortcuts() { () => helpOpen, () => false, ); - const [captureOpen, setCaptureOpen] = useState(false); + const capture = useSyncExternalStore( + subscribeCapture, + () => captureOpen, + () => false, + ); const offer = useShortcutOffer(); const openDay = useOpenDay(); const today = useTodayKey(); @@ -146,6 +164,10 @@ export function GlobalShortcuts() { event.preventDefault(); setCaptureOpen(true); return; + case "palette": + event.preventDefault(); + openCommandPalette(); + return; case "help": event.preventDefault(); setShortcutsHelpOpen(true); @@ -166,6 +188,7 @@ export function GlobalShortcuts() { document.removeEventListener("keydown", onKeyDown); // Leaving the shell (signing out) leaves the list closed. setShortcutsHelpOpen(false); + setCaptureOpen(false); }; }, []); @@ -178,7 +201,7 @@ export function GlobalShortcuts() { canCapture={offer.canCapture} /> {offer.canCapture ? ( - + ) : null} ); diff --git a/src/components/layout/keyboard-shortcuts-dialog.tsx b/src/components/layout/keyboard-shortcuts-dialog.tsx index f2dce5260..c8e307db4 100644 --- a/src/components/layout/keyboard-shortcuts-dialog.tsx +++ b/src/components/layout/keyboard-shortcuts-dialog.tsx @@ -7,6 +7,7 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { useTranslations } from "@/lib/i18n/context"; import { GO_TO_SHORTCUTS, + isApplePlatform, resolveGoTo, type ShortcutOffer, } from "@/lib/keyboard/global-shortcuts"; @@ -29,8 +30,9 @@ export interface KeyboardShortcutsDialogProps { interface Row { id: string; label: string; - /** Keys pressed one after the other. */ + /** Keys pressed one after the other, or held together with `chord`. */ sequence: ReadonlyArray; + chord?: boolean; } function Kbd({ children }: { children: string }) { @@ -67,7 +69,15 @@ export function KeyboardShortcutsDialog({ ]; }); + // Rendered only while open, so the platform is the browser's. + const mod = isApplePlatform() ? "⌘" : "Ctrl"; const actions: Row[] = [ + { + id: "palette", + label: t("shortcuts.search"), + sequence: [mod, "K"], + chord: true, + }, ...(canCapture ? [ { @@ -140,7 +150,7 @@ export function KeyboardShortcutsDialog({ {index > 0 ? ( - {t("shortcuts.then")} + {row.chord ? "+" : t("shortcuts.then")} ) : null} {key} diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index b8f31017d..691ea9bd8 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -9,6 +9,7 @@ import { Shield, Sun, } from "lucide-react"; +import { CommandPaletteTrigger } from "@/components/command-palette/command-palette-trigger"; import { AccountSwitcherMenuItems } from "@/components/layout/account-switcher-menu"; import { isSettingsUtilityDestination, @@ -89,7 +90,9 @@ export function TopBar() { // sidebar logo band reads too, so the two borders draw one // continuous line. Never restate a height here. className={cn( - "bg-card/80 border-border sticky top-0 z-40 flex items-center justify-between px-4 backdrop-blur-md md:px-6", + // `@container/topbar`: the search control sizes to the bar's own + // width, which a docked side panel narrows without the window moving. + "bg-card/80 border-border @container/topbar sticky top-0 z-40 flex items-center justify-between px-4 backdrop-blur-md md:px-6", SHELL_HEADER_BAND, )} // The status-bar inset of the installed app is the shell's @@ -110,6 +113,11 @@ export function TopBar() { sidebar. */} + {/* v1.42 — the command palette's door, on every signed-in page: a + search-field button where the bar has room, a magnifier where it + has not (and on a phone). `ml-auto` keeps it at the trailing end. */} + + {/* Page-owned actions (the Coach's conversations toggle). `ml-auto` keeps them at the trailing edge, right before the mobile avatar menu; `empty:hidden` takes the slot out of the row on every page diff --git a/src/components/settings/_card-header.tsx b/src/components/settings/_card-header.tsx index 097e369e9..16f53a465 100644 --- a/src/components/settings/_card-header.tsx +++ b/src/components/settings/_card-header.tsx @@ -55,6 +55,12 @@ export interface SettingsCardHeaderProps { * broken. Multi-sentence prose goes in the body as * `

` (foreground). */ description?: React.ReactNode; + /** Optional deep-link anchor: the header carries it as its `id` (with the + * shell's `scroll-mt-28`), so `/settings/

#` lands on this + * card. The command palette links every card it lists this way; the + * anchors it relies on are pinned in `src/lib/command-palette/ + * settings-cards.ts`. */ + anchor?: string; /** Optional right-aligned status surface — typically an * `` or a wrapper around badges. */ status?: React.ReactNode; @@ -69,10 +75,18 @@ export function SettingsCardHeader({ titleAccessory, description, status, + anchor, className, }: SettingsCardHeaderProps) { return ( -
+