mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
## 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>
114 lines
3.4 KiB
TypeScript
114 lines
3.4 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
|
|
type Theme = "light" | "dark";
|
|
|
|
interface ThemeContextValue {
|
|
theme: Theme;
|
|
setTheme: (theme: Theme) => void;
|
|
toggleTheme: () => void;
|
|
}
|
|
|
|
const THEME_STORAGE_KEY = "paperclip.theme";
|
|
const DARK_THEME_COLOR = "#18181b";
|
|
const LIGHT_THEME_COLOR = "#ffffff";
|
|
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
|
|
|
function resolveThemeFromDocument(): Theme {
|
|
if (typeof document === "undefined") return "dark";
|
|
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";
|
|
const root = document.documentElement;
|
|
root.classList.toggle("dark", isDark);
|
|
root.style.colorScheme = isDark ? "dark" : "light";
|
|
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
|
if (themeColorMeta instanceof HTMLMetaElement) {
|
|
themeColorMeta.setAttribute("content", isDark ? DARK_THEME_COLOR : LIGHT_THEME_COLOR);
|
|
}
|
|
}
|
|
|
|
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, 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(
|
|
() => ({
|
|
theme,
|
|
setTheme,
|
|
toggleTheme,
|
|
}),
|
|
[theme, setTheme, toggleTheme],
|
|
);
|
|
|
|
return (
|
|
<ThemeContext.Provider value={value}>
|
|
{children}
|
|
</ThemeContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useTheme() {
|
|
const context = useContext(ThemeContext);
|
|
if (!context) {
|
|
throw new Error("useTheme must be used within ThemeProvider");
|
|
}
|
|
return context;
|
|
}
|