feat(toolrate): light/dark/system theme toggle with FOUC guard
Build & Push Docker Image / build (push) Successful in 2m30s

- use-theme hook: localStorage (toolrate-theme, default system), matchMedia
  change listener, shared singleton listener, sets .dark + color-scheme
- ThemeToggle: lightbulb quick toggle (light/dark) + dropdown (light/dark/system)
- Sidebar footer + mobile header + standalone login page
- inline script in index.html to apply theme pre-render (no FOUC)
- recharts axis ticks use hsl(var(--foreground)) for dark-mode readability
This commit is contained in:
opencode
2026-08-01 19:47:39 +02:00
parent db397a14bc
commit d244a537ea
7 changed files with 199 additions and 18 deletions
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useState } from "react";
const STORAGE_KEY = "toolrate-theme";
const THEME_VALUES = ["light", "dark", "system"] as const;
export type Theme = (typeof THEME_VALUES)[number];
type ResolvedTheme = "light" | "dark";
function darkQuery(): MediaQueryList {
return window.matchMedia("(prefers-color-scheme: dark)");
}
export function resolveTheme(theme: Theme): ResolvedTheme {
if (theme === "system") {
return typeof window !== "undefined" && darkQuery().matches ? "dark" : "light";
}
return theme;
}
function applyTheme(resolved: ResolvedTheme) {
const root = document.documentElement;
root.classList.toggle("dark", resolved === "dark");
root.style.colorScheme = resolved;
}
function getStoredTheme(): Theme {
if (typeof window === "undefined") return "system";
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored && (THEME_VALUES as readonly string[]).includes(stored)
? (stored as Theme)
: "system";
} catch {
return "system";
}
}
function storeTheme(theme: Theme) {
try {
window.localStorage.setItem(STORAGE_KEY, theme);
} catch {
/* ignore */
}
}
const listeners = new Set<(theme: Theme) => void>();
function broadcast(theme: Theme) {
for (const listener of listeners) {
listener(theme);
}
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(getStoredTheme);
// Keep every mounted hook instance in sync (sidebar, mobile header, login).
useEffect(() => {
const listener = (next: Theme) => setThemeState(next);
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}, []);
useEffect(() => {
applyTheme(resolveTheme(theme));
if (theme !== "system") {
storeTheme(theme);
broadcast(theme);
return;
}
const onChange = () => applyTheme(resolveTheme("system"));
darkQuery().addEventListener("change", onChange);
return () => darkQuery().removeEventListener("change", onChange);
}, [theme]);
function setTheme(next: Theme) {
if (!(THEME_VALUES as readonly string[]).includes(next)) return;
setThemeState(next);
storeTheme(next);
broadcast(next);
applyTheme(resolveTheme(next));
}
return {
theme,
setTheme,
resolvedTheme: resolveTheme(theme),
};
}