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
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.
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.json | Config | +2 | -1 |
| messages/hi.json | Config | +2 | -1 |
| src/components/documents/viewer/document-strip.stories.tsx | Component | +36 | -0 |
| src/components/documents/viewer/document-strip.tsx | Component | +2 | -56 |
| src/components/documents/viewer/page-number-box.tsx | Component | +197 | -0 |
| src/components/documents/viewer/strip-presentation.test.ts | Component | +24 | -1 |
| src/components/documents/viewer/strip-presentation.ts | Component | +15 | -0 |