From 75dd43c8e3dbb45e101394c4cd343c291a016e19 Mon Sep 17 00:00:00 2001 From: ZProger Date: Sun, 16 Aug 2026 23:12:20 +0300 Subject: [PATCH] feat: manual theme picker (light/dark/system) in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/core/config.py | 2 +- e2e/tests/theme-switcher.spec.ts | 35 +++++++++++ frontend/index.html | 18 ++++++ frontend/package.json | 2 +- .../src/components/settings/ThemeCard.tsx | 26 ++++++++ frontend/src/index.css | 5 +- frontend/src/lib/i18n.ts | 8 +++ frontend/src/lib/theme.ts | 62 +++++++++++++++++++ frontend/src/main.tsx | 1 + frontend/src/pages/SettingsPage.tsx | 2 + 10 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 e2e/tests/theme-switcher.spec.ts create mode 100644 frontend/src/components/settings/ThemeCard.tsx create mode 100644 frontend/src/lib/theme.ts diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 21659f8..659dc40 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -6,7 +6,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict # Single source of truth for the running app's version — surfaced in the API # title and embedded in exported backups so an old file can be told apart # from a current one. -APP_VERSION = "0.18.0" +APP_VERSION = "0.19.0" class Settings(BaseSettings): diff --git a/e2e/tests/theme-switcher.spec.ts b/e2e/tests/theme-switcher.spec.ts new file mode 100644 index 0000000..e95fd4c --- /dev/null +++ b/e2e/tests/theme-switcher.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from "@playwright/test"; + +// The theme picker (Settings → Тема оформления): light/dark/system, with +// system meant to follow the OS live and light/dark meant to override it. +// See frontend/src/lib/theme.ts and index.html's anti-FOUC inline script. + +async function surface0(page: import("@playwright/test").Page): Promise { + return page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--surface-0").trim()); +} + +test.describe("theme picker", () => { + test.use({ colorScheme: "dark" }); // simulates the OS being in dark mode + + test("an explicit choice overrides the OS theme, and persists across reload", async ({ page }) => { + await page.goto("/settings"); + await expect(await surface0(page)).toBe("#0d0d0d"); // "system" follows the dark OS by default + + await page.getByRole("button", { name: "Светлая", exact: true }).click(); + await expect(await surface0(page)).toBe("#f9f9f7"); // forced light despite the dark OS + + await page.reload(); + await expect(await surface0(page)).toBe("#f9f9f7"); // survives a reload, not just in-memory state + + await page.getByRole("button", { name: "Системная", exact: true }).click(); + await expect(await surface0(page)).toBe("#0d0d0d"); // back to following the (still-dark) OS + }); + + test("switching back to system never leaves a stale forced theme behind", async ({ page }) => { + await page.goto("/settings"); + await page.getByRole("button", { name: "Тёмная", exact: true }).click(); + await page.getByRole("button", { name: "Системная", exact: true }).click(); + const dataTheme = await page.evaluate(() => document.documentElement.getAttribute("data-theme")); + expect(dataTheme).toBeNull(); + }); +}); diff --git a/frontend/index.html b/frontend/index.html index 59fac2e..b4cf15a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,6 +6,24 @@ Aurum — Личные финансы +
diff --git a/frontend/package.json b/frontend/package.json index 6f894ce..a13d3ce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "aurum-frontend", "private": true, - "version": "0.18.0", + "version": "0.19.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/settings/ThemeCard.tsx b/frontend/src/components/settings/ThemeCard.tsx new file mode 100644 index 0000000..9a127f8 --- /dev/null +++ b/frontend/src/components/settings/ThemeCard.tsx @@ -0,0 +1,26 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { PillSelector } from "@/components/layout/PillSelector"; +import { useTranslation } from "@/lib/i18n"; +import { useTheme, type Theme } from "@/lib/theme"; + +export function ThemeCard() { + const { t } = useTranslation(); + const { theme, setTheme } = useTheme(); + + const options: Array<{ value: Theme; label: string }> = [ + { value: "light", label: t("settings.themeLight") }, + { value: "dark", label: t("settings.themeDark") }, + { value: "system", label: t("settings.themeSystem") }, + ]; + + return ( + + + {t("settings.theme")} + + + + + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index c4bf4da..9a9d202 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -2,8 +2,9 @@ /* * Semantic tokens, light values first. Dark values are re-declared for both - * the OS-level `prefers-color-scheme` and an eventual manual `[data-theme]` - * toggle, per the dataviz skill's reference palette (references/palette.md). + * the OS-level `prefers-color-scheme` and the manual `[data-theme]` toggle + * (Settings → Theme, see lib/theme.ts), per the dataviz skill's reference + * palette (references/palette.md). */ :root { color-scheme: light; diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 97b17e3..b3c576d 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -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 = { "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.", diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts new file mode 100644 index 0000000..cbdf8b3 --- /dev/null +++ b/frontend/src/lib/theme.ts @@ -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 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 }; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 167b185..e4ef2c9 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter } from "react-router-dom"; import App from "@/App"; +import "@/lib/theme"; import "@/index.css"; const queryClient = new QueryClient({ diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index b850220..b5e11e7 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -2,10 +2,12 @@ import { AlertThresholdsCard } from "@/components/settings/AlertThresholdsCard"; import { BackupCard } from "@/components/settings/BackupCard"; import { CurrencyCard } from "@/components/settings/CurrencyCard"; import { LanguageCard } from "@/components/settings/LanguageCard"; +import { ThemeCard } from "@/components/settings/ThemeCard"; export function SettingsPage() { return (
+