From 0d208265e055400cb0f278bdf5925dc7a79c635b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 26 Jun 2026 14:45:16 -0700 Subject: [PATCH] [eric] theme: default to light + fix light/dark switch flicker --- backend/apps/settings/models.py | 2 +- frontend/src/shared/state/settingsSlice.ts | 2 +- frontend/src/shared/styles/ThemeContext.tsx | 24 ++++++++++++++++----- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 2c5be251..8714cc53 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -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" diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index ea12d876..c9a8fec5 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -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', diff --git a/frontend/src/shared/styles/ThemeContext.tsx b/frontend/src/shared/styles/ThemeContext.tsx index 321d84cc..94ce30cc 100644 --- a/frontend/src/shared/styles/ThemeContext.tsx +++ b/frontend/src/shared/styles/ThemeContext.tsx @@ -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({ mode: 'light', tokens: lightTokens, @@ -30,17 +40,21 @@ const ThemeContext = createContext({ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [mode, setModeState] = useState(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 {children}; };