| 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 |