color-scheme.ts RawHistoryFolder
1/**
2 * Color Scheme Detection Module
3 *
4 * Handles GTK theme detection and applies appropriate color scheme preferences
5 * to web pages. This module is injected at document-start to ensure proper
6 * theme initialization before page content loads.
7 */
8
9declare global {
10 interface Window {
11 __dumber_gtk_prefers_dark?: boolean;
12 __dumber_setTheme?: (theme: "light" | "dark") => void;
13 }
14}
15
16interface ColorSchemeConfig {
17 isDark: boolean;
18 colorScheme: "light" | "dark";
19 metaContent: string;
20 rootStyle: string;
21}
22
23class ColorSchemeManager {
24 private config: ColorSchemeConfig;
25 private metaElement: HTMLMetaElement | null = null;
26 private styleElement: HTMLStyleElement | null = null;
27 private userPreference: "light" | "dark" | null = null;
28 private static readonly STORAGE_KEY = "dumber.theme";
29 private static instance: ColorSchemeManager | null = null;
30 private static matchMediaPatched = false;
31 private static originalMatchMedia: typeof window.matchMedia | null = null;
32
33 constructor() {
34 ColorSchemeManager.instance = this;
35
36 this.userPreference = this.getStoredPreference();
37
38 // Use GTK preference if available, otherwise detect from system
39 const prefersDark = this.userPreference
40 ? this.userPreference === "dark"
41 : (window.__dumber_gtk_prefers_dark ?? false);
42
43 this.config = {
44 isDark: prefersDark,
45 colorScheme: prefersDark ? "dark" : "light",
46 metaContent: prefersDark ? "dark light" : "light dark",
47 rootStyle: prefersDark
48 ? ":root{color-scheme:dark;}"
49 : ":root{color-scheme:light;}",
50 };
51
52 this.initialize();
53 }
54
55 private initialize(): void {
56 try {
57 console.log(`[dumber] color-scheme set: ${this.config.colorScheme}`);
58 console.log(
59 `[dumber] GTK detected color mode: ${this.config.colorScheme}`,
60 );
61
62 // Notify native layer of theme preference
63 this.notifyNativeLayer();
64
65 // Inject color-scheme meta tag
66 this.injectColorSchemeMeta();
67
68 // Apply root styles
69 this.applyRootStyles();
70
71 // Override matchMedia for proper prefers-color-scheme support
72 this.overrideMatchMedia();
73
74 // Set up theme integration with existing UI components
75 this.setupThemeIntegration();
76 } catch (error) {
77 console.warn("[dumber] color-scheme injection failed", error);
78 }
79 }
80
81 private notifyNativeLayer(): void {
82 try {
83 (window as any).webkit?.messageHandlers?.dumber?.postMessage(
84 JSON.stringify({
85 type: "theme",
86 value: this.config.colorScheme,
87 }),
88 );
89 } catch {
90 // Ignore errors - native layer may not be available
91 }
92 }
93
94 private injectColorSchemeMeta(): void {
95 if (this.metaElement && this.metaElement.isConnected) {
96 this.metaElement.content = this.config.metaContent;
97 return;
98 }
99
100 const existing = document.querySelector(
101 'meta[name="color-scheme"]',
102 ) as HTMLMetaElement | null;
103 const meta = existing ?? document.createElement("meta");
104 meta.name = "color-scheme";
105 meta.content = this.config.metaContent;
106 if (!existing) {
107 document.documentElement.appendChild(meta);
108 }
109 this.metaElement = meta;
110 }
111
112 private applyRootStyles(): void {
113 if (this.styleElement && this.styleElement.isConnected) {
114 this.styleElement.textContent = this.config.rootStyle;
115 return;
116 }
117
118 const existing = document.querySelector(
119 "style[data-dumber-color-scheme]",
120 ) as HTMLStyleElement | null;
121 const style = existing ?? document.createElement("style");
122 style.setAttribute("data-dumber-color-scheme", "");
123 style.textContent = this.config.rootStyle;
124 if (!existing) {
125 document.documentElement.appendChild(style);
126 }
127 this.styleElement = style;
128 }
129
130 private overrideMatchMedia(): void {
131 if (ColorSchemeManager.matchMediaPatched) {
132 return;
133 }
134
135 const darkQuery = "(prefers-color-scheme: dark)";
136 const lightQuery = "(prefers-color-scheme: light)";
137 ColorSchemeManager.originalMatchMedia = window.matchMedia.bind(window);
138
139 window.matchMedia = (query: string): MediaQueryList => {
140 const manager = ColorSchemeManager.instance;
141 if (
142 manager &&
143 typeof query === "string" &&
144 (query.includes(darkQuery) || query.includes(lightQuery))
145 ) {
146 const matches = query.includes("dark")
147 ? manager.config.isDark
148 : !manager.config.isDark;
149
150 return {
151 matches,
152 media: query,
153 onchange: null,
154 addListener: () => {},
155 removeListener: () => {},
156 addEventListener: () => {},
157 removeEventListener: () => {},
158 dispatchEvent: () => false,
159 } as MediaQueryList;
160 }
161
162 const original = ColorSchemeManager.originalMatchMedia;
163 if (!original) {
164 throw new Error("Original matchMedia not available");
165 }
166
167 return original.call(window, query);
168 };
169
170 ColorSchemeManager.matchMediaPatched = true;
171 }
172
173 private setupThemeIntegration(): void {
174 // Integrate with unified theme setter if available
175 if (window.__dumber_setTheme) {
176 window.__dumber_setTheme(this.config.colorScheme);
177 } else {
178 // Retry after a short delay in case the bridge hasn't loaded yet
179 setTimeout(() => {
180 if (window.__dumber_setTheme) {
181 window.__dumber_setTheme(this.config.colorScheme);
182 } else {
183 // Fallback: directly apply dark class for Tailwind compatibility
184 console.warn("[dumber] Theme setter not available, using fallback");
185 if (this.config.isDark) {
186 document.documentElement.classList.add("dark");
187 } else {
188 document.documentElement.classList.remove("dark");
189 }
190 }
191 }, 100);
192 }
193 }
194
195 /**
196 * Updates the color scheme preference at runtime
197 * @param isDark Whether to use dark mode
198 */
199 public updateColorScheme(isDark: boolean): void {
200 this.config.isDark = isDark;
201 this.config.colorScheme = isDark ? "dark" : "light";
202 this.config.metaContent = isDark ? "dark light" : "light dark";
203 this.config.rootStyle = isDark
204 ? ":root{color-scheme:dark;}"
205 : ":root{color-scheme:light;}";
206
207 // Re-apply all configurations
208 this.initialize();
209 }
210
211 private getStoredPreference(): "light" | "dark" | null {
212 try {
213 const value = localStorage.getItem(ColorSchemeManager.STORAGE_KEY);
214 if (value === "light" || value === "dark") {
215 return value;
216 }
217 } catch (error) {
218 console.warn("[dumber] Failed to read stored theme preference", error);
219 }
220 return null;
221 }
222
223 private storePreference(theme: "light" | "dark"): void {
224 try {
225 localStorage.setItem(ColorSchemeManager.STORAGE_KEY, theme);
226 } catch (error) {
227 console.warn("[dumber] Failed to persist theme preference", error);
228 }
229 }
230
231 /**
232 * Persist and apply an explicit user preference
233 */
234 public setUserPreference(theme: "light" | "dark"): void {
235 if (this.userPreference === theme && this.config.colorScheme === theme) {
236 return;
237 }
238 this.userPreference = theme;
239 this.storePreference(theme);
240 this.updateColorScheme(theme === "dark");
241 }
242
243 public getCurrentTheme(): "light" | "dark" {
244 return this.config.colorScheme;
245 }
246}
247
248// Initialize color scheme management
249const colorSchemeManager = new ColorSchemeManager();
250
251// Export for potential runtime updates
252(window as any).__dumber_color_scheme_manager = colorSchemeManager;
253
254export default colorSchemeManager;
255
256declare global {
257 interface Window {
258 __dumber_color_scheme_manager?: {
259 setUserPreference: (theme: "light" | "dark") => void;
260 getCurrentTheme: () => "light" | "dark";
261 };
262 }
263}