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:
@@ -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,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user