PR: #619: feat(viewer): keyboard zoom — −/=/+ step, 0 back to Fit [Story-2398] by @ankitmaheshwari-com
View PR →

Story-2398: keyboard zoom — the keys every reader already has

The founder opened a document, pressed −, pressed =, pressed Shift+= — and nothing moved. Zoom existed, but only under the mouse: the strip's −/+ cluster was the sole way to reach it. Every reader a lawyer has ever used (Preview, Acrobat, Chrome's own PDF pane) answers those three keys, so their silence read as a broken viewer rather than as a missing feature.

The Reading Room's key map now owns them: − zooms out one step, = and + (Shift+=) zoom in one step, 0 returns to Fit.

The steps are the buttons' steps — because they are the buttons' handlers

The one way a key and a button can drift apart is by having two code paths. They do not have two. The shell already owned the zoom (it is a per-document, fit-relative preference in the persisted store — Story-2367, Bug-2356), and it already handed the strip three verbs. The key binder is handed the same three:

src/components/documents/viewer/document-permalink-shell.tsx

const zoomIn  = useCallback(() => zoomBy(ZOOM_STEP), [zoomBy]);
const zoomOut = useCallback(() => zoomBy(-ZOOM_STEP), [zoomBy]);

useZoomKeys({
  enabled: lens === "original",   // only the lens that HAS paper
  onZoomIn: zoomIn,
  onZoomOut: zoomOut,
  onZoomFit: zoomFit,
});

// ...and the strip is given the very same three:
paging={ lens === "original" ? { …, onZoomIn: zoomIn, onZoomOut: zoomOut, onZoomFit: zoomFit } : undefined }

So a press of + and a click of + step by the same ZOOM_STEP (10 fit-relative percent), clamp at the same ZOOM_MIN/ZOOM_MAX (50 / 400), read the same live base out of the store, and write the same per-document entry. There is no second step size to keep in sync, because there is no second implementation.

Where each key is bound, and why

KeysBound byWhy there
↑ ↓ ← → PageUp/Down Home End the scroll column (useReadingKeyboard) they move the scrolling element, which only the column holds
F the shell (useFocusKey) focus is the shell's state, and works on Text and Insights too
− = + 0 the shell (useZoomKeys) zoom is the shell's per-document state — the same state the strip writes

All four sets are declared in one map (readingKeyIntent), even though three different hooks act on it. That is deliberate and predates this Story: the shortcuts overlay reads the map, so a key declared anywhere else is a key counsel is never told about. The two hooks bound on the same window skip each other's intents, so one press never fires twice — the scroll column ignores zoom / zoom-fit / toggle-focus, and the zoom binder ignores everything else.

The guards, unchanged and inherited

SituationBehaviour
Caret in the strip's page box, a note composer, any contenteditablequiet — typing 0 types a 0
A dialog is open (delete confirm, the shortcuts overlay itself)quiet — the dialog owns the keyboard
⌘− / ⌘+ / ⌘0 (and the Ctrl equivalents)not intercepted — page zoom stays the browser's
Text / Insights lensnot bound at all — no paper, and the strip hides the cluster there for the same reason

The chord guard is structural, not a special case: readingKeyIntent returns null for any metaKey/ctrlKey/altKey press before it looks at the key. Shift is deliberately not in that list — it has to reach through, because + IS Shift+= (and ? is Shift+/, which is how the overlay opens).

Both = and + zoom in: = is the unshifted key the + is printed on, so counsel who does not reach for Shift still zooms in — and counsel who does gets the key the label promised.

The overlay lists them, automatically

The registry is the single declaration the ? overlay reads. Two rows were added to the Reading Room's View group, and that is the entire overlay change — no overlay component, story, or fixture was touched:

src/components/shortcuts/shortcut-registry.ts

{
  id: "view",
  bindings: [
    { id: "focus",   keys: ["F"] },
    { id: "zoom",    keys: ["−", "+"] },   // Story-2398
    { id: "zoomFit", keys: ["0"] },
  ],
},

The glyphs are what a reader expects on a cheat sheet, not the raw event.keys the map matches: the row reads − + because + is what the key is printed with. Copy landed in both locales (Shortcuts.readingRoom.actions.zoom, .zoomFit) — the registry test asserts every named key exists in en AND hi, so a one-locale row fails CI rather than rendering a raw message key at the one moment counsel asked for help.

Design frame, named honestly. The canonical frame (pramaan-desk .../keyboard-shortcuts-v2/keyboard-shortcuts.dc.html) governs how the overlay — zoom rows included — will look. That redesign is Story-2401, not this one. This Story delivers the KEYS and the REGISTRY ROWS the redesign will render; the rows appear in today's overlay in today's styling. No conflict with design/v1 tokens arose, because no styling was written here.

Verification

Unit tests for the map additions (use-reading-keyboard.test.ts): −/=/+ / 0 produce the right intents; Shift+= reaches through as +; ⌘−, ⌘=, Ctrl++, ⌘0 all return null. And for the binder itself: it fires the three handlers, stays quiet in an <input> and under an open role="dialog", is not bound when enabled: false (Text / Insights), and unbinds on unmount.
GateResult
pnpm lint0 errors (3 pre-existing useVirtualizer warnings)
pnpm test197 files, 1302 tests, all passing
pnpm buildclean
pnpm storybook:checkclean
git diff --checkclean

Storybook: Shortcuts/ShortcutsOverlay → Reading Room (registry map) is a READ of the registry, so the two new View rows appear there with no story edit. (Keyboard binding itself is shell state, not a component prop — it is proven by the hook tests above, not by a story.)

UAT (post-deploy, stg.app.pramaan.io). A green build is not deployed-runtime proof (LI-7). On a document's Original lens:
  1. Press − → indicator steps down 10% (100% → 90%). Press = → back to 100%. Press Shift+= → 110%. Press 0 → back to Fit (100%).
  2. Hold − down to the floor: it stops at 50%. Hold +: it stops at 400%. Same ends as the buttons.
  3. Press ⌘− / ⌘0 → the browser page zoom moves, and the paper's indicator does not.
  4. Click into the page-number box and type 0 → a 0 is typed; the zoom does not move.
  5. Press ? → the overlay lists − + and 0 under View.
  6. On the Text lens, press − / + → nothing happens (there is no paper to zoom).
Until that runs, the issue stays in Review.

Files changed

7 file(s), +243 / -70 lines

Path Role + -
messages/en.jsonConfig+3-1
messages/hi.jsonConfig+3-1
src/components/documents/viewer/document-permalink-shell.tsxComponent+16-3
src/components/documents/viewer/use-reading-keyboard.test.tsComponent+113-0
src/components/documents/viewer/use-reading-keyboard.tsComponent+88-9
src/components/shortcuts/shortcut-registry.tsComponent+11-4
src/components/shortcuts/shortcuts-overlay.stories.tsxComponent+9-52