/** * Theme preference: light, dark, or follow the system. * * Stored in localStorage rather than in the dataset, deliberately. The dataset * is the owner's budget — it is exported, imported and versioned, and a * per-device display choice does not belong in it. Importing a backup taken on * a desktop should not flip a phone to dark. * * Every storage access is wrapped: localStorage THROWS on access in some * configurations (a browser set to block site data, some private modes), not * merely returning null, and a theme preference must never be the reason the * app fails to boot. */ export const THEME_KEY = "budget2:theme"; /** What the user chose. */ export type ThemePreference = "light" | "dark" | "system"; /** What that resolves to right now. Only these two reach the DOM. */ export type ResolvedTheme = "light" | "dark"; const PREFERENCES: readonly string[] = ["light", "dark", "system"]; export function isThemePreference(value: unknown): value is ThemePreference { return typeof value === "string" && PREFERENCES.includes(value); } export function readThemePreference(): ThemePreference { try { const stored = localStorage.getItem(THEME_KEY); return isThemePreference(stored) ? stored : "system"; } catch { return "system"; } } export function storeThemePreference(preference: ThemePreference): void { try { localStorage.setItem(THEME_KEY, preference); } catch { // A preference that cannot be remembered is a small loss; a crash is not. } } export function prefersDark(): boolean { try { return window.matchMedia("(prefers-color-scheme: dark)").matches; } catch { return false; } } export function resolveTheme( preference: ThemePreference, systemPrefersDark: boolean, ): ResolvedTheme { if (preference === "system") return systemPrefersDark ? "dark" : "light"; return preference; } /** * shadcn's dark palette is keyed on a `.dark` class rather than a media query * (`@custom-variant dark (&:is(.dark *))` in styles/globals.css), so this * class is the single switch for both shadcn's variables and the app's own * `--budget-*` tokens. */ export function applyResolvedTheme(theme: ResolvedTheme): void { document.documentElement.classList.toggle("dark", theme === "dark"); applyColorScheme(theme); } /** * States the live scheme in ``, kept in step with the * `color-scheme` declarations in index.css. * * The meta tag rather than the stylesheet alone, because of Dark Reader: it * reads this tag first, and failing to find one it samples text and background * colours off the painted page and applies its own inversion. On our dark * palette that is a second darkening of an already dark app. Declaring "dark" * is what tells it the app is already there. * * index.html ships the tag and the pre-paint script sets it, so this is * normally an update; it creates the tag if it is missing rather than silently * doing nothing, which would leave Dark Reader guessing again. */ function applyColorScheme(theme: ResolvedTheme): void { let meta = document.querySelector('meta[name="color-scheme"]'); if (!meta) { meta = document.createElement("meta"); meta.name = "color-scheme"; document.head.appendChild(meta); } meta.content = theme; }