PR: #581: feat(viewer): continuous-scroll Original lens — fit-width by default [Story-2309] by @ankitmaheshwari-com
View PR →

Story-2309: Continuous-scroll Original lens (EPIC-2270)

The Original lens showed one page at a time, behind a prev/next pager. On a 443-page compendium that is not reading — it is a slide projector, and counsel has to learn a machine before they can read a filing. The founder ruling, from UAT on that exact document (2026-07-13): "Why can't somebody scroll like in Google Docs — it's too difficult to learn."

The idea in one line

The document is one continuous surface, and the paper renders at paper size — capped by the reading column. You scroll it. Nothing to learn.

Why the naive version detonates

"Render all the pages in a scroll" is a two-line change and a dead browser. 443 pdf.js canvases at ~125% is on the order of a gigabyte of bitmap and tens of thousands of text-layer spans. The tab does not get slow; it dies.

So the surface has to be virtualized — and virtualization has its own trap. If you don't know how tall a page is before you paint it, the scrollbar is a lie: it grows and lurches as pages arrive, and a drag to 60% lands somewhere different every time. The fix is that every page reserves its true box from pdf.js metrics from the first frame, painted or not.

The three pieces

FileJob
document-scroll-column.tsx (new) The reading surface. Virtualized with @tanstack/react-virtual (industry standard, same family as our query layer). Only the pages under the fold ± a two-page buffer exist in the DOM — the rest are absent, not hidden. It knows nothing about PDFs: it is handed page metrics and a page painter.
document-pdf-canvas.tsx (rewritten) Opens the document, learns the shape of every page, hands both to the column. The only importer of react-pdf/pdfjs — still dynamically imported, so the Text lens never pays for it (Bug-2150).
reading-presentation.ts (new) The arithmetic — fit-width, zoom clamping, "which page am I on". Pure, no React, no pdfjs, unit-tested. The shell, the lens, and Storybook all agree because they all read from here.
The column's ignorance of PDFs is the load-bearing decision. Because it takes metrics + a painter rather than a file, Storybook can drive it with 443 fixture sheets and no pdfjs in the story bundle at all — so the perf claim is testable in isolation, deterministically, in a second. A column that imported react-pdf could only ever be "verified" against a real PDF behind auth, which is exactly the fixtures-lie-in-your-favour trap.

Fit-width: paper at paper size, capped by the reading column

scale = min(availableWidth, MAX_READING_WIDTH) / pdfPageWidth   // floored at 100%

--reading-column-max: 1000px is a new token in design/v1/colors_and_type.css. It is also mirrored as a number (MAX_READING_WIDTH) — not laziness: the fit scale is a division by a value only pdf.js knows at runtime, and CSS cannot divide. Both sides carry a comment naming the other.

FrameAvailable to the paperFitRendered paper
1280px laptop744px (after the two gutters)125%744px — ~60–75 characters a line
1440px856px144%857px
2560px monitor1976px168%1000px — the cap bites, never a 1400px line
narrow / split screen320px100%the floor: never smaller than native scale

The cap is the whole point. Fit-width without a cap is a regression on a big monitor: it stretches a filing into a 1400px line that no one can read without moving their head. The floor is the mirror of it — a filing on a narrow frame stays legible and scrolls sideways rather than shrinking into nothing.

A chosen zoom is never fought

Manual zoom overrides the default and persists — in the Zustand ui-store (HR-23: partialize, version: 2 + migrate, skipHydration). It belongs there and not in TanStack because it is a preference about looking, not a fact about the document.

null is a real state — "never chose" — and is deliberately not collapsed to 100. Collapsing it would mean a person who never touched the zoom is permanently pinned at 100% and can never get fit-width back. The old v1 persisted payload has no zoom key at all, which migrate lands as exactly that null: an old client upgrades straight into fit-width.

The page indicator now runs in two directions

It has to follow the scroll (counsel reads down; the number counts up) and drive it (type 412, go to 412). Feed both through one setter and you get a fight: the scroll reports page 7, which the shell pushes back down as "go to page 7", which re-scrolls, which reports... So the seam has two names, on purpose:

