1import type { DesktopBridge } from "@t3tools/contracts";
2import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
3import * as Schema from "effect/Schema";
4import { useCallback, useEffect, useSyncExternalStore } from "react";
5import {
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
28import type { UiThemePreferences } from "@t3tools/contracts";
29
30type Theme = ThemePreference;
31const themeEditListeners = new Set<() => void>();
32export function subscribeThemeEdits(listener: () => void) {
33 themeEditListeners.add(listener);
34 return () => {
35 themeEditListeners.delete(listener);
36 };
37}
38function emitThemeEdit() {
39 for (const listener of themeEditListeners) listener();
40}
41
42export 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
54export 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
65type ThemeSnapshot = {
66 theme: Theme;
67 resolvedTheme: ThemeAppearance;
68 systemDark: boolean;
69 followSystem: boolean;
70 appearanceMode: ThemePreferenceMode;
71 themeHalves: ThemeHalves | null;
72};
73
74type DesktopThemeBridge = Pick<DesktopBridge, "setTheme">;
75
76const STORAGE_KEY = "t3code:theme";
77const MEDIA_QUERY = "(prefers-color-scheme: dark)";
78const 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). */
89export 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 */
99export function readThemeHalvesRaw(): { light?: string; dark?: string } {
100 if (typeof window === "undefined") return {};
101 return readStoredThemeHalvesRaw();
102}
103
104function 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 */
119function 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
136function themeHalvesSignature(halves: ThemeHalves | null): string {
137 return `${halves?.light ?? ""}|${halves?.dark ?? ""}`;
138}
139
140const THEME_COLOR_META_NAME = "theme-color";
141const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`;
142
143export 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
157export const isThemeStorageError = Schema.is(ThemeStorageError);
158
159export 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
171export const isDesktopThemeSyncError = Schema.is(DesktopThemeSyncError);
172
173let listeners: Array<() => void> = [];
174let lastSnapshot: ThemeSnapshot | null = null;
175let snapshotStale = true;
176let lastDesktopTheme: "light" | "dark" | "system" | null = null;
177let lastAppliedTheme: Omit<ThemeSnapshot, "resolvedTheme"> | null = null;
178let themeStorageReadFailure: ThemeStorageError | null = null;
179
180function emitChange() {
181 snapshotStale = true;
182 for (const listener of listeners) listener();
183}
184
185function getSystemDark() {
186 return (
187 typeof window !== "undefined" &&
188 typeof window.matchMedia === "function" &&
189 window.matchMedia(MEDIA_QUERY).matches
190 );
191}
192
193function 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
207function isThemePreferenceMode(value: string | null): value is ThemePreferenceMode {
208 return value === "light" || value === "dark" || value === "system";
209}
210
211export 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
225function 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
240export 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
258export 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
273function 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
297function 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
310function 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
324function 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
332export 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
361function 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
406export 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
420export 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
451if (typeof document !== "undefined" && typeof window !== "undefined") {
452 applyTheme(getStored());
453}
454
455function 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
490function getServerSnapshot() {
491 return DEFAULT_THEME_SNAPSHOT;
492}
493
494function handleSystemAppearanceChange() {
495 const storedTheme = getStored();
496 if (readAppearanceModePreference(storedTheme) === "system") {
497 applyTheme(storedTheme, { suppressTransitions: true });
498 }
499 emitChange();
500}
501
502function 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
522let removeWindowListeners: (() => void) | null = null;
523
524function 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
549export 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}