Follow OS color scheme by default, with a three-way toggle (System / Light / Dark) in the kebab menu. Light theme uses warm, neutral tones with soft card-to-background contrast. Semantic colors (damage, healing, conditions) keep their hue across themes. Closes #10 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
99 lines
2.4 KiB
TypeScript
99 lines
2.4 KiB
TypeScript
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
|
|
|
type ThemePreference = "system" | "light" | "dark";
|
|
type ResolvedTheme = "light" | "dark";
|
|
|
|
const STORAGE_KEY = "initiative:theme";
|
|
|
|
const listeners = new Set<() => void>();
|
|
let currentPreference: ThemePreference = loadPreference();
|
|
|
|
function loadPreference(): ThemePreference {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (raw === "light" || raw === "dark" || raw === "system") return raw;
|
|
} catch {
|
|
// storage unavailable
|
|
}
|
|
return "system";
|
|
}
|
|
|
|
function savePreference(pref: ThemePreference): void {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, pref);
|
|
} catch {
|
|
// quota exceeded or storage unavailable
|
|
}
|
|
}
|
|
|
|
function getSystemTheme(): ResolvedTheme {
|
|
if (typeof globalThis.matchMedia !== "function") return "dark";
|
|
return globalThis.matchMedia("(prefers-color-scheme: light)").matches
|
|
? "light"
|
|
: "dark";
|
|
}
|
|
|
|
function resolve(pref: ThemePreference): ResolvedTheme {
|
|
return pref === "system" ? getSystemTheme() : pref;
|
|
}
|
|
|
|
function applyTheme(resolved: ResolvedTheme): void {
|
|
document.documentElement.dataset.theme = resolved;
|
|
}
|
|
|
|
function notifyAll(): void {
|
|
for (const listener of listeners) {
|
|
listener();
|
|
}
|
|
}
|
|
|
|
// Apply on load
|
|
applyTheme(resolve(currentPreference));
|
|
|
|
// Listen for OS preference changes
|
|
if (typeof globalThis.matchMedia === "function") {
|
|
globalThis
|
|
.matchMedia("(prefers-color-scheme: light)")
|
|
.addEventListener("change", () => {
|
|
if (currentPreference === "system") {
|
|
applyTheme(resolve("system"));
|
|
notifyAll();
|
|
}
|
|
});
|
|
}
|
|
|
|
function subscribe(callback: () => void): () => void {
|
|
listeners.add(callback);
|
|
return () => listeners.delete(callback);
|
|
}
|
|
|
|
function getSnapshot(): ThemePreference {
|
|
return currentPreference;
|
|
}
|
|
|
|
const CYCLE: ThemePreference[] = ["system", "light", "dark"];
|
|
|
|
export function useTheme() {
|
|
const preference = useSyncExternalStore(subscribe, getSnapshot);
|
|
const resolved = resolve(preference);
|
|
|
|
useEffect(() => {
|
|
applyTheme(resolved);
|
|
}, [resolved]);
|
|
|
|
const setPreference = useCallback((pref: ThemePreference) => {
|
|
currentPreference = pref;
|
|
savePreference(pref);
|
|
applyTheme(resolve(pref));
|
|
notifyAll();
|
|
}, []);
|
|
|
|
const cycleTheme = useCallback(() => {
|
|
const idx = CYCLE.indexOf(currentPreference);
|
|
const next = CYCLE[(idx + 1) % CYCLE.length];
|
|
setPreference(next);
|
|
}, [setPreference]);
|
|
|
|
return { preference, resolved, setPreference, cycleTheme } as const;
|
|
}
|