| 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). */ |
| 5 | import { |
| 6 | createContext, |
| 7 | useCallback, |
| 8 | useContext, |
| 9 | useEffect, |
| 10 | useState, |
| 11 | type ReactNode, |
| 12 | } from "react"; |
| 13 | |
| 14 | export type ThemePreference = "light" | "dark" | "system"; |
| 15 | export type ResolvedTheme = "light" | "dark"; |
| 16 | |
| 17 | const STORAGE_KEY = "theme"; |
| 18 | |
| 19 | const 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 | |
| 28 | const systemDark = () => window.matchMedia("(prefers-color-scheme: dark)"); |
| 29 | |
| 30 | const resolve = (preference: ThemePreference): ResolvedTheme => |
| 31 | preference === "system" |
| 32 | ? systemDark().matches |
| 33 | ? "dark" |
| 34 | : "light" |
| 35 | : preference; |
| 36 | |
| 37 | const apply = (resolved: ResolvedTheme) => { |
| 38 | document.documentElement.classList.toggle("dark", resolved === "dark"); |
| 39 | }; |
| 40 | |
| 41 | const 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 | |
| 48 | export 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 | */ |
| 60 | export 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 | |
| 72 | export 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 | }; |