PR: #634: feat(documents): shared page-permalink helper for ?page=N deep links [Story-2435] by @ankitmaheshwari-com
View PR →

Story-2435 · pramaan-app · 15 Jul 2026

Every citation gets one door back to the exact page.

The document URL now accepts a page: .../doc/<id>?page=14 opens the Original scrolled to page 14. This is the first linkage primitive of the Insight Layer (Epic-2423) — without it, “the machine speaks from the margins” has no way to point counsel at the source it cites. The scroll itself already worked; what Story-2435 adds is the one shared way every surface builds and reads that link, so the grammar can never drift.

The one idea

Scroll position is state, not a view. Nobody bookmarks page 14 as a different VIEW of the document, so the page is a query string (?page=), never a route segment (route-per-mode doctrine). One helper writes the link, one helper reads it back, and both live in a single module — so the insight, chronology, contradiction and Brief surfaces that will cite pages tomorrow all speak the same grammar as the Insights dossier does today.

What shipped

Piece What it does
Shared page-permalink module New src/lib/documents/page-permalink.ts owns both directions: buildDocumentPageHref(base, page, { section }) appends ?page=N (and optional &section=) via URLSearchParams; readPagePermalink(searchParams) parses it back. The page/section query keys are constants exported here, defined once.
Five dossier links route through it dossier-assertions, dossier-what-matters, dossier-contradictions, dossier-outline and dossier-notes dropped their hand-built `${originalHref}?page=...` template literals for the helper. No call site string-builds a page URL any more.
The pane reads through it document-original-pane swapped its inline Number.parseInt(searchParams.get("page")) for readPagePermalink, and reads section through the shared SECTION_PARAM constant. On mount it pushes the landing page UP to the shell as a request, since the page lives in the strip.

How it degrades (clamp/ignore, no crash)

URL Result Where
?page=14 scrolls to page 14 read → request → scrollToIndex(13)
?page=99999 (count 40) scrolls to the last page Math.min(page, pageCount) in the scroll column
?page=0 / ?page=-3 opens at the top (which IS page 1) readPagePermalink returns null
?page=garbage / absent opens at the top, no request fired readPagePermalink returns null

Why the upper clamp lives downstream

The reader cannot clamp > pageCount — on mount, a landing page is asked for before pdf.js has reported how many pages the document even has. So the reader only floors at 1, and the scroll column applies Math.min(pageRequest.page, pageCount) - 1 once the count is known (its effect depends on pageCount for exactly this reason). One clamp, in the only place that knows the count.

The read path

?page=14 ──▶ readPagePermalink() ──▶ requestPage(14) [pane, on mount] │ shell holds page state ◀── pageRequest ┘ │ scrollToIndex( min(14, pageCount) - 1 ) ◀───────┘ [scroll column, once count known]

Coexists with what already shipped

The landing page is a PageRequest pushed to the shell — the same channel the strip's page box, the keyboard nav (Story-2318) and the section pins already use. It touches neither the per-document zoom store (Bug-2356 / Story-2367) nor F-focus mode (Story-2348): those are untouched state, and a page request rides straight past them.

No functions change

Confirmed: page count and scroll are entirely client-side. The founder flagged a possible endpoint; analysis holds there is none needed — no pramaan-functions change, and no separate functions ticket filed.

Optional debounced write-back — left out

Updating ?page= on scroll (so copying the URL mid-read preserves position) was scoped as optional-if-trivial. It is not trivial here: the page moves on every scroll frame, so it needs a debounce plus a router.replace that must not re-trigger the read effect or push history entries. Left out per the ticket; the deep-link (read) direction is complete.

Verify

Files changed

8 file(s), +128 / -9 lines

Path Role + -
src/components/documents/viewer/document-original-pane.tsxComponent+8-4
src/components/documents/viewer/insights/dossier-assertions.tsxComponent+2-1
src/components/documents/viewer/insights/dossier-contradictions.tsxComponent+3-1
src/components/documents/viewer/insights/dossier-notes.tsxComponent+4-1
src/components/documents/viewer/insights/dossier-outline.tsxComponent+4-1
src/components/documents/viewer/insights/dossier-what-matters.tsxComponent+3-1
src/lib/documents/page-permalink.test.tsLibrary+49-0
src/lib/documents/page-permalink.tsLibrary+55-0