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
5 changes: 5 additions & 0 deletions .changeset/system-surface-schemes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

Let direct rich-surface renders without an explicit mode follow the browser's system color scheme.
2 changes: 1 addition & 1 deletion server/richRender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ export async function renderDiff(
const options = {
diffStyle: part.layout ?? "unified",
theme: { dark: shiki.dark, light: shiki.light },
themeType: opts.mode === "dark" ? "dark" : "light",
themeType: opts.mode ?? "system",
preferredHighlighter: "shiki-js",
} as const;
const rendered = await Promise.all(
Expand Down
36 changes: 24 additions & 12 deletions server/surfacePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,11 @@ const KIT_ACCENTS_DARK: Record<string, string> = {
};
const kitAccentCss = (mode?: Mode): string => schemeCss(KIT_ACCENTS_LIGHT, KIT_ACCENTS_DARK, mode);

// When a scheme is pinned, force the document's used color-scheme to match so
// the UA-painted canvas, scrollbars, and native form controls follow it too
// (the token vars alone don't drive those). Overrides the static
// `color-scheme: light dark` default the kit/base CSS sets. Empty when the
// scheme is left to the OS, preserving the media-query behavior unchanged.
const colorSchemeCss = (mode?: Mode): string => (mode ? `:root{color-scheme:${mode}}` : "");
// Force the document's used color-scheme so the UA-painted canvas, scrollbars,
// and native form controls follow the same scheme as the theme vars (the vars
// alone don't drive those). Pinned frames get a single scheme; unpinned/direct
// loads opt into both schemes so the browser can resolve the user's system mode.
const colorSchemeCss = (mode?: Mode): string => `:root{color-scheme:${mode ?? "light dark"}}`;

// Origins html surfaces may load external resources from. Mirrors the allowlist
// agents already know from Claude's inline widget surface.
Expand Down Expand Up @@ -357,7 +356,8 @@ ${doc.body}
// own DOMPurify (securityLevel 'strict') runs first; the opaque origin is the
// second boundary. Theme colors are baked into the diagram at render time, so —
// like shiki's flip — they're PINNED to the chrome-resolved mode the viewer
// passed (mermaid can't do a media-query flip); absent mode defaults to light.
// passed. On a direct no-mode load, the iframe's own JS chooses the user's
// system scheme before mermaid renders.

const MERMAID_CSS = `
body { margin: 0; padding: 14px 16px; background: transparent; text-align: center; }
Expand Down Expand Up @@ -475,21 +475,33 @@ export function renderMermaidPage(doc: {
}): string {
const theme =
typeof doc.theme === "string" || doc.theme == null ? themeById(doc.theme) : doc.theme;
const palette = doc.mode === "dark" ? theme.dark : theme.light;
const { themeVariables, themeCSS } = mermaidThemeVars(palette, doc.mode);
const enc = (v: unknown) => JSON.stringify(v).replace(/</g, "\\u003c");
const pinned = doc.mode
? mermaidThemeVars(doc.mode === "dark" ? theme.dark : theme.light, doc.mode)
: null;
const light = pinned ? null : mermaidThemeVars(theme.light, "light");
const dark = pinned ? null : mermaidThemeVars(theme.dark, "dark");
const autoTheme = pinned
? ""
: `const __mql = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
const __systemDark = !!(__mql && __mql.matches);
const themeVariables = __systemDark ? ${enc(dark!.themeVariables)} : ${enc(light!.themeVariables)};
const themeCSS = __systemDark ? ${enc(dark!.themeCSS)} : ${enc(light!.themeCSS)};`;
const themeConfig = pinned
? `themeVariables: ${enc(pinned.themeVariables)},\n themeCSS: ${enc(pinned.themeCSS)},`
: `themeVariables,\n themeCSS,`;
// Embed source + theme as JS literals; escape `<` so a `</script>` in the
// diagram source can't break out of the module script.
const enc = (v: unknown) => JSON.stringify(v).replace(/</g, "\\u003c");
const loader = `
import mermaid from ${enc(MERMAID_CDN)};
const src = ${enc(doc.mermaid ?? "")};
${autoTheme}
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
suppressErrorRendering: true,
theme: 'base',
themeVariables: ${enc(themeVariables)},
themeCSS: ${enc(themeCSS)},
${themeConfig}
});
const el = document.getElementById('m');
try {
Expand Down
13 changes: 13 additions & 0 deletions test/richRender.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,19 @@ test("renderDiff: an empty patch and no files throws No diff content", async ()
await assert.rejects(() => renderDiff({ kind: "diff", patch: "" }), /No diff content/);
});

