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 document is one continuous surface, and the paper renders at paper size — capped by the reading column. You scroll it. Nothing to learn.
"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.
| File | Job |
|---|---|
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. |
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.
| Frame | Available to the paper | Fit | Rendered paper |
|---|---|---|---|
| 1280px laptop | 744px (after the two gutters) | 125% | 744px — ~60–75 characters a line |
| 1440px | 856px | 144% | 857px |
| 2560px monitor | 1976px | 168% | 1000px — the cap bites, never a 1400px line |
| narrow / split screen | 320px | 100% | 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.
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.
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:
| Direction | Name | Who fires it |
|---|---|---|
| Where counsel is | reportVisiblePage | The lens, as it scrolls. Scrolls nothing. |
| Where counsel asked to go | requestPage | The 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.
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.
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.
| Pages in the DOM (of 443) | 3–5 |
| Total DOM nodes | ~285 |
| Frame times, full-document fling | p50 13ms · p95 17ms · worst 19ms |
| Long frames (>50ms) | 0 |
| Fit-width computed | 144%, paper 857px — under the 1000px cap |
| Page indicator | 300 at deep scroll · 443 on jump-to-last · 1 at top |
| Dead canvas above first / below last page | 0 / 0 |
pnpm lint (0 errors) · pnpm test (1159 passed) ·
pnpm build · pnpm storybook:check ·
git diff --check — all green.
Documents/Viewer/ScrollColumn:
| Story | What it proves |
|---|---|
Compendium443Pages | The document that produced the ruling. Scroll it; watch the DOM stay tiny and the scrollbar stay honest. |
FocusNoGutters | Margins retracted — the paper takes the whole reading column. |
ManualZoom | A chosen zoom overrides fit-width and is not fought by it. |
ShortFiling | Three pages — no dead canvas above the first or below the last. |
getPage calls.
20 file(s), +1038 / -234 lines
| Path | Role | + | - |
|---|---|---|---|
| design/v1/colors_and_type.css | Styles | +7 | -0 |
| messages/en.json | Config | +2 | -1 |
| messages/hi.json | Config | +2 | -1 |
| package.json | Config | +1 | -0 |
| pnpm-lock.yaml | Config | +20 | -0 |
| src/app/globals.css | App route | +5 | -0 |
| src/components/documents/viewer/document-original-lens.tsx | Component | +102 | -174 |
| src/components/documents/viewer/document-original-pane.tsx | Component | +10 | -6 |
| src/components/documents/viewer/document-pdf-canvas.tsx | Component | +102 | -19 |
| src/components/documents/viewer/document-permalink-shell.tsx | Component | +49 | -11 |
| src/components/documents/viewer/document-scroll-column.stories.tsx | Component | +143 | -0 |
| src/components/documents/viewer/document-scroll-column.tsx | Component | +211 | -0 |
| src/components/documents/viewer/document-strip.stories.tsx | Component | +2 | -3 |
| src/components/documents/viewer/document-strip.tsx | Component | +70 | -8 |
| src/components/documents/viewer/reading-mode-context.tsx | Component | +22 | -2 |
| src/components/documents/viewer/reading-presentation.test.ts | Component | +70 | -0 |
| src/components/documents/viewer/reading-presentation.ts | Component | +66 | -0 |
| src/components/documents/viewer/section-gutter-row.tsx | Component | +111 | -0 |
| src/stores/ui-store.test.ts | Test | +18 | -3 |
| src/stores/ui-store.ts | Source | +25 | -6 |
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.
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.