PR: #591: feat(viewer): F toggles focus mode from the keyboard [Story-2348] by @ankitmaheshwari-com
View PR →

Story-2348: F toggles focus mode

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.

The idea in one line

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.

Why the key goes in the map even though the map does not act on 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

Why the SHELL acts on it, not the reading column

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);

The guards — the same three, not a new set

GuardWhat 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 untouched

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.

Evidence

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.

Honest seams (verification doctrine)

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.

Files changed

4 file(s), +141 / -5 lines

Path Role + -
src/components/documents/viewer/document-permalink-shell.tsxComponent+6-0
src/components/documents/viewer/document-scroll-column.stories.tsxComponent+6-0
src/components/documents/viewer/use-reading-keyboard.test.tsComponent+84-2
src/components/documents/viewer/use-reading-keyboard.tsComponent+45-3

Observability

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.