Resolved: light

Document

A reading surface for PDFs, built on @phauna/ds/document over pdf.js. Because pdf.js is browser-only, every viewer here is mounted through next/dynamic({ ssr: false }) from a 'use client' island with a self-hosted, version-matched worker — so nothing pdf.js touches ever runs during SSR or the build, and a load failure degrades to a calm 'preview unavailable' state instead of throwing.

DocumentViewer
The full reading surface: a toolbar (page back/forward, zoom out/in, in-document search) over a scrollable page canvas. Takes src (PDF URL), optional workerSrc, initialPage and onPageChange, plus an optional rich-PDF manifest for a text layer / critical facts.
PdfThumbnail
A single rendered page as a poster frame — src, page (default 1), width and workerSrc. No toolbar; ideal for cards, lists and pickers.
SSR safety
next/dynamic({ ssr: false }) keeps the pdf.js chunk out of the server render and the build's SSR pass; the underlying module also defers `import('pdfjs-dist')` into effects, so window/canvas are never touched on the server.
Worker
pdf.js needs a worker script. We self-host /pdf.worker.min.mjs (copied from pdfjs-dist/build, version-matched) and pass it as workerSrc, which the DS assigns to pdfjs GlobalWorkerOptions.workerSrc. Omitting workerSrc falls back to a CDN URL pinned to the resolved pdfjs version.
Degrade, never throw
DocumentViewer renders its own EmptyState on a PDF load error, and each mount is wrapped in an error boundary that catches a failed chunk load and shows a 'preview unavailable' EmptyState. The route stays up no matter what.

DocumentViewer

The full PDF reading surface on a tiny bundled sample (/sample.pdf), rendered entirely client-side with the self-hosted worker. Use the toolbar to page through, zoom, and search. While the chunk and first page load you see a calm skeleton — that fallback is all the server ever renders.

Loading viewer…
DocumentViewer · src="/sample.pdf" · workerSrc="/pdf.worker.min.mjs"

PdfThumbnail

A poster-frame render of a single page at a fixed width — no toolbar, no scroll. Drop it into cards, attachment lists or a page picker. Same self-hosted worker, same SSR-safe dynamic mount.

width=120
width=160
width=220
PdfThumbnail · page={1} · width={120|160|220}

Graceful degradation

Point the viewer at a source it cannot read (here an existing asset that isn't a valid PDF) and it does NOT throw: the viewer renders its own in-component error state, and an outer error boundary would catch a failed chunk load and swap in a 'preview unavailable' EmptyState. The surrounding page never crashes.

Loading viewer…
DocumentViewer · src="/file.svg" (unreadable source · deliberate failure)