PDF viewer data CompositeControllerEndpoint
The hx-pdf viewing shell: server-authorized bytes, lazy PDF.js rendering, toolbar slots for paging/zoom, URL deep-links — progressive enhancement over a download link.
Layer: L2 host · Recipe: unset — see docs/agent/pick-a-surface.md. Curriculum: AGENTS.md; pick matrix: docs/agent/pick-a-surface.md; blast radius: CONSUMER_MAP.md.
Copy this
<section class="pdf" data-pdf data-pdf-src="sample.pdf" data-pdf-lib="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/legacy/build/pdf.min.mjs" data-pdf-worker="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/legacy/build/pdf.worker.min.mjs">
<header class="pdf-toolbar" data-pdf-toolbar>
<button type="button" class="button" data-size="sm" data-variant="outline" data-pdf-prev>Previous</button>
<label>Page <input class="pdf-page-input" data-pdf-page value="1" inputmode="numeric" aria-label="Page number"></label>
<span class="pdf-page-count" data-pdf-page-count></span>
<button type="button" class="button" data-size="sm" data-variant="outline" data-pdf-next>Next</button>
<span class="pdf-toolbar-spacer"></span>
<button type="button" class="button" data-size="sm" data-variant="outline" data-pdf-zoom-out aria-label="Zoom out">−</button>
<button type="button" class="button" data-size="sm" data-variant="outline" data-pdf-zoom-in aria-label="Zoom in">+</button>
<button type="button" class="button" data-size="sm" data-variant="outline" data-pdf-fit-width>Fit width</button>
<a href="sample.pdf" class="button" data-size="sm" data-variant="ghost" data-pdf-download-link download>Download</a>
</header>
<div class="pdf-status" data-pdf-status aria-live="polite"></div>
<div class="pdf-stage" data-pdf-viewer tabindex="0">
<noscript><a href="sample.pdf" download>Download PDF</a></noscript>
</div>
</section>Server exchange
When the client affordance finishes (click, confirm, keystroke…), htmx issues this request. Your API must return the response fragment in the table — usually HTML, not JSON (unless the partial says otherwise). Dazzle often renders these routes from the app model; a standalone HTMX4 app implements them explicitly. The Envelope column is the exchange envelope (part of the Swap contract) — what the response may re-emit relative to the persistent slot.
Do not reimplement the gallery. Flash toasts (e.g. “Deleted (demo).”), /mock/* paths, and other static-site scaffolding are demo-only (MOCK_HTMX in site/build_site.py). They are not Hyperpart surface and not a product API. If an agent is stuck “making the toast work,” stop — implement the exchange row below instead.
| Request | Trigger | Response fragment | Swap | Envelope | States |
|---|---|---|---|---|---|
GET /_dazzle/documents/{entity}/{id}/{field}/file | PDF.js fetching document bytes (initial + Range requests as the user pages) | the file field's bytes — 200 whole-body or 206 partial with Content-Range; opaque 404 when the record is out of scope; 416 for unsatisfiable ranges | none (bytes consumed by the rendering engine) | none | — |
Swap contract
Agent-visible HTMX topology (ADR-0054 / decision 0012). exchange envelope = what the response may re-emit relative to the persistent slot (body_only | outer | none | host_owned | document). dual-lock validates part markup only — not this envelope. Stem: stems/morph-safe-hypermedia.md; decision: docs/decisions/0012-swap-identity-contract.md.
Gallery mocks may approximate morph with innerHTML — production follows the Swap + Envelope columns in Server exchange.
Exchanges (swap · envelope)
GET /_dazzle/documents/{entity}/{id}/{field}/file→ none (bytes consumed by the rendering engine) · envelope=none
Envelope rules
body_only— innerHTML / innerMorph into a slot; response is interior only (no re-wrap of slot id / nesteddata-dz-region).outer— outerHTML / outerMorph; response may carry identity.none— no HTML swap (JSON/204/bytes; client or OOB companion).host_owned— swap target/mode chosen by the host button’shx-target/hx-swap.document— full navigation / document load (not a fragment).- Slot owns stable
id/ domain keys; state in DOM, not Alpine.
Envelope response examples
What the server returns for each exchange on PDF viewer. Match the exchange envelope; dual-lock still applies to interior markup.
GET /_dazzle/documents/{entity}/{id}/{field}/file · envelope=none
Correct response for none (bytes / no HTML swap).
Do — correct response body
# envelope=none — opaque bytes (not an HTML fragment)
# HTTP 200 application/pdf (or 206 + Content-Range)
# Body: raw file bytes
# No HTML document chrome, no <html> wrapper
Don’t — violates none
<!-- WRONG: HTML page wrapping the PDF bytes -->
<!DOCTYPE html><html><body>…embed…</body></html>
How to use it
Seams
- data-pdf-src points at the document bytes (or range proxy)
- data-pdf-lib lazy-loads PDF.js as an ES module on first intersect
- data-pdf-state=url enables ?dzpdf-page / ?dzpdf-zoom deep-links
- optional [data-pdf-zoom] companion is leftover-honest (parseZoom; rest-state gallery omits it)
Do / Don't
| Do | Don't |
|---|---|
| lazy-load the engine from data-pdf-lib when the viewer enters view | eager-import PDF.js on every page that might show a document |
Pitfalls
- application controls ACCESS; PDF.js only renders — do not embed bytes in the bundle
- without JS the noscript download link IS the experience
- leftover zoom junk must not invent a scale (do not parseFloat leftover / URL junk)
Keyboard / AT
- toolbar page/zoom controls remain keyboard-operable
- noscript download is the progressive-enhancement floor
Related parts
DOM contract
What the emitted HTML must satisfy — the table is the required surface; Python under contracts/ is the package-internal dual-lock CI runs (tests/test_contracts.py), not an app route. Standalone HTMX4: implement the API so responses match this markup. Dazzle: the agent emits SSR that already satisfies it. Do not invent attrs outside these tables. For request/response wiring see Server exchange.
contracts/pdf.py
Required in the DOM: root [data-pdf] (part pdf). Emit only these attributes — inventing extras is fine only if controllers ignore them; omitting required ones fails CI (tests/test_contracts.py).
| Node | Attr | Constraint |
|---|---|---|
[data-pdf] | data-pdf-src | present (any value) |
[data-pdf] | data-pdf-lib | present (any value) |
[data-pdf-viewer] | — | — |
Module source
Import path is monorepo/package-local (from contracts._kit import …). Source-token form often uses data-*; gallery demos above are unprefixed. Do not copy this into app routes.
"""HYPERPART: pdf — progressive PDF shell (access + lazy PDF.js).
Leftover honesty (cycle 2151): leftover page junk (``2abc``, ``zzz``,
out-of-range) must not invent a page jump. ``parseInt("2abc")`` is not
a committed page. Empty input on blur restores from the current page.
Leftover honesty (cycle 2152): leftover zoom junk (``2abc``, ``zzz``,
``1e2``, out-of-[0.25, 8]) must not invent a scale. ``parseFloat("2abc")``
is not a committed zoom; URL ``?dzpdf-zoom`` leftover is the same parse.
Empty zoom companion on blur restores from the current zoom. Optional
``[data-dz-pdf-zoom]`` slot. Rest-state toolbar markup is unchanged.
"""
from contracts._kit import DomContract, Node, Present
DOM_CONTRACT = DomContract(
part="pdf",
root="[data-dz-pdf]",
nodes=(
Node(
"[data-dz-pdf]",
attrs={
"data-dz-pdf-src": Present(),
"data-dz-pdf-lib": Present(),
},
),
Node("[data-dz-pdf-viewer]", attrs={}),
),
)
__all__ = ["DOM_CONTRACT"]
Notes
pdf.js lazy-loads the library as an ES module from data-pdf-lib only when the viewer scrolls into view — no PDF bytes or engine in the bundle. In Dazzle, data-pdf-src points at the scope-gated range proxy (/_dazzle/documents/{entity}/{id}/{field}/file — document access IS entity access), and PDF.js range-requests pages on demand. data-pdf-state="url" opts a viewer into ?dzpdf-page/?dzpdf-zoom deep-links (replaceState — Back stays page navigation). Without JS the noscript download link is the whole experience. In production, VENDOR the PDF.js module — dynamic import() cannot carry SRI; the gallery's CDN pin is demo-only.Source files
One logical Hyperpart, 4 code items (CSS layered, JS bundled). Bound by HYPERPART: pdf — python tools/hyperpart.py pdf lists them.
site/registry.py · contracts/pdf.py · components/pdf.css:1 · controllers/dz-pdf.js