feat(document-viewer): rework the viewer for phone and PWA reading - #1741
Conversation
The "Sharper zoom" mode swapped the pdf.js canvas for an iframe pointed at the Supabase signed URL. That can never render in production: buildContentSecurityPolicy sets `default-src 'self'` and declares no frame-src/child-src, so a cross-origin frame inherits 'self' and is refused. It only appeared to work in demo/dev, where the PDF is a same-origin /demo-documents path — which is also all the Playwright suite exercises, so no browser test could see the failure. iOS additionally ignores the #page= fragment the embed relied on for deep links. Remove the reader, its lazy wrapper, and the preference machinery behind it. getDefaultPdfViewerMode() returned false unconditionally, so the 820px breakpoint and the rAF + matchMedia listener that watched it could never change the result — dead work on every viewer mount. "Open PDF" stays as the escape hatch on every platform: a top-level navigation, which CSP does not restrict. Add a source contract asserting no framed reader returns while the CSP has no cross-origin frame-src, since the demo-corpus E2E suite structurally cannot guard this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ER2xPFzPzoS8fAxkgHC8yo
The PDF panel stacked three separate bars before the first pixel of the
document: DocumentFrame's zoom band, the viewer-mode row, and PdfCanvasViewer's
own page toolbar. The page number was printed twice ("Page 1 of 1" and "of 1")
and Maximize2 meant "fit width" in one bar and "fullscreen" in the other.
DocumentFrame now owns every viewing control — page navigation, zoom, fit,
rotation, viewing aid and fullscreen — and PdfCanvasViewer renders source pixels
and nothing else. That collapses the duplication structurally rather than by
convention: there is one page readout because there is one component that can
render one.
Measured in Chromium at the document route, phone viewport: 48px of toolbar in a
single row, no horizontal overflow, at both 320x720 and 390x760 (was ~190px
across two bars). Six tap targets plus page navigation do not fit a 320px row, so
zoom goes inline from 380px up and the narrowest phones reach it, along with
fit/rotate/viewing aid/fullscreen, through one overflow menu — every item at the
production min-h-tap target.
Fullscreen moves to the frame so the chrome goes fullscreen with the document
instead of leaving a bare canvas, keeping the native-then-in-app fallback for
browsers that will not fullscreen a plain element (iOS Safari).
frameOwnsZoomChrome is retired: with the browser-engine reader gone there is one
reader, so the conditional split had no second case. The Sentry 15778840 guard it
carried survives as the zoomRef composition path, which is now the only thing
between a rapid pinch and dropped deltas.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ER2xPFzPzoS8fAxkgHC8yo
…page Two defects that only show up on a phone. Pinch did nothing in the viewer's default state. The gesture hook was called with `pinchZoom: pagesReady && !fitWidth`, and fit-width is how every document opens, so a two-finger pinch reached neither the viewer (the hook declined it) nor the browser (the holder's `touch-action: pan-y` suppresses native pinch-zoom). There was no way to magnify a page by gesture without first finding a zoom button. docs/design-system/COMPONENTS.md lists "native pinch zoom preserved" as a blocking phone clause. Pinch is now live in fit mode. The first delta drops fit, which switches the holder to `touch-action: none`, so the browser can only contend for the opening moment of a gesture and never the rest. Drag-to-pan stays gated on `!fitWidth` because fit mode needs the holder's native momentum scrolling. The canvas also blanked above roughly 2.3x zoom on iPhone. WebKit refuses to back a canvas beyond ~2^24 device pixels — it keeps the layout box and paints nothing, with no error to catch. The raster used a flat min(2.5, dpr) output scale, and dpr is 3 on iPhone, so A4 at VIEWER_MAX_ZOOM asked for about 50 megapixels, three times the ceiling. resolveCanvasRasterPlan gives up raster density before layout size, so a page that cannot be drawn at full device density is softer rather than blank, and never a different size than the reader asked for. Unit-tested across the whole supported zoom range at dpr 1-4, including an oversized sheet that cannot fit at CSS resolution at all. Not verifiable in this container: its bundled Chromium 141 predates the JS builtin pdf.js 6 needs, so the canvas fails to raster here regardless of these changes. The budget is covered by unit tests; the gesture needs a real phone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ER2xPFzPzoS8fAxkgHC8yo
The first phone viewport was spent before reaching any of the document. The
high-yield clinical summary rendered inside DocumentOverviewLanding, above the
PDF, and for a document with nothing indexed it still drew a full gradient
header, icon and heading around "A structured clinical summary has not been
indexed for this document yet."
buildDocumentSectionIndex has always described the opposite order — "main column
(overview -> PDF -> evidence -> text) then the aside rail" — so this moves the
DOM to the order the index already assumed rather than inventing a new one. The
summary card leaves the landing and becomes its own grid child; phone order is
set with max-sm:order-* so the desktop two-column layout is untouched.
Every grid child now carries an explicit phone order. An unordered grid item
defaults to `order: 0` and would sort ahead of `order-1`, which would have
thrown the rail to the top of the page.
The card renders nothing when the model yields nothing, instead of a shell
around its empty state.
Measured in Chromium at 390x844 against the demo corpus:
summarised document: PDF panel 651px -> 395px from the top
unsummarised document: PDF panel 537px -> 395px, and the empty card is gone
(1 summary card -> 0)
Correcting an analysis error from the plan while I am here: I had claimed the
section index could point at an empty summary section, and changed
hasStoredSummary to match the card. That was wrong — `source-summary` anchors the
rail's document-profile panel, which renders its label badges whether or not a
summary was indexed. hasStoredSummary is left exactly as it was.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ER2xPFzPzoS8fAxkgHC8yo
…e flip `/api/documents/:id` returns a window of pages centred on the requested one — nine, by defaultPageWindow — but `activePage` was a dependency of the detail effect, so flipping from page 3 to 4 refetched a window the client already held. The route sets no Cache-Control and the service worker never caches a query-string request, so each of those was a real round trip: one per tap, on a phone, on cellular. The client now remembers the window each payload covered, along with the request identity it was loaded under, and skips the effect when the only thing that moved is the page and the new page is inside that window. Anything else — a different document, chunk, retry attempt or auth identity — changes the signature and refetches as before. A chunk route never records a window at all, because its window is centred on the selected chunk rather than on the page. The decision is a pure function in document-viewer-navigation.ts alongside the existing load-key helpers, so the in-window/out-of-window/changed-identity cases are unit-tested rather than only reachable through the effect. Measured in Chromium against the demo corpus, flipping a page from the toolbar: 1 detail request before, 0 after. The demo documents top out at two pages, so the out-of-window refetch is covered by the unit tests rather than the browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ER2xPFzPzoS8fAxkgHC8yo
pdfjs.getDocument was called with the url alone, leaving disableAutoFetch at its default. pdf.js then keeps fetching the rest of the file in the background even when the reader only ever looks at one page — the wrong default for a long guideline opened on a phone, on cellular. The two flags are a pair: disabling pre-fetch does nothing while streaming is on, which the installed pdfjs-dist types state outright, so both are set and a contract test keeps them together. The trade is that later bytes are requested later, so a signed URL is more likely to expire mid-read against its 10-minute TTL. That path already exists and recovers: a range failure surfaces as an auth/HTTP error, isLikelyExpiredUrl catches it, and the parent re-issues the URL. I checked the budget rather than assuming it — signedUrlRefreshCountRef resets in handlePdfLoadSuccess on every successful open, so the cap is two *consecutive* failures, not two per session, and a long reading session is not dead-ended. Also release resources on teardown: the rendered page's decoded resources (pdf.js declines while a render is live, so this cannot cut one short) and the canvas backing store, which a canvas otherwise holds until collection — real memory on a phone for a document the reader has already left. Not verifiable here: this container's Chromium 141 predates a JS builtin pdf.js 6 needs, so pdf.js never initialises and no range request is ever issued. Covered by source contract; needs a real device or a current browser to observe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ER2xPFzPzoS8fAxkgHC8yo
…cate signed URLs Three small fixes to the document surface. The whole-document image preview carried loading="lazy". For an image-source document that element *is* the document and sits above the fold, so lazy-loading put the largest element on the page behind the browser's own lazy threshold and delayed LCP — audit finding LCP-1/LL-1 from performance-image-cwv-audit-2026-08-02, which Phase 0 half-fixed by adding decoding="async" and leaving the lazy attribute. It now loads eagerly with fetchPriority="high". useSignedImageUrl had no in-flight deduplication: the module LRU only helps once a response has landed, so a page holding several views of the same image — a figure and its lightbox, a rail panel and a filmstrip — minted a separate signed URL per consumer on first paint. One shared request per endpoint now, keyed by bearer token as well so a request started under one identity is never handed to another, and dropped on retry so a retry genuinely refetches. The rail's sticky offset was a hand-copied `lg:top-[69px]` for a bar whose height is not actually fixed — it moves with type size. useDocumentChromeMetrics already measures that row, so it now publishes the value and the rail offsets by it, keeping the literal only as a first-paint fallback. Wiring the viewer to the existing 100-id batch signed-URL route (src/app/api/images/signed-urls, which still has no caller anywhere in src/) is deliberately left out: it is a second data path for a below-fold cost, and the dedupe above takes the first-paint duplication out without one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ER2xPFzPzoS8fAxkgHC8yo
…rship notes Six rows captured from the document-viewer optimisation pass: the container Chromium that cannot raster pdf.js 6 (so no browser gate covers the viewer canvas), the physical iPhone acceptance owed for the pinch fix and canvas budget, the two competing clinical-summary surfaces on the phone route, the pdf.js decoder-asset probe, the still-uncalled batch signed-URL route, and the root-container test failure that makes every full-suite run report '1 failed'. ADOPTION.md still described DocumentFrame as a shell-only surround with no controls toolbar and PDF chrome on PdfCanvasViewer — already stale after PR 2a flipped that contract, and now the opposite of what ships. The phase-2 plan's component diagram still listed the removed NativePdfEmbed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ER2xPFzPzoS8fAxkgHC8yo
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 2 minutes Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (36)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ER2xPFzPzoS8fAxkgHC8yo
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 98b799a372
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@cursoragent Work the current open PR end-to-end. Confirm the PR number and GitHub head first from context. If more than one open PR could apply, stop and say which one you would use and why. Fetch and start from the remote tip that matches that GitHub head. If the named branch ref is missing or stale, use the PR head ref. Preserve unrelated local WIP, including any local-only ledger commits; do not discard dirty work, and do not treat a local-ahead commit as the reviewed tip. Do not merge the PR, force-push, rebase, or arm auto-merge unless I explicitly ask. No provider-backed gates without separate approval. If you cannot push or resolve threads, diagnose and comment only; if inline replies fail, resolve when possible and put dispositions in the summary comment. If auto-merge is already armed, push only for a real blocker, and avoid pushes that would cancel in-flight required CI unless the push itself clears that blocker. If the PR is already merged or closed: confirm the head and merge commit, note required-CI outcome, post one summary, and stop. Goal: deep review plus Bugbot, fix actionable issues with the smallest correct changes, clear merge / required-CI / thread blockers, run strong local offline verification, push fixes, append the review ledger, and post one PR summary. Prefer thoroughness over speed. Regenerate large assets only when a fix requires it; then run the asset check and keep compatibility aliases byte-identical where the repo uses them. Snapshot the GitHub head SHA: tip, base, behind/ahead, mergeable state, merge-tree versus origin/main (real conflict versus behind-but-clean), required checks on that tip including Production UI when selected, advisory separately, unresolved actionable threads. Missing checks while dirty are not green. If the tip moves mid-work, re-snapshot and continue from the new head. Ledger-lookup against that GitHub head under the heavy review-and-fix scope for this PR. Already reviewed at this head with clean merge-tree, green required checks, and no new actionable threads → summarize, comment, stop unless I asked for a fresh superseding pass. Follow the repo review protocol. Unblock once: real conflict → merge origin/main (prefer main’s shared queues; keep this PR’s notes); behind-but-clean → one sync, late if required CI is in flight; clean → leave. Labels like skip-branch-sync do not block a manual sync needed to clear a real blocker. No sync thrash. After any sync or push, re-snapshot tip, merge-tree, and required checks before declaring done. Dedupe the ledger if a merge touched it. Review high-confidence delta risks only. Separate PR-introduced defects from pre-existing re-emitted debt. For generated assets, review contracts, aliases, cache, and manifests—not every generated line. If protected RAG or ranking surfaces are touched, say so before editing; fix PR-body policy text only when wrong or missing. Ignore bot noise. No nit spam or broad rewrites. Escalate verification by touched risk: clinical / RAG / privacy / migrations / auth → domain check plus production-readiness when warranted; UI / phone-chrome / routing / styling → ensure plus phone-chrome or UI gates when warranted; generated assets or docs inventory/links → asset or docs checks when warranted. Fix P0/P1 always; clear scoped P2 when locally provable; else disposition and resolve. Required-check failures on this tip only; ignore advisory. Prefer reverting a bad autofix commit. After fixes: format and commit before push; smallest targeted proof; repo cheap gate; repo PR-local gate. No release, lighthouse, live eval, or live provider gates without approval. Push only this PR’s fix commits. Append ledger for the final GitHub head under the heavy scope. Never push a ledger-only tip—include ledger in a real fix push, or leave a no-change ledger append local and unpushed. One PR comment: tip, sync/merge-tree, fixed versus dispositioned, threads, required CI, decisive local gate lines, residual risks; merge left to me. Inline only for remaining human-needed P0/P1. Stop when merge-tree is clean on the current GitHub tip, actionable threads are clear, required checks are green or clearly in progress, heavy gates for scope passed, summary posted, merge left to me. |
|
PR #1741 worked end-to-end. Tip is Snapshot
Fixed & pushed
Verification
Left for you
|
There was a problem hiding this comment.
Pull request overview
Reworks the Document Viewer’s phone/PWA reading experience by removing a CSP-incompatible native PDF iframe path, consolidating PDF controls into a single DocumentFrame toolbar, restoring pinch-to-zoom in the default fit-width mode, and hardening PDF rendering/perf (canvas raster budget, on-demand byte fetching, and fewer detail refetches).
Changes:
- Remove the framed/native PDF reader path and its preference machinery; keep a single pdf.js canvas reader behind the existing lazy split.
- Consolidate viewing controls (page nav, zoom/fit, rotate, viewing aid, fullscreen) into
DocumentFrame, and makePdfCanvasViewerrender pixels only. - Improve phone experience and performance: pinch-to-zoom in fit mode, avoid iOS blank canvases via raster budgeting, skip in-window detail refetches, dedupe signed-image URL requests, and release raster resources on teardown.
Reviewed changes
Copilot reviewed 33 out of 33 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/use-viewer-gestures.dom.test.tsx | Adds jsdom coverage for pinch gating (enabled vs disabled). |
| tests/ui-smoke.spec.ts | Updates smoke coverage for single reader + new testids/overflow fullscreen path; asserts no iframes. |
| tests/ui-hydration.spec.ts | Removes persisted PDF mode hydration scenario; keeps a document deep-link scenario. |
| tests/header-scroll-hide-contract.test.ts | Updates contract to use measured collapse height CSS var (with fallback) instead of hardcoded offset. |
| tests/document-viewer-shell.dom.test.tsx | Removes NativePdfEmbed mock/export usage. |
| tests/document-viewer-pdf-reader-lazy.test.ts | Updates lazy-split assertions for single reader; adds CSP/no-iframe source contract. |
| tests/document-viewer-navigation-source.test.ts | Adds unit coverage for skipping detail requests within a loaded page window. |
| tests/document-frame.dom.test.tsx | Updates DOM tests for the single-toolbar model, phone overflow actions, and distinct fit/fullscreen. |
| tests/document-frame-contract.test.ts | Updates contract: DocumentFrame owns all controls; pinch default + raster budget + phone order assertions. |
| tests/document-detail-performance.test.ts | Updates keying assertion to keep PdfCanvasViewer keyed by documentId only. |
| tests/document-clinical-summary.dom.test.tsx | Asserts summary renders nothing when empty; keeps “unindexed” line when only priorities survive. |
| tests/client-performance-boundaries.test.ts | Adds guard for on-demand PDF fetching and raster teardown behavior. |
| tests/canvas-raster-budget.test.ts | New unit coverage for canvas pixel budgeting across zoom and DPRs. |
| src/lib/document-viewer-navigation.ts | Adds LoadedDetailWindow + canSkipDetailRequest helper. |
| src/components/ui/document-frame.tsx | Adds page nav, rotation, fullscreen, and phone overflow menu into the shared viewer toolbar. |
| src/components/DocumentViewer.tsx | Removes PDF mode preference; wires new toolbar state (page/rotate/fullscreen/pageCount), skip-detail-refetch, and phone DOM reorder. |
| src/components/document-viewer/use-pdf-viewer-preference.ts | Removes native/canvas preference hook (now single reader). |
| src/components/document-viewer/use-document-chrome-metrics.ts | Adds --document-collapse-height CSS var for sticky chrome offsets. |
| src/components/document-viewer/pdf-viewer-mode.ts | Removes legacy PDF mode storage/breakpoint helpers. |
| src/components/document-viewer/pdf-readers-lazy.tsx | Keeps only PdfCanvasViewer dynamic split; documents why no iframe/native reader exists. |
| src/components/document-viewer/pdf-canvas-viewer.tsx | Removes internal toolbars/chrome; adds on-demand PDF byte fetching, raster budget, pinch-in-fit, and teardown cleanup. |
| src/components/document-viewer/non-pdf-source-preview.tsx | Updates docs comment; makes image-source preview eager/high priority for LCP. |
| src/components/document-viewer/document-rail-panels.tsx | Adds className passthrough and uses measured collapse-height offset var. |
| src/components/document-viewer/document-overview-landing.tsx | Removes embedded clinical summary from the overview landing; updates header comment. |
| src/components/document-viewer/document-clinical-summary.tsx | Adds explicit “has content” helper and renders null when empty. |
| src/components/document-viewer/canvas-raster-budget.ts | New raster budgeting utility and constants. |
| src/components/document-viewer-lazy.tsx | Updates comment to reflect single dynamic PDF reader. |
| src/components/clinical-dashboard/use-signed-image-url.ts | Adds in-flight request dedup keyed by endpoint+identity; integrates with retry/markFailed. |
| src/app/globals.css | Removes pdf-toolbar print-hide selector; documents why it’s no longer needed. |
| docs/plans/document-viewer-phase2-unified-chrome.md | Updates plan diagram to reflect single-toolbar + pixels-only reader model. |
| docs/outstanding-issues.md | Captures new follow-ups (#278–#283) and bumps next-id marker. |
| docs/design-system/ADOPTION.md | Updates DocumentFrame adoption notes to reflect it now owns viewer chrome. |
| docs/branch-review-ledger.md | Appends a ledger row for this PR’s review/verification snapshot. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Scope pdf.js page cleanup to the effect-local page proxy so a rapid page flip cannot cleanup() the next page via a shared ref. - Write the signed-URL LRU only from an active consumer so a late in-flight response after an account switch cannot repopulate the endpoint cache with the prior identity's URL. - Bound DocumentFrame page navigation against pageCount and notify the parent route when pdf.js clamps an out-of-range deep link. Co-authored-by: BigSimmo <BigSimmo@users.noreply.github.com>
Replace the page-input w-11 literal and the overflow menu's shadow-lux alias so check:design-system-contract no longer regresses the DocumentFrame path budget introduced by the unified toolbar. Co-authored-by: BigSimmo <BigSimmo@users.noreply.github.com>
After moving the LRU write behind the active-consumer guard, pass an explicit url string so SignedUrlPayload stays required-url typed. Co-authored-by: BigSimmo <BigSimmo@users.noreply.github.com>
The shared renderedPageRef cleanup path was the Sentry race; the contract now asserts the effect-scoped pageToCleanup release instead. Co-authored-by: BigSimmo <BigSimmo@users.noreply.github.com>
Review-and-fix summary (PR #1741)Tip: Sync / merge-tree
Fixed (pushed)
Dispositioned threadsInline replies returned 403 Resource not accessible by integration. Threads were resolved via GraphQL; dispositions recorded here:
Required CI (at tip; advisory ignored)
Local gates (decisive lines)
Residual risks
Merge left to you. Auto-merge not armed. |
CI triageCI failed on this PR. Automated classification of the 4 failed job(s):
Compared with main CI run #9157 (cancelled). Classification is evidence routing, not permission to ignore a failure. Exact quarantined Playwright identities remain governed by the flake ledger. |
|
@cursoragent Work the current open PR end-to-end. Confirm the PR number and GitHub head first from context. If more than one open PR could apply, stop and say which one you would use and why. Fetch and start from the remote tip that matches that GitHub head. If the named branch ref is missing or stale, use the PR head ref. Preserve unrelated local WIP, including any local-only ledger commits; do not discard dirty work, and do not treat a local-ahead commit as the reviewed tip. Do not merge the PR, force-push, rebase, or arm auto-merge unless I explicitly ask. No provider-backed gates without separate approval. If you cannot push or resolve threads, diagnose and comment only; if inline replies fail, resolve when possible and put dispositions in the summary comment. If auto-merge is already armed, push only for a real blocker, and avoid pushes that would cancel in-flight required CI unless the push itself clears that blocker. If the PR is already merged or closed: confirm the head and merge commit, note required-CI outcome, post one summary, and stop. Goal: deep review plus Bugbot, fix actionable issues with the smallest correct changes, clear merge / required-CI / thread blockers, run strong local offline verification, push fixes, append the review ledger, and post one PR summary. Prefer thoroughness over speed. Regenerate large assets only when a fix requires it; then run the asset check and keep compatibility aliases byte-identical where the repo uses them. Snapshot the GitHub head SHA: tip, base, behind/ahead, mergeable state, merge-tree versus origin/main (real conflict versus behind-but-clean), required checks on that tip including Production UI when selected, advisory separately, unresolved actionable threads. Missing checks while dirty are not green. If the tip moves mid-work, re-snapshot and continue from the new head. Ledger-lookup against that GitHub head under the heavy review-and-fix scope for this PR. Already reviewed at this head with clean merge-tree, green required checks, and no new actionable threads → summarize, comment, stop unless I asked for a fresh superseding pass. Follow the repo review protocol. Unblock once: real conflict → merge origin/main (prefer main’s shared queues; keep this PR’s notes); behind-but-clean → one sync, late if required CI is in flight; clean → leave. Labels like skip-branch-sync do not block a manual sync needed to clear a real blocker. No sync thrash. After any sync or push, re-snapshot tip, merge-tree, and required checks before declaring done. Dedupe the ledger if a merge touched it. Review high-confidence delta risks only. Separate PR-introduced defects from pre-existing re-emitted debt. For generated assets, review contracts, aliases, cache, and manifests—not every generated line. If protected RAG or ranking surfaces are touched, say so before editing; fix PR-body policy text only when wrong or missing. Ignore bot noise. No nit spam or broad rewrites. Escalate verification by touched risk: clinical / RAG / privacy / migrations / auth → domain check plus production-readiness when warranted; UI / phone-chrome / routing / styling → ensure plus phone-chrome or UI gates when warranted; generated assets or docs inventory/links → asset or docs checks when warranted. Fix P0/P1 always; clear scoped P2 when locally provable; else disposition and resolve. Required-check failures on this tip only; ignore advisory. Prefer reverting a bad autofix commit. After fixes: format and commit before push; smallest targeted proof; repo cheap gate; repo PR-local gate. No release, lighthouse, live eval, or live provider gates without approval. Push only this PR’s fix commits. Append ledger for the final GitHub head under the heavy scope. Never push a ledger-only tip—include ledger in a real fix push, or leave a no-change ledger append local and unpushed. One PR comment: tip, sync/merge-tree, fixed versus dispositioned, threads, required CI, decisive local gate lines, residual risks; merge left to me. Inline only for remaining human-needed P0/P1. Stop when merge-tree is clean on the current GitHub tip, actionable threads are clear, required checks are green or clearly in progress, heavy gates for scope passed, summary posted, merge left to me. |
|
PR #1741 worked end-to-end. Tip is Snapshot
Fixed & pushed
Verification
Left for you
|
…ute page - Assert Zoom in inside the 320px overflow menu (it is not inline below 380px). - Reveal the phone header before opening the section sheet after scroll. - Clamp the route in handlePdfLoadSuccess when pdf.js reports a smaller count. Co-authored-by: BigSimmo <BigSimmo@users.noreply.github.com>
|
@cursoragent Unblock the current open PR. Confirm the PR number and GitHub head first from context. If more than one open PR could apply, stop and say which one you would use and why. Fetch and start from the remote tip that matches that GitHub head. If the named branch ref is missing or stale, use the PR head ref. Preserve unrelated local WIP; do not discard dirty work; do not treat a local-ahead commit as the reviewed tip. Do not merge the PR, force-push, rebase, or arm auto-merge. No provider-backed gates without approval. If you cannot push or resolve threads, diagnose and comment only; if inline replies fail, resolve when possible and put blocker dispositions in the summary comment. If auto-merge is already armed, push only for a real blocker and avoid cancelling in-flight required CI unless the push clears that blocker. If the PR is already merged or closed: confirm outcome, post one summary, and stop. Primary job: clear mergeability and required-CI blockers on this tip. Do a full product review only if needed to explain or fix a blocker. Stay light otherwise—no broad verification suites or Playwright unless needed to prove one concrete unblock fix. Snapshot the GitHub head: tip, behind/ahead, mergeable state, merge-tree versus origin/main (real conflict versus behind-but-clean), required checks on that tip including Production UI when selected, and unresolved threads that block merge or required CI. Missing checks while dirty are not green. Tip moved → re-snapshot. Ledger-lookup against that GitHub head under an unblock/fix scope for this PR. If already handled at this head for unblock work, merge-tree clean, required checks green, and no blocking threads: summarize, comment, stop. Unblock once: real conflict → merge origin/main with the smallest correct resolution (prefer main’s shared queues; keep this PR’s notes); behind-but-clean → one sync, late if required CI is in flight; clean → leave. skip-branch-sync and similar labels do not block a manual sync needed to clear a real blocker. No sync thrash. After any sync or push, re-snapshot tip, merge-tree, and required checks before declaring done. Dedupe the ledger if a merge touched it. Fix only what blocks required checks or mergeability on this tip. Ignore advisory red unless it reveals a real required failure. Prefer the smallest safe fix; if a bot/autofix commit caused the break, prefer revert. Prove with the smallest targeted check only. Format and commit before push. Push only blocker-fix commits. Append ledger for the final GitHub head under the unblock scope. Never push a ledger-only tip. One PR comment: tip, sync/merge-tree, what blocked and what you fixed or dispositioned, required CI status, residual risks; merge left to me. Stop when merge-tree is clean on the current GitHub tip, no blocking threads remain for merge/required CI, and required checks are green or clearly in progress. |
|
@cursoragent please resolve all failing CI testing and resolve all comments and resolve merge conflicts please. |
|
PR #1741 is unblocked on tip Cleared
Status
Merge left to you: #1741 |
Co-authored-by: BigSimmo <BigSimmo@users.noreply.github.com>
…tic) DocumentFrame controls scroll with the preview band; the overflow menu already anchors its dropdown on the relative <details> wrapper, so the toolbar itself does not need position:relative. Restores the Production UI contract that mobile PDF chrome stays static rather than creating a new positioning context.
Merge origin/main (phone Choose mode sheet #1723) to clear DIRTY mergeability. Allow DocumentFrame toolbar position relative (needed for phone overflow menu) in the PDF-first smoke assertion. Adopt the document-viewer Linux visual baseline from CI run 31265543648 for the intentional PDF-first chrome refresh, with refreshed provenance. Co-authored-by: BigSimmo <BigSimmo@users.noreply.github.com>
Co-authored-by: BigSimmo <BigSimmo@users.noreply.github.com>
…ct fix Co-authored-by: BigSimmo <BigSimmo@users.noreply.github.com>
Remote tip removed relative positioning from document-frame-controls (overflow anchors on details). Restore the Production UI contract to expect position static. Co-authored-by: BigSimmo <BigSimmo@users.noreply.github.com>
Unblock summary (PR #1741)Tip: Base: What blocked
Fixed / dispositioned
Required CIRestarted on Residual
Merge left to you. |
Adopt metrics from the advisory Lighthouse artifact on tip f20e908 (run 31265543648). Evidence was incomplete only because the committed baseline was measured on HeadlessChrome/150 and CI now runs 151; --update from those hosted reports clears the advisory gate. Co-authored-by: BigSimmo <BigSimmo@users.noreply.github.com>
Unblock summary (PR #1741) — tip updateTip: Base: Cleared this pass
Review comments were already resolved (Sentry / Codex / Copilot). No new unresolved threads. Required CIRestarted on Ledger for this tip left local/unpushed (no ledger-only tip). Merge left to you. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 37 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/components/clinical-dashboard/use-signed-image-url.ts:113
- The stale-response guard runs after the 401 side effect. If user A's request returns 401 after an account switch or token refresh, this inactive callback still calls
markSessionExpired()and expires user B's valid session. Checkactivebefore processing the status; the new account-switch test should also cover a late 401.
.then(({ status, data }) => {
if (status === 401) markSessionExpired();
if (!active) return;
src/components/document-viewer/pdf-canvas-viewer.tsx:403
- Enabling pinch in fit mode exposes a discontinuity: the handler multiplies
zoomRef(initially 1.1), while a 320px phone fits an A4 page at the 0.55 minimum. The first ~1% pinch therefore jumps the page from about 55% to 111% instead of zooming continuously. Seed the manual zoom from the current computed fit scale before applying the first pinch factor.
pinchZoom: pagesReady,
src/components/document-viewer/canvas-raster-budget.ts:69
- The hard
0.1floor can violate the very ceiling this helper promises: whenevercssArea > maxCanvasPixels / 0.1², the returned backing-store area exceedsMAX_CANVAS_PIXELSand WebKit can still blank large poster/custom-UserUnit PDFs. Let the pixel budget win over the density floor and add a case where the affordable scale is below 0.1.
const outputScale = Math.max(MIN_OUTPUT_SCALE, Math.min(preferred, affordable));
Resolve document-viewer baseline/provenance conflicts by taking main's #1741 capture as the interim golden; the masked refresh will be re-adopted from CI on this merged tip. Co-authored-by: BigSimmo <BigSimmo@users.noreply.github.com>
…ain sync Re-adopt from visual-baseline-31268982766 on the post-#1741 merge tip so the golden includes the unique data-document-sticky-header mask and the viewer rework from main. Co-authored-by: BigSimmo <BigSimmo@users.noreply.github.com>
…seline, and add a baseline adopt helper (#1743) * feat(design-system): mask the pinned chrome in the document-viewer baseline, and add a baseline adopt helper Two changes that make refreshing a golden after a deliberate design change cheap and readable, which is what the pixel gate needs in order to be read at all. Mask the viewport-pinned chrome (#278). The document-viewer target clips a ~2900px region against a 900px viewport, and it contains a `sm:sticky sm:top-0` header and a `sm:fixed` composer. Playwright stitches an oversized element capture, so both land partway DOWN the image, overlap whatever content sits behind them at that offset, and move whenever content above them changes height -- so an unrelated edit anywhere on the page redrew two bands of the golden and inflated every diff. Masked rather than clipped away, because both are real chrome that belongs in the frame and narrowing the selector would drop the rail panels this target exists to watch. Their own geometry is covered by the phone-chrome contracts in docs/search-chrome-behaviour.md, not by this pixel gate. Fail loudly when a mask matches nothing. A mask selector that matches no element masks nothing, silently -- the golden keeps comparing the region the mask was meant to exclude while the declaration reads as protection that is not there. Renaming a class is enough to cause it. Every declared mask must now resolve to at least one element before the comparison is trusted. That guard is what makes the mask above safe to rely on rather than merely present. Add scripts/adopt-visual-baselines.mjs (npm run design-system:baselines:adopt). Adopting previously meant hand-copying six PNGs and hand-assembling provenance.json with a SHA-256 and pixel dimensions per candidate, the capture commit, the run id and the reviewer attestation. Doing that by hand on every design change is the friction that makes people skip the refresh and leave a red advisory standing. It resolves candidates from both shapes the artifact can take -- visual-candidates/ when a target was awaiting a baseline, and <id>-actual.png when it compared and differed. It refuses a missing or non-numeric run id, a short or unknown capture commit, and a missing --reviewed-by, because that field records a HUMAN review of the images. It never captures screenshots: baselines are platform-scoped and a developer-machine shot lands where ubuntu CI never reads it. Dry run by default. The document-viewer golden is NOT refreshed here -- masking changes its pixels, so it must be re-shot from a CI run that already contains this mask. That is the refresh loop the helper exists for, and it needs this to land first. Verified: tsc 0 errors; lint exit 0; check:design-system-adoption exit 0; docs:check-inventory current at 232 npm scripts; docs:check-links exit 0; format:check clean. Helper exercised against two real artifacts -- it resolves all six candidates from run 31251091603 and correctly refuses run 31254917796, which was all-green and so contains no candidate images -- and all three input guards were confirmed to reject. Dry run wrote nothing. Refs #278, #118 * fix(design-system): support partial visual baseline refresh adoption When CI changes only some surfaces, passing targets emit no *-actual.png and unchanged images live under tests/__screenshots__/ in the artifact. The adopt helper now resolves candidates from diff output, artifact baselines, or committed goldens, records refresh provenance (including replacedCandidateIds), and validates capture-head AWAITING_BASELINE binding before writing. Also fix git cat-file reachability checks and run mask-selector validation before candidate capture. * chore(design-system): refresh document-viewer baseline after chrome masks Adopt the masked document-viewer capture from visual-baseline-31267928439 (tip ac1d0dd) via the partial-refresh helper. Clears the expected advisory diff introduced when the sticky header and fixed composer were masked. Co-authored-by: BigSimmo <BigSimmo@users.noreply.github.com> * fix(design-system): gate retained baselines on junit and uniquify the mask Copilot: retaining screenshots from the artifact without visual-junit proof could bless a stale golden for a target that failed before emitting an actual. Require a passing junit case for every retained id, refuse all-green refreshes, and only treat *-actual.png as a fresh diff. Also mask [data-document-sticky-header] instead of .edge-glass-header so the fail-loud guard cannot pass against the universal search header after a DocumentViewer rename. Co-authored-by: BigSimmo <BigSimmo@users.noreply.github.com> * chore(design-system): refresh masked document-viewer baseline after main sync Re-adopt from visual-baseline-31268982766 on the post-#1741 merge tip so the golden includes the unique data-document-sticky-header mask and the viewer rework from main. Co-authored-by: BigSimmo <BigSimmo@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: BigSimmo <BigSimmo@users.noreply.github.com>
…eview authorizationHeadersForAccessToken returns lowercase `authorization` per the Fetch/Headers convention, but the value is typed Record<string, string>, so reading `.Authorization` type-checks, returns undefined, and degrades to whatever fallback the caller wrote. PR #1741 made that mistake twice in one session and both sites were identity-scoping code — the in-flight signed-URL dedupe key collapsed every user onto one key, and the detail-window signature omitted the token it documented as present. Review caught both before merge. Recording the trap so the next person keying identity off that object meets it as a note rather than a defect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ER2xPFzPzoS8fAxkgHC8yo
…ation-tu8tnj issues: record the lowercase authorizationHeader trap from PR #1741 review
Phases 0-2 of the viewer redesign merged as PR #1741; Phase 3 was never started, so the viewer still rasters one page at a time and every page flip on a long guideline is a cold render. This brief scopes Phase 3 to every capability except crop -> page overlay, which is excluded because `bbox` is absent from `DocumentDetailImage` and plumbing it crosses into `src/lib/**document**` -- a path that trips `clinicalRiskPatterns` and forces a governance preflight. Toolbar density is recorded as already shipped in Phase 2. It also corrects ledger #279, which claimed the viewer canvas cannot be gated in a browser and proposed bumping Playwright or pinning `pdfjs-dist` down. Measured: pinned `playwright@1.62.1` expects Chromium 151.0.7922.34, this container ships 141.0.7390.37, and CI runs HeadlessChrome/151.0.0.0. `pdfjs-dist@6.2.108` calls `Map.prototype.getOrInsertComputed`, which ships in 151 and not 141, so the failure is container-only and neither remedy is needed. All line references verified against main at 8db1e53. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ER2xPFzPzoS8fAxkgHC8yo


Summary
Reworks the document viewer for phone and PWA reading. Each item is its own commit and is independently revertible while this PR is open.
Remove the CSP-blocked native PDF reader. The "Sharper zoom" mode swapped the pdf.js canvas for an iframe pointed at the Supabase signed URL. That can never render in production:
buildContentSecurityPolicysetsdefault-src 'self'and declares noframe-src/child-src, so a cross-origin frame inherits'self'and is refused. It only appeared to work in demo/dev, where the PDF is a same-origin/demo-documentspath — which is also all the Playwright suite exercises, so no browser test could see the failure. iOS additionally ignores the#page=fragment the embed relied on. Removed along with the preference machinery behind it, whosegetDefaultPdfViewerMode()returnedfalseunconditionally, so the 820px breakpoint and thematchMedialistener watching it could never change the result. "Open PDF" — a top-level navigation, which CSP does not restrict — remains the escape hatch on every platform. A source contract now asserts no framed reader returns while the CSP has no cross-originframe-src, since the demo-corpus E2E suite structurally cannot guard this.One toolbar instead of three stacked control bars. The PDF panel stacked
DocumentFrame's zoom band, the viewer-mode row, andPdfCanvasViewer's own page toolbar before the first pixel of the document. The page number was printed twice ("Page 1 of 1" and "of 1") andMaximize2meant "fit width" in one bar and "fullscreen" in the other.DocumentFramenow owns every viewing control andPdfCanvasViewerrenders source pixels only, so the duplication is gone structurally rather than by convention. Fullscreen moved to the frame so the chrome goes fullscreen with the document instead of leaving a bare canvas.Revive pinch-to-zoom. The gesture hook was called with
pinchZoom: pagesReady && !fitWidth, and fit-width is how every document opens, so a two-finger pinch reached neither the viewer (the hook declined it) nor the browser (the holder'stouch-action: pan-ysuppresses native pinch-zoom). There was no way to magnify a page by gesture without first finding a zoom button.docs/design-system/COMPONENTS.mdlists "native pinch zoom preserved" as a blocking phone clause.Stop iOS blanking the page at high zoom. WebKit refuses to back a canvas beyond ~2^24 device pixels — it keeps the layout box and paints nothing, with no error to catch. The raster used a flat
min(2.5, dpr)output scale, anddpris 3 on iPhone, so A4 atVIEWER_MAX_ZOOMasked for about 50 megapixels, three times the ceiling.resolveCanvasRasterPlangives up raster density before layout size, so a page that cannot be drawn at full device density is softer rather than blank.Put the source first on phones. The high-yield clinical summary rendered above the PDF, and for a document with nothing indexed it still drew a full gradient header, icon and heading around "A structured clinical summary has not been indexed for this document yet."
buildDocumentSectionIndexhas always described the opposite order, so this moves the DOM to the order the index already assumed. The card now renders nothing when it has nothing to say.Stop refetching the detail window on every page flip.
/api/documents/:idreturns a nine-page window centred on the requested page, butactivePagewas an effect dependency, so flipping from page 3 to 4 refetched a window the client already held — an uncached round trip per tap.Fetch PDF bytes on demand and release the raster.
getDocumentleftdisableAutoFetchat its default, so pdf.js kept pulling the rest of the file down even when the reader only looked at one page. Also releases the rendered page's decoded resources and the canvas backing store on teardown.Cheap image wins. The whole-document image preview carried
loading="lazy"although for an image-source document it is the document and sits above the fold (audit finding LCP-1/LL-1, half-fixed by Phase 0).useSignedImageUrlhad no in-flight deduplication, so several views of the same image each minted their own signed URL on first paint.Docs.
ADOPTION.mddescribedDocumentFrameas a shell-only surround with no controls toolbar — already stale after PR 2a, and now the opposite of what ships. Six follow-ups captured todocs/outstanding-issues.md.Measured in Chromium against the demo corpus, phone viewport:
Verification
npm run verify:pr-localIts stages were run individually because the aggregate halts on a pre-existing failure unrelated to this change (
tests/pr-handoff-stop.test.ts, which asserts a read-only directory blocks a write — root ignores the permission bits; reproduced identically on an unmodifiedbc33d41checkout, and captured as#283).npm run check:runtime— PASS, Node 24.15.0 / npm 11.17.0npm run check:installed-lock-parity— clean afternpm ci --include=devnpm run format:changed— "All matched files use Prettier code style!"npm run lint— clean,--max-warnings 0npm run typecheck— cleannpm run test— 5625 passed | 4 skipped, 1 failed (the pre-existing root-container failure above)npm run build— passed, "Client bundle secret surface check passed."npm run check:rag:fixtures— "Offline RAG fixture and manifest validation passed (36 golden cases, 23 suites)."npm run check:bundle-budget— "299 files, 1499.8 KiB gzip … baseline 1406.4 KiB gzip; within tolerance." Basebc33d41measures 1500.0 KiB on the same machine, so this change is bundle-neutral; the gap to the 2026-08-04 baseline is pre-existing drift and was not touched.UI verification not run:
npm run verify:uicannot exercise this surface in this container. Its Chromium is 141.0.7390.37, which predates aMapbuiltinpdfjs-dist6.2.108 calls, so the document route rendersthis[#methodPromises].getOrInsertComputed is not a functioninstead of a page — identically onbc33d41, so it is the environment and not a regression. Layout, toolbar geometry, DOM order, request counts and the absence of iframes were measured in that Chromium and are reported above; anything requiring a painted canvas was not. Captured as#278, with the physical iPhone acceptance owed for the pinch gesture and the canvas budget as#279.npm run verify:releasebefore release or handoff confidence claimsNot run — provider-backed, and not claimed.
npm run eval:retrieval:qualitynot applicable: no retrieval, ranking, selection, chunking or scoring behaviour changed.npm run check:production-readinessnot run — provider-backed. No clinical workflow, privacy, environment, Supabase or deployment behaviour changed; the source-governance surface touched is rendering only.RAG impact: no retrieval behaviour change — this change touches the document viewer's chrome, phone layout, canvas raster budget, client-side detail-window caching and signed-image request deduplication. No file under
src/lib/rag/**, clinical-search, retrieval-selection, ranking-config, answer-ranking, the eval harness or the golden fixture is modified, and no ranking comparator, score or ordering is read or written.Risk and rollout
PdfCanvasViewertoDocumentFrame; a mistake there degrades reading controls rather than document access or content. The canvas raster budget changes the resolution a page is drawn at, never which page or which document. The detail-window guard changes when a request is made, not what it returns or who may see it — every request-shaping input including the bearer token is in its signature, so a different document, chunk, retry or identity still refetches.disableAutoFetchmakes a signed URL more likely to expire mid-read; the existing recovery path handles that and its budget resets on every successful open, which was checked rather than assumed.Clinical Governance Preflight
Clinical KB Database(sjrfecxgysukkwxsowpy)Notes on the ones that carry real content here. Document access is unchanged: every signed URL is still minted by the same owner-scoped routes, the in-flight deduplication is keyed by bearer token so a request started under one identity can never be handed to another, and the detail-window guard's signature includes that token for the same reason. Source rendering stays pixel-faithful — no
filter,invert,color-schemeorbackdrop-filteron document pixels, still enforced bytests/document-frame-contract.test.ts. The canvas budget degrades resolution, never content: a page renders softer rather than blank, which is the conservative direction for a clinical document, and the previous behaviour was a silently blank page. Suppressing the empty clinical-summary card removes a shell around "not indexed yet"; it does not suppress an indexed summary, and the document's own section navigation still lists the summary section because that anchor targets the rail's profile panel, which renders its label badges regardless. No clinical decision-support behaviour changed, so the classification is unaffected.Notes
hasStoredSummaryto match the card. That was wrong —source-summaryanchors the rail's document-profile panel, which renders whether or not a summary was indexed.hasStoredSummaryis left exactly as it was.docs/outstanding-issues.mdas#278–#283: the container Chromium that cannot raster pdf.js 6, the physical iPhone acceptance owed, the two competing clinical-summary surfaces on the phone route, the pdf.js decoder-asset probe, the still-uncalled batch signed-URL route, and the root-container test failure.🤖 Generated with Claude Code
https://claude.ai/code/session_01ER2xPFzPzoS8fAxkgHC8yo
Generated by Claude Code