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