返回 DeepSeek-Reasonix
theme.ts
根目录 / desktop / frontend / src / lib / theme.ts
1 // theme.ts manages the appearance override. The stylesheet follows the OS via
2 // prefers-color-scheme unless data-theme forces "dark" or "light". A separate
3 // data-theme-style attribute selects a visual direction (graphite/aurora/slate/
4 // carbon/nocturne/amber) — orthogonal to theme, so every direction supports both
5 // light & dark.
6 //
7 // When running inside the Wails shell, applyTheme also syncs the native window
8 // theme (title bar, traffic lights, etc.) so the OS chrome matches the webview.
9
10 import { baseCodeReadabilityStylesheet } from "./codeReadability";
11
12 export type Theme = "auto" | "light" | "dark";
13 export type ResolvedTheme = Exclude<Theme, "auto">;
14
15 export const THEME_STYLES = [
16 "graphite",
17 "aurora",
18 "slate",
19 "carbon",
20 "nocturne",
21 "amber",
22 ] as const;
23
24 export type ThemeStyle = (typeof THEME_STYLES)[number];
25
26 // Old style identifiers map to the closest new direction so settings stored
27 // from previous versions still resolve to a valid value.
28 const LEGACY_STYLE_MAP: Record<string, ThemeStyle> = {
29 ember: "carbon",
30 midnight: "nocturne",
31 sandstone: "amber",
32 porcelain: "nocturne",
33 linen: "amber",
34 glacier: "slate",
35 };
36
37 const DEFAULT_THEME_STYLE: ThemeStyle = "graphite";
38 const DEFAULT_THEME: Theme = "auto";
39
40 const THEME_KEY = "reasonix-theme";
41 const STYLE_KEY = "reasonix-theme-style";
42 const AUTO_THEME_MEDIA_QUERY = "(prefers-color-scheme: light)";
43 const BASE_CODE_READABILITY_STYLE_ID = "reasonix-base-code-readability";
44 let currentTheme: Theme = DEFAULT_THEME;
45 let currentThemeStyle: ThemeStyle = DEFAULT_THEME_STYLE;
46 let autoThemeMediaQuery: MediaQueryList | null = null;
47
48 export function normalizeThemePreference(value: unknown): Theme {
49 if (typeof value === "object" && value !== null) {
50 return normalizeThemePreference((value as { mode?: unknown }).mode);
51 }
52 if (typeof value !== "string") return DEFAULT_THEME;
53 switch (value) {
54 case "auto":
55 return "auto";
56 case "light":
57 case "focus":
58 case "forest":
59 return "light";
60 case "dark":
61 case "midnight":
62 case "contrast":
63 return "dark";
64 default:
65 return DEFAULT_THEME;
66 }
67 }
68
69 export function isThemeStyle(value: unknown): value is ThemeStyle {
70 return typeof value === "string" && (THEME_STYLES as readonly string[]).includes(value);
71 }
72
73 export function getTheme(): Theme {
74 return currentTheme;
75 }
76
77 export function getResolvedTheme(theme: Theme = getTheme()): ResolvedTheme {
78 if (theme === "light" || theme === "dark") return theme;
79 if (typeof window !== "undefined" && window.matchMedia?.(AUTO_THEME_MEDIA_QUERY).matches) return "light";
80 return "dark";
81 }
82
83 // Direction is orthogonal to theme, but keep this helper so callers that
84 // stored values in the old "style implies theme" model can still ask.
85 export function defaultStyleForTheme(_theme: Theme = getTheme()): ThemeStyle {
86 return DEFAULT_THEME_STYLE;
87 }
88
89 // themeForStyle previously returned the dark/light forced by the style. Style
90 // is now independent of theme, so we keep the current theme.
91 export function themeForStyle(_style: ThemeStyle): ResolvedTheme {
92 return getResolvedTheme();
93 }
94
95 export function getThemeStyle(_theme: Theme = getTheme()): ThemeStyle {
96 return currentThemeStyle;
97 }
98
99 export function normalizeThemeStyleForTheme(style: string | undefined, _theme?: Theme): ThemeStyle {
100 if (typeof style !== "string") return DEFAULT_THEME_STYLE;
101 if (isThemeStyle(style)) return style;
102 return LEGACY_STYLE_MAP[style] ?? DEFAULT_THEME_STYLE;
103 }
104
105 export function applyTheme(theme: Theme, style: ThemeStyle = getThemeStyle(theme), options: { persist?: boolean } = {}): void {
106 if (typeof document === "undefined") return;
107 ensureBaseCodeReadabilityStyle();
108 const root = document.documentElement;
109 root.removeAttribute("data-theme-mode");
110 root.removeAttribute("data-theme-scheme");
111 if (theme === "auto") root.removeAttribute("data-theme");
112 else root.setAttribute("data-theme", theme);
113
114 const nextStyle: ThemeStyle = isThemeStyle(style) ? style : DEFAULT_THEME_STYLE;
115 currentTheme = theme;
116 currentThemeStyle = nextStyle;
117 root.setAttribute("data-theme-style", nextStyle);
118
119 // Sync the native window theme (title bar, traffic lights) to match.
120 const runtime = typeof window !== "undefined" ? window.runtime : undefined;
121 if (runtime) {
122 syncAutoThemeBackgroundListener(theme);
123 if (theme === "auto") {
124 runtime.WindowSetSystemDefaultTheme?.();
125 } else if (theme === "light") {
126 runtime.WindowSetLightTheme?.();
127 } else if (theme === "dark") {
128 runtime.WindowSetDarkTheme?.();
129 }
130 syncNativeWindowBackground(theme);
131 }
132
133 void options;
134 }
135
136 function ensureBaseCodeReadabilityStyle(): void {
137 if (document.getElementById(BASE_CODE_READABILITY_STYLE_ID)) return;
138 const style = document.createElement("style");
139 style.id = BASE_CODE_READABILITY_STYLE_ID;
140 style.textContent = baseCodeReadabilityStylesheet(THEME_STYLES);
141 document.head.appendChild(style);
142 }
143
144 function syncAutoThemeBackgroundListener(theme: Theme): void {
145 if (theme !== "auto") {
146 clearAutoThemeBackgroundListener();
147 return;
148 }
149 if (autoThemeMediaQuery || typeof window === "undefined" || !window.matchMedia) return;
150 autoThemeMediaQuery = window.matchMedia(AUTO_THEME_MEDIA_QUERY);
151 if (typeof autoThemeMediaQuery.addEventListener === "function") {
152 autoThemeMediaQuery.addEventListener("change", syncAutoThemeBackground);
153 } else {
154 autoThemeMediaQuery.addListener(syncAutoThemeBackground);
155 }
156 }
157
158 function clearAutoThemeBackgroundListener(): void {
159 if (!autoThemeMediaQuery) return;
160 if (typeof autoThemeMediaQuery.removeEventListener === "function") {
161 autoThemeMediaQuery.removeEventListener("change", syncAutoThemeBackground);
162 } else {
163 autoThemeMediaQuery.removeListener(syncAutoThemeBackground);
164 }
165 autoThemeMediaQuery = null;
166 }
167
168 function syncAutoThemeBackground(): void {
169 if (currentTheme === "auto" && typeof window !== "undefined" && window.runtime) {
170 syncNativeWindowBackground("auto");
171 }
172 }
173
174 export function readLegacyThemePreference(): { theme: Theme; style: ThemeStyle; hasValue: boolean } {
175 if (typeof localStorage === "undefined") return { theme: DEFAULT_THEME, style: DEFAULT_THEME_STYLE, hasValue: false };
176 let rawTheme: string | null = null;
177 let rawStyle: string | null = null;
178 try {
179 rawTheme = localStorage.getItem(THEME_KEY);
180 rawStyle = localStorage.getItem(STYLE_KEY);
181 } catch {
182 return { theme: DEFAULT_THEME, style: DEFAULT_THEME_STYLE, hasValue: false };
183 }
184 const hasValue = rawTheme !== null || rawStyle !== null;
185 let theme = DEFAULT_THEME;
186 if (rawTheme) {
187 try {
188 theme = normalizeThemePreference(JSON.parse(rawTheme) as unknown);
189 } catch {
190 theme = normalizeThemePreference(rawTheme);
191 }
192 }
193 const style = normalizeThemeStyleForTheme(rawStyle ?? undefined, theme);
194 return { theme, style, hasValue };
195 }
196
197 export function clearLegacyThemePreference(): void {
198 try {
199 localStorage.removeItem(THEME_KEY);
200 localStorage.removeItem(STYLE_KEY);
201 } catch {
202 /* ignore storage failures */
203 }
204 }
205
206 // initTheme runs before React mounts. It applies the saved theme to the DOM and
207 // sets the native window background colour to match the resolved theme, avoiding
208 // a white (or wrong-colour) flash while the webview paints its first frame.
209 export function initTheme(): void {
210 const theme = getTheme();
211 applyTheme(theme, getThemeStyle(theme), { persist: false });
212 }
213
214 function syncNativeWindowBackground(theme: Theme): void {
215 const runtime = typeof window !== "undefined" ? window.runtime : undefined;
216 if (!runtime?.WindowSetBackgroundColour) return;
217 const resolved = getResolvedTheme(theme);
218 if (resolved === "light") {
219 // Light shell: matches graphite --bg (#f4f3ef).
220 runtime.WindowSetBackgroundColour(244, 243, 239, 255);
221 } else {
222 // Dark shell: matches :root --bg (#090a0c).
223 runtime.WindowSetBackgroundColour(9, 10, 12, 255);
224 }
225 }
226
226 lines TYPESCRIPT