Skip to content

feat(attachments): inline PDF preview for application/pdf (lazy-inline)#296

Open
Stvad wants to merge 3 commits into
masterfrom
claude/brave-thompson-phw0ey
Open

feat(attachments): inline PDF preview for application/pdf (lazy-inline)#296
Stvad wants to merge 3 commits into
masterfrom
claude/brave-thompson-phw0ey

Conversation

@Stvad

@Stvad Stvad commented Jul 2, 2026

Copy link
Copy Markdown
Owner

What & why

Adds an inline PDF preview for application/pdf media blocks. Rebased onto master and re-implemented on the facilities the inline-audio increment (#300) introduced — the earlier version (stacked on #290) was an eager viewer; this adopts the lazy-inline pattern instead.

  • application/pdf is a LAZY-INLINE viewer (eager: false), mirroring audio: a metadata poster (preview + download) that resolves nothing on mount and arms the same fail-closed object-URL resolve via the renderer's content-scoped requestResolve latch only on the first preview intent. Once ready it renders a bounded-height <object type="application/pdf"> of the verified url; loading → spinner; a fail-closed resolve → a broken indicator.
  • Why not eager: PDFs can be large, so eagerly fetching + decrypting every one on mount is wasteful — and entirely wasted on iOS Safari, which can't inline-render a PDF at all. The lazy-inline model also closes the earlier version's downloadable-floor gap.
  • Reuses the audio increment's facilities: useMediaDownload + DownloadIconButton (generalized with a testid prop). PDF holds the "every attachment is at least downloadable" floor in every state (poster / preview / broken), since application/pdf no longer falls through to the file download fallback.
  • mediaBlock: PDF_MIME + isPdfMime (exact, case-insensitive). index: register pdfMediaViewer. No renderer change — a pure facet contribution.

Security (XSS) — and the sandbox question, answered empirically

The object URL's Blob is pinned to application/pdf by the isPdfMime match that selects the viewer (the Blob constructor also lowercases the type), so the browser routes it to its native PDF viewer and never HTML-sniffs hash-verified-but-non-PDF bytes into a same-origin document; PDF-level JS is sandboxed by the viewer itself (out-of-process).

A sandboxed iframe (no allow-same-origin) is deliberately not used. Verified in Chromium: a sandboxed opaque-origin frame cannot load a parent-origin blob: URL (net::ERR_BLOCKED_BY_CLIENT — blank), so it would break the preview while adding nothing the type-pin doesn't already give. The strongest form of "sandbox untrusted HTML" is applied elsewhere: text/html (and any unclaimed mime) is never inline-rendered — it goes to the transient octet-stream download.

Frame config (blob PDF) Result
<object type="application/pdf"> (shipped) loads
<iframe> (no sandbox) loads
<iframe sandbox> (no allow-same-origin) blocked / blank

Docs & verification

  • docs/media-attachments/design.html §11 / §15 (new "phase 8 — inline PDF" row) / scope / status banner updated, including the sandbox decision.
  • New tests mirror the audio branch: poster (no eager resolve), arm-on-preview → <object> typed application/pdf, loading, fail-closed broken (no <object>), download-from-poster (savable without arming), downloadable-floor when preview fails, disarm-on-content-change. isPdfMime + pickMediaViewer PDF dispatch.
  • Local yarn run check green (compile, lint, full test suite, sync-config, rpc-projections, no-service-role). CI now runs since this targets master.

🤖 Generated with Claude Code

https://claude.ai/code/session_01S2J741JwXMwT2wGYNM1j5u

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@Stvad Stvad force-pushed the claude/brave-thompson-phw0ey branch from a43aadd to 4629f2d Compare July 2, 2026 03:47
Base automatically changed from claude/inspiring-tesla-jbgxfe to master July 2, 2026 20:27
@Stvad Stvad force-pushed the claude/brave-thompson-phw0ey branch from 9153acc to a808a5b Compare July 2, 2026 21:30
@Stvad Stvad changed the title feat(attachments): inline PDF preview for application/pdf media blocks feat(attachments): inline PDF preview for application/pdf (lazy-inline) Jul 2, 2026
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://Stvad.github.io/knowledge-medium/pr-preview/pr-296/

Built to branch gh-pages at 2026-07-03 07:41 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@Stvad Stvad force-pushed the claude/brave-thompson-phw0ey branch from a808a5b to 292114a Compare July 2, 2026 21:39
Rebased onto master and re-implemented on the facilities the inline-audio
increment (#300) introduced, rather than the earlier eager approach.

- application/pdf is now a LAZY-INLINE viewer (eager: false), mirroring audio:
  a metadata poster ("Preview" + download) that resolves NOTHING on mount and
  arms the same fail-closed object-URL resolve via the renderer's content-scoped
  requestResolve latch only on the first preview intent. Once ready it renders a
  bounded-height <object type="application/pdf"> of the verified url; loading →
  spinner; a fail-closed resolve → a broken indicator.
- Why lazy over eager: the down-lane already replicates every media block to the
  local plaintext byte store (§8), so an eager resolve is usually a local HIT — not
  a re-download or re-decrypt — but it still reads the full (possibly large) bytes
  into a decrypted object-URL Blob and pins it in memory for the block's lifetime
  (a note of large PDFs holds them all, even un-viewed), and on iOS Safari (which
  can't inline-render a PDF) that Blob is pure waste. Lazy also closes the earlier
  eager version's downloadable-floor gap.
- UX: the poster carries an explicit "Preview" affordance (eye pill) so it's clear
  the row opens the PDF; the expanded preview is collapsible back to the poster
  (resolve stays armed → instant re-open, no refetch); and the object URL carries
  #navpanes=0 to hide the native viewer's page sidebar by default (Chrome PDFium
  open-param, best-effort per browser).
- Reuses the audio increment's shared facilities: useMediaDownload +
  DownloadIconButton (generalized with a `testid` prop). PDF holds the "every
  attachment is at least downloadable" floor in every state (poster/preview/broken),
  since application/pdf no longer hits the file download fallback.
- mediaBlock: PDF_MIME + isPdfMime (exact, case-insensitive). index: register
  pdfMediaViewer. No renderer change — a pure facet contribution.

Security / XSS (verified empirically in Chromium): the object URL's Blob is pinned
to application/pdf by the isPdfMime match, so the browser routes it to its native,
out-of-process PDF viewer and never HTML-sniffs hash-verified-but-non-PDF bytes into
a same-origin document; PDF-level JS is sandboxed by the viewer itself. A sandboxed
iframe is NOT an option for a blob: source: Chromium blocks a sandboxed frame from
loading a parent-origin blob: URL — even WITH allow-same-origin (which is honored for
an http doc; a separate blob-navigation block still fires, ERR_BLOCKED_BY_CLIENT). The
strongest form of "sandbox untrusted HTML" is applied elsewhere: text/html (and any
unclaimed mime) is never inline-rendered — it goes to the octet-stream download.
design.html §11/§15/scope/banner updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2J741JwXMwT2wGYNM1j5u
@Stvad Stvad force-pushed the claude/brave-thompson-phw0ey branch from 292114a to 353ddc4 Compare July 3, 2026 01:07
claude added 2 commits July 3, 2026 01:39
…pand jump)

The collapsed poster and the expanded player were two different top-level
elements (an inline-flex chip vs a block flex-col), so toggling repositioned
the widget — the header visibly shifted down from the top of the block when
the preview/player opened (reported for both PDF and audio).

Both viewers now render ONE stable container whose header row (filename +
controls + download) is always the first child, with the <object>/<audio>
body toggling in BELOW it. The header is the same element in the same position
in every state, so it can't move vertically on expand/collapse.

- PDF: a single toggle (Preview ⇄ collapse) on the stable header; the <object>
  mounts below only when expanded. Download stays in the header (the §11
  downloadable floor) in every state.
- Audio: same shape — the play affordance lives in the header while un-armed;
  once armed the native <audio controls> mounts below and the header shows just
  filename + download. Testids and behavior unchanged (tests pass as-is).

No behavior change beyond layout. yarn run check green (compile, lint, full
test suite, sync-config, rpc-projections, no-service-role).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2J741JwXMwT2wGYNM1j5u
…eference

A `((id))` reference renders the block's raw content inside the inline <a>
citation link (referenceLayout.tsx). The PDF/audio viewer containers were
block-level (`flex`), so they sat on that text line's baseline and dropped a
full line-height (~24px, measured) below the block's top — the "offset from the
top of the block" seen for both viewers.

Make each container `inline-flex` + `align-top` (vertical-align: top) so it hugs
the line top: flush in a reference AND unchanged when the media block renders
standalone (both measured 0px against the production CSS in the real
reference-link structure). Scoped to the two media viewers — no change to the
shared ReferenceLink, which must stay inline for text references.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2J741JwXMwT2wGYNM1j5u
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants