PR: #606: fix(viewer): fit-relative zoom — 100% is the reading column [Story-2367][Bug-2366] by @ankitmaheshwari-com
View PR →

Story-2367: fit-relative zoom — 100% is the reading column

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.

Where the unit lives (and where it does not)

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.

LayerUnit 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-pdfraw 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 rode along, and the fix is a deletion

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.

The old shape made that class of bug possible. The lens was the only thing that knew the frame and the page width, so it computed the live fit and reported it back up (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.

Honest finding on Bug-2366: the writing path does not exist on 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.

Migration: the old numbers are DROPPED, not converted

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:

VersionWhat it heldFate
v2ONE global raw percent, for every documentdropped (already, in Bug-2356)
v3a per-document map of raw pdf.js percentsdropped

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.

What counsel sees

BeforeAfter
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.

Verification

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):

CheckResult
Scan fixture (1654×2339), frame dragged 1280 → 900 → 780 in Fitpaper 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 resizeheld — a re-fit never fights a chosen zoom
GateResult
pnpm lint0 errors (3 pre-existing useVirtualizer warnings)
pnpm test192 files, 1269 tests, all passing
pnpm buildclean
pnpm storybook:checkclean
git diff --checkclean

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%.

Staging seam, named honestly. A green Storybook proves the component; only the live flow proves the feature (AGENTS.md, "done = system boundary"), and a green build is not deployed-runtime proof (LI-7). I have no authed staging session. Post-deploy UAT on stg.app.pramaan.io, on the founder's document (83dc60f9-cf90-4847-8751-8beb49b28a39, matter 13):
  1. Open it — the indicator must read 100%, not "88%".
  2. Narrow the window to ~900px, then ~780px — the paper re-fits, never runs off the right edge, the indicator stays 100%, and localStorage.pramaan-ui gains no entry for that document.
  3. Press + twice → 120%. Open a born-digital filing, press + twice → 120%. The two must be the same reading width.
Until that runs, both issues stay in Review.

Files changed

16 file(s), +438 / -173 lines

Path Role + -
messages/en.jsonConfig+0-1
messages/hi.jsonConfig+0-1
src/components/documents/viewer/document-original-lens.tsxComponent+4-7
src/components/documents/viewer/document-original-pane.tsxComponent+1-2
src/components/documents/viewer/document-pdf-canvas.tsxComponent+4-7
src/components/documents/viewer/document-permalink-shell.tsxComponent+22-25
src/components/documents/viewer/document-scroll-column.stories.tsxComponent+39-23
src/components/documents/viewer/document-scroll-column.test.tsxComponent+169-0
src/components/documents/viewer/document-scroll-column.tsxComponent+21-21
src/components/documents/viewer/document-strip.stories.tsxComponent+10-7
src/components/documents/viewer/document-strip.tsxComponent+11-6
src/components/documents/viewer/reading-mode-context.tsxComponent+6-5
src/components/documents/viewer/reading-presentation.test.tsComponent+55-34
src/components/documents/viewer/reading-presentation.tsComponent+47-23
src/stores/ui-store.test.tsTest+29-2
src/stores/ui-store.tsSource+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.