From cd1b4f275d636bfb4bc669136723d603b18e7dcb Mon Sep 17 00:00:00 2001 From: Jannes Stubbemann Date: Fri, 12 Jun 2026 23:03:54 +0200 Subject: [PATCH] feat(ui): default to system prefers-color-scheme for first-time visitors (supersedes part of #3732) (#5873) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The UI ships a dark/light theme toggle and persists the user's explicit choice in `localStorage` > - For first-time visitors with no stored choice, the pre-React bootstrap script in `ui/index.html` hardcoded `"dark"` and never consulted the OS preference > - As a result, users on a light-themed OS were forced into dark mode until they clicked the toggle once — a first-impression friction with no compensating benefit > - This pull request makes the bootstrap respect `prefers-color-scheme` for first-time visitors and adds a `matchMedia` listener so the in-app theme auto-follows OS changes until the user makes an explicit choice > - The benefit is a friction-free first visit that matches every other modern web app, with zero impact on users who have already chosen a theme ## Linked Issues or Issue Description No existing issue covers this directly — problem described in-PR (bug shape): - For first-time visitors with no stored theme choice, the pre-React bootstrap script in `ui/index.html` hardcoded `"dark"` and never consulted the OS `prefers-color-scheme` preference. - Users on a light-themed OS were forced into dark mode until they clicked the toggle once — first-impression friction with no compensating benefit. - This PR supersedes part of PR #3732 (the `prefers-color-scheme` bootstrap slice); no standalone issue was filed for it. ## What Changed - **`ui/index.html`** — the pre-React bootstrap script now computes a `prefersDark` fallback via `matchMedia("(prefers-color-scheme: dark)")`, guarded by a `typeof window.matchMedia === "function"` feature-detection check, in place of the hardcoded `"dark"` default. A stored `localStorage` value still takes precedence. - **`ui/src/context/ThemeContext.tsx`** — tracks an `hasExplicitChoice` flag. While `false`, a `MediaQueryList` `change` listener keeps the in-app theme in sync with OS theme switches. Once `setTheme` / `toggleTheme` runs, the choice is persisted and the listener is removed. ## Verification - `pnpm --filter @paperclipai/ui run typecheck` — clean. - `npx vitest run src/components/SidebarAccountMenu.test.tsx` — 1/1 pass (the one test that exercises `ThemeContext`). - Manual: launched the Vite UI dev server with a mocked `/api/auth/get-session` 401, navigated to `/auth` with no `localStorage.theme` set, and toggled the browser's emulated `prefers-color-scheme` between `dark` and `light`. Bootstrap renders the matching theme without a flash. Screenshots committed. **Screenshots — first-visit (no stored theme) with system pref:** | System dark | System light | | --- | --- | first-visit-prefers-dark first-visit-prefers-light (Without this PR, both screenshots would have rendered dark.) ## Risks Low. Purely additive: - A stored `localStorage` value still takes precedence over the OS preference, so users who have already picked a theme are unaffected. - SSR-safe: both new code paths are gated on `typeof window !== "undefined"` (the inline script only runs in the browser, and the React `matchMedia` listener is attached inside `useEffect`). - No new dependencies, no API surface change. ## Model Used Claude Opus 4.7 (1M context), extended thinking mode. ## Checklist - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] Thinking path traces from project context to this change - [x] Model used specified - [x] Checked ROADMAP.md — not in conflict with planned core work - [x] Tests run locally and pass - [x] No new test cases — the change is observable only via real `matchMedia` events, which jsdom does not faithfully implement; manual verification above - [x] UI change — before/after screenshots in `docs/pr-screenshots/pr-5873/` - [x] No documentation updates required - [x] Documented risks above - [x] Will address all Greptile and reviewer comments before merge Supersedes part of #3732 (the `prefers-color-scheme` bootstrap slice). --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Devin Foley --- ui/index.html | 6 +- ui/src/context/ThemeContext.test.tsx | 179 +++++++++++++++++++++++++++ ui/src/context/ThemeContext.tsx | 32 ++++- 3 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 ui/src/context/ThemeContext.test.tsx 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( () => ({