# Keyboard Shortcuts — Implementation Plan

**Spec:** docs/specs/2026-06-01-keyboard-shortcuts.md  ·  **Slug:** keyboard-shortcuts  ·  **Wave:** 4
**Depends on:** app-shell, foundation-auth-rbac

## Goal
Deliver a keyboard-driven navigation and action layer for `zync-app`: a command palette (`⌘K` / `Ctrl+K`), global navigation/creation sequence shortcuts (vim-style `G`→`D`, `N`→`I`), a help overlay (`?`), and per-page action shortcuts (focus search, export, save, send, print). Power users and keyboard/accessibility users gain fast, mouse-free operation. The feature is frontend-only — no database schema, no new API routes; it reuses the existing app-shell `Command` palette primitive, the `GET /api/search` endpoint, and client-side permission gating.

## Architecture
A centralized React hook layer lives in `apps/zync-app/src/hooks/`. `useGlobalShortcuts()` is mounted once at the app root (inside the app-shell `Shell` layout) and registers all global bindings (command palette, help overlay, `G`/`N` sequences). Per-page components call `usePageShortcuts(defs)` to register page-scoped bindings that auto-deregister on unmount. Both write into a single in-memory `ShortcutRegistry` (React context + ref store) so the help overlay can render the union of currently-active shortcuts, including the dynamic "On this page" group.

Key event handling: a single global `keydown` listener (installed by `ShortcutProvider`) consults the registry. It suppresses all shortcut firing when focus is inside `<input>`, `<textarea>`, or `[contenteditable]` (except explicit modifier combos like `⌘S` which the editor page opts into). Sequence shortcuts use a 1,000ms timeout buffer: the first key (`G` or `N`) arms a pending prefix; the second key within the window resolves the action, otherwise the prefix is discarded.

Permission gating: every shortcut is registered unconditionally. Permission-gated actions (e.g. `G`→`I` requires `invoices:read`) check the current session's permission set at fire time via a client `hasPermission(permissions, key)` helper and silently no-op when the user lacks access — mirroring how app-shell hides nav links. The session and its `permissions: string[]` array are read from `GET /api/auth/me` (locked route) through a `useSession()` React Query hook.

The command palette reuses the app-shell `Command` primitive (wraps `cmdk`, exported by `@zync/ui`). It renders three sections — Recent (recently visited routes from localStorage), Actions (create commands), Navigation (route commands) — plus live entity results. Typing queries `GET /api/search?q=...` (app-shell endpoint; debounced 200ms) and merges customer/project hits into the result list. `⌘K` is also already wired to the header search button in app-shell; this spec centralizes the binding so both entry points open the same modal.

Upstream consumed: app-shell `Shell` layout (mount point), `Command` primitive + `Dialog`/`DialogProps` from `@zync/ui`, `GET /api/search`, `GET /api/auth/me`, `useModuleEnabled` (to suppress nav shortcuts for disabled modules). Auth permission keys consumed verbatim: `invoices:read`, `invoices:write`, `customers:read`, `customers:write`, `marketing:read`, `marketing:write`, `projects:read`, `projects:write`, `users:manage`, `reports:read`.

## Tech Stack
- App: `apps/zync-app` (Vite + React, React Router, Cloudflare Workers runtime via the app-shell).
- Packages consumed: `@zync/ui` (`Command`, `Dialog`, `Badge`, `Kbd`-style rendering via `Badge`/`cn`), `@zync/types` (session/permission types).
- Libraries: `cmdk` (already a dependency via `@zync/ui` `Command`), `react-router-dom` (`useNavigate`), `@tanstack/react-query` (session + search fetching).
- No new Cloudflare bindings, no new API routes, no DB migration.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 4a | 1, 2 | shortcut types, registry context/provider, session+permission helper | Tasks 1 and 2 parallel after types |
| 4b | 3 | global keydown engine + sequence/prefix logic | After 1, 2 |
| 4c | 4, 5 | command palette modal, help overlay | After 3; 4 and 5 parallel |
| 4d | 6 | global shortcut definitions wired into Shell | After 3, 4, 5 |
| 4e | 7, 8, 9, 10 | per-page hooks: invoices, proposals, tasks board | After 6; all four parallel |
| 4f | 11 | accessibility + reduced-motion + tests | After all UI tasks |

