import type { DesktopBridge } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useSyncExternalStore } from "react"; const ThemePreference = Schema.Literals(["light", "dark", "system"]); type Theme = typeof ThemePreference.Type; type ThemeSnapshot = { theme: Theme; systemDark: boolean; }; type DesktopThemeBridge = Pick; const STORAGE_KEY = "t3code:theme"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { theme: "system", systemDark: false, }; const THEME_COLOR_META_NAME = "theme-color"; const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; export class ThemeStorageError extends Schema.TaggedErrorClass()( "ThemeStorageError", { operation: Schema.Literals(["read", "write"]), storageKey: Schema.String, theme: Schema.optional(ThemePreference), cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to ${this.operation} theme preference for ${this.storageKey}.`; } } export const isThemeStorageError = Schema.is(ThemeStorageError); export class DesktopThemeSyncError extends Schema.TaggedErrorClass()( "DesktopThemeSyncError", { theme: ThemePreference, cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to sync the ${this.theme} theme to the desktop shell.`; } } export const isDesktopThemeSyncError = Schema.is(DesktopThemeSyncError); let listeners: Array<() => void> = []; let lastSnapshot: ThemeSnapshot | null = null; let lastDesktopTheme: Theme | null = null; let lastAppliedTheme: ThemeSnapshot | null = null; let themeStorageReadFailure: ThemeStorageError | null = null; function emitChange() { for (const listener of listeners) listener(); } function getSystemDark() { return ( typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(MEDIA_QUERY).matches ); } export function readThemePreference(): Theme { if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme; let raw: string | null; try { raw = window.localStorage.getItem(STORAGE_KEY); } catch (cause) { throw new ThemeStorageError({ operation: "read", storageKey: STORAGE_KEY, cause, }); } if (raw === "light" || raw === "dark" || raw === "system") return raw; return DEFAULT_THEME_SNAPSHOT.theme; } export function writeThemePreference(theme: Theme): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(STORAGE_KEY, theme); themeStorageReadFailure = null; } catch (cause) { throw new ThemeStorageError({ operation: "write", storageKey: STORAGE_KEY, theme, cause, }); } } function getStored(): Theme { if (themeStorageReadFailure !== null) { return DEFAULT_THEME_SNAPSHOT.theme; } try { return readThemePreference(); } catch (cause) { const error = isThemeStorageError(cause) ? cause : new ThemeStorageError({ operation: "read", storageKey: STORAGE_KEY, cause, }); themeStorageReadFailure = error; console.error(error.message, { operation: error.operation, storageKey: error.storageKey, ...safeErrorLogAttributes(error), }); return DEFAULT_THEME_SNAPSHOT.theme; } } function ensureThemeColorMetaTag(): HTMLMetaElement { let element = document.querySelector(DYNAMIC_THEME_COLOR_SELECTOR); if (element) { return element; } element = document.createElement("meta"); element.name = THEME_COLOR_META_NAME; element.setAttribute("data-dynamic-theme-color", "true"); document.head.append(element); return element; } function normalizeThemeColor(value: string | null | undefined): string | null { const normalizedValue = value?.trim().toLowerCase(); if ( !normalizedValue || normalizedValue === "transparent" || normalizedValue === "rgba(0, 0, 0, 0)" || normalizedValue === "rgba(0 0 0 / 0)" ) { return null; } return value?.trim() ?? null; } function resolveBrowserChromeSurface(): HTMLElement { return ( document.querySelector("main[data-slot='sidebar-inset']") ?? document.querySelector("[data-slot='sidebar-inner']") ?? document.body ); } export function syncBrowserChromeTheme() { if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return; const surfaceColor = normalizeThemeColor( getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, ); const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor); const backgroundColor = surfaceColor ?? fallbackColor; if (!backgroundColor) return; document.documentElement.style.backgroundColor = backgroundColor; document.body.style.backgroundColor = backgroundColor; ensureThemeColorMetaTag().setAttribute("content", backgroundColor); } function applyTheme(theme: Theme, suppressTransitions = false) { if (typeof document === "undefined" || typeof window === "undefined") return; const systemDark = theme === "system" ? getSystemDark() : false; if (lastAppliedTheme?.theme === theme && lastAppliedTheme.systemDark === systemDark) { syncDesktopTheme(theme); return; } if (suppressTransitions) { document.documentElement.classList.add("no-transitions"); } const isDark = theme === "dark" || (theme === "system" && systemDark); document.documentElement.classList.toggle("dark", isDark); lastAppliedTheme = { theme, systemDark }; syncBrowserChromeTheme(); syncDesktopTheme(theme); if (suppressTransitions) { // Force a reflow so the no-transitions class takes effect before removal // oxlint-disable-next-line no-unused-expressions document.documentElement.offsetHeight; requestAnimationFrame(() => { document.documentElement.classList.remove("no-transitions"); }); } } export async function syncDesktopThemePreference( bridge: DesktopThemeBridge, theme: Theme, ): Promise { try { await bridge.setTheme(theme); } catch (cause) { throw new DesktopThemeSyncError({ theme, cause }); } } export function syncDesktopTheme(theme: Theme) { if (typeof window === "undefined") return; const bridge = window.desktopBridge; if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) { return; } lastDesktopTheme = theme; void syncDesktopThemePreference(bridge, theme).catch((cause: unknown) => { const error = isDesktopThemeSyncError(cause) ? cause : new DesktopThemeSyncError({ theme, cause }); console.error(error.message, { theme: error.theme, ...safeErrorLogAttributes(error), }); if (lastDesktopTheme === theme) { lastDesktopTheme = null; } }); } // Apply immediately on module load to prevent flash if (typeof document !== "undefined" && typeof window !== "undefined") { applyTheme(getStored()); } function getSnapshot(): ThemeSnapshot { if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT; const theme = getStored(); const systemDark = theme === "system" ? getSystemDark() : false; if (lastSnapshot && lastSnapshot.theme === theme && lastSnapshot.systemDark === systemDark) { return lastSnapshot; } lastSnapshot = { theme, systemDark }; return lastSnapshot; } function getServerSnapshot() { return DEFAULT_THEME_SNAPSHOT; } function subscribe(listener: () => void): () => void { if (typeof window === "undefined") return () => {}; listeners.push(listener); // Listen for system preference changes const mq = typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY) : null; const handleChange = () => { if (getStored() === "system") applyTheme("system", true); emitChange(); }; mq?.addEventListener("change", handleChange); // Listen for storage changes from other tabs const handleStorage = (e: StorageEvent) => { if (e.key === STORAGE_KEY) { themeStorageReadFailure = null; applyTheme(getStored(), true); emitChange(); } }; window.addEventListener("storage", handleStorage); return () => { listeners = listeners.filter((l) => l !== listener); mq?.removeEventListener("change", handleChange); window.removeEventListener("storage", handleStorage); }; } export function useTheme() { const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); const theme = snapshot.theme; const resolvedTheme: "light" | "dark" = theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; const setTheme = useCallback((next: Theme) => { if (typeof window === "undefined") return; try { writeThemePreference(next); } catch (cause) { const error = isThemeStorageError(cause) ? cause : new ThemeStorageError({ operation: "write", storageKey: STORAGE_KEY, theme: next, cause, }); console.error(error.message, { operation: error.operation, storageKey: error.storageKey, theme: next, ...safeErrorLogAttributes(error), }); return; } applyTheme(next, true); emitChange(); }, []); // Keep DOM in sync on mount/change useEffect(() => { applyTheme(theme); }, [theme]); return { theme, setTheme, resolvedTheme } as const; }