feat(toolrate): light/dark/system theme toggle with FOUC guard
Build & Push Docker Image / build (push) Successful in 2m30s
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:
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user