Skip to content

Commit a18f210

Browse files
benvinegarclaude
andauthored
fix(viewer): pin surface iframes to the chrome's resolved color scheme (#103)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c2e4443 commit a18f210

13 files changed

Lines changed: 324 additions & 55 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"sideshow": patch
3+
---
4+
5+
Fix surface iframes rendering in the wrong color scheme when it diverges from
6+
the chrome (e.g. dark chrome with a white, light-inked html part). Light/dark
7+
was resolved independently in every layer purely from the OS
8+
`prefers-color-scheme`, but a surface part is a separate iframe document whose
9+
scheme resolution can diverge from its embedder across the frame boundary. The
10+
viewer now resolves the scheme once and pins each sandboxed frame to it — html
11+
parts via a `mode` query param on `/s/:id` (with a forced `color-scheme`), and
12+
markdown/code/comment frames via `renderSandboxedPart` — so a frame always
13+
matches the chrome instead of re-deriving the scheme on its own. The theme
14+
tokens, the kit's teal/coral SVG accents, and shiki's dark flip are all pinned
15+
together. With no mode passed the OS media query is kept, so self-hosted parity
16+
is preserved.

e2e/theme.spec.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,82 @@ test("the picked theme persists across a reload", async ({ page, server }) => {
8585
.toBe("#f9f5d7");
8686
});
8787

