[eric] theme: default to light + fix light/dark switch flicker

This commit is contained in:
ciregenz
2026-06-27 17:21:16 -07:00
parent 0d6d998cad
commit 0d208265e0
3 changed files with 21 additions and 7 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ class AppSettings(BaseModel):
default_max_turns: Optional[int] = None
default_thinking_level: Literal["off", "low", "medium", "high", "auto"] = "auto"
zoom_sensitivity: float = 50.0
theme: str = "dark"
theme: str = "light"
# Shared across App Builder workspaces (each runs its own vite port / localStorage origin); null = follow system.
app_template_theme_override: Optional[Literal["light", "dark"]] = None
new_agent_shortcut: str = "Meta+l"
+1 -1
View File
@@ -123,7 +123,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
default_max_turns: null,
default_thinking_level: 'auto',
zoom_sensitivity: 50,
theme: 'dark',
theme: 'light',
new_agent_shortcut: 'Meta+l',
anthropic_api_key: null,
browser_homepage: 'https://duckduckgo.com',
+19 -5
View File
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useEffect, useMemo } from 'react';
import React, { createContext, useContext, useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { ClaudeTokens, lightTokens, darkTokens } from './claudeTokens';
type ThemeMode = 'light' | 'dark';
@@ -17,10 +17,20 @@ function getInitialMode(): ThemeMode {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') return stored;
} catch {}
if (window.matchMedia?.('(prefers-color-scheme: dark)').matches) return 'dark';
// Default light regardless of OS; the real choice arrives from settings a beat later, and matching that default here keeps the pre-load frame from flashing to the opposite theme.
return 'light';
}
// A theme swap repaints every element, and the app's hover transitions (transition: background-color / all) would each crossfade the new color at their own duration: that staggered animation IS the flicker. Kill all transitions for the single swap frame so the change is one instant cut; hovers animate again next tick.
function p_suppressTransitionsForSwap(): () => void {
const killer = document.createElement('style');
killer.appendChild(document.createTextNode('*,*::before,*::after{transition:none!important}'));
document.head.appendChild(killer);
void document.body.offsetHeight; // force a synchronous restyle so the rule applies to this frame
const id = window.setTimeout(() => killer.remove(), 0);
return () => { window.clearTimeout(id); killer.remove(); };
}
const ThemeContext = createContext<ThemeContextValue>({
mode: 'light',
tokens: lightTokens,
@@ -30,17 +40,21 @@ const ThemeContext = createContext<ThemeContextValue>({
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [mode, setModeState] = useState<ThemeMode>(getInitialMode);
const firstMount = useRef(true);
useEffect(() => {
try { localStorage.setItem(STORAGE_KEY, mode); } catch {}
if (firstMount.current) { firstMount.current = false; return; }
return p_suppressTransitionsForSwap();
}, [mode]);
const tokens = useMemo(() => (mode === 'dark' ? darkTokens : lightTokens), [mode]);
const toggleMode = () => setModeState((m) => (m === 'light' ? 'dark' : 'light'));
const setMode = (m: ThemeMode) => setModeState(m);
// Stable identities: SettingsLoader's "apply settings.theme" effect lists setMode in its deps, so a setter that changed every render made that effect re-fire on each toggle and re-assert the OLD persisted theme until the debounced save caught up: live theme snapped back for ~900ms = the switch flicker.
const toggleMode = useCallback(() => setModeState((m) => (m === 'light' ? 'dark' : 'light')), []);
const setMode = useCallback((m: ThemeMode) => setModeState(m), []);
const value = useMemo(() => ({ mode, tokens, toggleMode, setMode }), [mode, tokens]);
const value = useMemo(() => ({ mode, tokens, toggleMode, setMode }), [mode, tokens, toggleMode, setMode]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
};