From 1c0fabed351437b73ce1507db2a268e7db4cf790 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 24 Jun 2026 08:42:06 -0400 Subject: [PATCH 1/7] fix(viewer): load rich parts via blob: URL to dodge Chrome 149 srcdoc 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) --- viewer/src/SandboxedPart.tsx | 47 +++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/viewer/src/SandboxedPart.tsx b/viewer/src/SandboxedPart.tsx index 821be38..5c01bc3 100644 --- a/viewer/src/SandboxedPart.tsx +++ b/viewer/src/SandboxedPart.tsx @@ -1,10 +1,10 @@ -import { createMemo, onCleanup, onMount } from "solid-js"; +import { createEffect, createMemo, onCleanup, onMount } from "solid-js"; import { renderSandboxedPart } from "../../server/surfacePage.ts"; import { themeById } from "../../server/themes.ts"; import { activeTheme, resolvedMode } from "./theme.ts"; // location.origin is constant for the page lifetime — read it once, not per -// srcdoc rebuild. +// doc rebuild. const ORIGIN = location.origin; // Size a surface iframe from a height the in-frame bridge reported. Shared by @@ -25,6 +25,14 @@ export function applyFrameHeight(iframe: HTMLIFrameElement, reportedHeight: unkn // regression. `body`/`css` are reactive — a theme switch rebuilds the doc and // reloads the frame (the same way Card reloads html-part iframes on theme). // +// The doc is loaded via a `blob:` URL rather than `srcdoc`. A blob-URL iframe +// under `sandbox="allow-scripts"` (no `allow-same-origin`) still gets an OPAQUE +// origin — identical isolation to srcdoc — but it is NOT the opaque-origin +// *srcdoc* layout path that Chrome 149's field trial breaks (there, the frame +// measures as 0 height and never resizes). The blob is created from the same +// trusted string; it only becomes live DOM inside the opaque-origin frame, so +// the security model (opaque origin + the tight rich-part CSP) is unchanged. +// // Resize is handled locally: the bridge in the doc posts its content height, and // each frame sizes itself from messages whose source is its own contentWindow. // (Link clicks and the session-switch shortcut ride App's global bridge handler, @@ -42,6 +50,21 @@ export function SandboxedPart(props: { body: string; css: string; class?: string }), ); + // Point the frame at a fresh blob URL whenever the doc changes (theme switch, + // body/css update). Revoke the previous doc's URL once the new one is wired + // up — the old frame already loaded it — and revoke the live one on cleanup. + let currentUrl: string | null = null; + createEffect(() => { + const url = URL.createObjectURL(new Blob([doc()], { type: "text/html" })); + const prev = currentUrl; + currentUrl = url; + frame.src = url; + if (prev) URL.revokeObjectURL(prev); + }); + onCleanup(() => { + if (currentUrl) URL.revokeObjectURL(currentUrl); + }); + onMount(() => { const onMessage = (ev: MessageEvent) => { if (ev.source !== frame.contentWindow) return; @@ -51,25 +74,6 @@ export function SandboxedPart(props: { body: string; css: string; class?: string }; window.addEventListener("message", onMessage); onCleanup(() => window.removeEventListener("message", onMessage)); - - // Chrome field-trial workaround: certain Chrome 149 A/B experiments break - // layout measurement in opaque-origin srcdoc iframes — scrollHeight, - // offsetHeight, innerWidth all read as 0. The bridge may fire with only - // the body-padding height (≤ MIN_H) because the content was never laid - // out, then never re-fire because no resize occurs. Re-setting the - // srcdoc attribute forces a fresh HTML parse that consistently recovers - // layout. One retry after 2 s is enough; the re-parsed bridge fires - // within ~60 ms. - const retryId = setTimeout(() => { - if (frame.isConnected && frame.srcdoc && frame.offsetHeight <= MIN_H) { - const s = frame.srcdoc; - frame.srcdoc = ""; - requestAnimationFrame(() => { - frame.srcdoc = s; - }); - } - }, 2000); - onCleanup(() => clearTimeout(retryId)); }); return ( @@ -77,7 +81,6 @@ export function SandboxedPart(props: { body: string; css: string; class?: string ref={(el) => (frame = el)} class={props.class ?? "partframe"} sandbox="allow-scripts" - srcdoc={doc()} > ); } From ffd26ff6c52600b1d68a32bcdb23329f611f4b19 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 24 Jun 2026 08:55:03 -0400 Subject: [PATCH 2/7] test(e2e): prove blob: rich frame stays opaque-origin (script runs, can't reach board) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rich-part CSP allows 'unsafe-inline', so the opaque origin is the only thing containing a script. This injects a `; + const docHtml = renderSandboxedPart({ body: evil, css: "", origin: server.url }); + + await page.goto(server.url); + // Mount the rich frame exactly as SandboxedPart does: a blob: URL document in + // an iframe sandboxed with allow-scripts and NO allow-same-origin. + await page.evaluate((html) => { + const url = URL.createObjectURL(new Blob([html], { type: "text/html" })); + const f = document.createElement("iframe"); + f.id = "rich-probe"; + f.setAttribute("sandbox", "allow-scripts"); + f.src = url; + document.body.append(f); + }, docHtml); + + // The inline script ran (CSP allows it) and self-reported into its own DOM: + // window.origin is the opaque "null", and its write to the parent board threw. + const probe = page.frameLocator("#rich-probe").locator("#r"); + await expect(probe).toContainText("null", { timeout: 10_000 }); + await expect(probe).toContainText("parent-blocked"); + await expect(probe).not.toContainText("REACHED-PARENT"); + // The board document itself is untouched: the escape never reached it. + await expect.poll(() => page.evaluate(() => document.body.dataset.pwned)).toBeUndefined(); +}); From d5775a5812ce7e8e27f45fc48b5979536831cd42 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 24 Jun 2026 09:02:53 -0400 Subject: [PATCH 3/7] docs: note the blob: URL rich-frame load path + changeset Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/rich-parts-blob-url.md | 12 ++++++++++++ AGENTS.md | 7 +++++++ 2 files changed, 19 insertions(+) create mode 100644 .changeset/rich-parts-blob-url.md diff --git a/.changeset/rich-parts-blob-url.md b/.changeset/rich-parts-blob-url.md new file mode 100644 index 0000000..cca3c0a --- /dev/null +++ b/.changeset/rich-parts-blob-url.md @@ -0,0 +1,12 @@ +--- +"sideshow": patch +--- + +Fix rich parts (markdown/mermaid/diff/terminal/code and comments) that +intermittently rendered blank or clipped on reload under a Chrome 149 field +trial. The viewer now loads each rich frame from a `blob:` URL instead of +`srcdoc`, which sidesteps the opaque-origin _srcdoc_ layout path the field trial +breaks. The frame stays sandboxed `allow-scripts` with no `allow-same-origin`, +so its opaque origin and tight CSP are unchanged — the isolation boundary is +identical, only the document's load path differs. Removes the `srcdoc` reparse +retry the previous workaround added. diff --git a/AGENTS.md b/AGENTS.md index 022591b..1e2f942 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,6 +108,13 @@ consciously, not as a side effect): postMessage bridge, the write API). Gate each so contained content can't impersonate the user, exfiltrate, or exhaust the server; add any new channel the same way. +- Rich/comment frames load their document from a `blob:` URL, not `srcdoc`: a + Chrome 149 field trial breaks layout measurement in opaque-origin _srcdoc_ + iframes (they report 0 height and never resize). A `blob:` URL under + `sandbox="allow-scripts"` (no `allow-same-origin`) is the same opaque origin + but a different load path, so it dodges the bug without touching the isolation + boundary. Don't switch it back to `srcdoc`; e2e proves the blob frame stays + opaque (a script that runs in it can't reach the board). - WebKit quirk in sandboxed iframes: ResizeObserver's initial callback may not fire and `documentElement.scrollHeight` ratchets to viewport height — the bridge reports `body.scrollHeight` on `load` plus staggered timers. Don't From e97afb9d2c87e57a3732e3905e503b42a72fa0c5 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 24 Jun 2026 09:04:12 -0400 Subject: [PATCH 4/7] docs: trim the blob: URL note to a one-liner Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1e2f942..96d1c33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,13 +108,9 @@ consciously, not as a side effect): postMessage bridge, the write API). Gate each so contained content can't impersonate the user, exfiltrate, or exhaust the server; add any new channel the same way. -- Rich/comment frames load their document from a `blob:` URL, not `srcdoc`: a - Chrome 149 field trial breaks layout measurement in opaque-origin _srcdoc_ - iframes (they report 0 height and never resize). A `blob:` URL under - `sandbox="allow-scripts"` (no `allow-same-origin`) is the same opaque origin - but a different load path, so it dodges the bug without touching the isolation - boundary. Don't switch it back to `srcdoc`; e2e proves the blob frame stays - opaque (a script that runs in it can't reach the board). +- Rich/comment frames load their document from a `blob:` URL, not `srcdoc` — + same `allow-scripts` opaque origin, but a load path that dodges a Chrome 149 + `srcdoc` layout bug. Prefer `blob:` here; don't switch back to `srcdoc`. - WebKit quirk in sandboxed iframes: ResizeObserver's initial callback may not fire and `documentElement.scrollHeight` ratchets to viewport height — the bridge reports `body.scrollHeight` on `load` plus staggered timers. Don't From d06cae17c8dcff2c16dde6fda3d8b91602786c29 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 24 Jun 2026 09:08:26 -0400 Subject: [PATCH 5/7] docs: drop the bug detail from the blob: URL note Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 96d1c33..fe87f8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,8 +109,7 @@ consciously, not as a side effect): impersonate the user, exfiltrate, or exhaust the server; add any new channel the same way. - Rich/comment frames load their document from a `blob:` URL, not `srcdoc` — - same `allow-scripts` opaque origin, but a load path that dodges a Chrome 149 - `srcdoc` layout bug. Prefer `blob:` here; don't switch back to `srcdoc`. + same `allow-scripts` opaque origin, different load path. Use `blob:`. - WebKit quirk in sandboxed iframes: ResizeObserver's initial callback may not fire and `documentElement.scrollHeight` ratchets to viewport height — the bridge reports `body.scrollHeight` on `load` plus staggered timers. Don't From 52ff37f3ee4b13b8731a37af4e2aa4b3b3ea5815 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 24 Jun 2026 10:29:17 -0400 Subject: [PATCH 6/7] fix(viewer): serve rich parts from /f/:id with a sandbox header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/rich-parts-blob-url.md | 12 ------- .changeset/rich-parts-render-url.md | 13 +++++++ AGENTS.md | 6 ++-- e2e/isolation.spec.ts | 40 ++++++++++++--------- server/app.ts | 54 +++++++++++++++++++++++++++++ test/api.test.ts | 39 +++++++++++++++++++++ viewer/src/SandboxedPart.tsx | 38 ++++++++++---------- 7 files changed, 152 insertions(+), 50 deletions(-) delete mode 100644 .changeset/rich-parts-blob-url.md create mode 100644 .changeset/rich-parts-render-url.md diff --git a/.changeset/rich-parts-blob-url.md b/.changeset/rich-parts-blob-url.md deleted file mode 100644 index cca3c0a..0000000 --- a/.changeset/rich-parts-blob-url.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"sideshow": patch ---- - -Fix rich parts (markdown/mermaid/diff/terminal/code and comments) that -intermittently rendered blank or clipped on reload under a Chrome 149 field -trial. The viewer now loads each rich frame from a `blob:` URL instead of -`srcdoc`, which sidesteps the opaque-origin _srcdoc_ layout path the field trial -breaks. The frame stays sandboxed `allow-scripts` with no `allow-same-origin`, -so its opaque origin and tight CSP are unchanged — the isolation boundary is -identical, only the document's load path differs. Removes the `srcdoc` reparse -retry the previous workaround added. diff --git a/.changeset/rich-parts-render-url.md b/.changeset/rich-parts-render-url.md new file mode 100644 index 0000000..5d5cf60 --- /dev/null +++ b/.changeset/rich-parts-render-url.md @@ -0,0 +1,13 @@ +--- +"sideshow": patch +--- + +Fix rich parts (markdown/mermaid/diff/terminal/code and comments) that +intermittently rendered blank or clipped on reload under a Chrome 149 field +trial. The viewer now stages each rich frame's rendered document at `/f/:id` +and loads it by real URL — like html parts at `/s/:id` — instead of an in-memory +`srcdoc` document, which is the layout path the field trial breaks. The response +carries the same `sandbox` CSP header `/s/:id` uses, so the frame stays +opaque-origin with no `allow-same-origin` and its tight CSP is unchanged — the +isolation boundary is identical, only the document's load path differs. Removes +the `srcdoc` reparse retry the previous workaround added. diff --git a/AGENTS.md b/AGENTS.md index fe87f8c..9c1f8c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,8 +108,10 @@ consciously, not as a side effect): postMessage bridge, the write API). Gate each so contained content can't impersonate the user, exfiltrate, or exhaust the server; add any new channel the same way. -- Rich/comment frames load their document from a `blob:` URL, not `srcdoc` — - same `allow-scripts` opaque origin, different load path. Use `blob:`. +- Rich/comment frames stage their rendered doc at `/f/:id` (`POST /api/frames`) + and load it by real URL, like html parts at `/s/:id` — served with a `sandbox` + CSP header, so opaque origin, not srcdoc/blob. Keep the header; don't render + rich markup inline in the viewer. - WebKit quirk in sandboxed iframes: ResizeObserver's initial callback may not fire and `documentElement.scrollHeight` ratchets to viewport height — the bridge reports `body.scrollHeight` on `load` plus staggered timers. Don't diff --git a/e2e/isolation.spec.ts b/e2e/isolation.spec.ts index 5e02412..bf60cac 100644 --- a/e2e/isolation.spec.ts +++ b/e2e/isolation.spec.ts @@ -167,19 +167,20 @@ test("a top-level surface document loads in an opaque (sandboxed) origin", async expect(origin).toBe("null"); }); -// Rich parts now load via a blob: URL instead of srcdoc (to dodge a Chrome 149 -// field trial that breaks opaque-origin srcdoc layout). The rich-part CSP -// deliberately allows 'unsafe-inline' so the bridge runs without a nonce — which -// means the OPAQUE ORIGIN is the only thing containing a script. So the security -// question for the blob: switch is exactly: does a blob-loaded rich frame still -// get an opaque origin? This injects a body as if a markdown-it / mermaid / -// diff sanitizer bypass let raw `; const docHtml = renderSandboxedPart({ body: evil, css: "", origin: server.url }); + // Stage it the way the viewer does, then confirm the response itself forces + // the opaque-origin sandbox (the load-bearing header — not just the iframe + // attribute, so a top-level open is contained too). + const staged = await request.post(`${server.url}/api/frames`, { data: { html: docHtml } }); + const { id } = (await staged.json()) as { id: string }; + const served = await request.get(`${server.url}/f/${id}`); + expect(served.headers()["content-security-policy"]).toBe("sandbox allow-scripts"); + await page.goto(server.url); - // Mount the rich frame exactly as SandboxedPart does: a blob: URL document in - // an iframe sandboxed with allow-scripts and NO allow-same-origin. - await page.evaluate((html) => { - const url = URL.createObjectURL(new Blob([html], { type: "text/html" })); + await page.evaluate((src) => { const f = document.createElement("iframe"); f.id = "rich-probe"; f.setAttribute("sandbox", "allow-scripts"); - f.src = url; + f.src = src; document.body.append(f); - }, docHtml); + }, `${server.url}/f/${id}`); // The inline script ran (CSP allows it) and self-reported into its own DOM: // window.origin is the opaque "null", and its write to the parent board threw. diff --git a/server/app.ts b/server/app.ts index e81798e..23d5878 100644 --- a/server/app.ts +++ b/server/app.ts @@ -14,6 +14,7 @@ import { type Comment, htmlPart, MAX_ASSET_BYTES, + newId, partsByteLength, type Store, type Surface, @@ -190,6 +191,7 @@ function isPublicReadAllowed(path: string, mode: PublicReadMode): boolean { if (mode === "full") return true; if (path.startsWith("/session/")) return true; if (path.startsWith("/s/")) return true; + if (path.startsWith("/f/")) return true; if (path.startsWith("/a/")) return true; if (path.startsWith("/api/sessions/")) return true; if (path.startsWith("/api/surfaces/")) return true; @@ -255,6 +257,19 @@ export function createApp({ const app = new Hono(); const bus = new EventBus(); + // Transient host for viewer-rendered rich-part documents. Rich parts + // (markdown/diff/mermaid/terminal/code and comments) render in the viewer, so + // unlike an html part the server has no markup to serve at /s/:id — the viewer + // POSTs the rendered string here and embeds it as /f/:id. Serving it from a + // real URL (rather than srcdoc/blob) lets the response carry the same + // `sandbox` CSP header /s/:id uses, so the frame is opaque-origin however it + // loads, while dodging a Chrome layout bug that only afflicts in-memory iframe + // documents. The map is bounded and FIFO-evicted: docs are ephemeral and + // re-POSTed on every render, so dropping an old one costs a re-render, never + // correctness. + const MAX_FRAME_DOCS = 512; + const frameDocs = new Map(); + // Last-resort safety net: any handler that throws (rather than returning a // status) becomes a clean JSON 500 instead of leaking a stack or a bare crash. // Validation rejects bad input with 4xx before this, so reaching here means an @@ -526,6 +541,14 @@ export function createApp({ if (publicRead && c.req.method === "GET" && isPublicReadAllowed(path, publicRead)) { return next(); } + // Rich parts render in the viewer and are staged back for display via + // /api/frames → /f/:id. A public-read viewer must stage them to view the + // board, so this transient render cache is allowed even though it's a POST — + // it mutates no board state, the doc is opaque-sandboxed at /f/:id, and the + // map is bounded. + if (publicRead && c.req.method === "POST" && path === "/api/frames") { + return next(); + } if (isAuthenticated(c)) return next(); if (path.startsWith("/api") || path === "/mcp") { return c.json({ error: "unauthorized — send Authorization: Bearer " }, 401); @@ -916,6 +939,37 @@ export function createApp({ ); }); + // Stage a viewer-rendered rich-part document for display at /f/:id. The body + // is a complete sandboxed HTML doc the viewer built (see renderSandboxedPart); + // it only ever becomes live DOM at /f/:id, which serves it opaque-sandboxed. + app.post("/api/frames", async (c) => { + const body = await c.req.json().catch(() => null); + if (!body || typeof body.html !== "string" || !body.html) { + return c.json({ error: 'body must include a non-empty "html" string' }, 400); + } + const id = newId(); + frameDocs.set(id, body.html); + while (frameDocs.size > MAX_FRAME_DOCS) { + const oldest = frameDocs.keys().next().value; + if (oldest === undefined) break; + frameDocs.delete(oldest); + } + return c.json({ id }, 201); + }); + + app.get("/f/:id", (c) => { + const html = frameDocs.get(c.req.param("id")); + if (html === undefined) return c.text("Frame not found", 404); + c.header("X-Content-Type-Options", "nosniff"); + // The same opaque-origin sandbox /s/:id sets: a `sandbox` CSP header forces + // a null origin on ANY load (incl. a top-level open or shared link), so this + // viewer-rendered markup can never reach the board origin. `allow-scripts` + // so the in-doc resize/interaction bridge runs; no `allow-same-origin`, so + // agent- or user-authored markup stays walled off from the board. + c.header("Content-Security-Policy", "sandbox allow-scripts"); + return c.html(html); + }); + // --- assets (agent-uploaded images, traces, files) --- // Accepts raw bytes (the asset's own Content-Type, metadata via query) or a diff --git a/test/api.test.ts b/test/api.test.ts index fa33ea5..c0bc365 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -142,6 +142,45 @@ test("publishes a combined html+diff surface; /s renders the html part only", as assert.doesNotMatch(csp, /allow-same-origin/); }); +test("/api/frames stages a viewer-rendered doc that /f/:id serves opaque-sandboxed", async () => { + const app = makeApp(); + const html = "

