Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/rich-part-colorscheme.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 8 additions & 7 deletions e2e/theme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) => {
Expand All @@ -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");
});
});

Expand Down
17 changes: 10 additions & 7 deletions server/surfacePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.) -->
<base href="${doc.origin}/">
<style>${viewerThemeCss(theme, doc.mode)}${doc.css}</style>
<style>${viewerThemeCss(theme, doc.mode)}${doc.css}${colorSchemeCss(doc.mode)}</style>
</head>
<body>
${doc.body}
Expand Down Expand Up @@ -508,7 +511,7 @@ try {
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${buildCsp(doc.origin)}">
<base href="${doc.origin}/">
<style>${viewerThemeCss(theme, doc.mode)}${MERMAID_CSS}</style>
<style>${viewerThemeCss(theme, doc.mode)}${MERMAID_CSS}${colorSchemeCss(doc.mode)}</style>
</head>
<body>
<div id="m"></div>
Expand Down
25 changes: 18 additions & 7 deletions test/surfacePage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<p>x</p>", 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
Expand All @@ -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)"),
Expand All @@ -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", () => {
Expand Down
Loading