import { desktopBridge } from "./desktop"; import { createContext, useCallback, useMemo, useContext, useEffect, useState, type ReactNode, } from "react"; export type Appearance = "light" | "dark" | "system"; export const APPEARANCE_KEY = "opengeni.appearance"; export const SYSTEM_DARK_QUERY = "(prefers-color-scheme: dark)"; export function parseAppearance(value: string | null): Appearance { return value === "light" || value === "dark" ? value : "system"; } function readAppearance(): Appearance { try { return parseAppearance(window.localStorage.getItem(APPEARANCE_KEY)); } catch { return "system"; } } const AppearanceContext = createContext<{ appearance: Appearance; resolvedTheme: "light" | "dark"; setAppearance: (value: Appearance) => void; }>({ appearance: "system", resolvedTheme: "dark", setAppearance: () => {} }); export function AppearanceProvider({ children, hostTheme, scope = "document", }: { children: ReactNode; /** * A theme the host application dictates (Excel's Office theme, for example). * It wins over the stored and system appearance. */ hostTheme?: "light" | "dark" | undefined; /** * `document` (the web app): the theme goes on . `local` (an embed in * someone else's page): the page is left alone and the embed's own root * applies `resolvedTheme` (see `SessionApp`). */ scope?: "document" | "local" | undefined; }) { const [appearance, updateAppearance] = useState(readAppearance); // Server rendering has no window; the effect below reads the real value. const [systemDark, setSystemDark] = useState( () => typeof window !== "undefined" && window.matchMedia(SYSTEM_DARK_QUERY).matches, ); const resolvedTheme = hostTheme ?? (appearance === "system" ? (systemDark ? "dark" : "light") : appearance); useEffect(() => { const media = window.matchMedia(SYSTEM_DARK_QUERY); const onChange = () => setSystemDark(media.matches); const onStorage = (event: StorageEvent) => { if (event.key === APPEARANCE_KEY || event.key === null) updateAppearance(readAppearance()); }; onChange(); media.addEventListener("change", onChange); window.addEventListener("storage", onStorage); return () => { media.removeEventListener("change", onChange); window.removeEventListener("storage", onStorage); }; }, []); useEffect(() => { if (scope === "local") return; const root = document.documentElement; const changed = root.dataset.ogTheme !== undefined && root.dataset.ogTheme !== resolvedTheme; // Flip the whole palette at once: suspend color transitions for the frame // that swaps the tokens, so controls never pass through low-contrast mixes. if (changed) root.dataset.ogThemeSwitching = ""; root.dataset.ogTheme = resolvedTheme; root.classList.toggle("dark", resolvedTheme === "dark"); if (!changed) return; let second = 0; const first = window.requestAnimationFrame(() => { second = window.requestAnimationFrame(() => delete root.dataset.ogThemeSwitching); }); return () => { window.cancelAnimationFrame(first); window.cancelAnimationFrame(second); delete root.dataset.ogThemeSwitching; }; }, [resolvedTheme, scope]); // The desktop app follows, so window materials and controls match the page. useEffect(() => { desktopBridge?.setAppearance(appearance); }, [appearance]); const setAppearance = useCallback((value: Appearance) => { updateAppearance(value); try { window.localStorage.setItem(APPEARANCE_KEY, value); } catch { // A blocked storage area must not prevent changing this tab's appearance. } }, []); const value = useMemo( () => ({ appearance, resolvedTheme, setAppearance }), [appearance, resolvedTheme, setAppearance], ); return {children}; } export function useAppearance() { return useContext(AppearanceContext); }