rich

"; + const staged = await app.request("/api/frames", json({ html })); + assert.equal(staged.status, 201); + const { id } = (await staged.json()) as { id: string }; + assert.ok(id); + + const served = await app.request(`/f/${id}`); + assert.equal(served.status, 200); + assert.equal(await served.text(), html); + // The load-bearing header: opaque origin on any load (incl. a top-level open), + // allow-scripts for the bridge, never allow-same-origin. + const csp = served.headers.get("content-security-policy") ?? ""; + assert.match(csp, /\bsandbox\b/); + assert.match(csp, /\ballow-scripts\b/); + assert.doesNotMatch(csp, /allow-same-origin/); + assert.equal(served.headers.get("x-content-type-options"), "nosniff"); + + // Unknown / evicted id is a clean 404; empty body is a 400. + assert.equal((await app.request("/f/nope")).status, 404); + assert.equal((await app.request("/api/frames", json({ html: "" }))).status, 400); +}); + +test("/api/frames is reachable by a public-read viewer (rich parts must render to be viewed)", async () => { + const app = makeApp("secret", { publicRead: "full" }); + // No token: a GET read is allowed, and so is staging a rich frame to view it. + const staged = await app.request("/api/frames", json({ html: "

x

" })); + assert.equal(staged.status, 201); + const { id } = (await staged.json()) as { id: string }; + assert.equal((await app.request(`/f/${id}`)).status, 200); +}); + +test("/api/frames still requires auth when there is no public-read", async () => { + const app = makeApp("secret"); + assert.equal((await app.request("/api/frames", json({ html: "

