feat: manual theme picker (light/dark/system) in Settings

Theme previously followed only the OS's prefers-color-scheme, which
switches automatically on this user's Mac at sunset/sunrise. Adds an
explicit override in Settings: Light/Dark/System, System being the
previous (default) auto-follow-the-OS behavior.

Persisted client-side in localStorage (lib/theme.ts, same pattern as
language) rather than server-side like currency — it's a pure display
preference, and synchronous localStorage reads are what make the
anti-FOUC inline script in index.html possible. index.css already had the
[data-theme] CSS variables laid in ahead of time; only the JS to actually
set the attribute, and the Settings UI, were missing.

Covered by 2 new Playwright specs (theme-switcher.spec.ts) run against the
isolated e2e stack: explicit choice overrides a dark OS and survives
reload; switching back to "system" doesn't leave a stale forced theme.
This commit is contained in:
ZProger
2026-08-16 23:12:20 +03:00
parent 8e46726b4d
commit 75dd43c8e3
10 changed files with 157 additions and 4 deletions

View File

@@ -182,6 +182,10 @@ const ru = {
"settings.language": "Язык",
"settings.languageRussian": "Русский",
"settings.languageEnglish": "English",
"settings.theme": "Тема оформления",
"settings.themeLight": "Светлая",
"settings.themeDark": "Тёмная",
"settings.themeSystem": "Системная",
"settings.currency": "Основная валюта",
"settings.currencyHint":
"Меняет только отображение сумм — уже введённые значения не пересчитываются и не конвертируются.",
@@ -504,6 +508,10 @@ const en: Record<keyof typeof ru, string> = {
"settings.language": "Language",
"settings.languageRussian": "Русский",
"settings.languageEnglish": "English",
"settings.theme": "Theme",
"settings.themeLight": "Light",
"settings.themeDark": "Dark",
"settings.themeSystem": "System",
"settings.currency": "Primary currency",
"settings.currencyHint":
"Only changes how amounts are displayed — values you've already entered aren't recalculated or converted.",

62
frontend/src/lib/theme.ts Normal file
View File

@@ -0,0 +1,62 @@
import { useSyncExternalStore } from "react";
export type Theme = "light" | "dark" | "system";
const STORAGE_KEY = "aurum:theme";
// Kept in sync by hand with index.css's --surface-0 light/dark values —
// same pair the anti-FOUC inline script in index.html uses.
const SURFACE_0 = { light: "#f9f9f7", dark: "#0d0d0d" };
function readInitialTheme(): Theme {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark" || stored === "system") return stored;
return "system";
}
/** Mirrors what index.html's inline script does on first load — called
* again on every setTheme() so switching "system" while the OS is (say)
* dark takes effect immediately instead of waiting for the next reload. */
function applyTheme(theme: Theme): void {
if (theme === "system") {
document.documentElement.removeAttribute("data-theme");
} else {
document.documentElement.setAttribute("data-theme", theme);
}
const isDark =
theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", isDark ? SURFACE_0.dark : SURFACE_0.light);
}
let currentTheme: Theme = readInitialTheme();
const listeners = new Set<() => void>();
// The system-preference case needs no listener of its own — index.css's
// `@media (prefers-color-scheme: dark)` re-evaluates live on its own. This
// only exists to keep the <meta name="theme-color"> in step while the user
// has "system" selected and their OS theme changes mid-session.
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
if (currentTheme === "system") applyTheme(currentTheme);
});
export function getTheme(): Theme {
return currentTheme;
}
export function setTheme(theme: Theme): void {
if (theme === currentTheme) return;
currentTheme = theme;
localStorage.setItem(STORAGE_KEY, theme);
applyTheme(theme);
listeners.forEach((listener) => listener());
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useTheme() {
const theme = useSyncExternalStore(subscribe, () => currentTheme);
return { theme, setTheme };
}