## Tasks

### Task 1: Shortcut types & permission/session helpers
**Blocks:** 2, 3, 4, 5, 6  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/src/hooks/shortcuts/types.ts`
- Create: `apps/zync-app/src/hooks/shortcuts/hasPermission.ts`
- Create: `apps/zync-app/src/hooks/useSession.ts`
**Steps:**
- [ ] Define the `ShortcutDef`, `ShortcutCategory`, and `ShortcutContext` TypeScript interfaces (see Schema / Interfaces).
- [ ] Implement `hasPermission(permissions: string[], key: string): boolean` as a pure array-membership check (no API call); used for client-side gating only — server still enforces via `requirePermission`.
- [ ] Implement `useSession()` as a React Query hook fetching `GET /api/auth/me`, returning `{ session, permissions, isLoading }` where `permissions` defaults to `[]` while loading; staleTime 5 minutes, shared cache key `['auth','me']` (reuse the existing key if app-shell already defines one — do not duplicate the request).
- [ ] Ensure types are exported from a barrel `apps/zync-app/src/hooks/shortcuts/index.ts`.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/hooks/shortcuts/types.ts
export type ShortcutCategory = 'global' | 'navigation' | 'create' | 'page'

export interface ShortcutDef {
  /** Stable id, unique within a registration scope, e.g. 'go-invoices'. */
  id: string
  /** Display keys for the help overlay, e.g. ['G','I'] or ['⌘K']. */
  keys: string[]
  /** Sequence prefix for vim-style shortcuts: 'G' | 'N' | undefined for single/combo. */
  prefix?: 'G' | 'N'
  /** Matched key for the resolving keypress (lowercased, e.g. 'i', '/', 's'). */
  match: string
  /** Optional modifier requirement for combo shortcuts. */
  mod?: { meta?: boolean; ctrl?: boolean; shift?: boolean }
  category: ShortcutCategory
  label: string
  /** Permission key required to execute; undefined = always allowed. */
  permission?: string
  /** Executed on match; receives navigate + session helpers. */
  action: (ctx: ShortcutContext) => void
  /** When true the shortcut fires even while an input/textarea is focused (combo only). */
  allowInInput?: boolean
}

export interface ShortcutContext {
  navigate: (to: string) => void
  permissions: string[]
  hasPermission: (key: string) => boolean
}

export interface SessionData {
  userId: string
  tenantId: string
  permissions: string[]
}
```
```ts
// apps/zync-app/src/hooks/shortcuts/hasPermission.ts
export function hasPermission(permissions: string[], key: string): boolean {
  return permissions.includes(key)
}
```
**Acceptance:**
- [ ] `hasPermission(['invoices:read'], 'invoices:read')` returns `true`; `hasPermission([], 'invoices:read')` returns `false`.
- [ ] `useSession()` returns `permissions: []` (not undefined) during loading and never throws when `/api/auth/me` is pending.

### Task 2: Shortcut registry context & provider
**Blocks:** 3, 4, 5, 6, 7, 8, 9, 10  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-app/src/hooks/shortcuts/ShortcutProvider.tsx`
- Create: `apps/zync-app/src/hooks/shortcuts/registry.ts`
**Steps:**
- [ ] Implement a `ShortcutRegistry` backed by a `Map<string, ShortcutDef>` held in a `useRef` so registration/deregistration does not trigger re-renders of the keydown engine.
- [ ] Implement `ShortcutProvider` exposing `register(def): () => void` (returns an unregister disposer), `unregister(id)`, and `getActive(): ShortcutDef[]` via React context.
- [ ] Maintain a separate React state slice listing the currently-registered *page* shortcuts (category `'page'`) so the help overlay re-renders when the page changes; global shortcuts are static.
- [ ] Guard against duplicate ids: re-registering an existing id replaces it and warns in dev only.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/hooks/shortcuts/registry.ts
export interface ShortcutRegistry {
  register: (def: ShortcutDef) => () => void  // returns unregister disposer
  unregister: (id: string) => void
  getActive: () => ShortcutDef[]
  getPageShortcuts: () => ShortcutDef[]
}
export const ShortcutContext = createContext<ShortcutRegistry | null>(null)
export function useShortcutRegistry(): ShortcutRegistry { /* throws if no provider */ }
```
**Acceptance:**
- [ ] Registering a `'page'` shortcut and calling its disposer removes it from `getActive()` and `getPageShortcuts()`.
- [ ] No re-render storm: registering 10 shortcuts in a `useEffect` does not re-mount the keydown listener (verified by a render-count test or listener-identity assertion).

