Add a dark/light theme switcher to the navbar

The palette already lived in CSS custom properties, so dark mode is a
token-override block in the new theme.css, applied via data-theme on
the document element. A moon/sun toggle sits top right in the navbar;
an explicit choice persists in localStorage, otherwise the app follows
the OS preference live. An inline head script applies the theme before
first paint so a dark reload never flashes light.

Token hygiene sweep alongside: literal colours that broke under a dark
palette (timeline chip text, color-mix against white, calendar stripes
and bands, warning banners, nav background) moved into ten new tokens
with light-mode values unchanged. The flip clock keeps its fixed dark
look by design; the map keeps standard light tiles in both themes.
This commit is contained in:
2026-08-30 14:07:21 +02:00
parent 48c7a55bbd
commit 2f55723585
6 changed files with 207 additions and 16 deletions
+72
View File
@@ -12,6 +12,77 @@ function root() {
return document.getElementById('app');
}
// ---------- Theme switcher (dark/light) ----------
// Resolution order: explicit localStorage choice, else OS preference
// (followed live via the matchMedia listener below until the user picks
// explicitly). The inline script in index.html's <head> applies the initial
// attribute before first paint; this just keeps it in sync afterwards.
const THEME_KEY = 'theme';
function getStoredTheme() {
try {
return localStorage.getItem(THEME_KEY);
} catch (e) {
return null;
}
}
function setStoredTheme(theme) {
try {
localStorage.setItem(THEME_KEY, theme);
} catch (e) {
// localStorage may throw in privacy modes; the choice just won't persist.
}
}
function currentTheme() {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
}
let themeToggleBtn = null;
function updateThemeToggle() {
if (!themeToggleBtn) return;
const dark = currentTheme() === 'dark';
themeToggleBtn.textContent = dark ? '☀️' : '🌙';
themeToggleBtn.setAttribute('aria-label', dark ? 'Switch to light theme' : 'Switch to dark theme');
}
function onThemeToggle() {
const next = currentTheme() === 'dark' ? 'light' : 'dark';
applyTheme(next);
setStoredTheme(next);
updateThemeToggle();
}
function renderThemeToggle() {
themeToggleBtn = el('button', {
class: 'theme-toggle',
type: 'button',
title: 'Toggle theme',
onClick: onThemeToggle,
});
updateThemeToggle();
return themeToggleBtn;
}
// Module-scope, attached once, app-lifetime by design (no removal path
// needed — unlike per-view listeners, this isn't tied to any view's mount).
if (window.matchMedia) {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const onSchemeChange = (e) => {
if (getStoredTheme()) return; // an explicit choice always wins
applyTheme(e.matches ? 'dark' : 'light');
updateThemeToggle();
};
if (media.addEventListener) media.addEventListener('change', onSchemeChange);
else media.addListener(onSchemeChange); // older Safari
}
function navigate(hash) {
if (location.hash === hash) route();
else location.hash = hash;
@@ -48,6 +119,7 @@ function renderNav() {
el(
'div',
{ class: 'nav-right' },
renderThemeToggle(),
state.user ? renderUserArea() : null,
state.user ? el('button', { class: 'btn btn-ghost', onClick: onLogout }, 'Log out') : null,
),