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); +} 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], []); +});