### Task 3: Global keydown engine (sequence + combo + input suppression)
**Blocks:** 4, 5, 6  ·  **Blocked by:** 1, 2
**Files:**
- Modify: `apps/zync-app/src/hooks/shortcuts/ShortcutProvider.tsx`
- Create: `apps/zync-app/src/hooks/shortcuts/useKeydownEngine.ts`
**Steps:**
- [ ] Install one `window` `keydown` listener inside `ShortcutProvider` (mounted once at app root). Use `{ capture: false }`.
- [ ] On each keydown, first compute `isInInput = activeElement` is `<input>`, `<textarea>`, or `[contenteditable]` (or inside one). If `isInInput` and the event is not a modifier-combo opted-in via `allowInInput`, return without firing.
- [ ] Combo handling: if `event.metaKey || event.ctrlKey` (and optional `shiftKey`), match against registry defs whose `mod` matches; call `preventDefault()` before firing (e.g. `⌘S` must not trigger browser save, `⌘K` must not focus the URL bar).
- [ ] Sequence handling: maintain a `pendingPrefix` ref and a `pendingTimer`. When a bare `G` or `N` is pressed (no modifiers, not in input), set `pendingPrefix` and start a 1,000ms timeout that clears it. If a key arrives while a prefix is pending, look up the def with that `prefix` + `match`, clear prefix/timer, and fire if permission passes.
- [ ] Single-key handling (`?`, `/`, `E`, `S`, `P`, `N` as page-level): when no prefix pending and not in input, match a registry def with no `prefix` and no `mod`. Note `N` is dual-purpose: a bare `N` on the Tasks board opens the create modal (page shortcut), while `N` as a prefix is global — resolve precedence by checking for a pending-prefix-arming def first only when a global `N`-prefixed action set exists AND no page-level bare-`N` is registered for the current route; document that page bare-`N` (Tasks board) takes precedence over arming the global `N` prefix on that route.
- [ ] Before firing any def, evaluate `def.permission` via `hasPermission(session.permissions, def.permission)`; if it fails, no-op silently (do not navigate, do not error).
- [ ] All firing wrapped so an action throw is caught and logged in dev only (never crashes the listener).
**Acceptance:**
- [ ] Pressing `G` then `I` within 1s navigates to `/invoices` when the user has `invoices:read`; with no permission it does nothing.
- [ ] Pressing `G`, waiting 1.1s, then `I` does not navigate (prefix discarded).
- [ ] Typing `g` inside a focused `<input>` inserts the character and fires no shortcut.
- [ ] `⌘S` on the proposal editor calls the save action and the browser save dialog does not appear (`preventDefault` invoked).

