"use client"; import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; type Theme = "light" | "dark" | "system"; interface ThemeContextValue { theme: Theme; resolvedTheme: "light" | "dark"; setTheme: (theme: Theme) => void; toggleTheme: () => void; } const ThemeContext = createContext(null); const STORAGE_KEY = "mosaic-theme"; function getSystemTheme(): "light" | "dark" { if (typeof window === "undefined") return "dark"; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } function getStoredTheme(): Theme { if (typeof window === "undefined") return "system"; const stored = localStorage.getItem(STORAGE_KEY); if (stored === "light" || stored === "dark" || stored === "system") { return stored; } return "system"; } /** * Apply the resolved theme to the element via data-theme attribute. * The default (no attribute or data-theme="dark") renders dark — dark is default. * Light theme requires data-theme="light". */ function applyThemeAttribute(resolved: "light" | "dark"): void { const root = document.documentElement; if (resolved === "light") { root.setAttribute("data-theme", "light"); } else { // Remove the attribute so the default (dark) CSS variables apply. root.removeAttribute("data-theme"); } } interface ThemeProviderProps { children: ReactNode; defaultTheme?: Theme; } export function ThemeProvider({ children, defaultTheme = "system", }: ThemeProviderProps): React.JSX.Element { const [theme, setThemeState] = useState(defaultTheme); const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("dark"); const [mounted, setMounted] = useState(false); // Initialize theme from storage on mount useEffect(() => { setMounted(true); const storedTheme = getStoredTheme(); const resolved = storedTheme === "system" ? getSystemTheme() : storedTheme; setThemeState(storedTheme); setResolvedTheme(resolved); applyThemeAttribute(resolved); }, []); // Apply theme via data-theme attribute on html element useEffect(() => { if (!mounted) return; const resolved = theme === "system" ? getSystemTheme() : theme; applyThemeAttribute(resolved); setResolvedTheme(resolved); }, [theme, mounted]); // Listen for system theme changes useEffect(() => { if (!mounted || theme !== "system") return; const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleChange = (e: MediaQueryListEvent): void => { const resolved = e.matches ? "dark" : "light"; setResolvedTheme(resolved); applyThemeAttribute(resolved); }; mediaQuery.addEventListener("change", handleChange); return (): void => { mediaQuery.removeEventListener("change", handleChange); }; }, [theme, mounted]); const setTheme = useCallback((newTheme: Theme) => { setThemeState(newTheme); localStorage.setItem(STORAGE_KEY, newTheme); }, []); const toggleTheme = useCallback(() => { setTheme(resolvedTheme === "dark" ? "light" : "dark"); }, [resolvedTheme, setTheme]); // Prevent flash by not rendering until mounted if (!mounted) { return ( { // No-op during SSR }, toggleTheme: (): void => { // No-op during SSR }, }} > {children} ); } return ( {children} ); } export function useTheme(): ThemeContextValue { const context = useContext(ThemeContext); if (!context) { throw new Error("useTheme must be used within a ThemeProvider"); } return context; }