637 lines · 19.9 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";import {  applyThemePalette,  CUSTOM_THEMES_STORAGE_KEY,  invalidateCustomThemes,  canonicalThemePreference,  isKnownThemePreference,  getThemePreferenceMode,  parseThemeHalves,  resolveDesktopTheme,  resolveThemeAppearance,  resolveThemeHalf,  THEME_APPEARANCE_MODE_STORAGE_KEY,  THEME_FOLLOW_SYSTEM_STORAGE_KEY,  THEME_HALVES_STORAGE_KEY,  ThemePreference,  type ThemeAppearance,  type ThemeHalves,  type ThemePreferenceMode,} from "../themePalette"; type Theme = ThemePreference;type ThemeSnapshot = {  theme: Theme;  systemDark: boolean;  followSystem: boolean;  appearanceMode: ThemePreferenceMode;  themeHalves: ThemeHalves | null;}; 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,  followSystem: true,  appearanceMode: "system",  themeHalves: null,}; /** Live read of the stored appearance mix, for callers that must not rely on * a render-time snapshot (for example rollback after an async dialog). */export function readThemeHalves(): ThemeHalves | null {  return readStoredThemeHalves();} function readStoredThemeHalves(): ThemeHalves | null {  if (typeof window === "undefined") return null;  try {    return parseThemeHalves(window.localStorage.getItem(THEME_HALVES_STORAGE_KEY));  } catch {    return null;  }} function themeHalvesSignature(halves: ThemeHalves | null): string {  return `${halves?.light ?? ""}|${halves?.dark ?? ""}`;}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 snapshotStale = true;let lastDesktopTheme: "light" | "dark" | "system" | null = null;let lastAppliedTheme: ThemeSnapshot | null = null;let themeStorageReadFailure: ThemeStorageError | null = null; function emitChange() {  snapshotStale = true;  for (const listener of listeners) listener();} function getSystemDark() {  return (    typeof window !== "undefined" &&    typeof window.matchMedia === "function" &&    window.matchMedia(MEDIA_QUERY).matches  );} function readStoredFollowSystem(theme: Theme): boolean {  if (typeof window === "undefined") return theme === "system";   try {    const raw = window.localStorage.getItem(THEME_FOLLOW_SYSTEM_STORAGE_KEY);    if (raw === "true") return true;    if (raw === "false") return false;  } catch {    // Fall back to the legacy theme value when the separate preference is unavailable.  }   return theme === "system";} function isThemePreferenceMode(value: string | null): value is ThemePreferenceMode {  return value === "light" || value === "dark" || value === "system";} export function readAppearanceModePreference(theme: Theme): ThemePreferenceMode {  if (typeof window !== "undefined") {    try {      const raw = window.localStorage.getItem(THEME_APPEARANCE_MODE_STORAGE_KEY);      if (isThemePreferenceMode(raw)) return raw;    } catch {      // Fall back to the legacy preference below when storage is unavailable.    }  }   if (readStoredFollowSystem(theme)) return "system";  return getThemePreferenceMode(theme) ?? "light";} function writeAppearanceModePreference(appearanceMode: ThemePreferenceMode): void {  if (typeof window === "undefined") return;  try {    // The legacy follow-system flag is read-only migration input now; the    // mode key is the single source of truth.    window.localStorage.setItem(THEME_APPEARANCE_MODE_STORAGE_KEY, appearanceMode);  } catch (cause) {    throw new ThemeStorageError({      operation: "write",      storageKey: THEME_APPEARANCE_MODE_STORAGE_KEY,      cause,    });  }} 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 !== null && isKnownThemePreference(raw)) {    return canonicalThemePreference(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 rootStyles = getComputedStyle(document.documentElement);  const themeChromeColor = document.documentElement.dataset.themeId    ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background"))    : null;  const surfaceColor = normalizeThemeColor(    getComputedStyle(resolveBrowserChromeSurface()).backgroundColor,  );  const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor);  const backgroundColor = themeChromeColor ?? surfaceColor ?? fallbackColor;  if (!backgroundColor) return;   document.documentElement.style.backgroundColor = backgroundColor;  document.body.style.backgroundColor = backgroundColor;  // Update every theme-color meta so any element another layer added (for  // example a media-scoped one) carries the resolved color too.  const themeColorMetas = document.querySelectorAll<HTMLMetaElement>(    `meta[name="${THEME_COLOR_META_NAME}"]`,  );  if (themeColorMetas.length === 0) {    ensureThemeColorMetaTag().setAttribute("content", backgroundColor);    return;  }  for (const element of themeColorMetas) {    element.setAttribute("content", backgroundColor);  }} function applyTheme(theme: Theme, suppressTransitions = false) {  if (typeof document === "undefined" || typeof window === "undefined") return;  const appearanceMode = readAppearanceModePreference(theme);  const followSystem = appearanceMode === "system";  const systemDark = followSystem ? getSystemDark() : false;  const themeHalves = readStoredThemeHalves();  if (    lastAppliedTheme?.theme === theme &&    lastAppliedTheme.systemDark === systemDark &&    lastAppliedTheme.followSystem === followSystem &&    lastAppliedTheme.appearanceMode === appearanceMode &&    themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves)  ) {    syncDesktopTheme(theme, followSystem, appearanceMode);    return;  }   if (suppressTransitions) {    document.documentElement.classList.add("no-transitions");  }  const resolvedAppearance = resolveThemeAppearance(    theme,    systemDark,    followSystem,    appearanceMode,    themeHalves,  );  applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance);  const isDark = resolvedAppearance === "dark";  document.documentElement.classList.toggle("dark", isDark);  lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves };  syncBrowserChromeTheme();  syncDesktopTheme(theme, followSystem, appearanceMode);  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,  followSystem?: boolean,  appearanceMode?: ThemePreferenceMode,  halves: ThemeHalves | null = readStoredThemeHalves(),): Promise<void> {  try {    await bridge.setTheme(resolveDesktopTheme(theme, followSystem, appearanceMode, halves));  } catch (cause) {    throw new DesktopThemeSyncError({ theme, cause });  }} export function syncDesktopTheme(  theme: Theme,  followSystem?: boolean,  appearanceMode?: ThemePreferenceMode,) {  if (typeof window === "undefined") return;  const bridge = window.desktopBridge;  const halves = readStoredThemeHalves();  const desktopTheme = resolveDesktopTheme(theme, followSystem, appearanceMode, halves);  if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === desktopTheme) {    return;  }   lastDesktopTheme = desktopTheme;  void syncDesktopThemePreference(bridge, theme, followSystem, appearanceMode, halves).catch(    (cause: unknown) => {      const error = isDesktopThemeSyncError(cause)        ? cause        : new DesktopThemeSyncError({ theme, cause });      console.error(error.message, {        theme: error.theme,        ...safeErrorLogAttributes(error),      });      if (lastDesktopTheme === desktopTheme) {        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;  // Reading the preference hits localStorage, so only recompute after a  // change was signalled; useTheme consumers call this on every render.  if (!snapshotStale && lastSnapshot) return lastSnapshot;  snapshotStale = false;  const theme = getStored();  const appearanceMode = readAppearanceModePreference(theme);  const followSystem = appearanceMode === "system";  const systemDark = followSystem ? getSystemDark() : false;  const themeHalves = readStoredThemeHalves();   if (    lastSnapshot &&    lastSnapshot.theme === theme &&    lastSnapshot.systemDark === systemDark &&    lastSnapshot.followSystem === followSystem &&    lastSnapshot.appearanceMode === appearanceMode &&    themeHalvesSignature(lastSnapshot.themeHalves) === themeHalvesSignature(themeHalves)  ) {    return lastSnapshot;  }   lastSnapshot = { theme, systemDark, followSystem, appearanceMode, themeHalves };  return lastSnapshot;} function getServerSnapshot() {  return DEFAULT_THEME_SNAPSHOT;} function handleSystemAppearanceChange() {  const storedTheme = getStored();  if (readAppearanceModePreference(storedTheme) === "system") applyTheme(storedTheme, true);  emitChange();} function handleStorageChange(e: StorageEvent) {  if (e.key === STORAGE_KEY) {    themeStorageReadFailure = null;    applyTheme(getStored(), true);    emitChange();  } else if (e.key === THEME_FOLLOW_SYSTEM_STORAGE_KEY) {    applyTheme(getStored(), true);    emitChange();  } else if (e.key === THEME_APPEARANCE_MODE_STORAGE_KEY || e.key === THEME_HALVES_STORAGE_KEY) {    applyTheme(getStored(), true);    emitChange();  } else if (e.key === CUSTOM_THEMES_STORAGE_KEY || e.key === null) {    if (e.key === null) themeStorageReadFailure = null;    invalidateCustomThemes();    lastAppliedTheme = null;    applyTheme(getStored(), true);    emitChange();  }} let removeWindowListeners: (() => void) | null = null; function subscribe(listener: () => void): () => void {  if (typeof window === "undefined") return () => {};  listeners.push(listener);   // The system-preference and cross-tab listeners are shared by all  // subscribers; each event applies the theme once and notifies everyone.  if (!removeWindowListeners) {    const mq = typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY) : null;    mq?.addEventListener("change", handleSystemAppearanceChange);    window.addEventListener("storage", handleStorageChange);    removeWindowListeners = () => {      mq?.removeEventListener("change", handleSystemAppearanceChange);      window.removeEventListener("storage", handleStorageChange);    };  }   return () => {    listeners = listeners.filter((l) => l !== listener);    if (listeners.length === 0) {      removeWindowListeners?.();      removeWindowListeners = null;    }  };} export function useTheme() {  const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);  const theme = snapshot.theme;   const resolvedTheme: "light" | "dark" = resolveThemeAppearance(    theme,    snapshot.systemDark,    snapshot.followSystem,    snapshot.appearanceMode,    snapshot.themeHalves,  );   const setTheme = useCallback((next: Theme): boolean => {    if (typeof window === "undefined") return false;    try {      // Preserve the current mode before replacing a legacy or inferred theme      // preference. Otherwise a fresh System preference is re-inferred from      // the new theme's base appearance, which can switch a dark UI to light.      writeAppearanceModePreference(readAppearanceModePreference(getStored()));      // Choosing a whole theme replaces any automatic-mode mix. The mix is      // captured first so a failed preference write can put it back instead      // of erasing it or leaving it attached to the new theme.      const previousHalvesRaw = window.localStorage.getItem(THEME_HALVES_STORAGE_KEY);      window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY);      try {        writeThemePreference(next);      } catch (cause) {        if (previousHalvesRaw !== null) {          try {            window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, previousHalvesRaw);          } catch {            // Storage is failing wholesale; the outer handler reports it.          }        }        throw cause;      }    } catch (cause) {      const error = isThemeStorageError(cause)        ? cause        : new ThemeStorageError({

Lines 1–500 of 637.