diff --git a/ui/index.html b/ui/index.html
index 0625148a..6a5781e7 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -23,7 +23,11 @@
const lightThemeColor = "#ffffff";
try {
const stored = window.localStorage.getItem(key);
- const theme = stored === "light" || stored === "dark" ? stored : "dark";
+ const prefersDark =
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-color-scheme: dark)").matches;
+ const fallback = prefersDark ? "dark" : "light";
+ const theme = stored === "light" || stored === "dark" ? stored : fallback;
const isDark = theme === "dark";
document.documentElement.classList.toggle("dark", isDark);
document.documentElement.style.colorScheme = isDark ? "dark" : "light";
diff --git a/ui/src/context/ThemeContext.test.tsx b/ui/src/context/ThemeContext.test.tsx
new file mode 100644
index 00000000..e66bf590
--- /dev/null
+++ b/ui/src/context/ThemeContext.test.tsx
@@ -0,0 +1,179 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { ThemeProvider, useTheme } from "./ThemeContext";
+
+const THEME_STORAGE_KEY = "paperclip.theme";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+type MediaListener = (event: MediaQueryListEvent) => void;
+
+interface FakeMediaQueryList {
+ matches: boolean;
+ addEventListener: (type: "change", listener: MediaListener) => void;
+ removeEventListener: (type: "change", listener: MediaListener) => void;
+ dispatch: (matches: boolean) => void;
+ listenerCount: () => number;
+}
+
+function installMatchMedia(initialMatches: boolean): FakeMediaQueryList {
+ const listeners = new Set();
+ const mql: FakeMediaQueryList = {
+ matches: initialMatches,
+ addEventListener: (_type, listener) => {
+ listeners.add(listener);
+ },
+ removeEventListener: (_type, listener) => {
+ listeners.delete(listener);
+ },
+ dispatch: (matches) => {
+ mql.matches = matches;
+ const event = { matches } as MediaQueryListEvent;
+ listeners.forEach((listener) => listener(event));
+ },
+ listenerCount: () => listeners.size,
+ };
+ Object.defineProperty(window, "matchMedia", {
+ configurable: true,
+ writable: true,
+ value: (query: string) => {
+ if (query !== "(prefers-color-scheme: dark)") {
+ throw new Error(`unexpected media query: ${query}`);
+ }
+ return mql as unknown as MediaQueryList;
+ },
+ });
+ return mql;
+}
+
+describe("ThemeContext", () => {
+ let container: HTMLDivElement;
+ let observedTheme: "light" | "dark" | null = null;
+ let setTheme: ((theme: "light" | "dark") => void) | null = null;
+ let toggleTheme: (() => void) | null = null;
+
+ function Probe() {
+ const ctx = useTheme();
+ observedTheme = ctx.theme;
+ setTheme = ctx.setTheme;
+ toggleTheme = ctx.toggleTheme;
+ return null;
+ }
+
+ beforeEach(() => {
+ window.localStorage.clear();
+ document.documentElement.className = "";
+ document.documentElement.style.colorScheme = "";
+ observedTheme = null;
+ setTheme = null;
+ toggleTheme = null;
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ it("follows OS prefers-color-scheme changes while no explicit choice has been made", () => {
+ document.documentElement.classList.add("dark");
+ const mql = installMatchMedia(true);
+
+ const root = createRoot(container);
+ act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+
+ expect(observedTheme).toBe("dark");
+ expect(document.documentElement.classList.contains("dark")).toBe(true);
+ expect(mql.listenerCount()).toBe(1);
+
+ act(() => {
+ mql.dispatch(false);
+ });
+ expect(observedTheme).toBe("light");
+ expect(document.documentElement.classList.contains("dark")).toBe(false);
+ expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBeNull();
+
+ act(() => {
+ mql.dispatch(true);
+ });
+ expect(observedTheme).toBe("dark");
+ expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBeNull();
+
+ act(() => {
+ root.unmount();
+ });
+ });
+
+ it("stops listening to OS changes after the user makes an explicit choice", () => {
+ document.documentElement.classList.add("dark");
+ const mql = installMatchMedia(true);
+
+ const root = createRoot(container);
+ act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+
+ expect(mql.listenerCount()).toBe(1);
+
+ act(() => {
+ setTheme?.("light");
+ });
+ expect(observedTheme).toBe("light");
+ expect(mql.listenerCount()).toBe(0);
+ expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe("light");
+
+ act(() => {
+ mql.dispatch(true);
+ });
+ expect(observedTheme).toBe("light");
+
+ act(() => {
+ toggleTheme?.();
+ });
+ expect(observedTheme).toBe("dark");
+ expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe("dark");
+
+ act(() => {
+ root.unmount();
+ });
+ });
+
+ it("does not attach the OS listener when a stored choice already exists", () => {
+ window.localStorage.setItem(THEME_STORAGE_KEY, "light");
+ const mql = installMatchMedia(true);
+
+ const root = createRoot(container);
+ act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+
+ expect(mql.listenerCount()).toBe(0);
+
+ act(() => {
+ mql.dispatch(true);
+ });
+ expect(observedTheme).not.toBe("dark");
+
+ act(() => {
+ root.unmount();
+ });
+ });
+});
diff --git a/ui/src/context/ThemeContext.tsx b/ui/src/context/ThemeContext.tsx
index 81c0b8ad..4cf78f95 100644
--- a/ui/src/context/ThemeContext.tsx
+++ b/ui/src/context/ThemeContext.tsx
@@ -26,6 +26,16 @@ function resolveThemeFromDocument(): Theme {
return document.documentElement.classList.contains("dark") ? "dark" : "light";
}
+function hasStoredTheme(): boolean {
+ if (typeof window === "undefined") return false;
+ try {
+ const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
+ return stored === "light" || stored === "dark";
+ } catch {
+ return false;
+ }
+}
+
function applyTheme(theme: Theme) {
if (typeof document === "undefined") return;
const isDark = theme === "dark";
@@ -40,23 +50,43 @@ function applyTheme(theme: Theme) {
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState(() => resolveThemeFromDocument());
+ // Track whether the user has explicitly chosen a theme. If false, the
+ // theme is being derived from the OS `prefers-color-scheme` and should
+ // follow OS-level changes mid-session without being persisted.
+ const [hasExplicitChoice, setHasExplicitChoice] = useState(() => hasStoredTheme());
const setTheme = useCallback((nextTheme: Theme) => {
+ setHasExplicitChoice(true);
setThemeState(nextTheme);
}, []);
const toggleTheme = useCallback(() => {
+ setHasExplicitChoice(true);
setThemeState((current) => (current === "dark" ? "light" : "dark"));
}, []);
useEffect(() => {
applyTheme(theme);
+ if (!hasExplicitChoice) return;
try {
localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// Ignore local storage write failures in restricted environments.
}
- }, [theme]);
+ }, [theme, hasExplicitChoice]);
+
+ // When the user has not made an explicit choice, follow OS-level
+ // `prefers-color-scheme` changes so the UI flips alongside the OS theme.
+ useEffect(() => {
+ if (hasExplicitChoice) return;
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
+ const handleChange = (event: MediaQueryListEvent) => {
+ setThemeState(event.matches ? "dark" : "light");
+ };
+ media.addEventListener("change", handleChange);
+ return () => media.removeEventListener("change", handleChange);
+ }, [hasExplicitChoice]);
const value = useMemo(
() => ({