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 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.
| Keys | What they do | Why 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. |
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.
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.
| File | Job |
|---|---|
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.
|
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.
↑/↓ 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.
Documents/Viewer/ScrollColumn →
KeyboardNavigation: a 120-page mock document with the key map spelled
out. Drive the whole map, watch the page counter move with the paper,
then click into the page box and type: the keys go quiet under the caret. 120
fixture sheets, zero pdfjs — the map is provable in isolation, deterministically.
use-reading-keyboard.test.ts pins every
binding, that Escape is not claimed, and that modifier chords are left
alone. shared/keyboard.test.ts pins both guards, including the
caret-inside-a-contenteditable case above.
pnpm lint, pnpm test (1202
passing), pnpm build, pnpm storybook:check green.
7 file(s), +309 / -12 lines
| Path | Role | + | - |
|---|---|---|---|
| src/components/documents/register/use-register-keyboard.ts | Component | +2 | -12 |
| src/components/documents/viewer/document-scroll-column.stories.tsx | Component | +39 | -0 |
| src/components/documents/viewer/document-scroll-column.tsx | Component | +16 | -0 |
| src/components/documents/viewer/use-reading-keyboard.test.ts | Component | +49 | -0 |
| src/components/documents/viewer/use-reading-keyboard.ts | Component | +129 | -0 |
| src/components/shared/keyboard.test.ts | Component | +43 | -0 |
| src/components/shared/keyboard.ts | Component | +31 | -0 |