A typed PDF opened at "126%". A scan of the same filing, at the same reading width, opened at "88%". Both numbers were true, and both were useless: the raw pdf.js scale is a percentage of the page's self-declared size, and a scan declares the size of the photograph (a ~1654pt-wide page), not of the paper (595pt). The strip was publishing a fact about the file's box to a lawyer who was asking a question about their reading.
Zoom is now fit-relative everywhere a person can see it, step it, or the app can remember it: Fit == 100% of the reading column, for every document. Two documents at "120%" render the same column width — by construction, not by coincidence.
The raw pdf.js scale did not go away — it got demoted. It now exists in exactly one expression, inside the one component that knows both halves of the division (the live frame, and the page's own width), and it leaves that component only as pixels on paper:
src/components/documents/viewer/reading-presentation.ts
export function fitWidthScale(availableWidth, pageWidth) {
if (!(availableWidth > 0) || !(pageWidth > 0)) return 1;
return Math.min(availableWidth, MAX_READING_WIDTH) / pageWidth; // raw. internal.
}
export function readingScale(availableWidth, pageWidth, zoomPercent) {
return fitWidthScale(availableWidth, pageWidth) * (zoomPercent / 100);
}
At zoomPercent = 100 the paper is pageWidth × (column / pageWidth) —
the column, exactly, whatever the page claims to be. pageWidth cancels. That
cancellation is the story: it is why a scan and a born-digital filing at the same
displayed percent are the same width, and why 100% can never overflow the right edge.
| Layer | Unit it speaks |
|---|---|
The strip's indicator and its −/+ (document-strip.tsx) | fit-relative |
The shell's zoom state and its store writes (document-permalink-shell.tsx) | fit-relative |
The persisted per-document memory (ui-store.ts, Bug-2356's map) | fit-relative |
The reading-mode seam (reading-mode-context.tsx) | fit-relative |
The scroll column's scale handed to react-pdf | raw pdf.js — derived here, and nowhere else |
The clamps moved with the unit. ZOOM_MIN / ZOOM_MAX / ZOOM_STEP
are now 50 / 400 / 10, and they mean what they say: half the column, four times the
column, a tenth at a time. The old ZOOM_MIN = 10 existed only because a scan's raw fit
(~45%, or ~25% on a laptop once the gutters take their 536px) had to be expressible in the same
range as counsel's choices. It does not any more — a raw fit is never shown, never stored, and never
clamped.
The fit is also exact now, not floored to an integer percent. Flooring made two documents at the same displayed zoom render a few pixels apart, which is precisely the equality this Story exists to guarantee.
Bug-2366 (same PR): in Fit, a window resize flipped the indicator to "88%" and left
{"mode":"explicit","percent":88} in localStorage — a zoom counsel never
chose, poisoning the per-document memory PR #601 had just built.
onZoomPercentChange → reportZoomPercent → the shell's
reportedZoom), where it sat one careless line away from the store. That whole channel
is deleted. The fit-relative number IS the number the shell already holds; a
re-fit produces nothing to tell anyone. A resize now changes no state at all — the column
re-measures its frame and re-derives a scale, and that is the end of it.
What survives is a single invariant, and it is now structural rather than disciplinary:
setViewerZoom has exactly two callers, and both are user actions —
the −/+ handler and the Fit control.
main,
and I could not find a revision where it did (#601, #589, #581 all have the same two callers). The
staging repro's payload is the #601 shape, so staging was on #601 when it was
captured; my best read is a −/+ press during the orchestrator-driven repro, or a mid-deploy bundle.
The guard tests land regardless, and the refactor above removes the seam it would have needed.
The store goes to v4. Every zoom schema so far has been in a unit v4 does not speak, and neither is convertible where the conversion would have to happen:
| Version | What it held | Fate |
|---|---|---|
| v2 | ONE global raw percent, for every document | dropped (already, in Bug-2356) |
| v3 | a per-document map of raw pdf.js percents | dropped |
A stored 88 meant "88% of a scan's photo size" — which is a zoom counsel had
zoomed in to. Reading it as fit-relative would silently shrink their
paper to 88% of the column. Converting it needs the page width and the frame width, and
migrate() runs at rehydrate, where neither exists. So the entries go. The cost is one
forgotten zoom per document; the document reopens at Fit, which is the default it would have had
anyway, and one −/+ press restores it in a unit that will still mean something tomorrow.
| Before | After | |
|---|---|---|
| Opening a scan | indicator reads "Fit" (or, after a resize, a spurious "88%") | indicator reads 100% — the paper fills the reading column |
| Opening a typed filing | "Fit", and a raw "126%" the moment anything touched the zoom | 100% — the same number, for the same reading width |
| Pressing + | a step in raw scale: 88% → 98% on a scan, 126% → 136% on a filing (same press, different meaning) | 110% on both — a tenth wider than the column |
| Resizing the window in Fit | paper re-fits; indicator could flip and persist (Bug-2366) | paper re-fits; indicator stays 100%; nothing is written |
The indicator is still the Fit control: in explicit mode it shows the chosen
percent and hands the paper back to Fit when pressed; in Fit it rests, because there is nothing to
return to. The word "Fit" is gone from the strip — and with it the now-dead
DocumentViewer.nav.fit key, removed from both en and
hi. "100%" is not a shorter way of saying "Fit"; it is the sentence that was missing.
The regression guard drives the real DocumentScrollColumn — the
component that owns the ResizeObserver, and therefore the only place a re-fit could
ever have written itself back — with a stubbed observer and a stubbed frame width
(document-scroll-column.test.tsx):
| Check | Result |
|---|---|
| Scan fixture (1654×2339), frame dragged 1280 → 900 → 780 in Fit | paper re-fits to each frame; never exceeds the column — no right-edge overflow |
| …and the zoom store after those resizes | {} — untouched, in memory and on disk |
| Typed fixture vs scan fixture, both at a displayed 120% | same column width, and it is 1.2× the reading column on both |
| Explicit 200% through a resize | held — a re-fit never fights a chosen zoom |
| Gate | Result |
|---|---|
pnpm lint | 0 errors (3 pre-existing useVirtualizer warnings) |
pnpm test | 192 files, 1269 tests, all passing |
pnpm build | clean |
pnpm storybook:check | clean |
git diff --check | clean |
Storybook: Documents/Viewer/ScrollColumn → ZoomModesAndResize and
ScannedFilingFitsTheFrame both carry the frame-width control (1280 / 1000 / 780).
Drive them side by side: the indicator does not move on resize, and the scan and
the born-digital filing are the same width at the same percent.
Documents/Viewer/DocumentStrip → ZoomFitMode shows the indicator at 100%.
stg.app.pramaan.io, on the founder's document
(83dc60f9-cf90-4847-8751-8beb49b28a39, matter 13):
localStorage.pramaan-ui
gains no entry for that document.+ twice → 120%. Open a born-digital filing, press
+ twice → 120%. The two must be the same reading width.16 file(s), +438 / -173 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +0 | -1 |
| messages/hi.json | Config | +0 | -1 |
| src/components/documents/viewer/document-original-lens.tsx | Component | +4 | -7 |
| src/components/documents/viewer/document-original-pane.tsx | Component | +1 | -2 |
| src/components/documents/viewer/document-pdf-canvas.tsx | Component | +4 | -7 |
| src/components/documents/viewer/document-permalink-shell.tsx | Component | +22 | -25 |
| src/components/documents/viewer/document-scroll-column.stories.tsx | Component | +39 | -23 |
| src/components/documents/viewer/document-scroll-column.test.tsx | Component | +169 | -0 |
| src/components/documents/viewer/document-scroll-column.tsx | Component | +21 | -21 |
| src/components/documents/viewer/document-strip.stories.tsx | Component | +10 | -7 |
| src/components/documents/viewer/document-strip.tsx | Component | +11 | -6 |
| src/components/documents/viewer/reading-mode-context.tsx | Component | +6 | -5 |
| src/components/documents/viewer/reading-presentation.test.ts | Component | +55 | -34 |
| src/components/documents/viewer/reading-presentation.ts | Component | +47 | -23 |
| src/stores/ui-store.test.ts | Test | +29 | -2 |
| src/stores/ui-store.ts | Source | +20 | -9 |
Typed-%: the strip has no typed zoom input today — the indicator is the Fit control. There was nothing to convert. When one is added, it reads and writes this same fit-relative unit, because there is no other unit left in the viewer above the scroll column.