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/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");
});
});
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.) -->
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", () => {