1/** Three-state theme: light / dark / system, persisted in localStorage.
2 * The `.dark` class on <html> is the single source of truth; the
3 * pre-paint script in index.html applies it before hydration using the
4 * same storage contract (key "theme"; absent = system). */
5import {
6 createContext,
7 useCallback,
8 useContext,
9 useEffect,
10 useState,
11 type ReactNode,
12} from "react";
13
14export type ThemePreference = "light" | "dark" | "system";
15export type ResolvedTheme = "light" | "dark";
16
17const STORAGE_KEY = "theme";
18
19const readPreference = (): ThemePreference => {
20 try {
21 const stored = localStorage.getItem(STORAGE_KEY);
22 return stored === "light" || stored === "dark" ? stored : "system";
23 } catch {
24 return "system";
25 }
26};
27
28const systemDark = () => window.matchMedia("(prefers-color-scheme: dark)");
29
30const resolve = (preference: ThemePreference): ResolvedTheme =>
31 preference === "system"
32 ? systemDark().matches
33 ? "dark"
34 : "light"
35 : preference;
36
37const apply = (resolved: ResolvedTheme) => {
38 document.documentElement.classList.toggle("dark", resolved === "dark");
39};
40
41const ThemeContext = createContext<{
42 preference: ThemePreference;
43 /** What is actually showing right now — feed this to @pierre/diffs. */
44 resolved: ResolvedTheme;
45 setPreference: (preference: ThemePreference) => void;
46}>({ preference: "system", resolved: "light", setPreference: () => {} });
47
48export const useTheme = () => useContext(ThemeContext);
49
50/**
51 * Options fragment for `@pierre/diffs` components (FileDiff, CodeView, …).
52 * The library renders into a shadow root whose default `color-scheme` is
53 * `light dark` — i.e. it follows the OS, not our `.dark` class. Passing
54 * `themeType: resolved` pins the shadow root to OUR toggle. Never leave
55 * themeType at "system". Spread into the component's `options` prop:
56 *
57 * const diffTheme = useDiffThemeOptions();
58 * <FileDiff options={{ ...diffTheme }} ... />
59 */
60export const useDiffThemeOptions = (): {
61 readonly theme: { readonly light: string; readonly dark: string };
62 readonly themeType: ResolvedTheme;
63} => {
64 const { resolved } = useTheme();
65 return {
66 // Shiki theme pair; the library picks per light-dark() at paint time.
67 theme: { light: "github-light-default", dark: "github-dark-default" },
68 themeType: resolved,
69 };
70};
71
72export const ThemeProvider = ({ children }: { children: ReactNode }) => {
73 const [preference, setPreferenceState] =
74 useState<ThemePreference>(readPreference);
75 const [resolved, setResolved] = useState<ResolvedTheme>(() =>
76 resolve(readPreference()),
77 );
78
79 const setPreference = useCallback((next: ThemePreference) => {
80 try {
81 if (next === "system") localStorage.removeItem(STORAGE_KEY);
82 else localStorage.setItem(STORAGE_KEY, next);
83 } catch {
84 /* storage disabled — theme still applies for this page */
85 }
86 setPreferenceState(next);
87 const nextResolved = resolve(next);
88 setResolved(nextResolved);
89 apply(nextResolved);
90 }, []);
91
92 // Track OS changes while in "system".
93 useEffect(() => {
94 if (preference !== "system") return;
95 const media = systemDark();
96 const onChange = () => {
97 const nextResolved = resolve("system");
98 setResolved(nextResolved);
99 apply(nextResolved);
100 };
101 media.addEventListener("change", onChange);
102 return () => media.removeEventListener("change", onChange);
103 }, [preference]);
104
105 // Cross-tab sync (another tab toggled the theme).
106 useEffect(() => {
107 const onStorage = (event: StorageEvent) => {
108 if (event.key !== STORAGE_KEY && event.key !== null) return;
109 const next = readPreference();
110 setPreferenceState(next);
111 const nextResolved = resolve(next);
112 setResolved(nextResolved);
113 apply(nextResolved);
114 };
115 window.addEventListener("storage", onStorage);
116 return () => window.removeEventListener("storage", onStorage);
117 }, []);
118
119 return (
120 <ThemeContext.Provider value={{ preference, resolved, setPreference }}>
121 {children}
122 </ThemeContext.Provider>
123 );
124};