diff --git a/CHANGELOG.md b/CHANGELOG.md index f942fb915..d8228b219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,14 @@ 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. +- **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 new file mode 100644 index 000000000..0c1e64031 --- /dev/null +++ b/e2e/keyboard-shortcuts.spec.ts @@ -0,0 +1,216 @@ +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("/"); + 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(() => { + 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 expect(field).toBeVisible(); + 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..a019152f5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12554,6 +12554,52 @@ "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", + "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..32aea792d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12554,6 +12554,52 @@ "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", + "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..0d7c8804f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12554,6 +12554,52 @@ "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", + "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..c9cee17c2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12554,6 +12554,52 @@ "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", + "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..e77412ded 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12554,6 +12554,52 @@ "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", + "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..71da2dd6c 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12554,6 +12554,52 @@ "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": "이전 날", + "nextDay": "다음 날", + "dayHint": "하루가 열려 있을 때.", + "groups": { + "navigation": "탐색", + "actions": "작업", + "day": "하루" + } + }, "aboutPage": { "eyebrow": "정보", "title": "HealthLog 정보", diff --git a/messages/pl.json b/messages/pl.json index 71b857b83..1645f0469 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12554,6 +12554,52 @@ "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ń", + "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/__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/__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..acb73f00f 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -24,6 +24,8 @@ 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 { CommandPaletteMount } from "@/components/command-palette/command-palette.lazy"; import { DemoBanner } from "./demo-banner"; import { OfflineBanner } from "./offline-banner"; import { SharedRecordBanner } from "./shared-record-banner"; @@ -579,6 +581,13 @@ 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. */} + + {/* 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 new file mode 100644 index 000000000..4a8cd5130 --- /dev/null +++ b/src/components/layout/global-shortcuts.tsx @@ -0,0 +1,229 @@ +"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 { openCommandPalette } from "@/components/command-palette/palette-store"; +import dynamic from "next/dynamic"; +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. + */ + +// The list is loaded the first time it opens; no page pays for it before. +const KeyboardShortcutsDialog = dynamic( + () => + import("@/components/layout/keyboard-shortcuts-dialog").then( + (m) => m.KeyboardShortcutsDialog, + ), + { ssr: false }, +); + +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); +} + +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(); + 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 capture = useSyncExternalStore( + subscribeCapture, + () => captureOpen, + () => 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 "palette": + event.preventDefault(); + openCommandPalette(); + 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); + setCaptureOpen(false); + }; + }, []); + + // Mounted from the first open on, so the close animation still plays. + const [helpWanted, setHelpWanted] = useState(false); + if (open && !helpWanted) setHelpWanted(true); + + return ( + <> + {helpWanted ? ( + + ) : null} + {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..c8e307db4 --- /dev/null +++ b/src/components/layout/keyboard-shortcuts-dialog.tsx @@ -0,0 +1,168 @@ +"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, + isApplePlatform, + 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, or held together with `chord`. */ + sequence: ReadonlyArray; + chord?: boolean; +} + +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], + }, + ]; + }); + + // 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 + ? [ + { + 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 ? ( + + {row.chord ? "+" : 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/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 ( -
+