Add dark mode toggle in the sidebar

Adds a persistent dark/light theme toggle (localStorage +
data-bs-theme on <html>) so it applies globally, including the
circuit editor route which has no visible sidebar. An inline
beforeInteractive script sets the theme before hydration to avoid a
flash of the wrong theme.

Bootstrap components pick up data-bs-theme automatically; the custom
circuit-grid CSS previously used ~150 hardcoded hex colors, all
converted to semantic tokens in globals.css with light/dark values.

Verified with a headless-browser pass across the project list, project
detail page, circuit editor, project-device drawer and a settings
modal in both themes; no console errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-08-06 21:56:21 +02:00
parent a99980c47b
commit 64ccd1f829
4 changed files with 395 additions and 127 deletions

View file

@ -0,0 +1,63 @@
"use client";
import { useEffect, useState } from "react";
export const THEME_STORAGE_KEY = "leistungsbilanz:theme";
type Theme = "light" | "dark";
function applyTheme(theme: Theme) {
document.documentElement.setAttribute("data-bs-theme", theme);
}
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>("light");
// The inline script in layout.tsx already applied the persisted/system
// theme before hydration; read it back so the toggle starts in sync
// instead of flashing to "light" first.
useEffect(() => {
const current = document.documentElement.getAttribute("data-bs-theme");
setTheme(current === "dark" ? "dark" : "light");
}, []);
useEffect(() => {
function handleStorage(event: StorageEvent) {
if (
event.key === THEME_STORAGE_KEY &&
(event.newValue === "dark" || event.newValue === "light")
) {
setTheme(event.newValue);
applyTheme(event.newValue);
}
}
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
function toggleTheme() {
const next: Theme = theme === "dark" ? "light" : "dark";
setTheme(next);
applyTheme(next);
try {
localStorage.setItem(THEME_STORAGE_KEY, next);
} catch {
// Private browsing or disabled storage: theme still applies for
// this page load, just without persistence across reloads.
}
}
return (
<button
aria-pressed={theme === "dark"}
className="sidebar-theme-toggle"
onClick={toggleTheme}
type="button"
>
<span className="sidebar-icon" aria-hidden="true">
{theme === "dark" ? "☀" : "☾"}
</span>
{theme === "dark" ? "Hellmodus" : "Dunkelmodus"}
</button>
);
}