From 9a2b91c5a3e2851e25d18d79427dfa1b272e24a0 Mon Sep 17 00:00:00 2001 From: Phenisys Dev Agent Date: Tue, 25 Aug 2026 20:27:58 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(theme):=20label=20the=20system=20theme?= =?UTF-8?q?=20option=20as=20Syst=C3=A8me/System=20in=20all=20locales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/de/translation.json | 2 +- src/locales/en/translation.json | 2 +- src/locales/es/translation.json | 2 +- src/locales/fr/translation.json | 2 +- src/locales/it/translation.json | 2 +- src/locales/ja/translation.json | 2 +- src/locales/pt/translation.json | 2 +- src/locales/ru/translation.json | 2 +- src/locales/zh-CN/translation.json | 2 +- src/locales/zh-TW/translation.json | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 76531cde98..6e2255d58a 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1779,7 +1779,7 @@ }, "general": { "appearance": { - "auto": "Auto", + "auto": "System", "dark": "Dunkel", "description": "Steuern Sie das Erscheinungsbild von OpenWhispr", "light": "Hell", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 2d2cf17d65..07cbc85ee9 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1791,7 +1791,7 @@ }, "general": { "appearance": { - "auto": "Auto", + "auto": "System", "dark": "Dark", "description": "Control how OpenWhispr looks", "light": "Light", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 477eb9d523..bf97613086 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -1549,7 +1549,7 @@ }, "general": { "appearance": { - "auto": "Auto", + "auto": "Sistema", "dark": "Oscuro", "description": "Controla el aspecto de OpenWhispr", "light": "Claro", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index eb694026e0..9238b614b6 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -1791,7 +1791,7 @@ }, "general": { "appearance": { - "auto": "Auto", + "auto": "Système", "dark": "Sombre", "description": "Contrôlez l'apparence d'OpenWhispr", "light": "Clair", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index ac1b05ff34..15133f3298 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -1501,7 +1501,7 @@ }, "general": { "appearance": { - "auto": "Automatico", + "auto": "Sistema", "dark": "Scuro", "description": "Personalizza l'aspetto di OpenWhispr", "light": "Chiaro", diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index 552a85cfc0..2c0dc96a46 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -1501,7 +1501,7 @@ }, "general": { "appearance": { - "auto": "自動", + "auto": "システム", "dark": "ダーク", "description": "OpenWhispr の外観を設定", "light": "ライト", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 9ddabbdc96..0af7d7ac57 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -1480,7 +1480,7 @@ }, "general": { "appearance": { - "auto": "Auto", + "auto": "Sistema", "dark": "Escuro", "description": "Controle a aparência do OpenWhispr", "light": "Claro", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 75521be3bf..1f203f5568 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -1507,7 +1507,7 @@ }, "general": { "appearance": { - "auto": "Авто", + "auto": "Система", "dark": "Тёмная", "description": "Управляйте внешним видом OpenWhispr", "light": "Светлая", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index b7bb261d9c..6ac7382e1e 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -1501,7 +1501,7 @@ }, "general": { "appearance": { - "auto": "自动", + "auto": "系统", "dark": "深色", "description": "控制 OpenWhispr 的外观", "light": "浅色", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index f7560de073..cb3b86440a 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -1501,7 +1501,7 @@ }, "general": { "appearance": { - "auto": "自動", + "auto": "系统", "dark": "深色", "description": "控制 OpenWhispr 的外觀", "light": "淺色", From 39b395eab2b2c9606cfaf1310e263f7e9f37a2eb Mon Sep 17 00:00:00 2001 From: Phenisys Dev Agent Date: Tue, 25 Aug 2026 20:32:26 +0000 Subject: [PATCH 2/4] fix(theme): apply saved theme before first paint and share resolution logic Add an inline anti-flash bootstrap in index.html so windows open directly in the persisted theme (light/dark/system) instead of flashing light first. Extract the theme resolution into src/utils/theme.ts, reused by useTheme, with a single source of truth for the .dark class toggling. --- src/hooks/useTheme.ts | 44 ++++++++++++++++---------------------- src/index.html | 35 +++++++++++++++++++++++++++++-- src/utils/theme.ts | 49 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 28 deletions(-) create mode 100644 src/utils/theme.ts diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index 0dcbf62e8e..e8adc67e7e 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -1,5 +1,13 @@ import { useEffect } from "react"; import { useSettings } from "./useSettings"; +import { + applyThemeClass, + readStoredTheme, + resolveTheme, + type EffectiveTheme, +} from "../utils/theme"; + +const DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)"; export function useTheme() { const { theme, setTheme } = useSettings(); @@ -7,36 +15,20 @@ export function useTheme() { useEffect(() => { const htmlElement = document.documentElement; - // Determine effective theme - const effectiveTheme: "light" | "dark" = - theme === "auto" - ? window.matchMedia("(prefers-color-scheme: dark)").matches - ? "dark" - : "light" - : theme; - - // Apply dark class - if (effectiveTheme === "dark") { - htmlElement.classList.add("dark"); - document.body.classList.add("dark"); - } else { - htmlElement.classList.remove("dark"); - document.body.classList.remove("dark"); - } + // Determine effective theme (stored value, or system preference when auto) + const stored = readStoredTheme(window.localStorage); + const effectiveTheme: EffectiveTheme = resolveTheme( + stored, + window.matchMedia(DARK_SCHEME_QUERY).matches + ); + applyThemeClass(htmlElement, document.body, effectiveTheme); - // Listen for system preference changes (only when auto) + // Follow the system preference while the theme is set to "auto" if (theme === "auto") { - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const mediaQuery = window.matchMedia(DARK_SCHEME_QUERY); const handler = (e: MediaQueryListEvent) => { - if (e.matches) { - htmlElement.classList.add("dark"); - document.body.classList.add("dark"); - } else { - htmlElement.classList.remove("dark"); - document.body.classList.remove("dark"); - } + applyThemeClass(htmlElement, document.body, e.matches ? "dark" : "light"); }; - mediaQuery.addEventListener("change", handler); return () => mediaQuery.removeEventListener("change", handler); } diff --git a/src/index.html b/src/index.html index e767b282c7..89f3ea75d3 100644 --- a/src/index.html +++ b/src/index.html @@ -4,11 +4,42 @@ - + OpenWhispr +
- \ No newline at end of file + diff --git a/src/utils/theme.ts b/src/utils/theme.ts new file mode 100644 index 0000000000..36079b02b3 --- /dev/null +++ b/src/utils/theme.ts @@ -0,0 +1,49 @@ +/** + * Theme resolution helpers. + * + * The stored theme value is one of "light" | "dark" | "auto" (system). + * `resolveTheme` maps it to the effective "light" | "dark" used to toggle + * the `.dark` class. Kept framework-free so the same logic can run inline + * in index.html (anti-flash bootstrap, before React mounts) and in the + * useTheme hook, and be unit-tested in isolation. + */ + +export type StoredTheme = "light" | "dark" | "auto"; +export type EffectiveTheme = "light" | "dark"; + +export const THEME_STORAGE_KEY = "theme"; + +export function isStoredTheme(value: unknown): value is StoredTheme { + return value === "light" || value === "dark" || value === "auto"; +} + +export function readStoredTheme(storage: Pick): StoredTheme { + try { + const raw = storage.getItem(THEME_STORAGE_KEY); + if (raw && isStoredTheme(raw)) return raw; + } catch { + // localStorage may be unavailable (e.g. sandboxed/blocked) — fall through + } + return "auto"; +} + +/** Resolve a stored theme (with system preference) to the effective theme. */ +export function resolveTheme(stored: StoredTheme, prefersDark: boolean): EffectiveTheme { + if (stored === "auto") return prefersDark ? "dark" : "light"; + return stored; +} + +/** + * Apply (or remove) the `.dark` class on the document root. + * `html` and `body` both carry the class so any code that queries either + * sees a consistent state. + */ +export function applyThemeClass( + root: Pick, + body: Pick, + effective: EffectiveTheme +): void { + const isDark = effective === "dark"; + root.classList.toggle("dark", isDark); + body.classList.toggle("dark", isDark); +} From f89af13017af8c695563474140d4b95967d3624a Mon Sep 17 00:00:00 2001 From: Phenisys Dev Agent Date: Tue, 25 Aug 2026 20:32:28 +0000 Subject: [PATCH 3/4] test(theme): cover stored-theme resolution and .dark class application --- test/utils/theme.test.js | 76 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 test/utils/theme.test.js diff --git a/test/utils/theme.test.js b/test/utils/theme.test.js new file mode 100644 index 0000000000..a3399dca4c --- /dev/null +++ b/test/utils/theme.test.js @@ -0,0 +1,76 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const load = () => import("../../src/utils/theme.ts"); + +test("isStoredTheme accepts only light/dark/auto", async () => { + const { isStoredTheme } = await load(); + assert.equal(isStoredTheme("light"), true); + assert.equal(isStoredTheme("dark"), true); + assert.equal(isStoredTheme("auto"), true); + assert.equal(isStoredTheme("system"), false); + assert.equal(isStoredTheme(""), false); + assert.equal(isStoredTheme(null), false); + assert.equal(isStoredTheme(undefined), false); + assert.equal(isStoredTheme(42), false); +}); + +test("readStoredTheme returns the persisted value when valid", async () => { + const { readStoredTheme } = await load(); + const storage = { getItem: () => "dark" }; + assert.equal(readStoredTheme(storage), "dark"); + assert.equal(readStoredTheme({ getItem: () => "light" }), "light"); +}); + +test("readStoredTheme falls back to auto for unknown or missing values", async () => { + const { readStoredTheme } = await load(); + assert.equal(readStoredTheme({ getItem: () => "neon" }), "auto"); + assert.equal(readStoredTheme({ getItem: () => null }), "auto"); +}); + +test("readStoredTheme falls back to auto when storage throws", async () => { + const { readStoredTheme } = await load(); + const throwing = { + getItem: () => { + throw new Error("storage blocked"); + }, + }; + assert.equal(readStoredTheme(throwing), "auto"); +}); + +test("resolveTheme maps stored theme to effective theme", async () => { + const { resolveTheme } = await load(); + // Explicit choices win regardless of system preference + assert.equal(resolveTheme("light", true), "light"); + assert.equal(resolveTheme("dark", false), "dark"); + // auto follows the system preference + assert.equal(resolveTheme("auto", true), "dark"); + assert.equal(resolveTheme("auto", false), "light"); +}); + +test("applyThemeClass toggles .dark on root and body together", async () => { + const { applyThemeClass } = await load(); + const classes = { values: new Set() }; + const element = { + classList: { + toggle: (cls, on) => { + if (on) classes.values.add(cls); + else classes.values.delete(cls); + }, + }, + }; + const body = { + classList: { + toggle: (cls, on) => { + if (on) classes.values.add(`body:${cls}`); + else classes.values.delete(`body:${cls}`); + }, + }, + }; + + applyThemeClass(element, body, "dark"); + assert.deepEqual([...classes.values], ["dark", "body:dark"]); + + applyThemeClass(element, body, "light"); + assert.deepEqual([...classes.values], []); +}); From d1d3e68355a8c337f88b45f161473364abea4e8c Mon Sep 17 00:00:00 2001 From: Phenisys Dev Agent Date: Wed, 26 Aug 2026 08:53:24 +0000 Subject: [PATCH 4/4] fix(theme): restore Auto label for the auto theme option in all locales --- src/locales/de/translation.json | 2 +- src/locales/en/translation.json | 2 +- src/locales/es/translation.json | 2 +- src/locales/fr/translation.json | 2 +- src/locales/it/translation.json | 2 +- src/locales/ja/translation.json | 2 +- src/locales/pt/translation.json | 2 +- src/locales/ru/translation.json | 2 +- src/locales/zh-CN/translation.json | 2 +- src/locales/zh-TW/translation.json | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 6e2255d58a..76531cde98 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1779,7 +1779,7 @@ }, "general": { "appearance": { - "auto": "System", + "auto": "Auto", "dark": "Dunkel", "description": "Steuern Sie das Erscheinungsbild von OpenWhispr", "light": "Hell", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 07cbc85ee9..2d2cf17d65 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1791,7 +1791,7 @@ }, "general": { "appearance": { - "auto": "System", + "auto": "Auto", "dark": "Dark", "description": "Control how OpenWhispr looks", "light": "Light", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index bf97613086..477eb9d523 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -1549,7 +1549,7 @@ }, "general": { "appearance": { - "auto": "Sistema", + "auto": "Auto", "dark": "Oscuro", "description": "Controla el aspecto de OpenWhispr", "light": "Claro", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 9238b614b6..eb694026e0 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -1791,7 +1791,7 @@ }, "general": { "appearance": { - "auto": "Système", + "auto": "Auto", "dark": "Sombre", "description": "Contrôlez l'apparence d'OpenWhispr", "light": "Clair", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index 15133f3298..ac1b05ff34 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -1501,7 +1501,7 @@ }, "general": { "appearance": { - "auto": "Sistema", + "auto": "Automatico", "dark": "Scuro", "description": "Personalizza l'aspetto di OpenWhispr", "light": "Chiaro", diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index 2c0dc96a46..552a85cfc0 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -1501,7 +1501,7 @@ }, "general": { "appearance": { - "auto": "システム", + "auto": "自動", "dark": "ダーク", "description": "OpenWhispr の外観を設定", "light": "ライト", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 0af7d7ac57..9ddabbdc96 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -1480,7 +1480,7 @@ }, "general": { "appearance": { - "auto": "Sistema", + "auto": "Auto", "dark": "Escuro", "description": "Controle a aparência do OpenWhispr", "light": "Claro", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 1f203f5568..75521be3bf 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -1507,7 +1507,7 @@ }, "general": { "appearance": { - "auto": "Система", + "auto": "Авто", "dark": "Тёмная", "description": "Управляйте внешним видом OpenWhispr", "light": "Светлая", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 6ac7382e1e..b7bb261d9c 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -1501,7 +1501,7 @@ }, "general": { "appearance": { - "auto": "系统", + "auto": "自动", "dark": "深色", "description": "控制 OpenWhispr 的外观", "light": "浅色", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index cb3b86440a..f7560de073 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -1501,7 +1501,7 @@ }, "general": { "appearance": { - "auto": "系统", + "auto": "自動", "dark": "深色", "description": "控制 OpenWhispr 的外觀", "light": "淺色",