88+
// Regression: the chrome resolves light/dark from the OS via a CSS media query,
89+
// but an html part is a separate iframe document whose own scheme resolution can
90+
// diverge from the chrome's across the frame boundary — producing dark chrome
91+
// with a white, light-inked iframe. The viewer now pins each frame to the mode
92+
// it resolved (`&mode=` on the src + a forced `color-scheme`), so the iframe
93+
// renders the SAME scheme as the chrome regardless. The github dark html-part
94+
// surface (--color-background-primary) is #1c2128 = rgb(28, 33, 40).
95+
test.describe("with the OS in dark mode", () => {
96+
test.use({ colorScheme: "dark" });
97+
98+
test("the html-part iframe is pinned to dark, matching the chrome", async ({ page, server }) => {
99+
await publishParts(server.url, {
100+
title: "Themed",
101+
agent: "e2e",
102+
parts: [{ kind: "html", html: "<p>surface body</p>" }],
103+
});
104+
await page.goto(server.url);
105+
106+
const iframe = page.locator(".card iframe[src]");
107+
await expect(iframe).toHaveAttribute("src", /mode=dark/);
108+
109+
// the iframe document actually paints the dark surface — not the light
110+
// default it would fall back to if it re-derived the scheme on its own
111+
const body = page.locator(".card iframe[src]").contentFrame().locator("body");
112+
await expect
113+
.poll(() => body.evaluate((el) => getComputedStyle(el).backgroundColor))
114+
.toBe("rgb(28, 33, 40)");
115+
});
116+
117+
// The opaque html part forces `color-scheme` (so its UA scrollbars/controls
118+
// match), but a markdown part's frame is transparent so the themed card shows
119+
// through — forcing `color-scheme:dark` there would paint an opaque UA canvas
120+
// behind it. Its tokens are still pinned dark; only color-scheme stays unset.
121+
test("a transparent markdown frame is pinned dark but keeps no forced color-scheme", async ({
122+
page,
123+
server,
124+
}) => {
125+
await publishParts(server.url, {
126+
title: "Prose",
127+
agent: "e2e",
128+
parts: [{ kind: "markdown", markdown: "regular **prose** body" }],
129+
});
130+
await page.goto(server.url);
131+
132+
const frame = page.locator(".card iframe.mdframe").contentFrame();
133+
// pinned dark: the chrome text var resolved to the github dark ink
134+
await expect
135+
.poll(() => frame.locator("body").evaluate((el) => getComputedStyle(el).color))
136+
.toBe("rgb(230, 237, 243)");
137+
// but the root color-scheme is NOT forced, so the UA canvas stays transparent
138+
await expect
139+
.poll(() => frame.locator("html").evaluate((el) => getComputedStyle(el).colorScheme))
140+
.not.toBe("dark");
141+
});
142+
});
143+
144+
test.describe("with the OS in light mode", () => {
145+
test.use({ colorScheme: "light" });
146+
147+
test("the html-part iframe is pinned to light", async ({ page, server }) => {
148+
await publishParts(server.url, {
149+
title: "Themed",
150+
agent: "e2e",
151+
parts: [{ kind: "html", html: "<p>surface body</p>" }],
152+
});
153+
await page.goto(server.url);
154+
155+
const iframe = page.locator(".card iframe[src]");
156+
await expect(iframe).toHaveAttribute("src", /mode=light/);
157+
const body = page.locator(".card iframe[src]").contentFrame().locator("body");
158+
await expect
159+
.poll(() => body.evaluate((el) => getComputedStyle(el).backgroundColor))
160+
.toBe("rgb(255, 255, 255)");
161+
});
162+
});
163+
88164
test("a theme switch in one tab re-themes another open tab via SSE", async ({
89165
page,
90166
server,

server/app.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,12 +858,18 @@ export function createApp({
858858
// Theme: an explicit ?theme= (the viewer keys iframe srcs by it so a switch
859859
// reloads the frame) wins; otherwise the persisted board theme; else default.
860860
const themeId = c.req.query("theme") ?? (await store.getSetting("theme")) ?? DEFAULT_THEME_ID;
861+
// Scheme: the viewer passes the light/dark mode it resolved so the iframe is
862+
// pinned to it rather than re-deriving from the OS (which can diverge from
863+
// the chrome across the frame boundary). Absent/invalid → follow the OS.
864+
const modeParam = c.req.query("mode");
865+
const mode = modeParam === "light" || modeParam === "dark" ? modeParam : undefined;
861866
return c.html(
862867
renderHtmlPage({
863868
title,
864869
html: part.html,
865870
origin: new URL(c.req.url).origin,
866871
theme: themeById(themeId),
872+
mode,
867873
kits: part.kits,
868874
}),
869875
);

server/surfacePage.ts

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,41 @@
11
import { kitAssets } from "./kits.ts";
2-
import { type Theme, themeById, tokenThemeCss, viewerThemeCss } from "./themes.ts";
2+
import {
3+
type Mode,
4+
schemeCss,
5+
type Theme,
6+
themeById,
7+
tokenThemeCss,
8+
viewerThemeCss,
9+
} from "./themes.ts";
10+
11+
// The kit's two custom SVG accent ramps (teal, coral) aren't in the theme
12+
// palette, so they carry their own light/dark values. Like the theme tokens
13+
// they pin to a forced mode (no media query) when one is given, else flip with
14+
// the OS — kept in sync via the shared schemeCss. Dark overrides only bg/text;
15+
// the line color is shared, so it's repeated in both maps.
16+
const KIT_ACCENTS_LIGHT: Record<string, string> = {
17+
"c-teal-bg": "#e1f4f1",
18+
"c-teal-line": "#1fa996",
19+
"c-teal-text": "#0c6e62",
20+
"c-coral-bg": "#fdece5",
21+
"c-coral-line": "#e8835e",
22+
"c-coral-text": "#a44f28",
23+
};
24+
const KIT_ACCENTS_DARK: Record<string, string> = {
25+
...KIT_ACCENTS_LIGHT,
26+
"c-teal-bg": "rgba(31, 169, 150, 0.18)",
27+
"c-teal-text": "#6fd0c2",
28+
"c-coral-bg": "rgba(232, 131, 94, 0.18)",
29+
"c-coral-text": "#f0a987",
30+
};
31+
const kitAccentCss = (mode?: Mode): string => schemeCss(KIT_ACCENTS_LIGHT, KIT_ACCENTS_DARK, mode);
32+
33+
// When a scheme is pinned, force the document's used color-scheme to match so
34+
// the UA-painted canvas, scrollbars, and native form controls follow it too
35+
// (the token vars alone don't drive those). Overrides the static
36+
// `color-scheme: light dark` default the kit/base CSS sets. Empty when the
37+
// scheme is left to the OS, preserving the media-query behavior unchanged.
38+
const colorSchemeCss = (mode?: Mode): string => (mode ? `:root{color-scheme:${mode}}` : "");
339

440
// Origins html parts may load external resources from. Mirrors the allowlist
541
// agents already know from Claude's inline widget surface.
@@ -63,17 +99,7 @@ body {
6399
// attributes (fill/font-size on text, etc.) — that's why text styling is
64100
// opt-in via classes.
65101
const KIT_CSS = `
66-
:root {
67-
color-scheme: light dark;
68-
--c-teal-bg: #e1f4f1; --c-teal-line: #1fa996; --c-teal-text: #0c6e62;
69-
--c-coral-bg: #fdece5; --c-coral-line: #e8835e; --c-coral-text: #a44f28;
70-
}
71-
@media (prefers-color-scheme: dark) {
72-
:root {
73-
--c-teal-bg: rgba(31, 169, 150, 0.18); --c-teal-text: #6fd0c2;
74-
--c-coral-bg: rgba(232, 131, 94, 0.18); --c-coral-text: #f0a987;
75-
}
76-
}
102+
:root { color-scheme: light dark; }
77103
button {
78104
font: 500 14px/1.4 var(--font-sans);
79105
color: var(--color-text-primary);
@@ -210,11 +236,18 @@ function buildRichCsp(origin: string): string {
210236
// mermaid / DOMPurify / @pierre-diffs sanitizer bypass can no longer reach the
211237
// board. `css` is the part-specific stylesheet (prose/diff/mermaid rules);
212238
// chrome theme vars come from viewerThemeCss so the part matches the viewer.
239+
// `mode` PINS those vars (and any shiki dark-flip the css carries) to the
240+
// scheme the chrome resolved, so this frame can't diverge from it. Unlike an
241+
// html part, it deliberately does NOT force `color-scheme`: these frames are
242+
// transparent so the themed card surface shows through, and a forced
243+
// `color-scheme` would paint an opaque UA canvas behind them. They carry no
244+
// native scrollbars/controls that need it, so the var pinning alone suffices.
213245
export function renderSandboxedPart(doc: {
214246
body: string;
215247
css: string;
216248
origin: string;
217249
theme?: Theme | string;
250+
mode?: Mode;
218251
}): string {
219252
const theme =
220253
typeof doc.theme === "string" || doc.theme == null ? themeById(doc.theme) : doc.theme;
@@ -229,7 +262,7 @@ export function renderSandboxedPart(doc: {
229262
img-src in buildRichCsp allows that origin. (html parts don't need this —
230263
they load via /s/:id, whose URL is already the base.) -->
231264
<base href="${doc.origin}/">
232-
<style>${viewerThemeCss(theme)}${doc.css}</style>
265+
<style>${viewerThemeCss(theme, doc.mode)}${doc.css}</style>
233266
</head>
234267
<body>
235268
${doc.body}
@@ -243,6 +276,9 @@ export function renderHtmlPage(doc: {
243276
html: string;
244277
origin: string;
245278
theme?: Theme | string;
279+
// Pins the iframe's color scheme to the one the chrome resolved (see Mode).
280+
// Omitted → the scheme follows the OS via tokenThemeCss's media query.
281+
mode?: Mode;
246282
// Opt-in kits (kits.ts): their CSS/JS is injected after the base kit. The JS
247283
// is plain inline script — same trust level as the bridge, already covered by
248284
// the html-part CSP's `script-src 'unsafe-inline'`. Unknown ids are ignored.
@@ -258,7 +294,7 @@ export function renderHtmlPage(doc: {
258294
<meta name="viewport" content="width=device-width, initial-scale=1">
259295
<meta http-equiv="Content-Security-Policy" content="${buildCsp(doc.origin)}">
260296
<title>${escapeHtml(doc.title)}</title>
261-
<style>${tokenThemeCss(theme)}${TOKENS_CSS}${KIT_CSS}${kit.css}</style>
297+
<style>${tokenThemeCss(theme, doc.mode)}${TOKENS_CSS}${KIT_CSS}${kitAccentCss(doc.mode)}${kit.css}${colorSchemeCss(doc.mode)}</style>
262298
</head>
263299
<body>
264300
${SVG_DEFS}

server/themes.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ export interface Theme {
4141
dark: Palette;
4242
}
4343

44+
// A resolved color scheme. The chrome resolves this from the OS via a CSS
45+
// `@media (prefers-color-scheme)` query; surface iframes are separate documents
46+
// that don't reliably inherit that resolution, so the viewer passes the mode it
47+
// resolved into each frame to pin it to the chrome (see surfacePage / Card).
48+
export type Mode = "light" | "dark";
49+
4450
// Viewer chrome variables (styles.css names). Accent maps to the info state.
4551
function viewerVars(p: Palette): Record<string, string> {
4652
return {
@@ -105,21 +111,35 @@ const block = (vars: Record<string, string>) =>
105111
.join("");
106112

107113
// `:root` light + a `prefers-color-scheme: dark` override — emitted as a
108-
// <style> so the automatic OS light/dark flip keeps working with no JS.
109-
function schemeCss(light: Record<string, string>, dark: Record<string, string>): string {
114+
// <style> so the automatic OS light/dark flip keeps working with no JS. When
115+
// `mode` is given the scheme is PINNED to it instead: a single flat `:root`
116+
// block with no media query, so the document renders that mode regardless of
117+
// the OS preference. The viewer uses this to force a surface iframe to the mode
118+
// the chrome already resolved, since an iframe is a separate document whose
119+
// `prefers-color-scheme` evaluation can diverge from its embedder's.
120+
export function schemeCss(
121+
light: Record<string, string>,
122+
dark: Record<string, string>,
123+
mode?: Mode,
124+
): string {
125+
if (mode === "light") return `:root{${block(light)}}`;
126+
if (mode === "dark") return `:root{${block(dark)}}`;
110127
return `:root{${block(light)}}@media (prefers-color-scheme: dark){:root{${block(dark)}}}`;
111128
}
112129

113130
// Viewer chrome palette CSS (injected into the viewer document head). The
114131
// scheme-flipping chrome vars, plus the terminal vars which are scheme-
115132
// independent (always the dark palette) so they sit outside the media query.
116-
export function viewerThemeCss(t: Theme): string {
117-
return `${schemeCss(viewerVars(t.light), viewerVars(t.dark))}:root{${block(termVars(t.dark))}}`;
133+
// `mode` pins the scheme (see schemeCss) — used for the rich-part iframes the
134+
// chrome renders via renderSandboxedPart, not the chrome's own <head>.
135+
export function viewerThemeCss(t: Theme, mode?: Mode): string {
136+
return `${schemeCss(viewerVars(t.light), viewerVars(t.dark), mode)}:root{${block(termVars(t.dark))}}`;
118137
}
119138

120-
// Html-part token CSS (injected into each sandboxed surface iframe).
121-
export function tokenThemeCss(t: Theme): string {
122-
return schemeCss(tokenVars(t.light), tokenVars(t.dark));
139+
// Html-part token CSS (injected into each sandboxed surface iframe). `mode`
140+
// pins the scheme so the iframe matches the chrome (see schemeCss).
141+
export function tokenThemeCss(t: Theme, mode?: Mode): string {
142+
return schemeCss(tokenVars(t.light), tokenVars(t.dark), mode);
123143
}
124144

125145
export const THEMES: Theme[] = [

test/surfacePage.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import assert from "node:assert/strict";
22
import { test } from "node:test";
33
import { escapeHtml, renderHtmlPage, renderSandboxedPart } from "../server/surfacePage.ts";
4+
import { themeById } from "../server/themes.ts";
45

56
const ORIGIN = "http://localhost:4000";
67

@@ -117,6 +118,47 @@ test("theme tokens are injected and resolve unknown/absent themes to the default
117118
);
118119
});
119120

121+
test("a pinned mode forces the scheme into html parts but not transparent rich frames", () => {
122+
const gh = renderHtmlPage({ title: "t", html: "<p>x</p>", origin: ORIGIN, mode: "dark" });
123+
// the document's used color-scheme is forced so the UA canvas/scrollbars/
124+
// controls follow it, overriding the static `color-scheme: light dark` default
125+
assert.ok(/:root\{color-scheme:dark\}/.test(gh), "color-scheme must be pinned to dark");
126+
// and EVERYTHING that flips by scheme is pinned: the theme tokens AND the kit's
127+
// own teal/coral SVG accents — so no `@media (prefers-color-scheme)` survives to
128+
// second-guess the scheme inside the frame
129+
assert.ok(
130+
!gh.includes("@media (prefers-color-scheme: dark)"),
131+
"pinned mode drops the media query",
132+
);
133+
assert.ok(gh.includes("--c-teal-bg: rgba(31, 169, 150, 0.18)"), "kit teal accent pinned to dark");
134+
135+
// light pins the other way; absent mode keeps the OS-driven media query
136+
const light = renderHtmlPage({ title: "t", html: "<p>x</p>", origin: ORIGIN, mode: "light" });
137+
assert.ok(/:root\{color-scheme:light\}/.test(light), "color-scheme must be pinned to light");
138+
const auto = renderHtmlPage({ title: "t", html: "<p>x</p>", origin: ORIGIN });
139+
assert.ok(!auto.includes("color-scheme:dark"), "no mode → no forced scheme");
140+
assert.ok(auto.includes("@media (prefers-color-scheme: dark)"), "no mode → OS media query kept");
141+
142+
// rich/comment frames pin the same way — EXCEPT color-scheme. Those frames are
143+
// transparent so the themed card surface shows through; a forced color-scheme
144+
// would paint an opaque UA canvas behind them. So the tokens are pinned (flat
145+
// :root, dark --text, no media query) but color-scheme is left unset.
146+
const rich = renderSandboxedPart({ body: "x", css: "", origin: ORIGIN, mode: "dark" });
147+
const dark = themeById("github").dark;
148+
assert.ok(
149+
!rich.includes("color-scheme:"),
150+
"rich frame must NOT force color-scheme (stays transparent)",
151+
);
152+
assert.ok(
153+
!rich.includes("@media (prefers-color-scheme: dark)"),
154+
"rich tokens are pinned, no media query",
155+
);
156+
assert.ok(
157+
rich.includes(`--text: ${dark.text}`),
158+
"rich frame carries the pinned dark chrome vars",
159+
);
160+
});
161+
120162
test("renderSandboxedPart embeds the body and css inside the sandbox doc", () => {
121163
const doc = renderSandboxedPart({
122164
body: "<p>hello</p>",

test/themes.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,37 @@ test("tokenThemeCss emits the agent-facing --color-* tokens for each theme", ()
9999
);
100100
}
101101
});
102+
103+
// A pinned mode emits a single flat :root with that scheme's values and NO
104+
// media query, so a surface iframe renders the mode the chrome resolved rather
105+
// than re-deriving it from the OS across the frame boundary.
106+
test("a pinned mode forces the scheme with no prefers-color-scheme media query", () => {
107+
const gh = themeById("github");
108+
109+
const dark = tokenThemeCss(gh, "dark");
110+
assert.ok(!dark.includes("@media"), "dark mode must not emit a media query");
111+
// github dark surface is the html-part background-primary token
112+
assert.ok(dark.includes(`--color-background-primary: ${gh.dark.surface}`), "dark bg token");
113+
assert.ok(!dark.includes(gh.light.surface), "dark output must not carry light values");
114+
115+
const light = tokenThemeCss(gh, "light");
116+
assert.ok(!light.includes("@media"), "light mode must not emit a media query");
117+
assert.ok(light.includes(`--color-background-primary: ${gh.light.surface}`), "light bg token");
118+
119+
// viewerThemeCss pins the same way (used for rich-part iframes)
120+
const vdark = viewerThemeCss(gh, "dark");
121+
assert.ok(!vdark.includes("@media"), "viewer dark mode must not emit a media query");
122+
assert.ok(vdark.includes(`--bg: ${gh.dark.bg}`), "viewer dark --bg");
123+
// terminal vars still ride along (always the dark palette, scheme-independent)
124+
assert.ok(vdark.includes("--term-bg:"), "viewer keeps terminal vars when pinned");
125+
});
126+
127+
test("omitting the mode preserves the OS media-query behavior unchanged", () => {
128+
const gh = themeById("github");
129+
for (const css of [tokenThemeCss(gh), tokenThemeCss(gh, undefined), viewerThemeCss(gh)]) {
130+
assert.ok(
131+
css.includes("@media (prefers-color-scheme: dark)"),
132+
"no-mode output keeps the dark-scheme override",
133+
);
134+
}
135+
});

0 commit comments

Comments
 (0)