324 lines · 9.4 KB · TypeScript
RawDownload
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<DesktopBridge, "setTheme">; 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>()(  "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>()(  "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<HTMLMetaElement>(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<HTMLElement>("main[data-slot='sidebar-inset']") ??    document.querySelector<HTMLElement>("[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<void> {  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 flashif (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;}