DirectionNameWho fires it
Where counsel isreportVisiblePageThe lens, as it scrolls. Scrolls nothing.
Where counsel asked to gorequestPageThe strip's steppers and page box; the ?page=N pin from Insights.

The request carries a nonce, so asking for the same page twice (step to 5, scroll away, step to 5 again) still moves the document.

Rails and gutters, now that "the page" is plural

The machine's findings (left) and the conversation (right) used to hang off the page, because there was only one. Now they hang off each page's own frame — the column asks "what belongs on page N?" for every page it paints.

Naively that is a walk of every section on every scroll frame for every painted page, which is how a 443-page scroll goes to sleep. So the section geometry is resolved once per section set into a Map<page, placed[]> (a section already knows the pages it spans), and the scroll path is an O(1) lookup.

Mixed page sizes

A landscape exhibit dropped into a portrait compendium gets its own box, centered in the column — but rendered at the document's one scale, not re-fitted to fill the frame. Fitting each page independently would make the type size jump between pages of the same document, which is worse than the whitespace it saves.

Verified in a real browser, not just in green checks — the 443-page story driven with Playwright at 1440×900:
Pages in the DOM (of 443)3–5
Total DOM nodes~285
Frame times, full-document flingp50 13ms · p95 17ms · worst 19ms
Long frames (>50ms)0
Fit-width computed144%, paper 857px — under the 1000px cap
Page indicator300 at deep scroll · 443 on jump-to-last · 1 at top
Dead canvas above first / below last page0 / 0
pnpm lint (0 errors) · pnpm test (1159 passed) · pnpm build · pnpm storybook:check · git diff --check — all green.

Stories

Documents/Viewer/ScrollColumn:

StoryWhat it proves
Compendium443PagesThe document that produced the ruling. Scroll it; watch the DOM stay tiny and the scrollbar stay honest.
FocusNoGuttersMargins retracted — the paper takes the whole reading column.
ManualZoomA chosen zoom overrides fit-width and is not fought by it.
ShortFilingThree pages — no dead canvas above the first or below the last.
Known ceiling, deliberately left. Page metrics are harvested for the whole document in the background and swapped in one go, so a uniform document (nearly all of them) never sees the scrollbar move, and a mixed one corrects itself once rather than nudging 443 times. Until that swap lands, pages are laid out at the first page's size. If a document ever shows a visible correction on load, the upgrade is to stream the metrics in chunks — not to block first paint on 443 getPage calls.

Files changed

20 file(s), +1038 / -234 lines

Path Role + -
design/v1/colors_and_type.cssStyles+7-0
messages/en.jsonConfig+2-1
messages/hi.jsonConfig+2-1
package.jsonConfig+1-0
pnpm-lock.yamlConfig+20-0
src/app/globals.cssApp route+5-0
src/components/documents/viewer/document-original-lens.tsxComponent+102-174
src/components/documents/viewer/document-original-pane.tsxComponent+10-6
src/components/documents/viewer/document-pdf-canvas.tsxComponent+102-19
src/components/documents/viewer/document-permalink-shell.tsxComponent+49-11
src/components/documents/viewer/document-scroll-column.stories.tsxComponent+143-0
src/components/documents/viewer/document-scroll-column.tsxComponent+211-0
src/components/documents/viewer/document-strip.stories.tsxComponent+2-3
src/components/documents/viewer/document-strip.tsxComponent+70-8
src/components/documents/viewer/reading-mode-context.tsxComponent+22-2
src/components/documents/viewer/reading-presentation.test.tsComponent+70-0
src/components/documents/viewer/reading-presentation.tsComponent+66-0
src/components/documents/viewer/section-gutter-row.tsxComponent+111-0
src/stores/ui-store.test.tsTest+18-3
src/stores/ui-store.tsSource+25-6

Observability (pramaan-docs ADR-124)

Instrumentation-exempt, per the governing Story: a client-side render/zoom/scroll surface. No new business workflow boundary, API route, job, or model call — the page bytes still ride the existing presigned-URL fetch, which is already auto-instrumented.

One-sentence recall

The whole document, in one scroll, at paper size — capped by the reading column, and only the pages you can see are ever really there.