diff --git a/docs/API.md b/docs/API.md index ac8ba34..dbcb4af 100644 --- a/docs/API.md +++ b/docs/API.md @@ -402,6 +402,16 @@ Proxies `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&accept - SPA served from `public/`; all non-`/api` GETs fall back to `public/index.html` is NOT required — a single `index.html` with hash-based routing (`#/login`, `#/trips`, `#/trip/:id[/:tab]`) is the expected design, so no server-side fallback is needed. +### Theme switcher (dark/light, frontend-only) + +The palette already lives in CSS custom properties on `:root` (light). Dark mode is a token override, not a parallel stylesheet. + +- **Mechanism**: `document.documentElement` carries `data-theme="dark"` or `data-theme="light"`. `public/css/theme.css` (new, linked in index.html after styles.css) defines the complete dark palette under `[data-theme="dark"]` — every colour token `:root` defines (brand/bg/surface/border/text/danger/success/warn soft variants, shadows) gets a dark value; nothing may be defined only in the dark block. +- **Resolution order**: `localStorage.theme` (`"light"`/`"dark"`) if set, else the OS preference via `prefers-color-scheme` (followed LIVE through a `matchMedia` change listener until the user explicitly chooses). A tiny inline ` + + diff --git a/public/js/app.js b/public/js/app.js index 04a3bf3..0a48cf1 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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 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, ),