test("renderDiff: absent mode keeps the diff in system color-scheme", async () => {
const diff: DiffSurface = {
kind: "diff",
files: [{ filename: "f.ts", before: "const x = 1", after: "const x = 2" }],
};
const { body } = await renderDiff(diff);
assert.match(body, /color-scheme:\s*light dark/);
assert.doesNotMatch(
body.match(/<style data-theme-css="">[\s\S]*?<\/style>/)?.[0] ?? "",
/color-scheme:\s*(?:light|dark);/,
);
});

test("renderTerminal: ANSI codes are converted and a window bar is rendered", async () => {
const term: TerminalSurface = {
kind: "terminal",
Expand Down
31 changes: 26 additions & 5 deletions test/surfacePage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,15 @@ test("a pinned mode forces color-scheme into both html parts and transparent ric
);
assert.ok(gh.includes("--c-teal-bg: rgba(31, 169, 150, 0.18)"), "kit teal accent pinned to dark");

// light pins the other way; absent mode keeps the OS-driven media query
// light pins the other way; absent mode keeps OS-driven theme vars and opts the
// document into both UA color schemes so the browser can resolve the system mode.
const light = renderHtmlPage({ title: "t", html: "<p>x</p>", origin: ORIGIN, mode: "light" });
assert.ok(/:root\{color-scheme:light\}/.test(light), "color-scheme must be pinned to light");
const auto = renderHtmlPage({ title: "t", html: "<p>x</p>", origin: ORIGIN });
assert.ok(!auto.includes("color-scheme:dark"), "no mode → no forced scheme");
assert.ok(
/:root\{color-scheme:light dark\}/.test(auto),
"no mode → browser resolves light/dark from the OS",
);
assert.ok(auto.includes("@media (prefers-color-scheme: dark)"), "no mode → OS media query kept");

// rich/comment frames pin the same way, color-scheme INCLUDED. A sandboxed
Expand All @@ -164,16 +168,19 @@ test("a pinned mode forces color-scheme into both html parts and transparent ric
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.
// light pins light; an unpinned (no-mode) frame opts into both schemes so the
// browser resolves the user's system mode instead of defaulting the canvas to light.
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)",
/:root\{color-scheme:light dark\}/.test(
renderSandboxedPart({ body: "x", css: "", origin: ORIGIN }),
),
"no mode → browser resolves light/dark from the OS",
);
});

Expand Down Expand Up @@ -234,6 +241,20 @@ test("a mermaid page pins mermaid's derived colors to the scheme so the whole di
}
});

test("a no-mode mermaid page chooses the user's system scheme in the iframe", () => {
const auto = renderMermaidPage({ mermaid: "graph TD; A-->B", origin: ORIGIN, theme: "github" });
assert.ok(
auto.includes("matchMedia('(prefers-color-scheme: dark)')"),
"direct no-mode mermaid load should read the browser's system scheme",
);
assert.ok(auto.includes('"darkMode":true'), "auto loader embeds dark mermaid variables");
assert.ok(auto.includes('"darkMode":false'), "auto loader embeds light mermaid variables");
assert.ok(
/:root\{color-scheme:light dark\}/.test(auto),
"the document itself opts into system light/dark",
);
});

test("renderSandboxedPart embeds the body and css inside the sandbox doc", () => {
const doc = renderSandboxedPart({
body: "<p>hello</p>",
Expand Down
Loading