Skip to content
Source
Sign inSign up

useTheme.ts

669 lines20.5 KB

1import { pipe } from "effect/Function";
2import type { DesktopBridge } from "@t3tools/contracts";
3import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
4import * as Schema from "effect/Schema";
5import { useSyncExternalStore } from "react";
6import {
7 applyThemePalette,
8 CUSTOM_THEMES_STORAGE_KEY,
9 invalidateCustomThemes,
10 canonicalThemePreference,
11 isKnownThemePreference,
12 getThemePreferenceMode,
13 getThemePreviewAppearance,
14 parseThemeHalves,
15 resolveDesktopTheme,
16 resolveThemeAppearance,
17 resolveThemeHalf,
18 subscribeToThemePreview,
19 THEME_PREVIEW_ID,
20 THEME_APPEARANCE_MODE_STORAGE_KEY,
21 THEME_FOLLOW_SYSTEM_STORAGE_KEY,
22 THEME_HALVES_STORAGE_KEY,
23 ThemePreference,
24 type ThemeAppearance,
25 type ThemeHalves,
26 type ThemePreferenceMode,
27} from "../themePalette";
28​
29type Theme = ThemePreference;
30type ThemeSnapshot = {
31 theme: Theme;
32 resolvedTheme: ThemeAppearance;
33 systemDark: boolean;
34 followSystem: boolean;
35 appearanceMode: ThemePreferenceMode;
36 themeHalves: ThemeHalves | null;
37};
38​
39type DesktopThemeBridge = Pick<DesktopBridge, "setTheme">;
40​
41const STORAGE_KEY = "t3code:theme";
42const MEDIA_QUERY = "(prefers-color-scheme: dark)";
43const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = {
44 theme: "system",
45 resolvedTheme: "light",
46 systemDark: false,
47 followSystem: true,
48 appearanceMode: "system",
49 themeHalves: null,
50};
51​
52export function readThemeHalves(): ThemeHalves | null {
53 return readStoredThemeHalves();
55​
56/** Capture before setTheme clears the mix; unresolved published ids may still be valid. */
57export function readThemeHalvesRaw(): { light?: string; dark?: string } {
58 if (typeof window === "undefined") return {};
59 return readStoredThemeHalvesRaw();
61​
62function readStoredThemeHalves(): ThemeHalves | null {
63 if (typeof window === "undefined") return null;
64 try {
65 return parseThemeHalves(window.localStorage.getItem(THEME_HALVES_STORAGE_KEY));
66 } catch {
67 return null;
68 }
70​
71// Pruning unresolved published palettes would erase their halves when merging.
72function readStoredThemeHalvesRaw(): { light?: string; dark?: string } {
73 try {
74 const value: unknown = JSON.parse(
75 window.localStorage.getItem(THEME_HALVES_STORAGE_KEY) ?? "null",
76 );
77 if (value === null || typeof value !== "object") return {};
78 const halves: { light?: string; dark?: string } = {};
79 for (const appearance of ["light", "dark"] as const) {
80 const themeId = (value as Record<string, unknown>)[appearance];
81 if (typeof themeId === "string") halves[appearance] = themeId;
82 }
83 return halves;
84 } catch {
85 return {};
86 }
88​
89function themeHalvesSignature(halves: ThemeHalves | null): string {
90 return `${halves?.light ?? ""}|${halves?.dark ?? ""}`;
92​
93const THEME_COLOR_META_NAME = "theme-color";
94const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`;
95​
96export class ThemeStorageError extends Schema.TaggedError<ThemeStorageError>()(
97 "ThemeStorageError",
98 {
99 operation: Schema.Literals(["read", "write"]),
100 storageKey: Schema.String,
101 theme: Schema.optional(ThemePreference),
102 cause: Schema.Defect(),
103 },
104) {
105 override get message(): string {
106 return `Failed to ${this.operation} theme preference for ${this.storageKey}.`;
107 }
109​
110const isThemeStorageError = Schema.is(ThemeStorageError);
111​
112export class DesktopThemeSyncError extends Schema.TaggedError<DesktopThemeSyncError>()(
113 "DesktopThemeSyncError",
114 {
115 theme: ThemePreference,
116 cause: Schema.Defect(),
117 },
118) {
119 override get message(): string {
120 return `Failed to sync the ${this.theme} theme to the desktop shell.`;
121 }
123​
124const isDesktopThemeSyncError = Schema.is(DesktopThemeSyncError);
125​
126let listeners: Array<() => void> = [];
127let lastSnapshot: ThemeSnapshot | null = null;
128let snapshotStale = true;
129let lastDesktopTheme: "light" | "dark" | "system" | null = null;
130let lastAppliedTheme: Omit<ThemeSnapshot, "resolvedTheme"> | null = null;
131let themeStorageReadFailure: ThemeStorageError | null = null;
132​
133function emitChange() {
134 snapshotStale = true;
135 for (const listener of listeners) listener();
137​
138function getSystemDark() {
139 return (
140 typeof window !== "undefined" &&
141 typeof window.matchMedia === "function" &&
142 window.matchMedia(MEDIA_QUERY).matches
143 );
145​
146function readStoredFollowSystem(theme: Theme): boolean {
147 if (typeof window === "undefined") return theme === "system";
148​
149 try {
150 const raw = window.localStorage.getItem(THEME_FOLLOW_SYSTEM_STORAGE_KEY);
151 if (raw === "true") return true;
152 if (raw === "false") return false;
153 } catch {}
154​
155 return theme === "system";
157​
158function isThemePreferenceMode(value: string | null): value is ThemePreferenceMode {
159 return value === "light" || value === "dark" || value === "system";
161​
162export function readAppearanceModePreference(theme: Theme): ThemePreferenceMode {
163 if (typeof window !== "undefined") {
164 try {
165 const raw = window.localStorage.getItem(THEME_APPEARANCE_MODE_STORAGE_KEY);
166 if (isThemePreferenceMode(raw)) return raw;
167 } catch {}
168 }
169​
170 if (readStoredFollowSystem(theme)) return "system";
171 return getThemePreferenceMode(theme) ?? "light";
173​
174function writeAppearanceModePreference(appearanceMode: ThemePreferenceMode): void {
175 if (typeof window === "undefined") return;
176 try {
177 window.localStorage.setItem(THEME_APPEARANCE_MODE_STORAGE_KEY, appearanceMode);
178 } catch (cause) {
179 throw new ThemeStorageError({
180 operation: "write",
181 storageKey: THEME_APPEARANCE_MODE_STORAGE_KEY,
182 cause,
183 });
184 }
186​
187export function readThemePreference(): Theme {
188 if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme;
189 let raw: string | null;
190 try {
191 raw = window.localStorage.getItem(STORAGE_KEY);
192 } catch (cause) {
193 throw new ThemeStorageError({
194 operation: "read",
195 storageKey: STORAGE_KEY,
196 cause,
197 });
198 }
199 if (raw !== null && isKnownThemePreference(raw)) {
200 return canonicalThemePreference(raw);
201 }
202 return DEFAULT_THEME_SNAPSHOT.theme;
204​
205function writeThemePreference(theme: Theme): void {
206 if (typeof window === "undefined") return;
207 try {
208 window.localStorage.setItem(STORAGE_KEY, theme);
209 themeStorageReadFailure = null;
210 } catch (cause) {
211 throw new ThemeStorageError({
212 operation: "write",
213 storageKey: STORAGE_KEY,
214 theme,
215 cause,
216 });
217 }
219​
220function getStored(): Theme {
221 if (themeStorageReadFailure !== null) {
222 return DEFAULT_THEME_SNAPSHOT.theme;
223 }
224 try {
225 return readThemePreference();
226 } catch (cause) {
227 const error = isThemeStorageError(cause)
228 ? cause
229 : new ThemeStorageError({
230 operation: "read",
231 storageKey: STORAGE_KEY,
232 cause,
233 });
234 themeStorageReadFailure = error;
235 console.error(error.message, {
236 operation: error.operation,
237 storageKey: error.storageKey,
238 ...safeErrorLogAttributes(error),
239 });
240 return DEFAULT_THEME_SNAPSHOT.theme;
241 }
243​
244function ensureThemeColorMetaTag(): HTMLMetaElement {
245 let element = document.querySelector<HTMLMetaElement>(DYNAMIC_THEME_COLOR_SELECTOR);
246 if (element) {
247 return element;
248 }
249​
250 element = document.createElement("meta");
251 element.name = THEME_COLOR_META_NAME;
252 element.setAttribute("data-dynamic-theme-color", "true");
253 document.head.append(element);
254 return element;
256​
257function normalizeThemeColor(value: string | null | undefined): string | null {
258 const normalizedValue = value?.trim().toLowerCase();
259 if (
260 !normalizedValue ||
261 normalizedValue === "transparent" ||
262 normalizedValue === "rgba(0, 0, 0, 0)" ||
263 normalizedValue === "rgba(0 0 0 / 0)"
264 ) {
265 return null;
266 }
267​
268 return value?.trim() ?? null;
270​
271function resolveBrowserChromeSurface(): HTMLElement {
272 return (
273 document.querySelector<HTMLElement>("main[data-slot='sidebar-inset']") ??
274 document.querySelector<HTMLElement>("[data-slot='sidebar-inner']") ??
275 document.body
276 );
278​
279export function syncBrowserChromeTheme() {
280 if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return;
281 const rootStyles = getComputedStyle(document.documentElement);
282 const themeChromeColor = document.documentElement.dataset.themeId
283 ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background"))
284 : null;
285 const surfaceColor = normalizeThemeColor(
286 getComputedStyle(resolveBrowserChromeSurface()).backgroundColor,
287 );
288 const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor);
289 const backgroundColor = themeChromeColor ?? surfaceColor ?? fallbackColor;
290 if (!backgroundColor) return;
291​
292 document.documentElement.style.backgroundColor = backgroundColor;
293 document.body.style.backgroundColor = backgroundColor;
294 const themeColorMetas = document.querySelectorAll<HTMLMetaElement>(
295 `meta[name="${THEME_COLOR_META_NAME}"]`,
296 );
297 if (themeColorMetas.length === 0) {
298 ensureThemeColorMetaTag().setAttribute("content", backgroundColor);
299 return;
300 }
301 for (const element of themeColorMetas) {
302 element.setAttribute("content", backgroundColor);
303 }
305​
306function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview = true } = {}) {
307 if (typeof document === "undefined" || typeof window === "undefined") return;
308 if (preservePreview && document.documentElement.dataset?.themeId === THEME_PREVIEW_ID) {
309 return;
310 }
311 const appearanceMode = readAppearanceModePreference(theme);
312 const followSystem = appearanceMode === "system";
313 const systemDark = followSystem ? getSystemDark() : false;
314 const themeHalves = readStoredThemeHalves();
315 if (
316 lastAppliedTheme?.theme === theme &&
317 lastAppliedTheme.systemDark === systemDark &&
318 lastAppliedTheme.followSystem === followSystem &&
319 lastAppliedTheme.appearanceMode === appearanceMode &&
320 themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves)
321 ) {
322 syncDesktopTheme(theme, followSystem, appearanceMode);
323 return;
324 }
325​
326 if (suppressTransitions) {
327 document.documentElement.classList.add("no-transitions");
328 }
329 const resolvedAppearance = resolveThemeAppearance(
330 theme,
331 systemDark,
332 followSystem,
333 appearanceMode,
334 themeHalves,
335 );
336 applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance);
337 document.documentElement.classList.toggle("dark", resolvedAppearance === "dark");
338 lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves };
339 syncBrowserChromeTheme();
340 syncDesktopTheme(theme, followSystem, appearanceMode);
341 if (suppressTransitions) {
342 // Force a reflow so the no-transitions class takes effect before removal
343 void document.documentElement.offsetHeight;
344 requestAnimationFrame(() => {
345 document.documentElement.classList.remove("no-transitions");
346 });
347 }
349​
350async function syncDesktopThemePreference(
351 bridge: DesktopThemeBridge,
352 theme: Theme,
353 followSystem?: boolean,
354 appearanceMode?: ThemePreferenceMode,
355 halves: ThemeHalves | null = readStoredThemeHalves(),
356): Promise<void> {
357 try {
358 await bridge.setTheme(resolveDesktopTheme(theme, followSystem, appearanceMode, halves));
359 } catch (cause) {
360 throw new DesktopThemeSyncError({ theme, cause });
361 }
363​
364function syncDesktopTheme(
365 theme: Theme,
366 followSystem?: boolean,
367 appearanceMode?: ThemePreferenceMode,
368) {
369 if (typeof window === "undefined") return;
370 const bridge = window.desktopBridge;
371 const halves = readStoredThemeHalves();
372 const desktopTheme = resolveDesktopTheme(theme, followSystem, appearanceMode, halves);
373 if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === desktopTheme) {
374 return;
375 }
376​
377 lastDesktopTheme = desktopTheme;
378 void syncDesktopThemePreference(bridge, theme, followSystem, appearanceMode, halves).catch(
379 (cause: unknown) => {
380 const error = isDesktopThemeSyncError(cause)
381 ? cause
382 : new DesktopThemeSyncError({ theme, cause });
383 console.error(error.message, {
384 theme: error.theme,
385 ...safeErrorLogAttributes(error),
386 });
387 if (lastDesktopTheme === desktopTheme) {
388 lastDesktopTheme = null;
389 }
390 },
391 );
393​
394if (typeof document !== "undefined" && typeof window !== "undefined") {
395 applyTheme(getStored());
397​
398function getSnapshot(): ThemeSnapshot {
399 if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT;
400 // Cache localStorage reads until signalled; useTheme calls this every render.
401 if (!snapshotStale && lastSnapshot) return lastSnapshot;
402 snapshotStale = false;
403 const theme = getStored();
404 const appearanceMode = readAppearanceModePreference(theme);
405 const followSystem = appearanceMode === "system";
406 const systemDark = followSystem ? getSystemDark() : false;
407 const themeHalves = readStoredThemeHalves();
408​
409 const resolvedTheme = resolveThemeAppearance(
410 theme,
411 systemDark,
412 followSystem,
413 appearanceMode,
414 themeHalves,
415 );
416 if (
417 lastSnapshot &&
418 lastSnapshot.theme === theme &&
419 lastSnapshot.resolvedTheme === resolvedTheme &&
420 lastSnapshot.systemDark === systemDark &&
421 lastSnapshot.followSystem === followSystem &&
422 lastSnapshot.appearanceMode === appearanceMode &&
423 themeHalvesSignature(lastSnapshot.themeHalves) === themeHalvesSignature(themeHalves)
424 ) {
425 return lastSnapshot;
426 }
427​
428 lastSnapshot = { theme, resolvedTheme, systemDark, followSystem, appearanceMode, themeHalves };
429 return lastSnapshot;
431​
432function getServerSnapshot() {
433 return DEFAULT_THEME_SNAPSHOT;
435​
436function handleSystemAppearanceChange() {
437 const storedTheme = getStored();
438 if (readAppearanceModePreference(storedTheme) === "system") {
439 applyTheme(storedTheme, { suppressTransitions: true });
440 }
441 emitChange();
443​
444function handleStorageChange(e: StorageEvent) {
445 if (e.key === STORAGE_KEY) {
446 themeStorageReadFailure = null;
447 applyTheme(getStored(), { suppressTransitions: true });
448 emitChange();
449 } else if (e.key === THEME_FOLLOW_SYSTEM_STORAGE_KEY) {
450 applyTheme(getStored(), { suppressTransitions: true });
451 emitChange();
452 } else if (e.key === THEME_APPEARANCE_MODE_STORAGE_KEY || e.key === THEME_HALVES_STORAGE_KEY) {
453 applyTheme(getStored(), { suppressTransitions: true });
454 emitChange();
455 } else if (e.key === CUSTOM_THEMES_STORAGE_KEY || e.key === null) {
456 if (e.key === null) themeStorageReadFailure = null;
457 invalidateCustomThemes();
458 lastAppliedTheme = null;
459 applyTheme(getStored(), { suppressTransitions: true });
460 emitChange();
461 }
463​
464let removeWindowListeners: (() => void) | null = null;
465​
466function subscribe(listener: () => void): () => void {
467 if (typeof window === "undefined") return () => {};
468 listeners.push(listener);
469​
470 if (!removeWindowListeners) {
471 const mq = typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY) : null;
472 mq?.addEventListener("change", handleSystemAppearanceChange);
473 window.addEventListener("storage", handleStorageChange);
474 removeWindowListeners = () => {
475 mq?.removeEventListener("change", handleSystemAppearanceChange);
476 window.removeEventListener("storage", handleStorageChange);
477 };
478 }
479​
480 return () => {
481 listeners = listeners.filter((l) => l !== listener);
482 if (listeners.length === 0) {
483 removeWindowListeners?.();
484 removeWindowListeners = null;
485 }
486 };
488​
489/** For shortcuts and other handlers outside React that read the theme at event time. */
490export function getThemeSnapshot(): ThemeSnapshot {
491 return getSnapshot();
493​
494/** For code outside React (worker pools) that follows the rendered appearance. */
495export function subscribeRenderedAppearance(listener: () => void): () => void {
496 const unsubscribeTheme = subscribe(listener);
497 const unsubscribePreview = subscribeToThemePreview(listener);
498 return () => {
499 unsubscribeTheme();
500 unsubscribePreview();
501 };
503​
504export function getRenderedAppearance(): ThemeAppearance {
505 return getThemePreviewAppearance() ?? getSnapshot().resolvedTheme;
507​
508export function useRenderedAppearance() {
509 const { resolvedTheme } = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
510 const previewAppearance = useSyncExternalStore(
511 subscribeToThemePreview,
512 getThemePreviewAppearance,
513 () => null,
514 );
515 return previewAppearance ?? resolvedTheme;
517​
518export function setTheme(next: Theme): boolean {
519 if (typeof window === "undefined") return false;
520 try {
521 // Save the current mode first; inferring it from the new theme can switch dark to light.
522 pipe(getStored(), readAppearanceModePreference, writeAppearanceModePreference);
523 // A whole theme replaces the mix; retain it to restore on a failed preference write.
524 const previousHalvesRaw = window.localStorage.getItem(THEME_HALVES_STORAGE_KEY);
525 window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY);
526 try {
527 writeThemePreference(next);
528 } catch (cause) {
529 if (previousHalvesRaw !== null) {
530 try {
531 window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, previousHalvesRaw);
532 } catch {}
533 }
534 throw cause;
535 }
536 } catch (cause) {
537 const error = isThemeStorageError(cause)
538 ? cause
539 : new ThemeStorageError({
540 operation: "write",
541 storageKey: STORAGE_KEY,
542 theme: next,
543 cause,
544 });
545 console.error(error.message, {
546 operation: error.operation,
547 storageKey: error.storageKey,
548 theme: next,
549 ...safeErrorLogAttributes(error),
550 });
551 return false;
552 }
553 applyTheme(next, { suppressTransitions: true });
554 emitChange();
555 return true;
557​
558export function setAppearanceMode(nextAppearanceMode: ThemePreferenceMode): boolean {
559 if (typeof window === "undefined") return false;
560 try {
561 writeAppearanceModePreference(nextAppearanceMode);
562 } catch (cause) {
563 const error = isThemeStorageError(cause)
564 ? cause
565 : new ThemeStorageError({
566 operation: "write",
567 storageKey: THEME_APPEARANCE_MODE_STORAGE_KEY,
568 cause,
569 });
570 console.error(error.message, {
571 operation: error.operation,
572 storageKey: error.storageKey,
573 ...safeErrorLogAttributes(error),
574 });
575 return false;
576 }
577 themeStorageReadFailure = null;
578 applyTheme(getStored(), { suppressTransitions: true });
579 emitChange();
580 return true;
582​
583function setThemeHalf(appearance: ThemeAppearance, themeId: string | null): boolean {
584 if (typeof window === "undefined") return false;
585 try {
586 const current = readStoredThemeHalvesRaw();
587 const next: { light?: string; dark?: string } = { ...current };
588 if (themeId === null) delete next[appearance];
589 else next[appearance] = themeId;
590 if (next.light === undefined && next.dark === undefined) {
591 window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY);
592 } else {
593 window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, JSON.stringify(next));
594 }
595 } catch (cause) {
596 const error = new ThemeStorageError({
597 operation: "write",
598 storageKey: THEME_HALVES_STORAGE_KEY,
599 cause,
600 });
601 console.error(error.message, {
602 operation: error.operation,
603 storageKey: error.storageKey,
604 ...safeErrorLogAttributes(error),
605 });
606 return false;
607 }
608 applyTheme(getStored(), { suppressTransitions: true });
609 emitChange();
610 return true;
612​
613function clearThemeHalves(): boolean {
614 if (typeof window === "undefined") return false;
615 try {
616 window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY);
617 } catch (cause) {
618 const error = new ThemeStorageError({
619 operation: "write",
620 storageKey: THEME_HALVES_STORAGE_KEY,
621 cause,
622 });
623 console.error(error.message, {
624 operation: error.operation,
625 storageKey: error.storageKey,
626 ...safeErrorLogAttributes(error),
627 });
628 return false;
629 }
630 applyTheme(getStored(), { suppressTransitions: true });
631 emitChange();
632 return true;
634​
635export function refreshTheme({ preservePreview = false } = {}): void {
636 if (typeof window === "undefined") return;
637 lastAppliedTheme = null;
638 applyTheme(getStored(), { suppressTransitions: true, preservePreview });
639 emitChange();
641​
642export function useTheme() {
643 const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
644 const { theme, resolvedTheme } = snapshot;
645​
646 const setFollowSystem = (nextFollowSystem: boolean): boolean => {
647 const currentMode = readAppearanceModePreference(theme);
648 const nextMode = nextFollowSystem
649 ? "system"
650 : currentMode === "system"
651 ? (getThemePreferenceMode(theme) ?? "light")
652 : currentMode;
653 return setAppearanceMode(nextMode);
654 };
655​
656 return {
657 theme,
658 setTheme,
659 setAppearanceMode,
660 setFollowSystem,
661 setThemeHalf,
662 clearThemeHalves,
663 refreshTheme,
664 followSystem: snapshot.followSystem,
665 appearanceMode: snapshot.appearanceMode,
666 resolvedTheme,
667 themeHalves: snapshot.themeHalves,
668 } as const;