### Task 4: Command palette modal (`⌘K` / `Ctrl+K`)
**Blocks:** 6  ·  **Blocked by:** 1, 2, 3
**Files:**
- Create: `apps/zync-app/src/components/shortcuts/CommandPalette.tsx`
- Create: `apps/zync-app/src/hooks/shortcuts/useRecentRoutes.ts`
**Steps:**
- [ ] Build `CommandPalette` using the `Command` primitive from `@zync/ui` (wraps `cmdk`) inside a `Dialog` — centered floating modal, full-width on mobile (`max-w-lg` desktop, full-bleed under 640px).
- [ ] Render placeholder "Search commands, pages, or customers…" with a leading search icon. Sections in order: **Recent**, **Actions**, **Navigation**, plus live entity groups (Customers, Projects) when a query is present.
- [ ] Recent: read recently-visited routes from `localStorage('zync_tenant_recent_routes')` (tenant-scoped key per app-shell state-isolation rule). Maintained by `useRecentRoutes()` which pushes the current path on navigation (cap 8, dedupe).
- [ ] Actions section commands (each gated by permission — hidden if `!hasPermission`): New invoice (`N I`, `invoices:write` → navigate `/invoices/new`), New customer (`N C`, `customers:write` → `/customers/new`), New proposal (`N P`, `marketing:write` → `/proposals/new`), New task (`N T`, `projects:write` → `/projects` task create entry).
- [ ] Navigation section commands (gated, hidden if no permission): Dashboard (`G D`), Invoices (`G I`, `invoices:read`), Proposals (`G P`, `marketing:read`), Tasks (`G T`, `projects:read`), Customers (`G C`, `customers:read`), Settings (`G S`, `users:manage`), Reports (`G R`, `reports:read`). Suppress a navigation item when its module is disabled via `useModuleEnabled(moduleId)` (same rule as app-shell nav).
- [ ] Live entity search: on input change, debounce 200ms and call `GET /api/search?q=...`; merge `customers` and `projects` arrays from the response (each `SearchResult { id, label, description?, url }`) into Customers/Projects groups, max 5 each. Selecting a result navigates to `result.url`.
- [ ] Keyboard within palette: `↑`/`↓` move selection (cmdk default), `Enter` executes the highlighted command, `Esc` closes. Closing returns focus to the previously focused element.
- [ ] Expose an imperative open/close: palette open state lives in `ShortcutProvider` context (`isPaletteOpen`, `openPalette`, `closePalette`) so the global `⌘K` binding and the app-shell header search button both toggle the same instance.
**Schema / Interfaces:**
```ts
// Reuses app-shell GET /api/search response shape:
interface SearchResult { id: string; label: string; description?: string; url: string }
interface SearchResponse {
  tasks: SearchResult[]; projects: SearchResult[]; invoices: SearchResult[]
  customers: SearchResult[]; tickets: SearchResult[]; articles: SearchResult[]
}
// useRecentRoutes
export function useRecentRoutes(): { recent: { path: string; label: string }[] }
```
**Acceptance:**
- [ ] `⌘K` (and `Ctrl+K`) opens the palette from any route; `Esc` closes it and restores focus.
- [ ] Typing a customer name shows matching customers within ~200ms; selecting navigates to the customer URL.
- [ ] A user lacking `invoices:write` does not see "New invoice" in the Actions section.
- [ ] On mobile width the modal is full-width.

### Task 5: Shortcut help overlay (`?`)
**Blocks:** 6  ·  **Blocked by:** 1, 2, 3
**Files:**
- Create: `apps/zync-app/src/components/shortcuts/ShortcutHelpOverlay.tsx`
**Steps:**
- [ ] Build a `Dialog`-based modal listing all currently-active shortcuts grouped by category: **Global**, **Navigation (press G then…)**, **Create (press N then…)**, **On this page**.
- [ ] Render each row as `keys` (using `Badge`/kbd styling from `@zync/ui`, joined for sequences e.g. `G` `D`) + `label`.
- [ ] Source rows from the registry: Global/Navigation/Create from the static global defs; "On this page" dynamically from `getPageShortcuts()` so it reflects the current route's registered shortcuts. Hide a row if the user lacks its `permission` (so the overlay matches what actually fires).
- [ ] Bind `?` (Shift+`/`) globally to toggle this overlay; `Esc` closes. Do not fire `?` while focus is in an input.
- [ ] Provide a visible close button (`[✕]`) with `aria-label="Close keyboard shortcuts"`.
**Acceptance:**
- [ ] Pressing `?` from any non-input context opens the overlay listing Global, Navigation, Create sections.
- [ ] On `/invoices` the "On this page" section shows `/` (Focus search) and `E` (Export CSV); on `/dashboard` it shows no page rows.
- [ ] Permission-denied navigation shortcuts are omitted from the overlay for users lacking access.

