diff --git a/src/app/globals.css b/src/app/globals.css index 030b798..15d5adb 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -58,3 +58,20 @@ body { color: var(--foreground); font-family: var(--font-sans), Arial, Helvetica, sans-serif; } + +/* Smooth theme transitions without affecting initial page paint. */ +html.theme-transition, +html.theme-transition * { + transition: + background-color 200ms ease, + color 200ms ease, + border-color 200ms ease, + box-shadow 200ms ease; +} + +@media (prefers-reduced-motion: reduce) { + html.theme-transition, + html.theme-transition * { + transition: none !important; + } +} diff --git a/src/components/providers/ThemeProvider.tsx b/src/components/providers/ThemeProvider.tsx index 78d9249..a56ccb7 100644 --- a/src/components/providers/ThemeProvider.tsx +++ b/src/components/providers/ThemeProvider.tsx @@ -2,13 +2,15 @@ import { createContext, + useCallback, useContext, useEffect, useState, - useCallback, } from "react"; -type ThemeMode = "light" | "dark" | "high-contrast"; +export type ThemeMode = "light" | "dark" | "high-contrast"; + +const STORAGE_KEY = "utility-theme"; interface ThemeContextValue { mode: ThemeMode; @@ -17,52 +19,95 @@ interface ThemeContextValue { } const ThemeContext = createContext({ - mode: "dark", + mode: "light", setMode: () => {}, toggle: () => {}, }); +function getBrowserMode(): ThemeMode { + const root = document.documentElement; + + const rootMode = Array.from(root.classList).find( + (value): value is ThemeMode => + value === "light" || + value === "dark" || + value === "high-contrast" + ); + + if (rootMode) { + return rootMode; + } + + const stored = localStorage.getItem(STORAGE_KEY); + + if ( + stored === "light" || + stored === "dark" || + stored === "high-contrast" + ) { + return stored; + } + + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [mode, setModeState] = useState("dark"); - const [mounted, setMounted] = useState(false); + // Keep the server and first client render identical. + const [mode, setModeState] = useState("light"); useEffect(() => { - setMounted(true); - const stored = localStorage.getItem("utility-theme") as ThemeMode | null; - if (stored) { - setModeState(stored); - } else { - const prefersDark = window.matchMedia( - "(prefers-color-scheme: dark)" - ).matches; - setModeState(prefersDark ? "dark" : "light"); - } + const initialMode = getBrowserMode(); + setModeState(initialMode); + }, []); + + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + + const handleSystemThemeChange = (event: MediaQueryListEvent) => { + if (localStorage.getItem(STORAGE_KEY)) { + return; + } + + setModeState(event.matches ? "dark" : "light"); + }; + + mediaQuery.addEventListener("change", handleSystemThemeChange); + + return () => { + mediaQuery.removeEventListener("change", handleSystemThemeChange); + }; }, []); useEffect(() => { - if (!mounted) return; const root = document.documentElement; + + root.classList.add("theme-transition"); root.classList.remove("light", "dark", "high-contrast"); root.classList.add(mode); - localStorage.setItem("utility-theme", mode); - }, [mode, mounted]); + + localStorage.setItem(STORAGE_KEY, mode); + + const timeout = window.setTimeout(() => { + root.classList.remove("theme-transition"); + }, 250); + + return () => window.clearTimeout(timeout); + }, [mode]); const setMode = useCallback((next: ThemeMode) => { setModeState(next); }, []); const toggle = useCallback(() => { - setModeState((prev) => { - if (prev === "light") return "dark"; - if (prev === "dark") return "high-contrast"; + setModeState((previous) => { + if (previous === "light") return "dark"; + if (previous === "dark") return "high-contrast"; return "light"; }); }, []); - if (!mounted) { - return <>{children}; - } - return ( {children} diff --git a/src/components/spatial/Provisioner.tsx b/src/components/spatial/Provisioner.tsx index 0527dbf..dfd9fdd 100644 --- a/src/components/spatial/Provisioner.tsx +++ b/src/components/spatial/Provisioner.tsx @@ -236,7 +236,7 @@ export function Provisioner() { return (

Scan QR Code on Device

-
+
{error ? (