PR: #582: feat(viewer): keyboard navigation for the Reading Room [Story-2318] by @ankitmaheshwari-com
View PR →

Story-2318: Keyboard navigation in the Reading Room (Epic-2317)

Story-2309 made the Original lens one continuous scroll. Counsel can now read a 443-page compendium the way they read anything else — by scrolling it. What they still could not do was read it with their hands on the keyboard: no arrow keys, no page turn, no jump to the end. Every movement was a mouse movement.

A reading surface a litigator lives inside for an hour is a surface they should be able to drive without reaching for the trackpad. That is this Story.

The idea in one line

The keyboard drives the same scroll the mouse does — so the paper and the page indicator can never disagree, because there is only ever one of them moving.

The key map

KeysWhat they doWhy that shape
↑ ↓ Nudge the column by 96px — a few lines of type. Vertical arrows mean "read on" in every document surface counsel has ever used. Small enough that holding the key reads as scrolling, not as flicking through slides.
← → Snap to the previous / next page. The horizontal axis is where "turn the page" lives — it is the one movement in a document that is discrete. It snaps the page's top edge to the fold.
PageUp PageDown Step one viewport height. The OS-level convention. Not a page of paper — a screenful.
Home End First / last page. The ends of the document, one keystroke away from anywhere in it.
Esc Deliberately absent from this map. Focus mode owns Escape (it is how counsel gets the shell back). A second claim on it here is exactly how a key conflict is born — so the reading map never looks at it, and focus mode's behaviour is unchanged.

Why the naive version detonates

The two-line version is a keydown listener that moves a page state variable. It breaks in two ways at once.

One: two sources of truth for "where am I". Since Story-2309 the page indicator is derived from the scroll — the column reports the topmost page under the fold. A key handler that sets the page instead of scrolling to it creates a second, competing authority: the indicator says 12, the paper still shows 11, and the next scroll event yanks the number back. So ←/→ do not touch the page state at all — they scroll, and the indicator follows the scroll the same way it follows a mouse wheel. One mover, one truth.

Two: the shortcut eats the caret. A window-level arrow-key handler will happily page the document out from under counsel while they are typing a page number into the strip's box, or drafting a note in the composer. That is the guard below, and it is the load-bearing part of this change.

The guard is the feature. Keys are ignored while focus is in an input, textarea, select, or anywhere inside a contenteditable, and while any dialog is open. Modifier chords (⌘←, ⌘F) are left to the browser. A reading shortcut that fires while a caret is live is not a shortcut — it is a bug counsel will hit on their first note.

The pieces

FileJob
src/components/shared/keyboard.ts (new, shared) The two questions every global key map asks before it acts: isTypingTarget() and isDialogOpen(). It exists because this guard had now been written twice — the document register's map (Story-2295) had its own private copy — and a guard that is copy-pasted is a guard that will be fixed in one place and not the other. The register now imports it, and inherits the fix below.
viewer/use-reading-keyboard.ts (new) The map itself. Split in two on purpose: readingKeyIntent(event) is a pure function from a key to an intent — no DOM, no React, so the map is unit-testable — and the hook is the thin thing that binds it to the window and performs the intent.
viewer/document-scroll-column.tsx (wired) Calls the hook. This is the component that holds the scrolling element, knows what a page is worth in pixels, and already owns scrollToIndex — so the keyboard reuses the exact machinery the strip's page box uses. No new scroll path was invented for it.
A real bug fell out of the promotion. The register's original guard read target.isContentEditable, which is false for the node the caret actually sits on inside a rich-text editor — only the editor's ROOT carries the flag. The shared helper climbs with closest('[contenteditable="true"]') instead. Both surfaces are now correct; had the copy stayed, only one would have been.

Reduced motion

↑/↓ and PageUp/PageDown scroll smoothly — unless the reader has asked their OS for no motion, in which case the scroll is instant (prefers-reduced-motion). Smooth scrolling is the one motion the app's global MotionConfig cannot see, because the browser, not motion/react, performs it.

Verification

Files changed

7 file(s), +309 / -12 lines

Path Role + -
src/components/documents/register/use-register-keyboard.tsComponent+2-12
src/components/documents/viewer/document-scroll-column.stories.tsxComponent+39-0
src/components/documents/viewer/document-scroll-column.tsxComponent+16-0
src/components/documents/viewer/use-reading-keyboard.test.tsComponent+49-0
src/components/documents/viewer/use-reading-keyboard.tsComponent+129-0
src/components/shared/keyboard.test.tsComponent+43-0
src/components/shared/keyboard.tsComponent+31-0

Scope notes