PR: #602: feat(shortcuts): registry-driven keyboard shortcuts overlay [Story-2342] by @ankitmaheshwari-com
View PR →

Story-2342: the shortcuts overlay — ? tells counsel what this page listens for

PRAMAAN binds keys in several places already — the Reading Room's arrows, the register's j/k, F for focus mode — and told counsel about none of them. A shortcut nobody knows exists is a shortcut nobody uses. GitHub and Gmail solved this the same way twenty years apart: press ?, get the cheat sheet.

The idea in one line

The map is data, and the overlay READS it. A key map that lives only inside the useEffect that binds it is a map the cheat sheet has to copy by hand — and a hand-kept copy drifts the first time someone adds a key.

1. The registry — a surface declares its keys

src/components/shortcuts/shortcut-registry.ts. Descriptions are message keys, never display strings (AGENTS.md: module-level data carries stable ids, translation happens at render). The key glyphs are not localized — ↑, PageUp, F name the keys a keyboard physically prints, and those are the same in en and hi.

const READING_ROOM: ShortcutSurfaceDefinition = {
  id: "readingRoom",
  groups: [
    { id: "reading", bindings: [
        { id: "scroll",   keys: ["↑", "↓"] },
        { id: "viewport", keys: ["PageUp", "PageDown"] } ] },
    { id: "pages",   bindings: [
        { id: "stepPage", keys: ["←", "→"] },
        { id: "jumpPage", keys: ["Home", "End"] } ] },
    { id: "view",    bindings: [ { id: "focus", keys: ["F"] } ] },   // Story-2348
  ],
};

The Reading Room is the first registered surface, and its map mirrors readingKeyIntent exactly — F included. Story-2348 put F in that map precisely so this Story would find it there, even though the shell (not the lens) acts on it. That promise is now cashed: no follow-up edit was needed to register it.

One group is not a surface's to declare: general, holding ? itself. The provider binds that key on every surface, so no surface can forget it and no surface can spell it twice.

2. The provider — one ?, one overlay, for the whole workspace

ShortcutsProvider is mounted in AppChrome, which sits above every workspace route and above the Matter Action Rail. That placement is the whole design: both entry points — the key and the rail's keyboard icon — must open the same dialog, and the surface that declares the map is mounted underneath. Binding ? once here also means no page re-binds it for itself, which is how one key ends up doing two things.

PieceWhat it does
useShortcutSurface("readingRoom") Called by DocumentPermalinkShell. Claims the slot on mount, releases it on unmount — so the overlay shows the Reading Room's keys while a document is open, and nothing once counsel navigates away. The release is a functional update (current === id ? null : current) because unmount ordering is not ours to assume: a leaving surface must not clear a slot an arriving one already took.
useOpenShortcuts() The rail's keyboard icon. Returns a no-op outside the provider, so a Storybook-mounted rail still renders.
The ? listener Window-level, with the app's existing guards — not a new set.
if (event.metaKey || event.ctrlKey || event.altKey) return;  // chords stay the browser's
if (event.key !== "?") return;
if (isTypingTarget(event.target) || isDialogOpen()) return;  // a ? in a field is a ?

3. The overlay — dumb by construction

ShortcutsOverlay takes already-translated groups. The registry's message keys never reach the view, which is what lets a story feed it any map at all: the real registry's, a mock surface that does not exist yet, Hindi copy, or nothing. It lists only the active surface's keys — a cheat sheet listing the whole app is a manual, and counsel reading a document wants the keys that work on the paper in front of them.

4. The rail icon

A Keyboard icon joins the Action Rail's bottom group, beside Settings — the two controls that are about the app rather than the matter sit together. It goes through RailIconButton like every other rail control (Chore-2333), so its label reaches the user twice: as the accessible name and as a tooltip on hover and on keyboard focus. It is the one RailIconId that never lights the active indicator — it opens a dialog, it does not select a panel.

Design

No new colors, no new tokens. The <kbd> chip reuses the treatment already used in the reading column's key-map story (border-(--surface-hairline-app) · font-mono-app · text-ink-body); the title takes font-serif-display, group headings --ink-meta-muted. Copy ships in both locales (Shortcuts.* in en.json and hi.json), including the rail label.

Evidence

Unit tests. shortcuts-provider.test.tsx: ? opens the overlay showing the Reading Room's map plus the ? row; it stays silent while the event target is an <input>; the rail button opens the same dialog; with no surface registered the overlay says so rather than showing a void. shortcut-registry.test.ts: every message key the registry names exists in both en.json and hi.json — the one moment counsel asks for help is the worst moment to render a raw message key.

Storybook. Shortcuts/ShortcutsOverlay — the registry's real Reading Room map, a mock unregistered surface (proving the overlay is registry-blind), Hindi copy, and the empty state.

Checks: pnpm lint 0 errors (3 pre-existing warnings in document-scroll-column.tsx / avatar.tsx, untouched) · pnpm test 1260 passed / 191 files · pnpm build green · pnpm storybook:check green.

Honest seams (verification doctrine)

One surface is registered, not two. The register (j/k, space, /, v) has a key map and does not yet declare it — the Record page is owned by another agent this cycle, so touching it here would collide. Press ? on the register today and the overlay honestly says the page has no shortcuts. That is a registration gap, not a design gap: the registry entry is ~10 lines and two message blocks.

The live proof — ? on staging, the rail icon on a real matter, ⌘F still opening the browser's find bar — is a UAT step. jsdom proves the guards; only the running app proves the feel.

What died: the invisible key map. Every future shortcut (add note, new comment, zoom) self-documents by joining the registry — or it does not exist, as far as counsel is concerned.

Files changed

14 file(s), +687 / -9 lines

Path Role + -
messages/en.jsonConfig+29-1
messages/hi.jsonConfig+29-1
src/components/app-chrome.tsxComponent+6-0
src/components/documents/viewer/document-permalink-shell.tsxComponent+7-0
src/components/matter-detail/rail-icon-strip.tsxComponent+13-4
src/components/matter-detail/types.tsComponent+12-3
src/components/matters/matter-shell-chrome.tsxComponent+5-0
src/components/shortcuts/shortcut-registry.test.tsComponent+64-0
src/components/shortcuts/shortcut-registry.tsComponent+122-0
src/components/shortcuts/shortcuts-overlay.stories.tsxComponent+106-0
src/components/shortcuts/shortcuts-overlay.tsxComponent+81-0
src/components/shortcuts/shortcuts-provider.test.tsxComponent+81-0
src/components/shortcuts/shortcuts-provider.tsxComponent+124-0
tests/unit/app-chrome.test.tsxTest+8-0

Observability

Instrumentation-exempt per pramaan-docs ADR-124: a client-only overlay plus a local key registry. No API route, workflow boundary, queue, DB write, external call, or user-visible product-state change. No new span; no telemetry call was added.