### Task 6: Global shortcut definitions wired into the Shell
**Blocks:** 7, 8, 9, 10  ·  **Blocked by:** 3, 4, 5
**Files:**
- Create: `apps/zync-app/src/hooks/useGlobalShortcuts.ts`
- Create: `apps/zync-app/src/hooks/shortcuts/globalShortcuts.ts`
- Modify: `apps/zync-app/src/routes/Shell.tsx` (app-shell root layout)
- Modify: `apps/zync-app/src/main.tsx` (or app root) to mount `ShortcutProvider`
**Steps:**
- [ ] Define the full global shortcut table in `globalShortcuts.ts` exactly matching the spec (see Schema / Interfaces) — command palette, help overlay, the seven `G`-prefixed navigation defs, the four `N`-prefixed create defs.
- [ ] Implement `useGlobalShortcuts()` which registers all global defs once on mount (and unregisters on unmount); reads `permissions` from `useSession()` and `navigate` from `useNavigate()` to build each def's `ShortcutContext`.
- [ ] Mount `<ShortcutProvider>` at the app root wrapping the router so the keydown listener and palette/overlay state are global. Render `<CommandPalette />` and `<ShortcutHelpOverlay />` once inside the provider.
- [ ] Call `useGlobalShortcuts()` inside the `Shell` layout component (the persistent chrome) so it is active for all protected routes but not on `/login`.
- [ ] Ensure the app-shell header "Search… ⌘K" button calls `openPalette()` from context (replace any local open state it had).
**Schema / Interfaces:**
```ts
// apps/zync-app/src/hooks/shortcuts/globalShortcuts.ts
export const GLOBAL_SHORTCUTS: ShortcutDef[] = [
  { id: 'cmd-palette', keys: ['⌘K'], match: 'k', mod: { meta: true }, category: 'global',
    label: 'Open command palette', action: (c) => c /* openPalette via provider */ },
  { id: 'cmd-palette-ctrl', keys: ['Ctrl+K'], match: 'k', mod: { ctrl: true }, category: 'global',
    label: 'Open command palette', action: (c) => c },
  { id: 'help-overlay', keys: ['?'], match: '?', category: 'global',
    label: 'Show keyboard shortcut help', action: (c) => c },
  // Navigation (G then …)
  { id: 'go-dashboard', keys: ['G','D'], prefix: 'G', match: 'd', category: 'navigation',
    label: 'Dashboard', action: (c) => c.navigate('/dashboard') },
  { id: 'go-invoices', keys: ['G','I'], prefix: 'G', match: 'i', category: 'navigation',
    label: 'Invoices', permission: 'invoices:read', action: (c) => c.navigate('/invoices') },
  { id: 'go-proposals', keys: ['G','P'], prefix: 'G', match: 'p', category: 'navigation',
    label: 'Proposals', permission: 'marketing:read', action: (c) => c.navigate('/proposals') },
  { id: 'go-tasks', keys: ['G','T'], prefix: 'G', match: 't', category: 'navigation',
    label: 'Tasks', permission: 'projects:read', action: (c) => c.navigate('/projects') },
  { id: 'go-customers', keys: ['G','C'], prefix: 'G', match: 'c', category: 'navigation',
    label: 'Customers', permission: 'customers:read', action: (c) => c.navigate('/customers') },
  { id: 'go-settings', keys: ['G','S'], prefix: 'G', match: 's', category: 'navigation',
    label: 'Settings', permission: 'users:manage', action: (c) => c.navigate('/settings') },
  { id: 'go-reports', keys: ['G','R'], prefix: 'G', match: 'r', category: 'navigation',
    label: 'Reports', permission: 'reports:read', action: (c) => c.navigate('/reports') },
  // Create (N then …)
  { id: 'new-invoice', keys: ['N','I'], prefix: 'N', match: 'i', category: 'create',
    label: 'New invoice', permission: 'invoices:write', action: (c) => c.navigate('/invoices/new') },
  { id: 'new-customer', keys: ['N','C'], prefix: 'N', match: 'c', category: 'create',
    label: 'New customer', permission: 'customers:write', action: (c) => c.navigate('/customers/new') },
  { id: 'new-proposal', keys: ['N','P'], prefix: 'N', match: 'p', category: 'create',
    label: 'New proposal', permission: 'marketing:write', action: (c) => c.navigate('/proposals/new') },
  { id: 'new-task', keys: ['N','T'], prefix: 'N', match: 't', category: 'create',
    label: 'New task', permission: 'projects:write', action: (c) => c.navigate('/projects?new=task') },
]
export function useGlobalShortcuts(): void
```
**Acceptance:**
- [ ] All twelve global shortcuts fire correctly from `/dashboard` (subject to permissions).
- [ ] Global shortcuts are inactive on `/login` (Shell not mounted there).
- [ ] The header search button and `⌘K` open the same palette instance.

