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(); +}