"use client"; import { useCallback, useSyncExternalStore } from "react"; export type ThemePreference = "light" | "dark" | "auto"; export type ResolvedTheme = "light" | "dark"; type ThemeState = { preference: ThemePreference; theme: ResolvedTheme; }; type ToggleOrigin = { x: number; y: number }; const STORAGE_KEY = "pi-theme"; const PREFERENCE_CYCLE: ThemePreference[] = ["light", "dark", "auto"]; const SERVER_SNAPSHOT: ThemeState = { preference: "auto", theme: "light" }; const listeners = new Set<() => void>(); let state: ThemeState | null = null; let systemListening = false; function emit(): void { listeners.forEach((cb) => cb()); } function getSystemTheme(): ResolvedTheme { if (typeof window === "undefined") return "light"; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } function readStoredPreference(): ThemePreference { try { const value = localStorage.getItem(STORAGE_KEY); if (value === "light" || value === "dark" || value === "auto") return value; } catch { // ignore storage errors (private mode, quota, etc.) } return "auto"; } function resolveTheme(preference: ThemePreference): ResolvedTheme { return preference === "auto" ? getSystemTheme() : preference; } function applyDomTheme(theme: ResolvedTheme): void { if (typeof document === "undefined") return; document.documentElement.classList.toggle("dark", theme === "dark"); } function ensureState(): ThemeState { if (typeof window === "undefined") return SERVER_SNAPSHOT; if (state) return state; const preference = readStoredPreference(); const theme = resolveTheme(preference); applyDomTheme(theme); state = { preference, theme }; return state; } function setThemeState(preference: ThemePreference, theme: ResolvedTheme, persist: boolean): void { applyDomTheme(theme); if (persist) { try { localStorage.setItem(STORAGE_KEY, preference); } catch { // ignore storage errors (private mode, quota, etc.) } } state = { preference, theme }; emit(); } function syncAutoThemeFromSystem(): void { const current = ensureState(); if (current.preference !== "auto") return; const theme = getSystemTheme(); if (theme === current.theme) return; setThemeState("auto", theme, false); } function ensureSystemListener(): void { if (systemListening || typeof window === "undefined" || !window.matchMedia) return; const mql = window.matchMedia("(prefers-color-scheme: dark)"); mql.addEventListener("change", syncAutoThemeFromSystem); // Some browsers delay or miss scheme events while backgrounded. window.addEventListener("focus", syncAutoThemeFromSystem); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") syncAutoThemeFromSystem(); }); systemListening = true; } function subscribe(cb: () => void): () => void { listeners.add(cb); ensureState(); ensureSystemListener(); syncAutoThemeFromSystem(); return () => { listeners.delete(cb); }; } function getSnapshot(): ThemeState { return ensureState(); } function getServerSnapshot(): ThemeState { return SERVER_SNAPSHOT; } function nextPreference(preference: ThemePreference): ThemePreference { const index = PREFERENCE_CYCLE.indexOf(preference); return PREFERENCE_CYCLE[(index + 1) % PREFERENCE_CYCLE.length]; } export function useTheme() { const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); const toggleTheme = useCallback((origin?: ToggleOrigin) => { const current = ensureState(); const nextPref = nextPreference(current.preference); const nextTheme = resolveTheme(nextPref); const apply = () => { setThemeState(nextPref, nextTheme, true); }; const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; const supportsVT = typeof document.startViewTransition === "function"; if (!supportsVT || reduceMotion) { apply(); return; } const x = origin?.x ?? window.innerWidth / 2; const y = origin?.y ?? window.innerHeight / 2; const endRadius = Math.hypot( Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y), ); const transition = document.startViewTransition(apply); transition.ready .then(() => { document.documentElement.animate( { clipPath: [ `circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`, ], }, { duration: 450, easing: "cubic-bezier(0.22, 0.61, 0.36, 1)", pseudoElement: "::view-transition-new(root)", }, ); }) .catch(() => { // transition cancelled — ignore }); }, []); return { theme: snapshot.theme, preference: snapshot.preference, toggleTheme, isDark: snapshot.theme === "dark", }; }