### Task 7: Invoices per-page shortcuts (list + detail)
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Modify: `apps/zync-app/src/features/invoices/InvoiceListPage.tsx`
- Modify: `apps/zync-app/src/features/invoices/InvoiceDetailPage.tsx`
**Steps:**
- [ ] In `InvoiceListPage`, call `usePageShortcuts([...])` registering: `/` → focus the list search input (ref), `E` → trigger "Export current filter results as CSV" (call the existing export handler with current filters).
- [ ] In `InvoiceDetailPage`, register: `E` → Edit invoice (only when status is `DRAFT`; otherwise no-op), `S` → Send invoice (only `DRAFT`), `P` → Print/PDF (open print/PDF view).
- [ ] Guard the `DRAFT`-only actions inside the action function (check `invoice.status === 'DRAFT'` per the `InvoiceStatus` enum) so the binding is always registered but silently no-ops otherwise.
- [ ] Ensure each `usePageShortcuts` registration auto-deregisters on unmount.
**Schema / Interfaces:**
```ts
usePageShortcuts([
  { id: 'inv-focus-search', keys: ['/'], match: '/', category: 'page',
    label: 'Focus search', action: () => searchRef.current?.focus() },
  { id: 'inv-export-csv', keys: ['E'], match: 'e', category: 'page',
    label: 'Export current filter as CSV', action: exportCurrentFilter },
])
```
**Acceptance:**
- [ ] On `/invoices`, `/` focuses the search field and `E` exports the filtered CSV.
- [ ] On `/invoices/:id` for a DRAFT invoice, `E`/`S`/`P` perform edit/send/print; for a non-DRAFT invoice `E` and `S` do nothing while `P` still prints.
- [ ] Navigating away unregisters the page shortcuts (the help overlay no longer lists them).

### Task 8: Proposals per-page shortcuts (list + editor)
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Modify: `apps/zync-app/src/features/proposals/ProposalListPage.tsx`
- Modify: `apps/zync-app/src/features/proposals/ProposalEditor.tsx`
**Steps:**
- [ ] In `ProposalListPage`, register `/` → focus search field.
- [ ] In `ProposalEditor`, register two combo shortcuts with `allowInInput: true` so they fire even while the editor body is focused: `⌘S` / `Ctrl+S` → Save draft (call save handler, `preventDefault`), `⌘⇧P` / `Ctrl+Shift+P` → Preview proposal.
- [ ] The combo defs set `mod: { meta:true }` / `{ ctrl:true }` and (for preview) `shift:true`; the engine's combo path already calls `preventDefault`.
**Schema / Interfaces:**
```ts
usePageShortcuts([
  { id: 'prop-save', keys: ['⌘S'], match: 's', mod: { meta: true }, allowInInput: true,
    category: 'page', label: 'Save draft', action: saveDraft },
  { id: 'prop-save-ctrl', keys: ['Ctrl+S'], match: 's', mod: { ctrl: true }, allowInInput: true,
    category: 'page', label: 'Save draft', action: saveDraft },
  { id: 'prop-preview', keys: ['⌘⇧P'], match: 'p', mod: { meta: true, shift: true }, allowInInput: true,
    category: 'page', label: 'Preview proposal', action: previewProposal },
  { id: 'prop-preview-ctrl', keys: ['Ctrl+Shift+P'], match: 'p', mod: { ctrl: true, shift: true },
    allowInInput: true, category: 'page', label: 'Preview proposal', action: previewProposal },
])
```
**Acceptance:**
- [ ] On `/proposals`, `/` focuses search.
- [ ] In the proposal editor `⌘S` saves the draft without opening the browser save dialog, even while typing in the editor body.
- [ ] `⌘⇧P` opens the proposal preview.

