feat(ui): default to system prefers-color-scheme for first-time visitors (supersedes part of #3732) (#5873)

## 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 |
| --- | --- |
<img width="1280" height="800" alt="first-visit-prefers-dark"
src="https://github.com/user-attachments/assets/c30274d5-a2e0-4ed8-b7ef-b96b8caac5ca"
/>
<img width="1280" height="800" alt="first-visit-prefers-light"
src="https://github.com/user-attachments/assets/ebd8e9cf-0be1-4b36-a6f8-eb38b00dff5c"
/>


(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) <noreply@anthropic.com>
Co-authored-by: Devin Foley <devin@paperclip.ing>
This commit is contained in:
Jannes Stubbemann
2026-06-12 23:03:54 +02:00
committed by GitHub
parent 4c26b984a7
commit cd1b4f275d
3 changed files with 215 additions and 2 deletions
+5 -1
View File
@@ -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";
+179
View File
@@ -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<MediaListener>();
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(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
});
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(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
});
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(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
});
expect(mql.listenerCount()).toBe(0);
act(() => {
mql.dispatch(true);
});
expect(observedTheme).not.toBe("dark");
act(() => {
root.unmount();
});
});
});
+31 -1
View File
@@ -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<Theme>(() => 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<boolean>(() => 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(
() => ({