feat(viewer): render rich parts server-side, served from /s/:id#125
Merged
Conversation
… bug Prototype alternative to #101. Keeps the opaque-origin security model (sandbox="allow-scripts", no allow-same-origin, unchanged rich-part CSP) but loads the doc from a blob: URL instead of srcdoc, sidestepping the opaque-origin *srcdoc* layout path the Chrome 149 field trial breaks. Removes the #85 reparse retry so the blob path is tested on its own. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…an't reach board) The rich-part CSP allows 'unsafe-inline', so the opaque origin is the only thing containing a script. This injects a <script> as if a sanitizer bypass let it through, lets it run, and asserts window.origin is "null" and its write to the parent board is blocked — the same guarantee srcdoc gave. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Supersedes the blob: URL approach. blob: kept the opaque origin but is still an in-memory iframe document, so the async-rendered parts (diff, mermaid) never laid out under the Chrome 149 field trial. Instead stage each rich frame's rendered doc at POST /api/frames and load it by real URL from GET /f/:id — exactly how html parts load from /s/:id. The response carries the same `sandbox` CSP header, so the frame is opaque-origin on any load (a real navigation, not srcdoc/blob), which the affected Chrome lays out normally. - New bounded, FIFO-evicted in-memory frame store (runtime-agnostic). - /f/:id is public-read like /s/:id; POST /api/frames is allowed for public-read viewers so rich parts still render on read-only boards. - e2e proves /f/:id is opaque (a script that runs can't reach the board) and that the response carries the sandbox header. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Render markdown/code/diff/terminal on the server and serve each from /s/:id?part=N by real URL under a sandbox CSP header — the opaque-origin, real-navigation load path html parts already use, which the Chrome 149 field trial doesn't break (it defers layout only for in-memory srcdoc/blob: docs). - server/richRender.ts: shiki (JS regex engine), @pierre/diffs (shiki-js SSR), markdown-it (html:false), ansi_up — runtime-agnostic, run on the Worker DO. - renderSandboxedPart wraps rich bodies under a tighter CSP than html parts: no connect-src, no CDN script source — no exfil even if markup escaped. - mermaid needs a DOM, so renderMermaidPage emits a self-rendering doc that loads mermaid from the CDN inside the sandbox (html-part CSP). - Versioned+themed /s/:id responses are immutable: long-lived Cache-Control plus an in-memory (id,part,version,theme,mode) LRU render cache. - Remove POST /api/frames, GET /f/:id, the transient frame store, and the related auth/public-read exceptions; drop the dead viewer render components, highlight.ts, and mermaid/shiki from the client bundle (12.7MB -> 86KB). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
benvinegar
added a commit
that referenced
this pull request
Jun 24, 2026
Mermaid diagrams didn't fully re-theme on a light/dark flip — some of the diagram changed color, but not all of it. After authoring in light mode and toggling to dark, arrowheads stayed dark while the edges they cap flipped, and mermaid-derived fills read as light-mode. Root cause: the mermaid renderer drove mermaid's `base` theme from the design tokens but only set the box/line/text variables explicitly, leaving mermaid to derive the rest. Two derivation paths stayed stuck in light mode: 1. `darkMode` was never passed. Mermaid's base theme branches on a `darkMode` flag for the colors it computes itself (edge-label background, row stripes, the cScale/surface ramps). Unset → all computed for a light canvas. 2. `background` was never set → it defaulted to a hardcoded `#f4f4f4`. Anything derived as `invert(background)` — most visibly arrowheads — came out near-black in both schemes, so an edge flipped but its arrowhead didn't. Fix, ported to the server-side renderer (mermaidThemeVars in surfacePage.ts, where rich-part rendering now lives after #125): - Pass `darkMode: mode === "dark"` so mermaid's own derivations resolve to the active scheme. The page is server-rendered pinned to the chrome-resolved mode (the iframe src carries it), and re-fetched on flip — so no client re-render is needed. - Pass `background: surface` (the real card backdrop) so invert-derived colors track the theme. - Pin the previously invert-derived colors to the viewer's tokens: `arrowheadColor` → muted (matches the edges), and nodeTextColor/titleColor/ classText/secondary/tertiaryTextColor → the text token. Adds a hermetic regression test (test/surfacePage.test.ts) asserting the mermaid loader pins darkMode/background/arrowhead/text to the resolved scheme in both modes — no CDN or real mermaid needed, matching the stubbed e2e path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
benvinegar
added a commit
that referenced
this pull request
Jun 24, 2026
Mermaid diagrams didn't fully re-theme on a light/dark flip — some of the diagram changed color, but not all of it. After authoring in light mode and toggling to dark, arrowheads stayed dark while the edges they cap flipped, and mermaid-derived fills read as light-mode. Root cause: the mermaid renderer drove mermaid's `base` theme from the design tokens but only set the box/line/text variables explicitly, leaving mermaid to derive the rest. Two derivation paths stayed stuck in light mode: 1. `darkMode` was never passed. Mermaid's base theme branches on a `darkMode` flag for the colors it computes itself (edge-label background, row stripes, the cScale/surface ramps). Unset → all computed for a light canvas. 2. `background` was never set → it defaulted to a hardcoded `#f4f4f4`. Anything derived as `invert(background)` — most visibly arrowheads — came out near-black in both schemes, so an edge flipped but its arrowhead didn't. Fix, ported to the server-side renderer (mermaidThemeVars in surfacePage.ts, where rich-part rendering now lives after #125): - Pass `darkMode: mode === "dark"` so mermaid's own derivations resolve to the active scheme. The page is server-rendered pinned to the chrome-resolved mode (the iframe src carries it), and re-fetched on flip — so no client re-render is needed. - Pass `background: surface` (the real card backdrop) so invert-derived colors track the theme. - Pin the previously invert-derived colors to the viewer's tokens: `arrowheadColor` → muted (matches the edges), and nodeTextColor/titleColor/ classText/secondary/tertiaryTextColor → the text token. Adds a hermetic regression test (test/surfacePage.test.ts) asserting the mermaid loader pins darkMode/background/arrowhead/text to the resolved scheme in both modes — no CDN or real mermaid needed, matching the stubbed e2e path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jun 24, 2026
benvinegar
added a commit
that referenced
this pull request
Jun 25, 2026
Visiting a bare /s/:id URL now opens a full-page standalone view of that one surface — title and parts only, no sidebar, session feed, or comment thread — with a small "made with sideshow" watermark beneath it, instead of resolving the link into its session's stream. The route used to render a standalone surface page; #125 (multi-part surfaces) and #130 (link-preview SPA shell) left bare /s/:id resolving to the feed. This restores the standalone view within the new architecture: - state: a `standaloneSurface` signal + `bootstrap()` entry point. A bare surface route (surfaceId, no session) fetches the surface and enters standalone mode; `applyRoute` toggles it on back/forward. Falls back to the board if the surface can't be fetched. - App: renders `StandaloneView` (the one surface + watermark) instead of the board chrome, and titles the tab after the surface. - Card: a `standalone` prop strips the version dropdown, "updated" meta, comment thread, and scroll/URL observer — keeping the iframe registration so parts render in the same sandboxed frames sized by the same resize bridge. - styles: centered single-surface layout + watermark. No server change: bare /s/:id still serves the SPA shell with link-preview metadata (#130); the viewer picks the layout from the route. Works under the deployed /u/:user prefix via appPath/basePath. Updated the url-routing e2e test (it pinned the feed behavior) and added a changeset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Rich parts (markdown/code/diff/terminal/mermaid) intermittently render blank or
clipped on reload under a Chrome 149 field trial. Root cause: opaque-origin
in-memory iframe documents (
srcdoc,blob:) defer layout on the affectedprofile, so the resize bridge measures
0and the frame stays collapsed. Theasync-rendered parts (diff, mermaid) are hit hardest — they never lay out.
Approach
Keep the opaque-origin isolation; change how the document is produced and
delivered. Rich parts now render server-side and load the same way html
parts already do — from
/s/:id?part=Nby real URL with asandboxCSPresponse header — instead of an in-memory
srcdoc/blob:document. A realnavigation lays out normally on the affected Chrome while staying opaque-origin.
The earlier iterations on this branch (blob: URL, then a
POST /api/frames→/f/:idviewer→server staging round-trip) are superseded: a spike confirmed thetext renderers run on Workers with no DOM/WASM, so there's no reason to render in
the viewer and ship the string back.
server/richRender.tsrenders markdown/code/diff/terminal server-side:shiki on the JS regex engine (no oniguruma WASM),
@pierre/diffsSSR viashiki-js, markdown-it (html:false), ansi_up — all runtime-agnostic, sothey run on the Worker DO too.
/s/:id?part=Nserves each part underContent-Security-Policy: sandbox allow-scripts.renderSandboxedPartwraps rich bodies under a tighterin-doc CSP than html parts: no
connect-src, no CDN script source.renderMermaidPageemits a self-rendering docthat imports mermaid from the CDN inside the sandbox (html-part CSP).
/s/:idresponses are immutable: long-livedCache-Controlplus an in-memory(id,part,version,theme,mode)LRU rendercache (single-instance DO; swap for KV/Cache API if multi-instance).
POST /api/frames,GET /f/:id, the transient frame store, and therelated auth/public-read exceptions. Drops the dead viewer render components,
highlight.ts, and mermaid/shiki from the client bundle (12.7MB → 86KB).Why this keeps the security model
The core invariant is untouched: rich frames are opaque-origin via the same
sandboxresponse header/s/:idrelies on — noallow-same-origin, ever.The rich CSP deliberately allows
'unsafe-inline'(the bridge runs without anonce), so the opaque origin is the only thing containing a script — and its
CSP has no
connect-srcand no CDN/board script source, so even a hypotheticalescaped script could neither phone home nor reach the board. Defense in depth
behind the markdown-it escape.
e2e (
isolation.spec.ts): publish a markdown part with an embedded<script>,assert it's escaped to text, the
/s/:idresponse carries the sandbox header,window.originis the opaque"null", the in-doc CSP has noconnect-src, andscript-srcis'unsafe-inline'only.mermaid.spec.tsstubs the CDN import toexercise our loader wiring (render → inject SVG, and the error fallback)
hermetically.
Validation
npm test— 207/207 passnpm run typecheck/lint— passmermaid, removed endpoints) — pass
🤖 Generated with Claude Code