### Task 9: Tasks board per-page shortcuts
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Modify: `apps/zync-app/src/features/tasks/TaskBoardPage.tsx`
**Steps:**
- [ ] Register `N` → open the new-task create modal (bare single key, page-scoped). Because bare `N` is also the global create prefix, the engine must give the page-level bare-`N` precedence on this route (per Task 3 precedence rule); document this so global `N`-prefix create sequences are intentionally shadowed on the tasks board.
- [ ] Register `/` → focus the search/filter field.
**Schema / Interfaces:**
```ts
usePageShortcuts([
  { id: 'task-new', keys: ['N'], match: 'n', category: 'page',
    label: 'New task', action: openCreateTaskModal },
  { id: 'task-focus-filter', keys: ['/'], match: '/', category: 'page',
    label: 'Focus search/filter', action: () => filterRef.current?.focus() },
])
```
**Acceptance:**
- [ ] On `/projects/:id/tasks`, `N` opens the create-task modal and `/` focuses the filter.
- [ ] The help overlay "On this page" section lists `N` and `/` on the tasks board.

### Task 10: `usePageShortcuts` hook export & barrel
**Blocks:** —  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-app/src/hooks/usePageShortcuts.ts`
- Modify: `apps/zync-app/src/hooks/shortcuts/index.ts`
**Steps:**
- [ ] Implement `usePageShortcuts(defs: Omit<ShortcutDef,'action'> & { action } []): void` that registers each def with the registry on mount and disposes all on unmount (via the disposers returned from `register`), keyed on a stable dependency (the def ids).
- [ ] Build each def's `ShortcutContext` from `useSession()` + `useNavigate()` so page actions can navigate and check permissions if needed.
- [ ] Re-export `usePageShortcuts`, `useGlobalShortcuts`, `ShortcutProvider`, `useShortcutRegistry`, `CommandPalette`, `ShortcutHelpOverlay` from the barrel.
**Schema / Interfaces:**
```ts
export function usePageShortcuts(defs: ShortcutDef[]): void
```
**Acceptance:**
- [ ] Mounting a component that calls `usePageShortcuts` registers its shortcuts; unmounting removes them (no leak into the next route).
- [ ] Re-rendering the host component with identical def ids does not re-register (stable identity).

### Task 11: Accessibility, reduced-motion & tests
**Blocks:** —  ·  **Blocked by:** 4, 5, 6, 7, 8, 9, 10
**Files:**
- Modify: `apps/zync-app/src/components/shortcuts/CommandPalette.tsx`
- Modify: `apps/zync-app/src/components/shortcuts/ShortcutHelpOverlay.tsx`
- Create: `apps/zync-app/src/hooks/shortcuts/__tests__/keydownEngine.test.ts`
- Create: `apps/zync-app/src/components/shortcuts/__tests__/CommandPalette.test.tsx`
**Steps:**
- [ ] A11y: both modals use `Dialog` with `role="dialog"` + `aria-modal="true"`, a labelled title (`aria-labelledby`), focus trap, and focus restoration to the trigger on close. Palette input has an accessible label; result list uses `role="listbox"`/`option` (cmdk provides these — verify they render).
- [ ] Reduced motion: gate any palette/overlay open/close transition behind `prefers-reduced-motion: no-preference`; under reduced motion the modal appears with no animation. Use the design-system motion tokens / `prefers-reduced-motion` media query — never an unconditional animation.
- [ ] Ensure shortcut hints in the overlay are not the only affordance: every shortcut maps to a mouse-reachable action elsewhere (nav links, buttons) — confirm no action is keyboard-only-reachable in a way that blocks mouse/AT users.
- [ ] Tests (engine): sequence within/after 1s window; input-focus suppression; combo `preventDefault`; permission no-op; bare-`N` page precedence on tasks board.
- [ ] Tests (palette): opens on `⌘K`, debounced search merges customer results, permission-filtered Actions, `Esc` restores focus.
**Acceptance:**
- [ ] Engine and palette test suites pass.
- [ ] With `prefers-reduced-motion: reduce`, the palette and overlay open without transition animation.
- [ ] Both modals trap focus and restore it to the prior element on close (verified in test).
- [ ] No shortcut fires while focus is inside an `<input>`, `<textarea>`, or `[contenteditable]` (except opted-in editor combos).
