From d927caa619ac0916f2128a38ebea9d680e443a0c Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 30 Jun 2026 18:43:32 -0400 Subject: [PATCH 1/2] fix(surface): pin color-scheme on rich-part frames (no white canvas in dark mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markdown/code/diff/mermaid surfaces render in sandboxed, opaque-origin iframes. Those default to `color-scheme: normal` (light), so in dark mode the UA painted a WHITE canvas behind the transparent body and the dark-mode text (--text) washed out over it — a markdown caption looked like a near-blank white box on a dark board. renderSandboxedPart/renderMermaidPage now emit colorSchemeCss(mode), the same pin html surfaces already use, so the frame's UA canvas tracks the card in both schemes. A prior comment claimed omitting the property kept the frame transparent; it doesn't — it just leaves the canvas light. That note is corrected, and the regression test now asserts the pin (it previously asserted the buggy "must NOT force color-scheme"). Co-Authored-By: Claude Opus 4.8 --- .changeset/rich-part-colorscheme.md | 10 ++++++++++ server/surfacePage.ts | 17 ++++++++++------- test/surfacePage.test.ts | 25 ++++++++++++++++++------- 3 files changed, 38 insertions(+), 14 deletions(-) create mode 100644 .changeset/rich-part-colorscheme.md diff --git a/.changeset/rich-part-colorscheme.md b/.changeset/rich-part-colorscheme.md new file mode 100644 index 0000000..0c90426 --- /dev/null +++ b/.changeset/rich-part-colorscheme.md @@ -0,0 +1,10 @@ +--- +"sideshow": patch +--- + +Fix markdown/code/diff/mermaid surfaces rendering on a white canvas (washed-out +text) on a dark board. These rich-part frames are sandboxed opaque-origin +iframes, which default to `color-scheme: normal` (light), so in dark mode the UA +painted a white backdrop behind the transparent body. They now pin `color-scheme` +to the resolved scheme — like html surfaces already do — so the frame's canvas +tracks the card in both light and dark. diff --git a/server/surfacePage.ts b/server/surfacePage.ts index 1523a2d..81690a1 100644 --- a/server/surfacePage.ts +++ b/server/surfacePage.ts @@ -312,11 +312,14 @@ function buildRichCsp(origin: string): string { // workspace. `css` is the surface-specific stylesheet (prose/diff/mermaid rules); // chrome theme vars come from viewerThemeCss so the surface matches the viewer. // `mode` PINS those vars (and any shiki dark-flip the css carries) to the -// scheme the chrome resolved, so this frame can't diverge from it. Unlike an -// html surface, it deliberately does NOT force `color-scheme`: these frames are -// transparent so the themed card surface shows through, and a forced -// `color-scheme` would paint an opaque UA canvas behind them. They carry no -// native scrollbars/controls that need it, so the var pinning alone suffices. +// scheme the chrome resolved, so this frame can't diverge from it — and that +// includes `color-scheme`. A sandboxed, opaque-origin iframe loaded by URL +// defaults to `color-scheme: normal` (i.e. light): in dark mode the UA paints a +// WHITE canvas behind the transparent body, and the dark-mode text (--text) is +// washed out over it. (An earlier note here assumed omitting the property kept +// the frame transparent; it doesn't — it just leaves the canvas light.) Pinning +// the scheme, exactly as an html surface does, makes the UA canvas track the +// card so the frame reads as transparent in both schemes. export function renderSandboxedPart(doc: { body: string; css: string; @@ -337,7 +340,7 @@ export function renderSandboxedPart(doc: { img-src in buildRichCsp allows that origin. (html surfaces don't need this — they load via /s/:id, whose URL is already the base.) --> - + ${doc.body} @@ -508,7 +511,7 @@ try { - +
diff --git a/test/surfacePage.test.ts b/test/surfacePage.test.ts index 575cc26..569f8a9 100644 --- a/test/surfacePage.test.ts +++ b/test/surfacePage.test.ts @@ -125,7 +125,7 @@ test("theme tokens are injected and resolve unknown/absent themes to the default ); }); -test("a pinned mode forces the scheme into html parts but not transparent rich frames", () => { +test("a pinned mode forces color-scheme into both html parts and transparent rich frames", () => { const gh = renderHtmlPage({ title: "t", html: "

x

", origin: ORIGIN, mode: "dark" }); // the document's used color-scheme is forced so the UA canvas/scrollbars/ // controls follow it, overriding the static `color-scheme: light dark` default @@ -146,15 +146,15 @@ test("a pinned mode forces the scheme into html parts but not transparent rich f assert.ok(!auto.includes("color-scheme:dark"), "no mode → no forced scheme"); assert.ok(auto.includes("@media (prefers-color-scheme: dark)"), "no mode → OS media query kept"); - // rich/comment frames pin the same way — EXCEPT color-scheme. Those frames are - // transparent so the themed card surface shows through; a forced color-scheme - // would paint an opaque UA canvas behind them. So the tokens are pinned (flat - // :root, dark --text, no media query) but color-scheme is left unset. + // rich/comment frames pin the same way, color-scheme INCLUDED. A sandboxed + // opaque-origin iframe defaults to `color-scheme: normal` (light), so without + // this pin the UA paints a white canvas behind the transparent body and the + // dark-mode text washes out. Pinning it makes the canvas track the dark card. const rich = renderSandboxedPart({ body: "x", css: "", origin: ORIGIN, mode: "dark" }); const dark = themeById("github").dark; assert.ok( - !rich.includes("color-scheme:"), - "rich frame must NOT force color-scheme (stays transparent)", + /:root\{color-scheme:dark\}/.test(rich), + "rich frame must pin color-scheme so the UA canvas isn't white in dark mode", ); assert.ok( !rich.includes("@media (prefers-color-scheme: dark)"), @@ -164,6 +164,17 @@ test("a pinned mode forces the scheme into html parts but not transparent rich f rich.includes(`--text: ${dark.text}`), "rich frame carries the pinned dark chrome vars", ); + // light pins light; an unpinned (no-mode) frame leaves the scheme to the OS. + assert.ok( + /:root\{color-scheme:light\}/.test( + renderSandboxedPart({ body: "x", css: "", origin: ORIGIN, mode: "light" }), + ), + "light mode pins color-scheme:light", + ); + assert.ok( + !/:root\{color-scheme:/.test(renderSandboxedPart({ body: "x", css: "", origin: ORIGIN })), + "no mode → scheme left to the OS (the @media query may still mention prefers-color-scheme)", + ); }); test("a mermaid page pins mermaid's derived colors to the scheme so the whole diagram flips", () => { From 04b42220f24556a31cd93977c9ff146b84c0d27a Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 30 Jun 2026 18:50:32 -0400 Subject: [PATCH 2/2] test(e2e): assert markdown frame pins color-scheme dark (was asserting the bug) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e theme spec encoded the old buggy behavior — that a transparent markdown frame keeps no forced color-scheme. With the canvas now pinned to the resolved scheme, it must be `dark` in dark mode. Invert the assertion and its comment to match. Co-Authored-By: Claude Opus 4.8 --- e2e/theme.spec.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/e2e/theme.spec.ts b/e2e/theme.spec.ts index ed2b34a..be5cac1 100644 --- a/e2e/theme.spec.ts +++ b/e2e/theme.spec.ts @@ -147,11 +147,12 @@ test.describe("with the OS in dark mode", () => { await expect(page.locator(".card iframe[src]")).toHaveAttribute("src", /mode=light/); }); - // The opaque html part forces `color-scheme` (so its UA scrollbars/controls - // match), but a markdown part's frame is transparent so the themed card shows - // through — forcing `color-scheme:dark` there would paint an opaque UA canvas - // behind it. Its tokens are still pinned dark; only color-scheme stays unset. - test("a transparent markdown frame is pinned dark but keeps no forced color-scheme", async ({ + // A markdown part's frame is a sandboxed opaque-origin iframe, which defaults + // to `color-scheme: normal` (light): in dark mode the UA paints a WHITE canvas + // behind the transparent body and the dark-mode text washes out. So it pins + // `color-scheme` to the resolved scheme — like the html part — and the canvas + // tracks the dark card instead of going white. + test("a transparent markdown frame is pinned to the dark color-scheme", async ({ page, server, }) => { @@ -167,10 +168,10 @@ test.describe("with the OS in dark mode", () => { await expect .poll(() => frame.locator("body").evaluate((el) => getComputedStyle(el).color)) .toBe("rgb(230, 237, 243)"); - // but the root color-scheme is NOT forced, so the UA canvas stays transparent + // and color-scheme is pinned dark, so the UA canvas is dark, not white await expect .poll(() => frame.locator("html").evaluate((el) => getComputedStyle(el).colorScheme)) - .not.toBe("dark"); + .toBe("dark"); }); });