diff --git a/.changeset/smooth-bats-switch.md b/.changeset/smooth-bats-switch.md
new file mode 100644
index 0000000..23fab36
--- /dev/null
+++ b/.changeset/smooth-bats-switch.md
@@ -0,0 +1,5 @@
+---
+"sideshow": patch
+---
+
+Add a light/dark/system color-mode switcher next to the theme picker.
diff --git a/e2e/theme.spec.ts b/e2e/theme.spec.ts
index 674b18a..ed2b34a 100644
--- a/e2e/theme.spec.ts
+++ b/e2e/theme.spec.ts
@@ -114,6 +114,39 @@ test.describe("with the OS in dark mode", () => {
.toBe("rgb(28, 33, 40)");
});
+ test("the color-mode switcher can force light and persists locally", async ({ page, server }) => {
+ await publishParts(server.url, {
+ title: "Themed",
+ agent: "e2e",
+ parts: [{ kind: "html", html: "
surface body
" }],
+ });
+ await page.goto(server.url);
+
+ const iframe = page.locator(".card iframe[src]");
+ await expect(iframe).toHaveAttribute("src", /mode=dark/);
+
+ await page.getByRole("button", { name: "Light mode" }).click();
+ await expect(page.getByRole("button", { name: "Light mode" })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ await expect(iframe).toHaveAttribute("src", /mode=light/);
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ getComputedStyle(document.documentElement).getPropertyValue("--bg").trim(),
+ ),
+ )
+ .toBe("#f6f8fa");
+
+ await page.reload();
+ await expect(page.getByRole("button", { name: "Light mode" })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ 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
diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx
index 22c82bd..c0cb045 100644
--- a/viewer/src/App.tsx
+++ b/viewer/src/App.tsx
@@ -14,8 +14,16 @@ import { host, isShadow, navHostEl, root, SLOTS } from "./host.ts";
import { applyFrameHeight, Card, cardEls, frameForSource } from "./Card.tsx";
import { renderNotes } from "./notes.ts";
import { SessionTimeline } from "./SessionTimeline.tsx";
-import { PlugIcon } from "./icons.tsx";
-import { activeTheme, initTheme, setTheme, themeOptions } from "./theme.ts";
+import { MoonIcon, PlugIcon, SunIcon, SystemIcon } from "./icons.tsx";
+import {
+ activeTheme,
+ colorModePreference,
+ type ColorModePreference,
+ initTheme,
+ setColorModePreference,
+ setTheme,
+ themeOptions,
+} from "./theme.ts";
import {
applyRoute,
bootstrap,
@@ -754,17 +762,54 @@ function ConnectModal(props: { onClose: () => void }) {
// Board-level theme selector. Persists via PUT /api/theme; the choice re-themes
// chrome, markdown/diff syntax, and html surfaces together (see theme.ts).
+function ModeIcon(props: { mode: ColorModePreference }) {
+ if (props.mode === "dark") return ;
+ if (props.mode === "light") return ;
+ return ;
+}
+
+const COLOR_MODE_LABELS: Record = {
+ system: "System",
+ light: "Light",
+ dark: "Dark",
+};
+const COLOR_MODE_OPTIONS: ColorModePreference[] = ["system", "light", "dark"];
+
+function ColorModeSwitcher() {
+ return (
+
+
+ {(mode) => (
+
+ )}
+
+
+ );
+}
+
function ThemePicker() {
return (
-
-
+
+
+
+
);
}
diff --git a/viewer/src/icons.tsx b/viewer/src/icons.tsx
index 9364dd0..55231bc 100644
--- a/viewer/src/icons.tsx
+++ b/viewer/src/icons.tsx
@@ -82,6 +82,48 @@ export function PlugIcon() {
);
}
+// lucide: monitor-cog — "system" color scheme.
+export function SystemIcon() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// lucide: sun
+export function SunIcon() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// lucide: moon
+export function MoonIcon() {
+ return (
+
+
+
+ );
+}
+
// lucide: trash-2
export function TrashIcon() {
return (
diff --git a/viewer/src/styles.css b/viewer/src/styles.css
index 7206ca0..a003365 100644
--- a/viewer/src/styles.css
+++ b/viewer/src/styles.css
@@ -266,22 +266,81 @@ aside > .brand {
color: var(--text);
}
.theme-picker {
+ --theme-control-h: 30px;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
-.theme-picker label {
- color: var(--muted);
+.theme-select-wrap {
+ position: relative;
+ flex: 1;
+ min-width: 0;
+}
+.theme-select-wrap::after {
+ content: "";
+ position: absolute;
+ right: 12px;
+ top: 50%;
+ width: 7px;
+ height: 7px;
+ border-right: 1.5px solid var(--muted);
+ border-bottom: 1.5px solid var(--muted);
+ pointer-events: none;
+ transform: translateY(-62%) rotate(45deg);
}
.theme-picker select {
- flex: 1;
+ width: 100%;
+ height: var(--theme-control-h);
+ box-sizing: border-box;
+ appearance: none;
+ -webkit-appearance: none;
font: inherit;
color: var(--text);
background: var(--surface);
border: 0.5px solid var(--border-2);
- border-radius: 6px;
- padding: 4px 6px;
+ border-radius: 7px;
+ padding: 0 30px 0 14px;
+}
+.mode-switcher {
+ flex: none;
+ display: inline-flex;
+ height: var(--theme-control-h);
+ box-sizing: border-box;
+ overflow: hidden;
+ border: 0.5px solid var(--border-2);
+ border-radius: 7px;
+ background: var(--surface);
+}
+.mode-switcher button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--theme-control-h);
+ height: 100%;
+ box-sizing: border-box;
+ padding: 0;
+ border: 0;
+ border-left: 0.5px solid var(--border);
+ color: var(--muted);
+ background: transparent;
+ cursor: pointer;
+}
+.mode-switcher button:first-child {
+ border-left: 0;
+}
+.mode-switcher button:hover {
+ color: var(--text);
+ background: var(--hover);
+}
+.mode-switcher button.active {
+ color: var(--accent);
+ background: var(--accent-bg);
+}
+.mode-switcher svg {
+ width: 13px;
+ height: 13px;
+ display: block;
}
/* update notice: sidebar banner + release-notes card (#whatsNew) */
diff --git a/viewer/src/theme.ts b/viewer/src/theme.ts
index a178762..2c42745 100644
--- a/viewer/src/theme.ts
+++ b/viewer/src/theme.ts
@@ -20,8 +20,27 @@ import {
export { themeOptions };
+export type ColorModePreference = "system" | Mode;
+
+const COLOR_MODE_KEY = "sideshow:color-mode";
+const COLOR_MODE_PREFERENCES: ColorModePreference[] = ["system", "light", "dark"];
+
+function readColorModePreference(): ColorModePreference {
+ try {
+ const stored = localStorage.getItem(COLOR_MODE_KEY);
+ return COLOR_MODE_PREFERENCES.includes(stored as ColorModePreference)
+ ? (stored as ColorModePreference)
+ : "system";
+ } catch {
+ return "system";
+ }
+}
+
const [activeThemeState, setActiveTheme] = createSignal(DEFAULT_THEME_ID);
export const activeTheme = activeThemeState;
+const [colorModePreferenceState, setColorModePreferenceState] =
+ createSignal(readColorModePreference());
+export const colorModePreference = colorModePreferenceState;
// The OS light/dark resolution — the same signal the chrome's injected
// `@media (prefers-color-scheme: dark)` rules key off. Surfaces render in
@@ -34,18 +53,24 @@ export const activeTheme = activeThemeState;
const darkQuery =
typeof matchMedia === "function" ? matchMedia("(prefers-color-scheme: dark)") : null;
const [prefersDark, setPrefersDark] = createSignal(!!darkQuery?.matches);
+export const resolvedMode = (): Mode => {
+ const preference = colorModePreferenceState();
+ if (preference !== "system") return preference;
+ return prefersDark() ? "dark" : "light";
+};
+
// On an OS light/dark flip the resolved palette changes without a theme change,
-// so re-push it to the host (below) after updating the mode signal.
+// so re-push it to the host (below) after updating the mode signal. If the user
+// has forced light/dark, the OS change does not affect the resolved mode.
function syncModeCookie() {
- document.cookie = `sideshow_mode=${prefersDark() ? "dark" : "light"};path=/;max-age=31536000;SameSite=Lax`;
+ document.cookie = `sideshow_mode=${resolvedMode()};path=/;max-age=31536000;SameSite=Lax`;
}
syncModeCookie();
darkQuery?.addEventListener("change", (e) => {
setPrefersDark(e.matches);
syncModeCookie();
- emitThemeTokens();
+ if (colorModePreferenceState() === "system") emitThemeTokens();
});
-export const resolvedMode = (): Mode => (prefersDark() ? "dark" : "light");
// Push the fully-resolved palette to the host. Symmetric with router.navigate:
// the engine owns the themes and TELLS the host its colors (on initial apply, on
@@ -70,7 +95,10 @@ function applyPalette(id: string) {
el.id = STYLE_ID;
container.appendChild(el);
}
- const css = viewerThemeCss(themeById(id));
+ const preference = colorModePreferenceState();
+ const scheme = preference === "system" ? undefined : preference;
+ const colorSchemeCss = `:root{color-scheme:${scheme ?? "light dark"};}`;
+ const css = `${viewerThemeCss(themeById(id), scheme)}${colorSchemeCss}`;
el.textContent = isShadow() ? css.replace(/:root\b/g, ":host") : css;
}
@@ -79,6 +107,7 @@ export function applyTheme(id: string) {
const theme = themeById(id);
applyPalette(theme.id);
setActiveTheme(theme.id);
+ syncModeCookie();
emitThemeTokens();
}
@@ -94,3 +123,17 @@ export async function setTheme(id: string) {
applyTheme(id);
await api("/api/theme", { method: "PUT", body: JSON.stringify({ id }) }).catch(() => null);
}
+
+// User picked a color mode: local to this browser, because "system" means the
+// viewer should follow this device's OS preference.
+export function setColorModePreference(preference: ColorModePreference) {
+ setColorModePreferenceState(preference);
+ try {
+ localStorage.setItem(COLOR_MODE_KEY, preference);
+ } catch {
+ // Ignore unavailable storage; the in-memory choice still applies.
+ }
+ applyPalette(activeThemeState());
+ syncModeCookie();
+ emitThemeTokens();
+}