? 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 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.
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.
?, 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.
| Piece | What 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 ?
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.
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.
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.
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.
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.
14 file(s), +687 / -9 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +29 | -1 |
| messages/hi.json | Config | +29 | -1 |
| src/components/app-chrome.tsx | Component | +6 | -0 |
| src/components/documents/viewer/document-permalink-shell.tsx | Component | +7 | -0 |
| src/components/matter-detail/rail-icon-strip.tsx | Component | +13 | -4 |
| src/components/matter-detail/types.ts | Component | +12 | -3 |
| src/components/matters/matter-shell-chrome.tsx | Component | +5 | -0 |
| src/components/shortcuts/shortcut-registry.test.ts | Component | +64 | -0 |
| src/components/shortcuts/shortcut-registry.ts | Component | +122 | -0 |
| src/components/shortcuts/shortcuts-overlay.stories.tsx | Component | +106 | -0 |
| src/components/shortcuts/shortcuts-overlay.tsx | Component | +81 | -0 |
| src/components/shortcuts/shortcuts-provider.test.tsx | Component | +81 | -0 |
| src/components/shortcuts/shortcuts-provider.tsx | Component | +124 | -0 |
| tests/unit/app-chrome.test.tsx | Test | +8 | -0 |
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.