Focus mode — the viewer taking the whole frame — was mouse-only to enter
(the expand button on the strip) and keyboard-only to leave (Esc). That
asymmetry is the bug. Counsel who reads a 200-page compendium with the arrow keys should
never have to go find a button to get the screen out of the way. One key, both directions:
F takes the frame, F gives it back.
One map, one owner. The key is declared in the reading key map alongside the arrows, and acted on by the shell — because focus is the shell's state, and every lens deserves it.
Story-2342 will ship a shortcuts overlay that lists the Reading Room's keys. It reads
readingKeyIntent. A key that hides in its own private handler is a key counsel
is never told about — so F joins the map as an intent, exactly like
ArrowRight:
export type ReadingKeyIntent =
| { kind: "scroll"; delta: number }
| { kind: "viewport"; direction: 1 | -1 }
| { kind: "step-page"; direction: 1 | -1 }
| { kind: "jump-page"; edge: "first" | "last" }
| { kind: "toggle-focus" }; // NEW — handled by the SHELL, on every lens
useReadingKeyboard is mounted by document-scroll-column — which
only exists on the Original lens. Focus mode is not an Original-lens
feature: counsel reading extracted text or the Insights dossier wants the full frame just as
much. And focus is state the shell owns (it is the shell that goes
fixed inset-0).
So the shell binds a second, tiny hook from the same module, and the column's hook
explicitly steps over the intent. That skip is not decoration — it is what
stops F from firing twice on the Original lens (once per listener),
which would toggle focus on and straight back off.
// use-reading-keyboard.ts — inside useReadingKeyboard (the column, Original only)
const intent = readingKeyIntent(event);
if (!intent || intent.kind === "toggle-focus") return; // the shell owns this one
// use-reading-keyboard.ts — the new hook (the shell, every lens)
export function useFocusKey(toggleFocus: () => void): void { ... }
// document-permalink-shell.tsx
useFocusKey(toggleFocus);
| Guard | What it prevents |
|---|---|
isTypingTarget — input / textarea / select / contenteditable |
Typing an "f" into the page-number box or the note composer must type an f, not
swallow the screen. The contenteditable check climbs with closest, so a
caret on a child node of an editor still counts. |
isDialogOpen — any role="dialog" / alertdialog |
While a modal is up the keyboard belongs to it. |
Bare key only — the map returns null on meta / ctrl / alt |
⌘F stays browser find. No modifier chord is stolen. |
Esc is still bound by the shell only while focus is on, and is still deliberately absent from the key map — focus mode owns it, and a second claim on it is how a key conflict starts. This Story adds a key; it does not renegotiate that one.
Unit tests (use-reading-keyboard.test.ts, 13 passing). The
map: f and F → toggle-focus; ⌘F and
Ctrl+F → null. The hook (renderHook, jsdom, real
dispatched KeyboardEvents): toggles on F and toggles back on the next
F; silent while the event target is an <input>; silent inside a
contenteditable; silent while a role="dialog" is mounted; does
not fire on ⌘F; unbinds on unmount.
Storybook: DocumentScrollColumn → KeyboardNavigation — the
key-map note now lists F and says plainly that it is shell-bound and
therefore inert in that story. A fixture that pretended F worked there would be the
fixture lying in our favour.
Checks: pnpm lint 0 errors (3 pre-existing
react-hooks/incompatible-library warnings in
document-scroll-column.tsx, untouched) · pnpm test 1230 passed /
187 files · pnpm build green.
The tests prove the map, the toggle, and the three guards in jsdom. The live proof — F on the real permalink, on all three lenses, and ⌘F still opening the browser's find bar — is a UAT step on staging; this Story does not claim it.
The shortcuts overlay does not exist yet (Story-2342). What this Story guarantees is that
when it lands, F is already in the map it reads — no follow-up edit
to register it.
What died: the mouse round-trip to enter focus mode. Reading a document end to end — page, focus, page — is now a keyboard-only act.
4 file(s), +141 / -5 lines
| Path | Role | + | - |
|---|---|---|---|
| src/components/documents/viewer/document-permalink-shell.tsx | Component | +6 | -0 |
| src/components/documents/viewer/document-scroll-column.stories.tsx | Component | +6 | -0 |
| src/components/documents/viewer/use-reading-keyboard.test.ts | Component | +84 | -2 |
| src/components/documents/viewer/use-reading-keyboard.ts | Component | +45 | -3 |
Instrumentation-exempt per pramaan-docs ADR-124: a client-only keyboard handler inside an existing component tree. No new API route, handler, job, workflow, or user-visible server state. No new span.