PR: #621: feat(viewer): page-number dropdown in the strip pager [Story-2399] by @ankitmaheshwari-com
View PR →

Story-2399 · pramaan-app · 15 Jul 2026

The page box learns to open.

The strip already told counsel where they were — page 108 of 443 — and let them type a number to move. What it never did was let them look. Typing 412 requires already knowing you want 412. This Story gives the page box a caret, and behind the caret the whole document as a list: somewhere to look around from, not just a box to answer into.

The one idea

One jump, three doors. The steppers, the typed number, and now the list all land on the same onGoToPage. There is no second path into the document and no second idea of what a page is — so the indicator and the paper cannot drift apart, whichever door counsel came through.

What shipped

Piece What it does
A caret on the page box The number counsel types and the caret that opens the list now share one frame, so the hover and focus wash lands on the whole control rather than half of it. Typing is untouched: the input is not the trigger, so a keystroke never opens a menu over the document.
The house menu, not the OS one The list is the repo’s own DropdownMenu (src/components/ui/dropdown-menu.tsx) — token-lit, opening downward under the box. Nothing about the menu was hand-rolled, which is also why its manners are free: Esc, outside click, arrows, Enter, focus trap.
443 pages that do not jank The rows are virtualized with @tanstack/react-virtual (already a dependency, and already how the reading column renders pages). A 443-page compilation puts ~25 rows in the DOM, not 443, so opening the pager on the longest brief in the matter costs what it costs on a six-page notice.
It opens where counsel already is On page 108, the list opens centred on 108 with 108 focused — not at page 1. A list you have to scroll back through to find yourself is not somewhere to look around from. This is also what leaves Enter something to commit and starts the arrows under counsel’s hand.
One rule for what a page is The range check behind a typed number moved out of the input and into parsePageJump() in strip-presentation.ts, where the list can share it. A half-typed “4” on the way to “412” still moves nothing; only a whole, in-range page does.

Why the list opens centred — and how

The naive version scrolls the list after it opens: render at page 1, then scroll to 108. That flashes the top of the document on the way, and it fights Radix, which is focusing the content in the same breath. Rows here are an exact height (26px), so the opening offset is not something to discover — it is arithmetic. Seeding the virtualizer with it means the current row is in the DOM on the first render, before any paint.

open │ ├─ initialOffset = (page-1)·ROW_H − LIST_H/2 + ROW_H/2 ← arithmetic, not a scroll │ └─ virtualizer's FIRST render window already contains page 108 │ ├─ useLayoutEffect: scrollTop = initialOffset ← before paint, so no flash │ ├─ Radix focuses the content itself (preventScroll) ← its own opening move │ └─ rAF: focus [data-page-index="107"] ← lands AFTER Radix, not racing it └─ Enter now has something to commit; arrows start at 108

Focus and virtualization then cooperate without any custom key handling: arrowing onto a row scrolls it into view, and that scroll renders the rows beyond it before the next keypress. So overscan never has to cover more than one step, and the focus walk crosses the rendered window’s edge without noticing it.

What was verified, and how

A green build is not proof that a menu opens. The story was driven in a real browser against the built Storybook — the 443-page mock, which is the case the ruling was written against.

Claim Observed
Virtualized 25 rows in the DOM of 443; still 25 after arrowing 30 rows down
Opens downward, on the current page data-side="bottom"; window 96–120; focus on 108
Arrows cross the window edge focus walked 109 → 138 unbroken (the open window ended at 120)
Enter commits; Esc and outside click close menu closed on each (Esc by real key press)
Typing unchanged digits accepted; the menu stays shut while typing

The seam

parsePageJump() is stricter than the Number.parseInt it replaces: "4.5" and "1e2" used to commit as pages 4 and 1, and now commit as nothing. For a typed page number — digits — behaviour is identical, which is what the AC asked for; the change only closes readings of the box that were never pages.

Observability (pramaan-docs ADR-124): instrumentation-exempt. This is a client-only viewer affordance over the existing goToPage path. It adds no API route, handler, job, queue, DB/outbox write, external call, or LLM/tool boundary, and changes no user-visible product state beyond local viewer scroll position. No new instrumented boundary was introduced, so no manual span is added.

Files changed

7 file(s), +278 / -59 lines

Path Role + -
messages/en.jsonConfig+2-1
messages/hi.jsonConfig+2-1
src/components/documents/viewer/document-strip.stories.tsxComponent+36-0
src/components/documents/viewer/document-strip.tsxComponent+2-56
src/components/documents/viewer/page-number-box.tsxComponent+197-0
src/components/documents/viewer/strip-presentation.test.tsComponent+24-1
src/components/documents/viewer/strip-presentation.tsComponent+15-0