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/smooth-bats-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

Add a light/dark/system color-mode switcher next to the theme picker.
33 changes: 33 additions & 0 deletions e2e/theme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<p>surface body</p>" }],
});
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
Expand Down
65 changes: 55 additions & 10 deletions viewer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <MoonIcon />;
if (props.mode === "light") return <SunIcon />;
return <SystemIcon />;
}

const COLOR_MODE_LABELS: Record<ColorModePreference, string> = {
system: "System",
light: "Light",
dark: "Dark",
};
const COLOR_MODE_OPTIONS: ColorModePreference[] = ["system", "light", "dark"];

function ColorModeSwitcher() {
return (
<div class="mode-switcher" role="group" aria-label="Color mode">
<For each={COLOR_MODE_OPTIONS}>
{(mode) => (
<button
type="button"
classList={{ active: colorModePreference() === mode }}
aria-label={`${COLOR_MODE_LABELS[mode]} mode`}
aria-pressed={colorModePreference() === mode}
title={`${COLOR_MODE_LABELS[mode]} mode`}
onClick={() => setColorModePreference(mode)}
>
<ModeIcon mode={mode} />
</button>
)}
</For>
</div>
);
}

function ThemePicker() {
return (
<div class="theme-picker">
<label for="themeSel">theme</label>
<select
id="themeSel"
value={activeTheme()}
onChange={(e) => void setTheme(e.currentTarget.value)}
>
<For each={themeOptions()}>{(t) => <option value={t.id}>{t.label}</option>}</For>
</select>
<span class="theme-select-wrap">
<select
id="themeSel"
aria-label="Theme"
value={activeTheme()}
onChange={(e) => void setTheme(e.currentTarget.value)}
>
<For each={themeOptions()}>{(t) => <option value={t.id}>{t.label}</option>}</For>
</select>
</span>
<ColorModeSwitcher />
</div>
);
}
Expand Down
42 changes: 42 additions & 0 deletions viewer/src/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,48 @@ export function PlugIcon() {
);
}

// lucide: monitor-cog — "system" color scheme.
export function SystemIcon() {
return (
<Icon>
<rect width="20" height="14" x="2" y="3" rx="2" />
<path d="M8 21h8" />
<path d="M12 17v4" />
<path d="m15.2 10.2.6-.3" />
<path d="m8.2 13.8.6-.3" />
<path d="m13.5 13.5.3.6" />
<path d="m10.2 6.2.3.6" />
<circle cx="12" cy="10" r="2" />
</Icon>
);
}

// lucide: sun
export function SunIcon() {
return (
<Icon>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</Icon>
);
}

// lucide: moon
export function MoonIcon() {
return (
<Icon>
<path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" />
</Icon>
);
}

// lucide: trash-2
export function TrashIcon() {
return (
Expand Down
69 changes: 64 additions & 5 deletions viewer/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down
53 changes: 48 additions & 5 deletions viewer/src/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ColorModePreference>(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
Expand All @@ -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
Expand All @@ -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;
}

Expand All @@ -79,6 +107,7 @@ export function applyTheme(id: string) {
const theme = themeById(id);
applyPalette(theme.id);
setActiveTheme(theme.id);
syncModeCookie();
emitThemeTokens();
}

Expand All @@ -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();
}
Loading