| 1 | import type { DesktopBridge } from "@t3tools/contracts"; |
| 2 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 3 | import * as Schema from "effect/Schema"; |
| 4 | import { useCallback, useEffect, useSyncExternalStore } from "react"; |
| 5 | import { |
| 6 | applyThemePalette, |
| 7 | CUSTOM_THEMES_STORAGE_KEY, |
| 8 | invalidateCustomThemes, |
| 9 | canonicalThemePreference, |
| 10 | isKnownThemePreference, |
| 11 | getThemePreferenceMode, |
| 12 | parseThemeHalves, |
| 13 | resolveDesktopTheme, |
| 14 | resolveThemeAppearance, |
| 15 | resolveThemeHalf, |
| 16 | THEME_PREVIEW_ID, |
| 17 | THEME_APPEARANCE_MODE_STORAGE_KEY, |
| 18 | THEME_FOLLOW_SYSTEM_STORAGE_KEY, |
| 19 | THEME_HALVES_STORAGE_KEY, |
| 20 | ThemePreference, |
| 21 | type ThemeAppearance, |
| 22 | type ThemeHalves, |
| 23 | type ThemePreferenceMode, |
| 24 | getCustomThemePreferences, |
| 25 | replaceCustomThemePreferences, |
| 26 | } from "../themePalette"; |
| 27 | |
| 28 | import type { UiThemePreferences } from "@t3tools/contracts"; |
| 29 | |
| 30 | type Theme = ThemePreference; |
| 31 | const themeEditListeners = new Set<() => void>(); |
| 32 | export function subscribeThemeEdits(listener: () => void) { |
| 33 | themeEditListeners.add(listener); |
| 34 | return () => { |
| 35 | themeEditListeners.delete(listener); |
| 36 | }; |
| 37 | } |
| 38 | function emitThemeEdit() { |
| 39 | for (const listener of themeEditListeners) listener(); |
| 40 | } |
| 41 | |
| 42 | export function readUiThemePreferences(): UiThemePreferences { |
| 43 | const selection = readThemePreference(); |
| 44 | const halves = readThemeHalvesRaw(); |
| 45 | const themeIds = [selection, ...Object.values(halves)]; |
| 46 | return { |
| 47 | selection, |
| 48 | mode: readAppearanceModePreference(selection), |
| 49 | halves, |
| 50 | customThemes: getCustomThemePreferences(themeIds), |
| 51 | }; |
| 52 | } |
| 53 | |
| 54 | export function applyUiThemePreferences(preferences: UiThemePreferences) { |
| 55 | replaceCustomThemePreferences(preferences.customThemes); |
| 56 | window.localStorage.setItem(STORAGE_KEY, preferences.selection); |
| 57 | writeAppearanceModePreference(preferences.mode); |
| 58 | window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, JSON.stringify(preferences.halves)); |
| 59 | themeStorageReadFailure = null; |
| 60 | lastAppliedTheme = null; |
| 61 | applyTheme(preferences.selection, { suppressTransitions: true }); |
| 62 | emitChange(); |
| 63 | } |
| 64 | |
| 65 | type ThemeSnapshot = { |
| 66 | theme: Theme; |
| 67 | resolvedTheme: ThemeAppearance; |
| 68 | systemDark: boolean; |
| 69 | followSystem: boolean; |
| 70 | appearanceMode: ThemePreferenceMode; |
| 71 | themeHalves: ThemeHalves | null; |
| 72 | }; |
| 73 | |
| 74 | type DesktopThemeBridge = Pick<DesktopBridge, "setTheme">; |
| 75 | |
| 76 | const STORAGE_KEY = "t3code:theme"; |
| 77 | const MEDIA_QUERY = "(prefers-color-scheme: dark)"; |
| 78 | const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { |
| 79 | theme: "system", |
| 80 | resolvedTheme: "light", |
| 81 | systemDark: false, |
| 82 | followSystem: true, |
| 83 | appearanceMode: "system", |
| 84 | themeHalves: null, |
| 85 | }; |
| 86 | |
| 87 | /** Live read of the stored appearance mix, for callers that must not rely on |
| 88 | * a render-time snapshot (for example rollback after an async dialog). */ |
| 89 | export function readThemeHalves(): ThemeHalves | null { |
| 90 | return readStoredThemeHalves(); |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * The stored mix as written, without resolvability pruning. Flows that |
| 95 | * rebuild the whole mix must capture this before a `setTheme` clears it: a |
| 96 | * published id resolves only once its set has streamed in, and treating "not |
| 97 | * resolvable yet" as "absent" silently rewrites that half. |
| 98 | */ |
| 99 | export function readThemeHalvesRaw(): { light?: string; dark?: string } { |
| 100 | if (typeof window === "undefined") return {}; |
| 101 | return readStoredThemeHalvesRaw(); |
| 102 | } |
| 103 | |
| 104 | function readStoredThemeHalves(): ThemeHalves | null { |
| 105 | if (typeof window === "undefined") return null; |
| 106 | try { |
| 107 | return parseThemeHalves(window.localStorage.getItem(THEME_HALVES_STORAGE_KEY)); |
| 108 | } catch { |
| 109 | return null; |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * The stored mix as written, without resolvability pruning. An environment |
| 115 | * published id resolves only once its set has streamed in, so a write that |
| 116 | * merged over the pruned parse would silently erase that half whenever the |
| 117 | * other one changed before the set arrived. |
| 118 | */ |
| 119 | function readStoredThemeHalvesRaw(): { light?: string; dark?: string } { |
| 120 | try { |
| 121 | const value: unknown = JSON.parse( |
| 122 | window.localStorage.getItem(THEME_HALVES_STORAGE_KEY) ?? "null", |
| 123 | ); |
| 124 | if (value === null || typeof value !== "object") return {}; |
| 125 | const halves: { light?: string; dark?: string } = {}; |
| 126 | for (const appearance of ["light", "dark"] as const) { |
| 127 | const themeId = (value as Record<string, unknown>)[appearance]; |
| 128 | if (typeof themeId === "string") halves[appearance] = themeId; |
| 129 | } |
| 130 | return halves; |
| 131 | } catch { |
| 132 | return {}; |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | function themeHalvesSignature(halves: ThemeHalves | null): string { |
| 137 | return `${halves?.light ?? ""}|${halves?.dark ?? ""}`; |
| 138 | } |
| 139 | |
| 140 | const THEME_COLOR_META_NAME = "theme-color"; |
| 141 | const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; |
| 142 | |
| 143 | export class ThemeStorageError extends Schema.TaggedError<ThemeStorageError>()( |
| 144 | "ThemeStorageError", |
| 145 | { |
| 146 | operation: Schema.Literals(["read", "write"]), |
| 147 | storageKey: Schema.String, |
| 148 | theme: Schema.optional(ThemePreference), |
| 149 | cause: Schema.Defect(), |
| 150 | }, |
| 151 | ) { |
| 152 | override get message(): string { |
| 153 | return `Failed to ${this.operation} theme preference for ${this.storageKey}.`; |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | export const isThemeStorageError = Schema.is(ThemeStorageError); |
| 158 | |
| 159 | export class DesktopThemeSyncError extends Schema.TaggedError<DesktopThemeSyncError>()( |
| 160 | "DesktopThemeSyncError", |
| 161 | { |
| 162 | theme: ThemePreference, |
| 163 | cause: Schema.Defect(), |
| 164 | }, |
| 165 | ) { |
| 166 | override get message(): string { |
| 167 | return `Failed to sync the ${this.theme} theme to the desktop shell.`; |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | export const isDesktopThemeSyncError = Schema.is(DesktopThemeSyncError); |
| 172 | |
| 173 | let listeners: Array<() => void> = []; |
| 174 | let lastSnapshot: ThemeSnapshot | null = null; |
| 175 | let snapshotStale = true; |
| 176 | let lastDesktopTheme: "light" | "dark" | "system" | null = null; |
| 177 | let lastAppliedTheme: Omit<ThemeSnapshot, "resolvedTheme"> | null = null; |
| 178 | let themeStorageReadFailure: ThemeStorageError | null = null; |
| 179 | |
| 180 | function emitChange() { |
| 181 | snapshotStale = true; |
| 182 | for (const listener of listeners) listener(); |
| 183 | } |
| 184 | |
| 185 | function getSystemDark() { |
| 186 | return ( |
| 187 | typeof window !== "undefined" && |
| 188 | typeof window.matchMedia === "function" && |
| 189 | window.matchMedia(MEDIA_QUERY).matches |
| 190 | ); |
| 191 | } |
| 192 | |
| 193 | function readStoredFollowSystem(theme: Theme): boolean { |
| 194 | if (typeof window === "undefined") return theme === "system"; |
| 195 | |
| 196 | try { |
| 197 | const raw = window.localStorage.getItem(THEME_FOLLOW_SYSTEM_STORAGE_KEY); |
| 198 | if (raw === "true") return true; |
| 199 | if (raw === "false") return false; |
| 200 | } catch { |
| 201 | // Fall back to the legacy theme value when the separate preference is unavailable. |
| 202 | } |
| 203 | |
| 204 | return theme === "system"; |
| 205 | } |
| 206 | |
| 207 | function isThemePreferenceMode(value: string | null): value is ThemePreferenceMode { |
| 208 | return value === "light" || value === "dark" || value === "system"; |
| 209 | } |
| 210 | |
| 211 | export function readAppearanceModePreference(theme: Theme): ThemePreferenceMode { |
| 212 | if (typeof window !== "undefined") { |
| 213 | try { |
| 214 | const raw = window.localStorage.getItem(THEME_APPEARANCE_MODE_STORAGE_KEY); |
| 215 | if (isThemePreferenceMode(raw)) return raw; |
| 216 | } catch { |
| 217 | // Fall back to the legacy preference below when storage is unavailable. |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | if (readStoredFollowSystem(theme)) return "system"; |
| 222 | return getThemePreferenceMode(theme) ?? "light"; |
| 223 | } |
| 224 | |
| 225 | function writeAppearanceModePreference(appearanceMode: ThemePreferenceMode): void { |
| 226 | if (typeof window === "undefined") return; |
| 227 | try { |
| 228 | // The legacy follow-system flag is read-only migration input now; the |
| 229 | // mode key is the single source of truth. |
| 230 | window.localStorage.setItem(THEME_APPEARANCE_MODE_STORAGE_KEY, appearanceMode); |
| 231 | } catch (cause) { |
| 232 | throw new ThemeStorageError({ |
| 233 | operation: "write", |
| 234 | storageKey: THEME_APPEARANCE_MODE_STORAGE_KEY, |
| 235 | cause, |
| 236 | }); |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | export function readThemePreference(): Theme { |
| 241 | if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme; |
| 242 | let raw: string | null; |
| 243 | try { |
| 244 | raw = window.localStorage.getItem(STORAGE_KEY); |
| 245 | } catch (cause) { |
| 246 | throw new ThemeStorageError({ |
| 247 | operation: "read", |
| 248 | storageKey: STORAGE_KEY, |
| 249 | cause, |
| 250 | }); |
| 251 | } |
| 252 | if (raw !== null && isKnownThemePreference(raw)) { |
| 253 | return canonicalThemePreference(raw); |
| 254 | } |
| 255 | return DEFAULT_THEME_SNAPSHOT.theme; |
| 256 | } |
| 257 | |
| 258 | export function writeThemePreference(theme: Theme): void { |
| 259 | if (typeof window === "undefined") return; |
| 260 | try { |
| 261 | window.localStorage.setItem(STORAGE_KEY, theme); |
| 262 | themeStorageReadFailure = null; |
| 263 | } catch (cause) { |
| 264 | throw new ThemeStorageError({ |
| 265 | operation: "write", |
| 266 | storageKey: STORAGE_KEY, |
| 267 | theme, |
| 268 | cause, |
| 269 | }); |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | function getStored(): Theme { |
| 274 | if (themeStorageReadFailure !== null) { |
| 275 | return DEFAULT_THEME_SNAPSHOT.theme; |
| 276 | } |
| 277 | try { |
| 278 | return readThemePreference(); |
| 279 | } catch (cause) { |
| 280 | const error = isThemeStorageError(cause) |
| 281 | ? cause |
| 282 | : new ThemeStorageError({ |
| 283 | operation: "read", |
| 284 | storageKey: STORAGE_KEY, |
| 285 | cause, |
| 286 | }); |
| 287 | themeStorageReadFailure = error; |
| 288 | console.error(error.message, { |
| 289 | operation: error.operation, |
| 290 | storageKey: error.storageKey, |
| 291 | ...safeErrorLogAttributes(error), |
| 292 | }); |
| 293 | return DEFAULT_THEME_SNAPSHOT.theme; |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | function ensureThemeColorMetaTag(): HTMLMetaElement { |
| 298 | let element = document.querySelector<HTMLMetaElement>(DYNAMIC_THEME_COLOR_SELECTOR); |
| 299 | if (element) { |
| 300 | return element; |
| 301 | } |
| 302 | |
| 303 | element = document.createElement("meta"); |
| 304 | element.name = THEME_COLOR_META_NAME; |
| 305 | element.setAttribute("data-dynamic-theme-color", "true"); |
| 306 | document.head.append(element); |
| 307 | return element; |
| 308 | } |
| 309 | |
| 310 | function normalizeThemeColor(value: string | null | undefined): string | null { |
| 311 | const normalizedValue = value?.trim().toLowerCase(); |
| 312 | if ( |
| 313 | !normalizedValue || |
| 314 | normalizedValue === "transparent" || |
| 315 | normalizedValue === "rgba(0, 0, 0, 0)" || |
| 316 | normalizedValue === "rgba(0 0 0 / 0)" |
| 317 | ) { |
| 318 | return null; |
| 319 | } |
| 320 | |
| 321 | return value?.trim() ?? null; |
| 322 | } |
| 323 | |
| 324 | function resolveBrowserChromeSurface(): HTMLElement { |
| 325 | return ( |
| 326 | document.querySelector<HTMLElement>("main[data-slot='sidebar-inset']") ?? |
| 327 | document.querySelector<HTMLElement>("[data-slot='sidebar-inner']") ?? |
| 328 | document.body |
| 329 | ); |
| 330 | } |
| 331 | |
| 332 | export function syncBrowserChromeTheme() { |
| 333 | if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return; |
| 334 | const rootStyles = getComputedStyle(document.documentElement); |
| 335 | const themeChromeColor = document.documentElement.dataset.themeId |
| 336 | ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background")) |
| 337 | : null; |
| 338 | const surfaceColor = normalizeThemeColor( |
| 339 | getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, |
| 340 | ); |
| 341 | const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor); |
| 342 | const backgroundColor = themeChromeColor ?? surfaceColor ?? fallbackColor; |
| 343 | if (!backgroundColor) return; |
| 344 | |
| 345 | document.documentElement.style.backgroundColor = backgroundColor; |
| 346 | document.body.style.backgroundColor = backgroundColor; |
| 347 | // Update every theme-color meta so any element another layer added (for |
| 348 | // example a media-scoped one) carries the resolved color too. |
| 349 | const themeColorMetas = document.querySelectorAll<HTMLMetaElement>( |
| 350 | `meta[name="${THEME_COLOR_META_NAME}"]`, |
| 351 | ); |
| 352 | if (themeColorMetas.length === 0) { |
| 353 | ensureThemeColorMetaTag().setAttribute("content", backgroundColor); |
| 354 | return; |
| 355 | } |
| 356 | for (const element of themeColorMetas) { |
| 357 | element.setAttribute("content", backgroundColor); |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview = true } = {}) { |
| 362 | if (typeof document === "undefined" || typeof window === "undefined") return; |
| 363 | // Keep the editor's draft visible until an explicit refresh restores the selection. |
| 364 | if (preservePreview && document.documentElement.dataset?.themeId === THEME_PREVIEW_ID) { |
| 365 | return; |
| 366 | } |
| 367 | const appearanceMode = readAppearanceModePreference(theme); |
| 368 | const followSystem = appearanceMode === "system"; |
| 369 | const systemDark = followSystem ? getSystemDark() : false; |
| 370 | const themeHalves = readStoredThemeHalves(); |
| 371 | if ( |
| 372 | lastAppliedTheme?.theme === theme && |
| 373 | lastAppliedTheme.systemDark === systemDark && |
| 374 | lastAppliedTheme.followSystem === followSystem && |
| 375 | lastAppliedTheme.appearanceMode === appearanceMode && |
| 376 | themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves) |
| 377 | ) { |
| 378 | syncDesktopTheme(theme, followSystem, appearanceMode); |
| 379 | return; |
| 380 | } |
| 381 | |
| 382 | if (suppressTransitions) { |
| 383 | document.documentElement.classList.add("no-transitions"); |
| 384 | } |
| 385 | const resolvedAppearance = resolveThemeAppearance( |
| 386 | theme, |
| 387 | systemDark, |
| 388 | followSystem, |
| 389 | appearanceMode, |
| 390 | themeHalves, |
| 391 | ); |
| 392 | applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance); |
| 393 | document.documentElement.classList.toggle("dark", resolvedAppearance === "dark"); |
| 394 | lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves }; |
| 395 | syncBrowserChromeTheme(); |
| 396 | syncDesktopTheme(theme, followSystem, appearanceMode); |
| 397 | if (suppressTransitions) { |
| 398 | // Force a reflow so the no-transitions class takes effect before removal |
| 399 | void document.documentElement.offsetHeight; |
| 400 | requestAnimationFrame(() => { |
| 401 | document.documentElement.classList.remove("no-transitions"); |
| 402 | }); |
| 403 | } |
| 404 | } |
| 405 | |
| 406 | export async function syncDesktopThemePreference( |
| 407 | bridge: DesktopThemeBridge, |
| 408 | theme: Theme, |
| 409 | followSystem?: boolean, |
| 410 | appearanceMode?: ThemePreferenceMode, |
| 411 | halves: ThemeHalves | null = readStoredThemeHalves(), |
| 412 | ): Promise<void> { |
| 413 | try { |
| 414 | await bridge.setTheme(resolveDesktopTheme(theme, followSystem, appearanceMode, halves)); |
| 415 | } catch (cause) { |
| 416 | throw new DesktopThemeSyncError({ theme, cause }); |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | export function syncDesktopTheme( |
| 421 | theme: Theme, |
| 422 | followSystem?: boolean, |
| 423 | appearanceMode?: ThemePreferenceMode, |
| 424 | ) { |
| 425 | if (typeof window === "undefined") return; |
| 426 | const bridge = window.desktopBridge; |
| 427 | const halves = readStoredThemeHalves(); |
| 428 | const desktopTheme = resolveDesktopTheme(theme, followSystem, appearanceMode, halves); |
| 429 | if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === desktopTheme) { |
| 430 | return; |
| 431 | } |
| 432 | |
| 433 | lastDesktopTheme = desktopTheme; |
| 434 | void syncDesktopThemePreference(bridge, theme, followSystem, appearanceMode, halves).catch( |
| 435 | (cause: unknown) => { |
| 436 | const error = isDesktopThemeSyncError(cause) |
| 437 | ? cause |
| 438 | : new DesktopThemeSyncError({ theme, cause }); |
| 439 | console.error(error.message, { |
| 440 | theme: error.theme, |
| 441 | ...safeErrorLogAttributes(error), |
| 442 | }); |
| 443 | if (lastDesktopTheme === desktopTheme) { |
| 444 | lastDesktopTheme = null; |
| 445 | } |
| 446 | }, |
| 447 | ); |
| 448 | } |
| 449 | |
| 450 | // Apply immediately on module load to prevent flash |
| 451 | if (typeof document !== "undefined" && typeof window !== "undefined") { |
| 452 | applyTheme(getStored()); |
| 453 | } |
| 454 | |
| 455 | function getSnapshot(): ThemeSnapshot { |
| 456 | if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT; |
| 457 | // Reading the preference hits localStorage, so only recompute after a |
| 458 | // change was signalled; useTheme consumers call this on every render. |
| 459 | if (!snapshotStale && lastSnapshot) return lastSnapshot; |
| 460 | snapshotStale = false; |
| 461 | const theme = getStored(); |
| 462 | const appearanceMode = readAppearanceModePreference(theme); |
| 463 | const followSystem = appearanceMode === "system"; |
| 464 | const systemDark = followSystem ? getSystemDark() : false; |
| 465 | const themeHalves = readStoredThemeHalves(); |
| 466 | |
| 467 | const resolvedTheme = resolveThemeAppearance( |
| 468 | theme, |
| 469 | systemDark, |
| 470 | followSystem, |
| 471 | appearanceMode, |
| 472 | themeHalves, |
| 473 | ); |
| 474 | if ( |
| 475 | lastSnapshot && |
| 476 | lastSnapshot.theme === theme && |
| 477 | lastSnapshot.resolvedTheme === resolvedTheme && |
| 478 | lastSnapshot.systemDark === systemDark && |
| 479 | lastSnapshot.followSystem === followSystem && |
| 480 | lastSnapshot.appearanceMode === appearanceMode && |
| 481 | themeHalvesSignature(lastSnapshot.themeHalves) === themeHalvesSignature(themeHalves) |
| 482 | ) { |
| 483 | return lastSnapshot; |
| 484 | } |
| 485 | |
| 486 | lastSnapshot = { theme, resolvedTheme, systemDark, followSystem, appearanceMode, themeHalves }; |
| 487 | return lastSnapshot; |
| 488 | } |
| 489 | |
| 490 | function getServerSnapshot() { |
| 491 | return DEFAULT_THEME_SNAPSHOT; |
| 492 | } |
| 493 | |
| 494 | function handleSystemAppearanceChange() { |
| 495 | const storedTheme = getStored(); |
| 496 | if (readAppearanceModePreference(storedTheme) === "system") { |
| 497 | applyTheme(storedTheme, { suppressTransitions: true }); |
| 498 | } |
| 499 | emitChange(); |
| 500 | } |
| 501 | |
| 502 | function handleStorageChange(e: StorageEvent) { |
| 503 | if (e.key === STORAGE_KEY) { |
| 504 | themeStorageReadFailure = null; |
| 505 | applyTheme(getStored(), { suppressTransitions: true }); |
| 506 | emitChange(); |
| 507 | } else if (e.key === THEME_FOLLOW_SYSTEM_STORAGE_KEY) { |
| 508 | applyTheme(getStored(), { suppressTransitions: true }); |
| 509 | emitChange(); |
| 510 | } else if (e.key === THEME_APPEARANCE_MODE_STORAGE_KEY || e.key === THEME_HALVES_STORAGE_KEY) { |
| 511 | applyTheme(getStored(), { suppressTransitions: true }); |
| 512 | emitChange(); |
| 513 | } else if (e.key === CUSTOM_THEMES_STORAGE_KEY || e.key === null) { |
| 514 | if (e.key === null) themeStorageReadFailure = null; |
| 515 | invalidateCustomThemes(); |
| 516 | lastAppliedTheme = null; |
| 517 | applyTheme(getStored(), { suppressTransitions: true }); |
| 518 | emitChange(); |
| 519 | } |
| 520 | } |
| 521 | |
| 522 | let removeWindowListeners: (() => void) | null = null; |
| 523 | |
| 524 | function subscribe(listener: () => void): () => void { |
| 525 | if (typeof window === "undefined") return () => {}; |
| 526 | listeners.push(listener); |
| 527 | |
| 528 | // The system-preference and cross-tab listeners are shared by all |
| 529 | // subscribers; each event applies the theme once and notifies everyone. |
| 530 | if (!removeWindowListeners) { |
| 531 | const mq = typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY) : null; |
| 532 | mq?.addEventListener("change", handleSystemAppearanceChange); |
| 533 | window.addEventListener("storage", handleStorageChange); |
| 534 | removeWindowListeners = () => { |
| 535 | mq?.removeEventListener("change", handleSystemAppearanceChange); |
| 536 | window.removeEventListener("storage", handleStorageChange); |
| 537 | }; |
| 538 | } |
| 539 | |
| 540 | return () => { |
| 541 | listeners = listeners.filter((l) => l !== listener); |
| 542 | if (listeners.length === 0) { |
| 543 | removeWindowListeners?.(); |
| 544 | removeWindowListeners = null; |
| 545 | } |
| 546 | }; |
| 547 | } |
| 548 | |
| 549 | export function useTheme() { |
| 550 | const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); |
| 551 | const { theme, resolvedTheme } = snapshot; |
| 552 | |
| 553 | const setTheme = useCallback((next: Theme): boolean => { |
| 554 | if (typeof window === "undefined") return false; |
| 555 | try { |
| 556 | // Preserve the current mode before replacing a legacy or inferred theme |
| 557 | // preference. Otherwise a fresh System preference is re-inferred from |
| 558 | // the new theme's base appearance, which can switch a dark UI to light. |
| 559 | writeAppearanceModePreference(readAppearanceModePreference(getStored())); |
| 560 | // Choosing a whole theme replaces any automatic-mode mix. The mix is |
| 561 | // captured first so a failed preference write can put it back instead |
| 562 | // of erasing it or leaving it attached to the new theme. |
| 563 | const previousHalvesRaw = window.localStorage.getItem(THEME_HALVES_STORAGE_KEY); |
| 564 | window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY); |
| 565 | try { |
| 566 | writeThemePreference(next); |
| 567 | } catch (cause) { |
| 568 | if (previousHalvesRaw !== null) { |
| 569 | try { |
| 570 | window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, previousHalvesRaw); |
| 571 | } catch { |
| 572 | // Storage is failing wholesale; the outer handler reports it. |
| 573 | } |
| 574 | } |
| 575 | throw cause; |
| 576 | } |
| 577 | } catch (cause) { |
| 578 | const error = isThemeStorageError(cause) |
| 579 | ? cause |
| 580 | : new ThemeStorageError({ |
| 581 | operation: "write", |
| 582 | storageKey: STORAGE_KEY, |
| 583 | theme: next, |
| 584 | cause, |
| 585 | }); |
| 586 | console.error(error.message, { |
| 587 | operation: error.operation, |
| 588 | storageKey: error.storageKey, |
| 589 | theme: next, |
| 590 | ...safeErrorLogAttributes(error), |
| 591 | }); |
| 592 | return false; |
| 593 | } |
| 594 | applyTheme(next, { suppressTransitions: true }); |
| 595 | emitChange(); |
| 596 | emitThemeEdit(); |
| 597 | return true; |
| 598 | }, []); |
| 599 | |
| 600 | const setAppearanceMode = useCallback((nextAppearanceMode: ThemePreferenceMode): boolean => { |
| 601 | if (typeof window === "undefined") return false; |
| 602 | try { |
| 603 | writeAppearanceModePreference(nextAppearanceMode); |
| 604 | } catch (cause) { |
| 605 | const error = isThemeStorageError(cause) |
| 606 | ? cause |
| 607 | : new ThemeStorageError({ |
| 608 | operation: "write", |
| 609 | storageKey: THEME_APPEARANCE_MODE_STORAGE_KEY, |
| 610 | cause, |
| 611 | }); |
| 612 | console.error(error.message, { |
| 613 | operation: error.operation, |
| 614 | storageKey: error.storageKey, |
| 615 | ...safeErrorLogAttributes(error), |
| 616 | }); |
| 617 | return false; |
| 618 | } |
| 619 | themeStorageReadFailure = null; |
| 620 | applyTheme(getStored(), { suppressTransitions: true }); |
| 621 | emitChange(); |
| 622 | emitThemeEdit(); |
| 623 | return true; |
| 624 | }, []); |
| 625 | |
| 626 | const setFollowSystem = useCallback( |
| 627 | (nextFollowSystem: boolean): boolean => { |
| 628 | const currentMode = readAppearanceModePreference(theme); |
| 629 | const nextMode = nextFollowSystem |
| 630 | ? "system" |
| 631 | : currentMode === "system" |
| 632 | ? (getThemePreferenceMode(theme) ?? "light") |
| 633 | : currentMode; |
| 634 | return setAppearanceMode(nextMode); |
| 635 | }, |
| 636 | [setAppearanceMode, theme], |
| 637 | ); |
| 638 | |
| 639 | const setThemeHalf = useCallback( |
| 640 | (appearance: ThemeAppearance, themeId: string | null): boolean => { |
| 641 | if (typeof window === "undefined") return false; |
| 642 | try { |
| 643 | const current = readStoredThemeHalvesRaw(); |
| 644 | const next: { light?: string; dark?: string } = { ...current }; |
| 645 | if (themeId === null) delete next[appearance]; |
| 646 | else next[appearance] = themeId; |
| 647 | if (next.light === undefined && next.dark === undefined) { |
| 648 | window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY); |
| 649 | } else { |
| 650 | window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, JSON.stringify(next)); |
| 651 | } |
| 652 | } catch (cause) { |
| 653 | const error = new ThemeStorageError({ |
| 654 | operation: "write", |
| 655 | storageKey: THEME_HALVES_STORAGE_KEY, |
| 656 | cause, |
| 657 | }); |
| 658 | console.error(error.message, { |
| 659 | operation: error.operation, |
| 660 | storageKey: error.storageKey, |
| 661 | ...safeErrorLogAttributes(error), |
| 662 | }); |
| 663 | return false; |
| 664 | } |
| 665 | applyTheme(getStored(), { suppressTransitions: true }); |
| 666 | emitChange(); |
| 667 | emitThemeEdit(); |
| 668 | return true; |
| 669 | }, |
| 670 | [], |
| 671 | ); |
| 672 | |
| 673 | const clearThemeHalves = useCallback((): boolean => { |
| 674 | if (typeof window === "undefined") return false; |
| 675 | try { |
| 676 | window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY); |
| 677 | } catch (cause) { |
| 678 | const error = new ThemeStorageError({ |
| 679 | operation: "write", |
| 680 | storageKey: THEME_HALVES_STORAGE_KEY, |
| 681 | cause, |
| 682 | }); |
| 683 | console.error(error.message, { |
| 684 | operation: error.operation, |
| 685 | storageKey: error.storageKey, |
| 686 | ...safeErrorLogAttributes(error), |
| 687 | }); |
| 688 | return false; |
| 689 | } |
| 690 | applyTheme(getStored(), { suppressTransitions: true }); |
| 691 | emitChange(); |
| 692 | emitThemeEdit(); |
| 693 | return true; |
| 694 | }, []); |
| 695 | |
| 696 | const refreshTheme = useCallback(({ preservePreview = false } = {}) => { |
| 697 | if (typeof window === "undefined") return; |
| 698 | lastAppliedTheme = null; |
| 699 | applyTheme(getStored(), { suppressTransitions: true, preservePreview }); |
| 700 | emitChange(); |
| 701 | }, []); |
| 702 | |
| 703 | // Keep DOM in sync on mount/change |
| 704 | useEffect(() => { |
| 705 | applyTheme(theme); |
| 706 | }, [snapshot.appearanceMode, theme]); |
| 707 | |
| 708 | return { |
| 709 | theme, |
| 710 | setTheme, |
| 711 | setAppearanceMode, |
| 712 | setFollowSystem, |
| 713 | setThemeHalf, |
| 714 | clearThemeHalves, |
| 715 | refreshTheme, |
| 716 | followSystem: snapshot.followSystem, |
| 717 | appearanceMode: snapshot.appearanceMode, |
| 718 | resolvedTheme, |
| 719 | themeHalves: snapshot.themeHalves, |
| 720 | } as const; |
| 721 | } |