Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 18 additions & 26 deletions src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,34 @@
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();

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);
}
Expand Down
35 changes: 33 additions & 2 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,42 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="./assets/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./assets/fonts/noto-sans.css">
<link rel="stylesheet" href="./assets/fonts/noto-sans.css" />
<title>OpenWhispr</title>
<script>
// Anti-flash theme bootstrap: apply the saved theme before the first
// paint so windows never flash light when the user chose dark/system.
// Kept inline and framework-free on purpose — it must run before the
// CSSOM paints and before any module loads. Mirrors src/utils/theme.ts.
(function () {
try {
var stored = null;
try {
stored = window.localStorage.getItem("theme");
} catch (e) {
/* localStorage unavailable — fall back to system */
}
var dark = false;
if (stored === "light") {
dark = false;
} else if (stored === "dark") {
dark = true;
} else {
dark =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
}
if (dark) {
document.documentElement.classList.add("dark");
}
} catch (e) {
/* never block rendering on theme bootstrap */
}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.jsx"></script>
</body>
</html>
</html>
49 changes: 49 additions & 0 deletions src/utils/theme.ts
Original file line number Diff line number Diff line change
@@ -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<Storage, "getItem">): 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<HTMLElement, "classList">,
body: Pick<HTMLElement, "classList">,
effective: EffectiveTheme
): void {
const isDark = effective === "dark";
root.classList.toggle("dark", isDark);
body.classList.toggle("dark", isDark);
}
76 changes: 76 additions & 0 deletions test/utils/theme.test.js
Original file line number Diff line number Diff line change
@@ -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], []);
});
Loading