x

" }))).status, 401); + assert.equal((await app.request("/api/frames", authedJson({ html: "

x

" }))).status, 201); +}); + test("a snippet's kits ride the html part and inject the kit CSS/JS at /s", async () => { const app = makeApp(); const res = await app.request( diff --git a/viewer/src/SandboxedPart.tsx b/viewer/src/SandboxedPart.tsx index 5c01bc3..4bfc90a 100644 --- a/viewer/src/SandboxedPart.tsx +++ b/viewer/src/SandboxedPart.tsx @@ -1,6 +1,7 @@ import { createEffect, createMemo, onCleanup, onMount } from "solid-js"; import { renderSandboxedPart } from "../../server/surfacePage.ts"; import { themeById } from "../../server/themes.ts"; +import { api, appPath } from "./api.ts"; import { activeTheme, resolvedMode } from "./theme.ts"; // location.origin is constant for the page lifetime — read it once, not per @@ -25,13 +26,13 @@ export function applyFrameHeight(iframe: HTMLIFrameElement, reportedHeight: unkn // regression. `body`/`css` are reactive — a theme switch rebuilds the doc and // reloads the frame (the same way Card reloads html-part iframes on theme). // -// The doc is loaded via a `blob:` URL rather than `srcdoc`. A blob-URL iframe -// under `sandbox="allow-scripts"` (no `allow-same-origin`) still gets an OPAQUE -// origin — identical isolation to srcdoc — but it is NOT the opaque-origin -// *srcdoc* layout path that Chrome 149's field trial breaks (there, the frame -// measures as 0 height and never resizes). The blob is created from the same -// trusted string; it only becomes live DOM inside the opaque-origin frame, so -// the security model (opaque origin + the tight rich-part CSP) is unchanged. +// The doc is staged at /f/:id and loaded by real URL — exactly like an html +// part at /s/:id — not srcdoc/blob. The response carries a `sandbox` CSP header, +// so the frame is opaque-origin (identical isolation), and a real navigation +// avoids a Chrome layout bug that only afflicts in-memory iframe documents +// (srcdoc/blob), where the heavier async-rendered parts never lay out. The +// server has no markup for a rich part (it renders here), so we POST the string +// and point the frame at the id it returns. // // Resize is handled locally: the bridge in the doc posts its content height, and // each frame sizes itself from messages whose source is its own contentWindow. @@ -50,19 +51,18 @@ export function SandboxedPart(props: { body: string; css: string; class?: string }), ); - // Point the frame at a fresh blob URL whenever the doc changes (theme switch, - // body/css update). Revoke the previous doc's URL once the new one is wired - // up — the old frame already loaded it — and revoke the live one on cleanup. - let currentUrl: string | null = null; + // Stage the doc at /f/:id whenever it changes (theme switch, body/css update, + // async render completing) and point the frame there. POST is async, so a + // sequence guard drops a stale response if a newer doc raced ahead of it. + let seq = 0; createEffect(() => { - const url = URL.createObjectURL(new Blob([doc()], { type: "text/html" })); - const prev = currentUrl; - currentUrl = url; - frame.src = url; - if (prev) URL.revokeObjectURL(prev); - }); - onCleanup(() => { - if (currentUrl) URL.revokeObjectURL(currentUrl); + const html = doc(); + const mine = ++seq; + void api<{ id: string }>("/api/frames", { method: "POST", body: JSON.stringify({ html }) }) + .then(({ id }) => { + if (mine === seq) frame.src = appPath(`/f/${id}`); + }) + .catch(() => {}); }); onMount(() => { From e6e8ff3e3f5b92244d84373885e9d14a43fc6d7a Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 24 Jun 2026 13:32:48 -0400 Subject: [PATCH 7/7] feat(viewer): render rich parts server-side, served from /s/:id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/rich-parts-render-url.md | 13 - .changeset/rich-parts-server-render.md | 18 + AGENTS.md | 63 +- docs/rich-parts-server-render-spike.md | 236 +++++ e2e/isolation.spec.ts | 94 +- e2e/mermaid.spec.ts | 46 +- e2e/public-read.spec.ts | 2 +- e2e/theme.spec.ts | 23 +- e2e/viewer.spec.ts | 19 +- package-lock.json | 1277 +----------------------- package.json | 5 +- server/app.ts | 155 +-- server/richRender.ts | 405 ++++++++ server/surfacePage.ts | 143 +++ test/api.test.ts | 121 ++- tsconfig.workers.json | 1 + viewer/src/App.tsx | 3 +- viewer/src/Card.tsx | 108 +- viewer/src/CodePart.tsx | 195 ---- viewer/src/DiffPart.tsx | 144 --- viewer/src/MarkdownPart.tsx | 133 --- viewer/src/MermaidPart.tsx | 171 ---- viewer/src/SandboxedPart.tsx | 86 -- viewer/src/TerminalPart.tsx | 72 -- viewer/src/highlight.ts | 82 -- viewer/src/styles.css | 68 +- viewer/src/theme.ts | 7 +- 27 files changed, 1206 insertions(+), 2484 deletions(-) delete mode 100644 .changeset/rich-parts-render-url.md create mode 100644 .changeset/rich-parts-server-render.md create mode 100644 docs/rich-parts-server-render-spike.md create mode 100644 server/richRender.ts delete mode 100644 viewer/src/CodePart.tsx delete mode 100644 viewer/src/DiffPart.tsx delete mode 100644 viewer/src/MarkdownPart.tsx delete mode 100644 viewer/src/MermaidPart.tsx delete mode 100644 viewer/src/SandboxedPart.tsx delete mode 100644 viewer/src/TerminalPart.tsx delete mode 100644 viewer/src/highlight.ts diff --git a/.changeset/rich-parts-render-url.md b/.changeset/rich-parts-render-url.md deleted file mode 100644 index 5d5cf60..0000000 --- a/.changeset/rich-parts-render-url.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"sideshow": patch ---- - -Fix rich parts (markdown/mermaid/diff/terminal/code and comments) that -intermittently rendered blank or clipped on reload under a Chrome 149 field -trial. The viewer now stages each rich frame's rendered document at `/f/:id` -and loads it by real URL — like html parts at `/s/:id` — instead of an in-memory -`srcdoc` document, which is the layout path the field trial breaks. The response -carries the same `sandbox` CSP header `/s/:id` uses, so the frame stays -opaque-origin with no `allow-same-origin` and its tight CSP is unchanged — the -isolation boundary is identical, only the document's load path differs. Removes -the `srcdoc` reparse retry the previous workaround added. diff --git a/.changeset/rich-parts-server-render.md b/.changeset/rich-parts-server-render.md new file mode 100644 index 0000000..1ba46ef --- /dev/null +++ b/.changeset/rich-parts-server-render.md @@ -0,0 +1,18 @@ +--- +"sideshow": patch +--- + +Fix rich parts (markdown/code/diff/terminal) that intermittently rendered blank +or clipped on reload under a Chrome 149 field trial, by rendering them +server-side and serving each from `/s/:id?part=N` by real URL — the same +opaque-origin, real-navigation load path html parts already use, which the field +trial doesn't break (it defers layout only for in-memory `srcdoc`/`blob:` +documents). Rich documents render with shiki, @pierre/diffs, markdown-it, and +ansi_up on the server (no DOM/WASM, so they run on the Worker too) under a tight +`sandbox` CSP response header with no `connect-src` and no CDN script source. +Mermaid, which needs a DOM, instead emits a self-rendering document that loads +mermaid from the CDN inside the sandbox. Versioned, themed `/s/:id` responses +are immutable, so they now carry a long-lived `Cache-Control` and an in-memory +render cache. Removes the viewer→server `POST /api/frames` → `/f/:id` round-trip +and transient frame store the previous workaround added, and drops mermaid and +shiki from the viewer bundle. diff --git a/AGENTS.md b/AGENTS.md index 9c1f8c9..646291e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,15 +47,23 @@ consciously, not as a side effect): kit's CSS/JS into the sandbox after the base. Runtime-agnostic; allowlisted in `surfaceParts` and listed at `/api/kits`. Adding a kit is a registry entry + a guide bullet — no new part kind, no native-render surface. +- `server/richRender.ts` — server-side renderers for the rich kinds + (`renderMarkdown`/`renderCode`/`renderDiff`/`renderTerminal` → `{body, css}`), + runtime-agnostic so they run on the Worker DO too (shiki on the JS regex + engine, @pierre/diffs SSR via `shiki-js`, markdown-it, ansi_up — no WASM/DOM). + `/s/:id` calls these and wraps the result in `renderSandboxedPart`. - `server/surfacePage.ts` — sandboxed documents for surface markup. `renderHtmlPage` wraps an html part (CDN-allowlist CSP + the postMessage bridge: resize, sendPrompt, openLink) and injects any opted-in kits (`kits.ts`). - `renderSandboxedPart` wraps markup the viewer rendered - to a string (markdown/mermaid/diff/terminal) under a tighter CSP (no - `connect-src`, no CDN) — see `viewer/src/SandboxedPart.tsx`. Image and trace - parts stay native because they have no HTML sink (the viewer renders them with - text nodes / `` / JSX). No agent markup is ever set as `innerHTML` in the - trusted viewer origin. + `renderSandboxedPart` wraps a server-rendered rich body (markdown/code/diff/ + terminal — see `richRender.ts`) under a tighter CSP (no `connect-src`, no CDN). + `renderMermaidPage` is the one exception: mermaid needs a DOM, so it can't be + server-rendered — instead it emits a self-rendering doc that loads mermaid from + the CDN allowlist (so it uses the html-part CSP, which permits the CDN). Image + and trace parts stay native because they have no HTML sink (the viewer renders + them with text nodes / `` / JSX), and comments render as escaped Solid + text nodes. No agent markup is ever set as `innerHTML` in the trusted viewer + origin. - `server/themes.ts` — theme registry (github/gruvbox/one), runtime-agnostic so both server and viewer import it. One `Palette` per light/dark per theme; the viewer-chrome vars and the html-part `--color-*` tokens are both _derived_ @@ -90,35 +98,40 @@ consciously, not as a side effect): channel, so any markup that executes there can read every surface, act as the user, and inject prompts back to the agent. The rule applies to every part kind, comments, and anything else agent-authored. The two safe ways to render - it: (a) **build a STRING and hand it to a sandbox iframe** — `SandboxedPart` - for viewer-rendered parts (markdown/mermaid/diff/terminal, comments) and - `renderHtmlPage` at `/s/:id` for html parts; or (b) **keep it as data and + it: (a) **build a STRING and serve it from `/s/:id` under a `sandbox` CSP + header** — `renderHtmlPage` for html parts, `renderSandboxedPart` for the + server-rendered rich kinds (markdown/code/diff/terminal), and + `renderMermaidPage` for the mermaid CDN doc; or (b) **keep it as data and render with Solid text nodes / element attributes**, which escape by - construction (image, trace). String-building in the viewer is fine — a string - is not a DOM sink; danger only starts when it reaches the DOM, which must - happen at an opaque origin. When you add a part kind, pick (a) or (b); never a - third way. The iframes are sandboxed without `allow-same-origin` (opaque - origin) and `connect-src`-free for rich parts (no exfil even if contained - script runs); never weaken this. Treat anything agent- or user-produced as - untrusted, whatever its kind or route. Content served from a board-origin URL - must be sandboxed by the response itself (a `sandbox` CSP **header**), not just - the embedding iframe — a top-level load bypasses the attribute (as `/s/:id` - does). + construction (image, trace, and comments — plain escaped text). String-building + on the server is fine — a string is not a DOM sink; danger only starts when it + reaches the DOM, which must happen at an opaque origin. When you add a part + kind, pick (a) or (b); never a third way. The iframes are sandboxed without + `allow-same-origin` (opaque origin) and `connect-src`-free for rich parts (no + exfil even if contained script runs); never weaken this. Treat anything agent- + or user-produced as untrusted, whatever its kind or route. Content served from + a board-origin URL must be sandboxed by the response itself (a `sandbox` CSP + **header**), not just the embedding iframe — a top-level load bypasses the + attribute (as `/s/:id` does). - Untrusted content can reach the host only through narrow channels (the postMessage bridge, the write API). Gate each so contained content can't impersonate the user, exfiltrate, or exhaust the server; add any new channel the same way. -- Rich/comment frames stage their rendered doc at `/f/:id` (`POST /api/frames`) - and load it by real URL, like html parts at `/s/:id` — served with a `sandbox` - CSP header, so opaque origin, not srcdoc/blob. Keep the header; don't render - rich markup inline in the viewer. +- Every part that becomes HTML (html + the rich kinds) is rendered server-side + and served from `/s/:id?part=N` by real URL under a `sandbox` CSP header — + opaque origin, not srcdoc/blob (which a Chrome 149 field trial fails to lay + out). There is no viewer→server render round-trip and no transient frame store; + don't reintroduce one, and don't render rich markup inline in the trusted + viewer. Versioned+themed `/s/:id` responses are immutable, so they carry a + long-lived `Cache-Control` and a per-`(id,part,version,theme,mode)` in-memory + render cache (single-instance DO; swap for KV/Cache API if multi-instance). - WebKit quirk in sandboxed iframes: ResizeObserver's initial callback may not fire and `documentElement.scrollHeight` ratchets to viewport height — the bridge reports `body.scrollHeight` on `load` plus staggered timers. Don't "simplify" it back; e2e covers it on real WebKit. Watch the inverse too: the bridge sizes the frame from `body.scrollHeight`, so a `white-space: pre-wrap` - on `body` makes the template's surrounding newlines render as blank lines and - inflate the height — scope `pre-wrap` to a wrapper element (see `CMT_CSS`). + on `body` makes a template's surrounding newlines render as blank lines and + inflate the height — scope `pre-wrap` to a wrapper element. - Feedback cursor: each session carries `agentSeq`, the highest comment seq already delivered to the agent. Piggyback collection and `author=user` waits advance it, and `author=user` session waits with no explicit `after` diff --git a/docs/rich-parts-server-render-spike.md b/docs/rich-parts-server-render-spike.md new file mode 100644 index 0000000..9b12f3f --- /dev/null +++ b/docs/rich-parts-server-render-spike.md @@ -0,0 +1,236 @@ +# Rich-part rendering: spike plan + context + +> Working/handoff doc. Not part of the shippable PR — delete or gitignore before merge. +> Branch: `fix/rich-parts-blob-url` (PR #125). Written 2026-06-24. + +## TL;DR + +We set out to fix rich parts (markdown/diff/terminal/mermaid/code + comments) +rendering blank/clipped on reload under a Chrome 149 field trial. The fix that +**currently works and is committed in PR #125** serves each rich part's rendered +HTML from a real URL (`/f/:id`) with a `sandbox` CSP header, instead of an +in-memory `srcdoc`/`blob:` document. It keeps full opaque-origin isolation and is +confirmed working on the affected Chrome 149 profile. + +But that introduced a `POST /api/frames` endpoint + a transient server store + +a public-read auth exception — and surfaced a pre-existing perf problem (the +viewer is a 12.7 MB single-file inlined-JS bundle). We've decided to **pivot to +rendering rich parts server-side** and serve them all from `/s/:id` like html +parts, deleting the `POST`/`/f/:id` path. This also shrinks the client bundle. + +**Next action: run the spike below** to confirm shiki + @pierre/diffs can render +under the runtime-agnostic / Cloudflare Workers constraint, _before_ tearing out +the `/f/:id` path. + +--- + +## The bug (root cause) + +A Chrome 149 field trial defers layout in **opaque-origin _in-memory_ iframe +documents** (`srcdoc` and `blob:`). The resize bridge measures `scrollHeight` as +`0`, so the frame stays collapsed and the part renders blank — non-deterministic +per reload. The async-rendered parts (diff via @pierre SSR, mermaid via +`await mermaid.render`) are worst: they render empty first, then a second load +that never lays out, so they **never** appear. + +**Key learning — the trigger is the in-memory document, NOT opaque origin.** +Proof: html parts at `/s/:id` are _also_ opaque-origin (via a `sandbox` CSP +response header) but were never affected, because they load by **real HTTP +navigation**. So: real-URL doc + sandbox header = opaque origin that lays out +normally. (#101's `allow-same-origin` approach mis-diagnosed this as +opaque-origin-in-general and traded away isolation to fix it.) + +## What we tried (chronological) + +1. **#101 (not ours): `sandbox="allow-scripts allow-same-origin"` + CSP nonce.** + Makes rich frames same-origin with the board → collapses defense-in-depth to a + single nonce boundary; a running script could reach `parent.fetch`/`parent.document`. + Rejected on security grounds (rewrites the core "never weaken" invariant). +2. **`blob:` URL (kept opaque origin).** Still an in-memory document → diff/mermaid + never load on the affected profile. Rejected. (Superseded commits still in branch + history; net diff is the `/f/:id` approach.) +3. **`/f/:id` sandbox-header (CURRENT, committed).** Viewer POSTs the rendered + string to `/api/frames`; `/f/:id` serves it with `Content-Security-Policy: +sandbox allow-scripts`. Real URL → dodges the bug; opaque origin → isolation + intact. **Confirmed working on Chrome 149.** Downsides that triggered the pivot: + a write endpoint, a transient store, a public-read POST exception, no caching. + +## Other key learnings + +- **Rich-part CSP allows `'unsafe-inline'`** (so the bridge runs without a nonce). + Therefore the **opaque origin is the ONLY thing containing a script** in a rich + frame. Any approach must keep rich content opaque-origin. The e2e isolation test + asserts a script that _runs_ in the frame can't read its origin or write the parent. +- **Perf (pre-existing, NOT caused by this PR):** the self-hosted viewer is built + by `vite-plugin-singlefile` into one `viewer/dist/index.html` that is **12.7 MB, + 99.9% inline JS**, served **uncompressed**. Brutal initial/uncached fetch + (~5.6 s to hornet). The source already code-splits (mermaid/katex/shiki are + `await import()`ed) but singlefile re-inlines it all. Server-side rendering would + pull shiki/markdown-it/@pierre out of the client bundle — fixing this too. +- **No server-side mermaid renderer is viable.** Mermaid needs a DOM (d3/SVG); + only `@mermaid-js/mermaid-cli` (Puppeteer) or jsdom — both heavy, Node-only, not + Workers. So mermaid stays client-rendered, inside a server-emitted sandboxed doc + that loads mermaid JS (CDN) — reusing the html-part CDN/kit machinery. + +## Current PR #125 state + +Branch `fix/rich-parts-blob-url`. Net diff vs `main` = the `/f/:id` approach. + +- `server/app.ts`: `POST /api/frames` (bounded FIFO `frameDocs` map, `MAX_FRAME_DOCS=512`), + `GET /f/:id` (serves with `sandbox allow-scripts` + `nosniff`), `/f/` added to + `isPublicReadAllowed`, POST-public-read exception in auth middleware. `newId` imported. +- `viewer/src/SandboxedPart.tsx`: POSTs doc → sets `frame.src = appPath('/f/'+id)` + with a seq guard. (Was srcdoc, then blob, now `/f/:id`.) +- `test/api.test.ts`: 3 tests (frames stage/serve+headers/404/400; public-read reachable; auth required). +- `e2e/isolation.spec.ts`: rewritten test proves `/f/:id` is opaque + carries the sandbox header. +- `AGENTS.md`: note about `/f/:id` load path. `.changeset/rich-parts-render-url.md`. +- Validation: `npm test` 208/208, chromium e2e green, typecheck/lint/format clean. + WebKit e2e NOT run (host missing `libicu74` etc.). Confirmed on Chrome 149 profile. + +**If the pivot lands, most of the above gets removed** (POST/`/f/:id`/store/exception), +replaced by extending `/s/:id` to rich kinds. Decide whether to keep #125 as the +interim fix or fold the refactor into it. + +## Target architecture (the pivot) + +Serve **every** part from `/s/:id?part=N` (real URL + `sandbox` header), like html +parts already do. Viewer part components become thin iframes pointing there. + +- markdown / terminal / code / diff → **server-rendered HTML** at `/s/:id`. +- mermaid → server emits a **self-rendering sandboxed doc** (loads mermaid from CDN). +- comments → server-rendered escaped text. +- Delete `POST /api/frames` + `/f/:id` + store + auth exception. +- Wins: no write endpoint, cacheable (`?ver=`), smaller client bundle. + +**Important fallback insight:** the "emit a sandboxed self-rendering doc at `/s/:id` +that loads the renderer from CDN" trick (the mermaid plan) works for **any** +renderer that can't run on Workers. So there are two viable end-states, and the +spike picks per-renderer: + +- **(A) True server-side render** — smallest payload, cacheable HTML; needs the + lib to run on the Worker DO. +- **(B) Self-rendering sandboxed doc** — lib loads in-iframe from CDN; no Workers + renderer needed; still real-URL `/s/:id` (no POST, dodges the bug). Mermaid uses (B). + +Either end-state removes the POST and keeps opaque-origin isolation. + +## The spike (de-risk before refactor) + +Goal: determine whether shiki and @pierre/diffs can render under +`tsconfig.workers.json` (runtime-agnostic, no `node:` imports) and at runtime on +the Worker DO. markdown-it and ansi_up are pure JS (low risk). + +Steps: + +1. Create `server/richRender.ts` (runtime-agnostic) with + `renderMarkdown/renderTerminal/renderCode/renderDiff(part, {theme, mode}) → {body, css}`, + porting logic + CSS strings from `viewer/src/{MarkdownPart,TerminalPart,CodePart,DiffPart}.tsx`. +2. Confirm it typechecks under **all three** tsc programs, especially + `tsconfig.workers.json` (add `server/richRender.ts` to the workers-agnostic set). +3. **shiki on Workers**: use the JS regex engine (`createJavaScriptRegexEngine` + from `shiki/engine/javascript`) to avoid wasm/oniguruma, or load wasm explicitly. + Render a code block; confirm no `node:`/wasm-fetch blockers. +4. **@pierre/diffs SSR**: import its SSR API in that program; render a patch; confirm + it runs without a DOM. +5. Verdict per renderer: (A) server-render if clean, else (B) self-rendering doc. + mermaid is (B) regardless. + +If shiki/@pierre fight Workers → don't force server-render; use (B) for them +(emit a `/s/:id` doc that loads the lib from CDN and renders in the sandboxed +iframe). Still removes the POST and serves from a real URL. + +## SPIKE RESULT — DONE 2026-06-24: all four render server-side (A). ✅ + +`server/richRender.ts` is written and committed-to-branch (untracked, not in a +commit yet) with `renderMarkdown/renderTerminal/renderCode/renderDiff(part, +{theme, mode}) → {body, css}`, ported from the viewer parts. It is **not yet +wired into any route** — it's the spike artifact the refactor will consume. + +Four independent signals all green: + +1. **Typecheck** — `server/richRender.ts` added to `tsconfig.workers.json` + include; `npm run typecheck` (node + workers + viewer) passes. shiki, + `shiki/engine/javascript`, `@pierre/diffs`, `@pierre/diffs/ssr`, `markdown-it`, + `ansi_up` all resolve and typecheck under workers types. +2. **Node runtime** — each renderer executed (no DOM): markdown emits a + `class="shiki"` fenced block, terminal collapses CRs, code applies the + `counter-reset:line` start, diff produces ~50KB of @pierre SSR + (`diffs-container` + declarative shadow roots). +3. **Clean workerd bundle** — `esbuild --platform=browser +--conditions=workerd,worker,browser` bundles it with **zero `node:` builtins** + and zero real `require()` (the one match was a Ruby TextMate grammar regex). +4. **Real workerd execution** — ran the four renderers inside `wrangler dev` on + workerd via a throwaway worker; all returned `ok:true` (`allOk:true`), + including the heavy @pierre SSR diff. So no runtime-global gap either. + +**Verdict per renderer:** + +- markdown → **(A) server-render** ✅ (markdown-it + shiki-js) +- terminal → **(A) server-render** ✅ (ansi_up, pure JS) +- code → **(A) server-render** ✅ (shiki-js) +- diff → **(A) server-render** ✅ (@pierre/diffs SSR on `preferredHighlighter: +"shiki-js"`) +- mermaid → **(B) self-rendering sandboxed doc** (unchanged; needs a DOM, loads + mermaid from CDN inside the `/s/:id` iframe). + +Best-case outcome: the pivot is fully de-risked, no (B) fallback needed for the +text renderers. **The refactor (extend `/s/:id` to rich kinds, delete +`POST /api/frames` + `/f/:id` + store + auth exception) is cleared to start.** + +Notes for the refactor: + +- shiki/markdown-it/mermaid are currently **devDependencies** (viewer-bundled). + Once `richRender.ts` is imported by server/workers runtime code they must move + to **dependencies** (Node server needs them at runtime; wrangler bundles them + into the DO). `@pierre/diffs` + `ansi_up` are already deps. +- The Worker bundle absorbs shiki (~10MB of grammars when fully inlined) — but + that's server-side; the **client** bundle shrinks (shiki/markdown-it/@pierre + leave the viewer), which is the perf win the doc wanted. Confirm shiki langs + still load on-demand under wrangler's bundler (dynamic `import()` of grammars), + or accept the inlined set. +- `richRender.ts` duplicates `shikiSchemeCss` and the CSS strings from the viewer + parts — once the parts become thin `/s/:id` iframes, delete the viewer copies + (MarkdownPart/CodePart/DiffPart/TerminalPart render logic, `highlight.ts`) so + there's one source of truth. + +## File/wiring reference (avoid re-reading) + +- **Build:** `vite.config.ts` uses `viteSingleFile` → 12.7 MB inlined `viewer/dist/index.html`. + Embed build (`viewer/vite.embed.config.ts`, `build:embed`) does NOT inline → already chunked. +- **Serving the viewer:** + - Node: `server/index.ts:14-22` `readFile(viewer/dist/index.html)` at boot → `viewerHtml`. + - Workers: `workers/index.ts:7` `import viewerHtml from "../viewer/dist/index.html"` (wrangler.jsonc + Text rule: `globs: ["**/*.html","**/*.md"]`). One DO (`SideshowBoard`) runs everything; no static-asset routes. +- **`server/app.ts`:** `createApp({store, viewerHtml, ...})` ~241. `/s/:id` handler ~867 + (html-only, 404s other kinds; sets `sandbox allow-scripts` header ~898; reads `?ver=&theme=&mode=`). + `isPublicReadAllowed` ~189. Auth middleware ~499-535 (`authToken`/`publicRead`/cookie). `MAX_BODY_BYTES=16MiB` ~34. +- **`server/surfacePage.ts`:** `renderHtmlPage` (html parts, CDN-allowlist CSP, kits), `renderSandboxedPart` + (rich parts, tighter CSP), `buildRichCsp`, `buildCsp` (html, has CDN_ALLOWLIST), `BRIDGE_JS`, `escapeHtml`. +- **`server/kits.ts`:** opt-in CSS/JS bundles injected into html-part docs — the mechanism to inject a mermaid loader. +- **Viewer parts:** `viewer/src/{MarkdownPart,DiffPart,MermaidPart,TerminalPart,CodePart,SandboxedPart}.tsx`. + CSS strings: `MD_CSS`, `DIFF_CSS`, `MERMAID_CSS`, `TERM_CSS`, `CMT_CSS` (in Card.tsx). Move server-side. + - markdown-it: `html:false, linkify:true`. mermaid: `securityLevel:'strict'`, dynamic import. + shiki: `loadLangs` async. diff: @pierre/diffs SSR API. +- **`viewer/src/Card.tsx`:** html parts embed ` - - - - - - - - - - - - - - - )} @@ -421,11 +411,11 @@ function CommentRow(props: { comment: ViewComment }) { data-cid={props.comment.id} > {props.comment.author === "user" ? "you" : props.comment.author} - ${escapeHtml(props.comment.text)}`} - css={CMT_CSS} - /> + {/* Plain comment text rendered as a Solid text node — escapes by + construction (the invariant's option-(b) for data), so no iframe is + needed. `white-space: pre-wrap` (in styles.css) keeps the author's + line breaks. */} +
{props.comment.text}
`; - const head = hasHead - ? `
${filename}${langBadge}${copyBtn}
` - : copyBtn; - // Embed the raw code as a JS string for the copy handler. Escape < so a - // in the code can't break out of the inline script tag. - const codeJs = JSON.stringify(code).replace(/${head}${preWithStart}`; - }; - - const render = () => setHtml(buildBody()); - - // Re-highlight when the board theme changes (shiki pair swap). - createEffect(() => { - setCurrentThemes(themeById(activeTheme()).shiki); - render(); - }); - - onMount(() => { - let disposed = false; - onCleanup(() => (disposed = true)); - render(); // initial paint (plain text if lang not yet loaded) - const lang = props.part.language; - if (lang && lang !== "text") { - void (async () => { - await loadLangs([lang]); - if (!disposed) render(); - })(); - } - }); - - return ( - - ); -} diff --git a/viewer/src/DiffPart.tsx b/viewer/src/DiffPart.tsx deleted file mode 100644 index 7fe6955..0000000 --- a/viewer/src/DiffPart.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { createEffect, createSignal, onCleanup, onMount } from "solid-js"; -import { - type FileDiffMetadata, - getFiletypeFromFileName, - parseDiffFromFile, - parsePatchFiles, - preloadHighlighter, - processFile, - type SupportedLanguages, -} from "@pierre/diffs"; -import { preloadFileDiff } from "@pierre/diffs/ssr"; -import type { DiffPart as DiffPartData } from "./api.ts"; -import { themeById } from "../../server/themes.ts"; -import { SandboxedPart } from "./SandboxedPart.tsx"; -import { activeTheme } from "./theme.ts"; - -// Wrapper styles for the sandbox iframe. Each file's diff is a @pierre/diffs SSR -// fragment mounted in its OWN declarative shadow root (it ships its own scoped -// stylesheet, keyed off :host), so the iframe body only spaces the files. -const DIFF_CSS = ` -body { margin: 0; padding: 0; background: transparent; font-size: 12.5px; } -diffs-container { display: block; } -diffs-container + diffs-container { border-top: 0.5px solid var(--border); } -`; - -// The shiki light/dark pair follows the board theme (kept identical to -// MarkdownPart so a diff and a fenced code block read as one syntax theme). -const shikiPair = () => { - const t = themeById(activeTheme()); - return { dark: t.shiki.dark, light: t.shiki.light }; -}; - -// The viewer theme is purely prefers-color-scheme driven (see styles.css), so -// the diff follows the OS/browser scheme and re-renders when it flips. -const darkQuery = window.matchMedia("(prefers-color-scheme: dark)"); -const [isDark, setIsDark] = createSignal(darkQuery.matches); -darkQuery.addEventListener("change", (e) => setIsDark(e.matches)); - -// A small base set of langs the highlighter always loads; the rest are -// inferred from the part's filenames. preloadHighlighter only loads what we -// ask for, so we keep this lean to avoid pulling in every shiki grammar. -const BASE_LANGS = ["text", "json", "javascript", "typescript", "tsx", "jsx"]; - -// Turn a DiffPart into one FileDiffMetadata per file: prefer an explicit -// unified patch, else build a diff from each before/after pair. -function buildFileDiffs(part: DiffPartData): { diffs: FileDiffMetadata[]; langs: string[] } { - const langs = new Set(BASE_LANGS); - const diffs: FileDiffMetadata[] = []; - - if (part.patch) { - // parsePatchFiles returns one ParsedPatch per commit; each carries a - // files[] of FileDiffMetadata. Flatten them into a flat per-file list. - for (const parsed of parsePatchFiles(part.patch)) { - for (const fd of parsed.files) { - diffs.push(fd); - if (fd.name) langs.add(getFiletypeFromFileName(fd.name)); - } - } - // Some patches (a bare hunk with no `diff --git` header) yield no files - // from parsePatchFiles; fall back to treating the whole text as one file. - if (diffs.length === 0) { - const fd = processFile(part.patch); - if (fd) diffs.push(fd); - } - } else if (part.files) { - for (const f of part.files) { - const lang = f.language ?? getFiletypeFromFileName(f.filename); - langs.add(lang); - diffs.push( - parseDiffFromFile( - { name: f.filename, contents: f.before, lang: lang as SupportedLanguages }, - { name: f.filename, contents: f.after, lang: lang as SupportedLanguages }, - ), - ); - } - } - return { diffs, langs: [...langs] }; -} - -export function DiffPart(props: { part: DiffPartData }) { - const [error, setError] = createSignal(null); - const [body, setBody] = createSignal(null); - - onMount(() => { - let disposed = false; - onCleanup(() => (disposed = true)); - - // Render to an HTML STRING (per file, via the SSR API) whenever the board - // theme or color scheme changes — string building is not a DOM sink, so - // doing it in the trusted viewer is safe; SandboxedPart parses it inside an - // opaque-origin iframe. Each file's fragment goes in its own declarative - // shadow root so its scoped :host stylesheet applies. - createEffect(() => { - const dark = isDark(); - const shiki = shikiPair(); - void (async () => { - try { - const { diffs, langs } = buildFileDiffs(props.part); - if (diffs.length === 0) { - setError("No diff content."); - return; - } - await preloadHighlighter({ - themes: [shiki.dark, shiki.light], - langs: langs as SupportedLanguages[], - preferredHighlighter: "shiki-js", - }); - if (disposed) return; - const options = { - diffStyle: props.part.layout ?? "unified", - theme: { dark: shiki.dark, light: shiki.light }, - themeType: dark ? "dark" : "light", - preferredHighlighter: "shiki-js", - } as const; - const rendered = await Promise.all( - diffs.map((fileDiff) => preloadFileDiff({ fileDiff, options })), - ); - if (disposed) return; - setError(null); - setBody( - rendered - .map( - (r) => - ``, - ) - .join(""), - ); - } catch (err) { - if (!disposed) setError(err instanceof Error ? err.message : "Could not render diff."); - } - })(); - }); - }); - - return ( -
- {error() ? ( -
Couldn't render diff — {error()}
- ) : ( - - )} -
- ); -} diff --git a/viewer/src/MarkdownPart.tsx b/viewer/src/MarkdownPart.tsx deleted file mode 100644 index 08f7127..0000000 --- a/viewer/src/MarkdownPart.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { createEffect, createSignal, onCleanup, onMount } from "solid-js"; -import MarkdownIt from "markdown-it"; -import type { MarkdownPart as MarkdownPartData } from "./api.ts"; -import { themeById } from "../../server/themes.ts"; -import { SandboxedPart } from "./SandboxedPart.tsx"; -import { activeTheme, resolvedMode } from "./theme.ts"; -import { setCurrentThemes, highlight, loadLangs, shikiSchemeCss } from "./highlight.ts"; - -// Prose styles for the rendered markdown — shipped INTO the sandbox iframe (the -// markup no longer lives in the trusted viewer DOM, so styles.css can't reach -// it). The document body is the prose root, so selectors are bare element names; -// chrome color vars come from viewerThemeCss (injected by renderSandboxedPart). -const MD_CSS = ` -body { - margin: 0; - padding: 4px 16px 14px; - background: transparent; - color: var(--text); - font: - 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - overflow-wrap: anywhere; -} -h1, h2, h3, h4 { line-height: 1.3; margin: 1.2em 0 0.5em; font-weight: 600; } -h1 { font-size: 1.5em; } -h2 { font-size: 1.25em; } -h3 { font-size: 1.1em; } -body > :first-child { margin-top: 0.4em; } -p, ul, ol, blockquote, table { margin: 0.5em 0; } -ul, ol { padding-left: 1.5em; } -li { margin: 0.2em 0; } -a { color: var(--accent); text-decoration: none; } -a:hover { text-decoration: underline; } -code { - font: 0.875em ui-monospace, monospace; - background: var(--hover); - padding: 0.12em 0.35em; - border-radius: 4px; -} -pre { - background: var(--panel); - border: 0.5px solid var(--border); - border-radius: 8px; - padding: 10px 12px; - overflow: auto; -} -pre code { background: none; padding: 0; font-size: 12.5px; } -/* shiki dual-theme: light values render inline; the dark flip is appended at - render time via shikiSchemeCss(resolvedMode()) — pinned to the chrome's scheme - so this sandboxed iframe doesn't re-derive light/dark from the OS. */ -blockquote { - margin-left: 0; - padding-left: 12px; - border-left: 2px solid var(--border-2); - color: var(--muted); -} -table { border-collapse: collapse; font-size: 13px; } -th, td { border: 0.5px solid var(--border); padding: 4px 8px; text-align: left; } -th { background: var(--hover); } -img { max-width: 100%; height: auto; border-radius: 6px; } -hr { border: none; border-top: 0.5px solid var(--border); margin: 1em 0; } -`; - -// Dual-theme highlighting: shiki emits both themes inline (color + -// --shiki-dark), and shikiSchemeCss flips between them for the resolved scheme -// (pinned to the chrome, not the OS — see highlight.ts). Which light/dark PAIR -// is used follows the board theme (DiffPart and CodePart use the same pair so -// code blocks, diffs, and code parts read as one syntax theme). The shared -// highlighter lives in highlight.ts. - -const md = new MarkdownIt({ - html: false, - linkify: true, - highlight: (code, lang) => highlight(code, lang) ?? "", -}); - -// Open links in a new tab: the markdown renders inside the viewer document -// itself, so a bare anchor click would navigate the whole board away. -const renderLinkOpen = - md.renderer.rules.link_open ?? - ((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options)); -md.renderer.rules.link_open = (tokens, idx, options, env, self) => { - tokens[idx].attrSet("target", "_blank"); - tokens[idx].attrSet("rel", "noopener noreferrer"); - return renderLinkOpen(tokens, idx, options, env, self); -}; - -// The languages named on fenced code blocks (```ts, ~~~python). Aliases are -// resolved by shiki's loadLanguage; unknown names settle as rejected and are -// ignored, so the block just renders unhighlighted. -function fenceLangs(src: string): string[] { - const langs = new Set(); - const re = /^[ \t]*(?:```|~~~)[ \t]*([\w+#.-]+)/gm; - let m: RegExpExecArray | null; - while ((m = re.exec(src))) langs.add(m[1].toLowerCase()); - return [...langs]; -} - -export function MarkdownPart(props: { part: MarkdownPartData }) { - const [html, setHtml] = createSignal(""); - const render = () => setHtml(md.render(props.part.markdown ?? "")); - - // Re-highlight when the board theme changes: point the highlight hook at the - // new shiki pair, then re-render. All pairs are preloaded, so this is sync. - createEffect(() => { - setCurrentThemes(themeById(activeTheme()).shiki); - render(); - }); - - onMount(() => { - let disposed = false; - onCleanup(() => (disposed = true)); - // Paint immediately (prose + any already-loaded langs), then upgrade code - // blocks once their grammars are loaded. - render(); - const want = fenceLangs(props.part.markdown ?? ""); - if (want.length === 0) return; - void (async () => { - await loadLangs(want); - if (!disposed) render(); - })(); - }); - - // The rendered HTML is a STRING built here in the trusted viewer (safe — no - // DOM sink); SandboxedPart parses it inside an opaque-origin iframe, so even a - // markdown-it/shiki regression can't touch the board. - return ( - - ); -} diff --git a/viewer/src/MermaidPart.tsx b/viewer/src/MermaidPart.tsx deleted file mode 100644 index 614c387..0000000 --- a/viewer/src/MermaidPart.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { createEffect, createSignal, onCleanup, onMount } from "solid-js"; -import type { MermaidPart as MermaidPartData } from "./api.ts"; -import { SandboxedPart } from "./SandboxedPart.tsx"; -import { probeEl } from "./host.ts"; -import { activeTheme } from "./theme.ts"; - -// Wrapper styles shipped into the sandbox iframe. Mermaid bakes theme colors -// into the SVG itself (read from the trusted viewer's vars at render time), so -// the iframe only needs to center and constrain it. -const MERMAID_CSS = ` -body { margin: 0; padding: 14px 16px; background: transparent; text-align: center; } -svg { max-width: 100%; height: auto; } -`; - -// Mermaid bakes theme colors into the SVG at render time (unlike shiki's -// dual-theme output, which a CSS rule can flip), so the diagram must be -// re-rendered when the OS color scheme changes OR the board theme switches. -// Reuse the same prefers-color-scheme signal pattern DiffPart uses. -const darkQuery = window.matchMedia("(prefers-color-scheme: dark)"); -const [isDark, setIsDark] = createSignal(darkQuery.matches); -darkQuery.addEventListener("change", (e) => setIsDark(e.matches)); - -// mermaid.render namespaces the SVG's internal ids with this; it must be unique -// per render across the whole document, so a module-level counter, not a uuid. -let seq = 0; - -// Mermaid's stock themes ignore our design tokens, so the diagram reads as -// generic mermaid. Instead drive its `base` theme from the viewer's own CSS -// custom properties (read live — this part renders in the trusted origin, so -// getComputedStyle is fine). The vars already flip light/dark, so re-rendering -// on a scheme change (below) is all that's needed to stay in sync. Returns the -// `themeVariables` + `themeCSS` mermaid needs to match sideshow's look. -function sideshowTheme() { - const css = getComputedStyle(probeEl()); - const v = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback; - - const text = v("--text", "#1a1915"); - const muted = v("--muted", "#5f5e56"); - const border = v("--border-2", "rgba(20,20,10,0.25)"); - const panel = v("--panel", "#f3f2ec"); - const surface = v("--surface", "#ffffff"); - const bg = v("--bg", "#faf9f5"); - const accent = v("--accent", "#185fa5"); - const accentBg = v("--accent-bg", "#e6f1fb"); - // The viewer has no font token — its system stack lives on `body` — so match - // the diagram font to whatever the rest of the viewer is actually rendering. - const font = getComputedStyle(probeEl()).fontFamily || "ui-sans-serif, system-ui, sans-serif"; - - return { - themeVariables: { - fontFamily: font, - fontSize: "14px", - // shared / flowchart - primaryColor: panel, - primaryBorderColor: border, - primaryTextColor: text, - secondaryColor: surface, - tertiaryColor: bg, - mainBkg: panel, - nodeBorder: border, - lineColor: muted, - textColor: text, - clusterBkg: bg, - clusterBorder: border, - edgeLabelBackground: bg, - // sequence diagrams have their own palette - actorBkg: panel, - actorBorder: border, - actorTextColor: text, - actorLineColor: muted, - signalColor: muted, - signalTextColor: text, - labelBoxBkgColor: surface, - labelBoxBorderColor: border, - labelTextColor: text, - loopTextColor: text, - noteBkgColor: accentBg, - noteBorderColor: border, - noteTextColor: text, - sequenceNumberColor: surface, - }, - // Flat-and-clean to match the design language: rounded rects, hairline - // strokes, no heavy borders. Plus agent-facing accent classes (see below). - themeCSS: ` - .node rect, .node polygon, rect.actor, .labelBox { rx: 8px; ry: 8px; } - .node rect, rect.actor { stroke-width: 1px; } - .edgePath .path, .flowchart-link, .actor-line, - .messageLine0, .messageLine1 { stroke-width: 1px; } - - /* Agent-applied highlight classes, colored from --accent. Apply in a - flowchart with A:::accent (a node) or 'class A,B accent'. 'accent' - fills a node with the brand color; 'accentLine' recolors an edge - (pair with linkStyle to target a specific link). */ - .node.accent > rect, .node.accent > polygon, .node.accent > circle, - .node.accent > path { fill: ${accentBg}; stroke: ${accent}; } - .node.accent .nodeLabel, .node.accent span, .node.accent text { fill: ${accent}; color: ${accent}; } - .flowchart-link.accentLine, .edgePath.accentLine > .path { stroke: ${accent}; } - `, - }; -} - -export function MermaidPart(props: { part: MermaidPartData }) { - const [svg, setSvg] = createSignal(""); - const [error, setError] = createSignal(null); - - onMount(() => { - let disposed = false; - onCleanup(() => (disposed = true)); - - const render = async () => { - const src = props.part.mermaid ?? ""; - try { - // Lazy-load mermaid (a heavy dep) only when a mermaid part actually - // mounts. mermaid is the default export. - const mermaid = (await import("mermaid")).default; - // securityLevel 'strict' makes mermaid sanitize the generated SVG with - // its bundled DOMPurify and disables inline HTML labels and click - // handlers — this part renders in the trusted viewer origin (no - // sandbox), so never relax it. suppressErrorRendering keeps a parse - // failure from injecting mermaid's "bomb" graphic into document.body; - // we render our own error fallback instead. - const { themeVariables, themeCSS } = sideshowTheme(); - mermaid.initialize({ - startOnLoad: false, - securityLevel: "strict", - suppressErrorRendering: true, - theme: "base", - themeVariables, - themeCSS, - }); - const { svg: out } = await mermaid.render(`mmd-${seq++}`, src); - if (!disposed) { - setError(null); - setSvg(out); - } - } catch (e) { - if (!disposed) { - setSvg(""); - setError(e instanceof Error ? e.message : "Could not render diagram."); - } - } - }; - - // Initial paint, plus a re-render whenever the color scheme flips or the - // board theme changes: the effect reads both signals synchronously (so - // they're tracked), then renders with the current palette. applyTheme - // injects the chrome vars before activeTheme() updates, so the - // getComputedStyle in sideshowTheme() already sees the new values. The - // first run does the initial paint. - createEffect(() => { - isDark(); - activeTheme(); - void render(); - }); - }); - - return ( -
- {error() ? ( -
- Couldn’t render diagram — {error()} -
{props.part.mermaid}
-
- ) : ( - // The SVG string is produced here (trusted), then parsed inside an - // opaque-origin iframe — a second boundary behind mermaid's DOMPurify. - - )} -
- ); -} diff --git a/viewer/src/SandboxedPart.tsx b/viewer/src/SandboxedPart.tsx deleted file mode 100644 index 4bfc90a..0000000 --- a/viewer/src/SandboxedPart.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { createEffect, createMemo, onCleanup, onMount } from "solid-js"; -import { renderSandboxedPart } from "../../server/surfacePage.ts"; -import { themeById } from "../../server/themes.ts"; -import { api, appPath } from "./api.ts"; -import { activeTheme, resolvedMode } from "./theme.ts"; - -// location.origin is constant for the page lifetime — read it once, not per -// doc rebuild. -const ORIGIN = location.origin; - -// Size a surface iframe from a height the in-frame bridge reported. Shared by -// SandboxedPart (rich/comment frames) and App's bridge handler (html-part -// frames) so every sandboxed surface clamps to the same bounds — min one line, -// max generous enough for a long diff/markdown without runaway growth. -const MIN_H = 24; -const MAX_H = 4000; -export function applyFrameHeight(iframe: HTMLIFrameElement, reportedHeight: unknown): void { - iframe.style.height = Math.min(Math.max(Number(reportedHeight), MIN_H), MAX_H) + "px"; -} - -// Renders agent-produced markup (markdown, mermaid, diff) inside the SAME -// opaque-origin sandbox html parts use, instead of innerHTML in the trusted -// viewer. The caller renders the part to a STRING (string building is not a DOM -// sink, so it is safe in the trusted origin); the markup only becomes live DOM -// inside this iframe, where an opaque origin + tight CSP contain any sanitizer -// regression. `body`/`css` are reactive — a theme switch rebuilds the doc and -// reloads the frame (the same way Card reloads html-part iframes on theme). -// -// The doc is staged at /f/:id and loaded by real URL — exactly like an html -// part at /s/:id — not srcdoc/blob. The response carries a `sandbox` CSP header, -// so the frame is opaque-origin (identical isolation), and a real navigation -// avoids a Chrome layout bug that only afflicts in-memory iframe documents -// (srcdoc/blob), where the heavier async-rendered parts never lay out. The -// server has no markup for a rich part (it renders here), so we POST the string -// and point the frame at the id it returns. -// -// Resize is handled locally: the bridge in the doc posts its content height, and -// each frame sizes itself from messages whose source is its own contentWindow. -// (Link clicks and the session-switch shortcut ride App's global bridge handler, -// which keys off message type, not the frame registry.) -export function SandboxedPart(props: { body: string; css: string; class?: string }) { - let frame!: HTMLIFrameElement; - - const doc = createMemo(() => - renderSandboxedPart({ - body: props.body, - css: props.css, - origin: ORIGIN, - theme: themeById(activeTheme()), - mode: resolvedMode(), - }), - ); - - // Stage the doc at /f/:id whenever it changes (theme switch, body/css update, - // async render completing) and point the frame there. POST is async, so a - // sequence guard drops a stale response if a newer doc raced ahead of it. - let seq = 0; - createEffect(() => { - const html = doc(); - const mine = ++seq; - void api<{ id: string }>("/api/frames", { method: "POST", body: JSON.stringify({ html }) }) - .then(({ id }) => { - if (mine === seq) frame.src = appPath(`/f/${id}`); - }) - .catch(() => {}); - }); - - onMount(() => { - const onMessage = (ev: MessageEvent) => { - if (ev.source !== frame.contentWindow) return; - const d = ev.data as { __sideshow?: boolean; type?: string; height?: number } | null; - if (!d || !d.__sideshow || d.type !== "resize") return; - applyFrameHeight(frame, d.height); - }; - window.addEventListener("message", onMessage); - onCleanup(() => window.removeEventListener("message", onMessage)); - }); - - return ( - - ); -} diff --git a/viewer/src/TerminalPart.tsx b/viewer/src/TerminalPart.tsx deleted file mode 100644 index b0ad515..0000000 --- a/viewer/src/TerminalPart.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { createMemo } from "solid-js"; -import { AnsiUp } from "ansi_up"; -import { escapeHtml } from "../../server/surfacePage.ts"; -import type { TerminalPart as TerminalPartData } from "./api.ts"; -import { SandboxedPart } from "./SandboxedPart.tsx"; - -// The terminal window's styles, shipped into the sandbox iframe. The terminal is -// intentionally a dark window regardless of theme (ANSI assumes a dark backdrop); -// the --term-* vars come from viewerThemeCss so it adopts the theme's hue. -const TERM_CSS = ` -body { margin: 0; background: var(--term-bg); } -.term-bar { - display: flex; align-items: center; gap: 8px; - padding: 7px 12px; background: var(--term-bar); - border-bottom: 0.5px solid #000; -} -.term-dots { display: inline-flex; gap: 6px; } -.term-dots span { width: 11px; height: 11px; border-radius: 50%; background: #555; } -.term-dots span:nth-child(1) { background: #ff5f56; } -.term-dots span:nth-child(2) { background: #ffbd2e; } -.term-dots span:nth-child(3) { background: #27c93f; } -.term-title { font-size: 11.5px; color: var(--term-title); font-family: ui-monospace, monospace; } -.term-body { - margin: 0; padding: 12px 14px; overflow-x: auto; white-space: pre; - color: var(--term-fg); - font: 12.5px/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace; - tab-size: 8; -} -`; - -// Resolve carriage returns before AnsiUp (which only understands SGR, not -// cursor motion). A bare `\r` returns the cursor to column 0, so progress bars -// and spinners — npm/pip/cargo/git/docker all do this — redraw a line many -// times in one "line". Normalize CRLF first, then collapse each line to the -// text after its final `\r` (last redraw wins). This is not VT emulation; it is -// just enough that captured build/download logs show their final state instead -// of every stacked frame. Cursor-addressing TUIs remain out of scope. -function resolveCarriageReturns(text: string): string { - return text - .replace(/\r\n/g, "\n") - .split("\n") - .map((line) => { - const lastCr = line.lastIndexOf("\r"); - return lastCr === -1 ? line : line.slice(lastCr + 1); - }) - .join("\n"); -} - -// Render terminal output as a styled terminal window inside the same -// opaque-origin sandbox as the other rich parts. AnsiUp converts SGR escapes to -// inline-styled s and HTML-escapes everything else (escape_html defaults -// to true); the whole window is built as a STRING here (safe — not a DOM sink) -// and only parsed inside the iframe, so even an ansi_up regression can't reach -// the board. SGR-only for now: cursor-addressing sequences are ignored — the -// wire shape (see TerminalPart in server/types.ts) is renderer-agnostic so a -// full VT emulator can replace this later without changing storage, CLI, or MCP. -export function TerminalPart(props: { part: TerminalPartData }) { - const body = createMemo(() => { - const au = new AnsiUp(); - au.use_classes = false; // inline rgb styles — no class palette to ship - const ansi = au.ansi_to_html(resolveCarriageReturns(props.part.text ?? "")); - const title = escapeHtml(props.part.title ?? "terminal"); - const width = props.part.cols ? ` style="width:${Number(props.part.cols)}ch"` : ""; - return ( - `
` + - `${title}
` + - `
${ansi}
` - ); - }); - return ; -} diff --git a/viewer/src/highlight.ts b/viewer/src/highlight.ts deleted file mode 100644 index 431b50f..0000000 --- a/viewer/src/highlight.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { Highlighter } from "shiki"; -import { type Mode, THEMES as REGISTRY } from "../../server/themes.ts"; - -export type ShikiPair = { light: string; dark: string }; - -// shiki emits dual-theme output: the light theme inline (`color:`) plus a -// `--shiki-dark` custom prop on every span. This rule overrides color/background -// with those vars to render the dark theme. Code/markdown parts render inside a -// sandboxed iframe, so — like the surface tokens — the flip is PINNED to the -// scheme the chrome resolved when a mode is given (no media query), keeping the -// frame in lockstep with the chrome instead of re-deriving from the OS across -// the frame boundary. Without a mode it follows the OS (self-hosted default). -const SHIKI_DARK_RULE = - ".shiki, .shiki span { color: var(--shiki-dark) !important; background-color: var(--shiki-dark-bg) !important; }"; - -export function shikiSchemeCss(mode?: Mode): string { - if (mode === "dark") return SHIKI_DARK_RULE; - if (mode === "light") return ""; - return `@media (prefers-color-scheme: dark){${SHIKI_DARK_RULE}}`; -} - -// Every shiki theme any registry theme might select — preloaded once so a -// theme switch is just a re-highlight, no async load. -const ALL_THEMES = [...new Set(REGISTRY.flatMap((t) => [t.shiki.light, t.shiki.dark]))]; - -// The active light/dark shiki pair, read by the synchronous highlight function. -// Updated reactively from MarkdownPart/CodePart via setCurrentThemes(). -let currentThemes: ShikiPair = { - light: REGISTRY[0].shiki.light, - dark: REGISTRY[0].shiki.dark, -}; - -export function setCurrentThemes(pair: ShikiPair): void { - currentThemes = pair; -} - -// One lazily-created highlighter shared across all parts that highlight code -// (MarkdownPart fenced blocks, CodePart). Built on shiki's JavaScript regex -// engine (no oniguruma WASM) — the grammars are already in the bundle via -// @pierre/diffs, so this adds no meaningful weight. Languages load on demand. -let highlighter: Highlighter | null = null; -let highlighterPromise: Promise | null = null; - -export function getHighlighter(): Promise { - if (!highlighterPromise) { - highlighterPromise = (async () => { - const [{ createHighlighter }, { createJavaScriptRegexEngine }] = await Promise.all([ - import("shiki"), - import("shiki/engine/javascript"), - ]); - highlighter = await createHighlighter({ - themes: ALL_THEMES, - langs: [], - engine: createJavaScriptRegexEngine({ forgiving: true }), - }); - return highlighter; - })(); - } - return highlighterPromise; -} - -// Synchronous highlight — returns shiki's dual-theme HTML string, or null if -// the highlighter or language isn't loaded yet. Callers fall back to plain -// escaped text and re-render after loadLangs() resolves. -export function highlight(code: string, lang: string): string | null { - if (highlighter && lang) { - try { - return highlighter.codeToHtml(code, { lang, themes: currentThemes }); - } catch { - return null; - } - } - return null; -} - -// Load languages async; settles silently on unknown ids (shiki's loadLanguage -// throws synchronously on an unknown id, so each call is wrapped in an async -// fn that turns the throw into a settled rejection we ignore). -export async function loadLangs(langs: string[]): Promise { - const hl = await getHighlighter(); - await Promise.allSettled(langs.map(async (l) => hl.loadLanguage(l as never))); -} diff --git a/viewer/src/styles.css b/viewer/src/styles.css index f6e97fb..a13ab99 100644 --- a/viewer/src/styles.css +++ b/viewer/src/styles.css @@ -451,6 +451,10 @@ select.vbadge { .card-head .act.icon.del:hover { color: var(--danger); } +/* Every part that becomes HTML (html, markdown, code, diff, terminal, mermaid) + renders as a sandboxed iframe pointed at /s/:id. These rules only size the + frame and draw the card separator; the in-frame resize bridge sets the real + height (this 120px is just a seed so there's no zero-height flash). */ iframe { display: block; width: 100%; @@ -459,27 +463,6 @@ iframe { border-top: 0.5px solid var(--border); background: transparent; } -/* Rich parts (markdown, mermaid, diff) render inside opaque-origin sandbox - iframes — their content styles live in each component's CSS string, shipped - into the frame. These rules only size the frame in the card; the bridge sets - its height from the reported content height. */ -.partframe { - display: block; - width: 100%; - border: 0; - background: transparent; -} -.mdframe { - border-top: 0.5px solid var(--border); -} -.diffpart { - border-top: 0.5px solid var(--border); -} -.diff-error { - padding: 10px 14px; - font-size: 12px; - color: var(--faint); -} /* Shown when a part's kind isn't recognized by this (possibly stale) viewer. */ .part-unsupported { border-top: 0.5px solid var(--border); @@ -503,35 +486,11 @@ iframe { font-size: 12px; color: var(--muted); } -/* Terminal part renders inside a sandbox iframe (its window styles ship in - TerminalPart's CSS string); this only draws the separator from the card. */ -.termframe { - border-top: 0.5px solid var(--border); -} .asset-gone { padding: 10px 14px; font-size: 12px; color: var(--faint); } -.mermaidpart { - border-top: 0.5px solid var(--border); -} -.mermaid-error { - padding: 10px 14px; - font-size: 12px; - color: var(--faint); - text-align: left; -} -.mermaid-error pre { - margin: 6px 0 0; - padding: 8px 10px; - background: var(--panel); - border: 0.5px solid var(--border); - border-radius: 8px; - overflow: auto; - font: 12px var(--font-mono, ui-monospace, monospace); - color: var(--text); -} .tracepart { border-top: 0.5px solid var(--border); padding: 10px 14px 12px; @@ -666,12 +625,6 @@ iframe { .json-null { color: var(--faint); } -.codepart { - border-top: 0.5px solid var(--border); -} -.codeframe { - border-top: 0.5px solid var(--border); -} /* The thread is layout only — the comment list and the footer toolbar each draw their own full-width separator and padding. That keeps every divider spanning the whole card and the spacing identical whether or not the card has comments @@ -694,15 +647,14 @@ iframe { .cmt.user .who { color: var(--accent); } -/* Comment text renders in an opaque-origin sandbox iframe (its text styles ship - in CMT_CSS). flex:1 takes the row's free space; the resize bridge sets height, - but seed a one-line height so there's no tall-iframe flash before it reports. */ -.cmtframe { +/* Comment text is plain text rendered as a Solid text node (escapes by + construction — no iframe), so it lives in the trusted DOM. flex:1 takes the + row's free space; pre-wrap keeps the author's line breaks. */ +.cmt-text { flex: 1; min-width: 0; - height: 24px; - border: 0; - background: transparent; + white-space: pre-wrap; + word-break: break-word; } .cmt .when { flex: none; diff --git a/viewer/src/theme.ts b/viewer/src/theme.ts index 74ca0c6..dc591fd 100644 --- a/viewer/src/theme.ts +++ b/viewer/src/theme.ts @@ -27,9 +27,10 @@ export const activeTheme = activeThemeState; // `@media (prefers-color-scheme: dark)` rules key off. Surface parts render in // separate iframes whose own scheme resolution can diverge from the chrome's // (an embedder doesn't reliably propagate it across the frame boundary), so -// each frame is pinned to this mode instead (Card html parts via the `mode` -// query param; SandboxedPart rich/comment frames via renderSandboxedPart). It -// is reactive, so an OS flip rebuilds the frames in lockstep with the chrome. +// each frame is pinned to this mode instead — every part frame carries it as +// the `/s/:id?mode=` query param, so the server bakes the resolved scheme into +// the rendered doc. It is reactive, so an OS flip reloads the frames in lockstep +// with the chrome. const darkQuery = typeof matchMedia === "function" ? matchMedia("(prefers-color-scheme: dark)") : null; const [prefersDark, setPrefersDark] = createSignal(!!darkQuery?.matches);