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:
+10
@@ -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 `<script>` in `index.html`'s `<head>` applies the attribute BEFORE the stylesheets paint (no flash of wrong theme). `localStorage` access wrapped in try/catch (privacy modes throw).
|
||||
- **Toggle**: an icon button at the TOP RIGHT of the navbar (in `.nav-right`, before the user area) — shows 🌙 when light (click → dark) and ☀️ when dark (click → light), `aria-label` "Switch to dark/light theme". Clicking stores the explicit choice and swaps the attribute; no page reload.
|
||||
- **Token hygiene sweep** (required, same commit): hard-coded colours that break under a dark palette move to tokens — `timeline.css` chip/block text `#334155` → a new `--chip-text` token and its `color-mix(… , white)` backgrounds → `color-mix(… , var(--surface))`; any equivalent literals in `styles.css` likewise. The flip-clock stays dark by design (its own fixed colours are the aesthetic, both themes). The Leaflet map keeps standard light tiles in both themes (out of scope); its container chrome uses tokens like everything else. Entry/type accent colours from `format.js` (`--chip` inline vars) are theme-neutral and unchanged.
|
||||
- styles.css is at its 500-line cap: the dark palette, the toggle button styles, and any new tokens' *dark* values live in `theme.css`; edits to styles.css are replacements only (introducing a new token's light value in `:root` is allowed, growth beyond the cap is not).
|
||||
|
||||
### Trip page tabs & compact nav header (frontend-only)
|
||||
|
||||
The trip page is split into three tabs, and the old full-width trip-header card is REMOVED — its contents move into the top navbar so the page starts with actual content.
|
||||
|
||||
+24
-12
@@ -28,6 +28,18 @@
|
||||
--shadow-lg: 0 18px 48px rgba(15, 23, 42, 0.22);
|
||||
|
||||
--font: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
|
||||
/* Theme-hygiene tokens (dark values in theme.css) */
|
||||
--chip-text: #334155;
|
||||
--nav-bg: rgba(255, 255, 255, 0.9);
|
||||
--auth-tint: #dbe7ff;
|
||||
--stripe-a: #f1f5f9;
|
||||
--stripe-b: #eef2f7;
|
||||
--warn-soft-bg: #fffbeb;
|
||||
--warn-soft-border: #fde68a;
|
||||
--warn-soft-text: #92400e;
|
||||
--role-owner-bg: #fef3c7;
|
||||
--chip-price-bg: rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -123,7 +135,7 @@ textarea.input { resize: vertical; }
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.7rem 1.4rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
background: var(--nav-bg);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
@@ -148,7 +160,7 @@ textarea.input { resize: vertical; }
|
||||
|
||||
/* ---------- Auth ---------- */
|
||||
.auth-wrap { min-height: 100vh; display: grid; place-items: center; padding: 1.5rem;
|
||||
background: radial-gradient(1200px 600px at 50% -10%, #dbe7ff 0%, var(--bg) 55%); }
|
||||
background: radial-gradient(1200px 600px at 50% -10%, var(--auth-tint) 0%, var(--bg) 55%); }
|
||||
.auth-card { width: 100%; max-width: 400px; padding: 1.8rem; }
|
||||
.auth-head { text-align: center; margin-bottom: 1.3rem; }
|
||||
.auth-head h1 { margin: 0.4rem 0 0.2rem; }
|
||||
@@ -161,7 +173,7 @@ textarea.input { resize: vertical; }
|
||||
.auth-lead { color: var(--text-muted); font-size: 0.92rem; }
|
||||
.token-input { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.token-reveal { gap: 1rem; }
|
||||
.token-warning { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; font-size: 0.85rem; padding: 0.7rem 0.85rem; border-radius: var(--radius-sm); line-height: 1.45; }
|
||||
.token-warning { background: var(--warn-soft-bg); border: 1px solid var(--warn-soft-border); color: var(--warn-soft-text); font-size: 0.85rem; padding: 0.7rem 0.85rem; border-radius: var(--radius-sm); line-height: 1.45; }
|
||||
.token-box { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 1.35rem; font-weight: 700; letter-spacing: 0.1em; text-align: center; padding: 0.9rem; background: var(--surface-2); border: 2px dashed var(--border-strong); border-radius: var(--radius-sm); user-select: all; word-break: break-all; }
|
||||
.token-actions { display: flex; justify-content: center; }
|
||||
|
||||
@@ -196,7 +208,7 @@ textarea.input { resize: vertical; }
|
||||
.trip-card.drop-before { box-shadow: inset 0 3px 0 var(--brand); }
|
||||
.trip-card.drop-after { box-shadow: inset 0 -3px 0 var(--brand); }
|
||||
.role-badge { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700; padding: 0.15rem 0.5rem; border-radius: 999px; }
|
||||
.role-owner { background: #fef3c7; color: #92400e; }
|
||||
.role-owner { background: var(--role-owner-bg); color: var(--warn-soft-text); }
|
||||
.role-editor { background: var(--brand-soft); color: var(--brand-dark); }
|
||||
.new-trip-form { display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
|
||||
@@ -227,8 +239,8 @@ textarea.input { resize: vertical; }
|
||||
a 7-column grid mirroring the days row; bands place themselves via inline
|
||||
grid-column/row (lane) styles from stayBands.js. */
|
||||
.cal-week-bands { --band-h: 1.45rem; display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px 6px; }
|
||||
.cal-band { height: var(--band-h); display: flex; align-items: center; min-width: 0; padding: 0 0.5rem; font-size: 0.72rem; font-weight: 600; color: #334155; background: color-mix(in srgb, var(--chip) 20%, white); border: 1px solid color-mix(in srgb, var(--chip) 45%, white); border-left: 3px solid var(--chip); cursor: pointer; transition: background 0.12s; }
|
||||
.cal-band:hover { background: color-mix(in srgb, var(--chip) 32%, white); }
|
||||
.cal-band { height: var(--band-h); display: flex; align-items: center; min-width: 0; padding: 0 0.5rem; font-size: 0.72rem; font-weight: 600; color: var(--chip-text); background: color-mix(in srgb, var(--chip) 20%, var(--surface)); border: 1px solid color-mix(in srgb, var(--chip) 45%, var(--surface)); border-left: 3px solid var(--chip); cursor: pointer; transition: background 0.12s; }
|
||||
.cal-band:hover { background: color-mix(in srgb, var(--chip) 32%, var(--surface)); }
|
||||
.cal-band.round-l { border-top-left-radius: 999px; border-bottom-left-radius: 999px; }
|
||||
.cal-band.round-r { border-top-right-radius: 999px; border-bottom-right-radius: 999px; }
|
||||
.cal-band-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
@@ -238,14 +250,14 @@ textarea.input { resize: vertical; }
|
||||
.cal-day { min-width: 0; min-height: 92px; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0.35rem; display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.cal-day.clickable { cursor: pointer; transition: border-color 0.12s, box-shadow 0.12s; }
|
||||
.cal-day.clickable:hover { border-color: var(--brand); box-shadow: 0 0 0 2px var(--brand-soft); }
|
||||
.cal-day.cal-out { background: repeating-linear-gradient(45deg, #f1f5f9, #f1f5f9 8px, #eef2f7 8px, #eef2f7 16px); color: var(--text-faint); }
|
||||
.cal-day.cal-out { background: repeating-linear-gradient(45deg, var(--stripe-a), var(--stripe-a) 8px, var(--stripe-b) 8px, var(--stripe-b) 16px); color: var(--text-faint); }
|
||||
.cal-day-head { display: flex; align-items: center; gap: 0.3rem; }
|
||||
.cal-daynum { font-weight: 700; font-size: 0.82rem; }
|
||||
.cal-out .cal-daynum { color: var(--text-faint); font-weight: 600; }
|
||||
.cal-month { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; color: var(--brand); }
|
||||
.cal-flag { margin-left: auto; color: var(--warn); font-size: 0.75rem; }
|
||||
.cal-chips { display: flex; flex-direction: column; gap: 3px; overflow: hidden; }
|
||||
.cal-chip { display: flex; align-items: center; gap: 0.25rem; min-width: 0; font-size: 0.72rem; padding: 0.12rem 0.35rem; border-radius: 6px; background: color-mix(in srgb, var(--chip) 14%, white); border-left: 3px solid var(--chip); color: #334155; }
|
||||
.cal-chip { display: flex; align-items: center; gap: 0.25rem; min-width: 0; font-size: 0.72rem; padding: 0.12rem 0.35rem; border-radius: 6px; background: color-mix(in srgb, var(--chip) 14%, var(--surface)); border-left: 3px solid var(--chip); color: var(--chip-text); }
|
||||
.cal-chip .chip-price { flex-shrink: 0; }
|
||||
.chip-icon { flex-shrink: 0; }
|
||||
.chip-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
@@ -272,7 +284,7 @@ textarea.input { resize: vertical; }
|
||||
.wp-dot-wrap { background: none; border: none; }
|
||||
.wp-dot { display: block; width: 10px; height: 10px; border-radius: 50%; background: var(--wp-color, var(--brand)); border: 2px solid #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.35); }
|
||||
.map-popup strong { font-size: 0.95rem; }
|
||||
.map-popup-sub { color: #64748b; font-size: 0.8rem; margin-top: 0.15rem; }
|
||||
.map-popup-sub { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.15rem; }
|
||||
.map-popup-loc { font-size: 0.82rem; margin-top: 0.15rem; }
|
||||
|
||||
.leg-list, .leg-list-empty { margin-top: 1rem; }
|
||||
@@ -350,7 +362,7 @@ textarea.input { resize: vertical; }
|
||||
.field-currency, .field-price { flex: 0 0 110px; min-width: 90px; }
|
||||
.input-currency { text-transform: uppercase; }
|
||||
.currency-tag { display: inline-block; font-weight: 700; font-size: 0.7rem; letter-spacing: 0.04em; background: var(--surface-2); border: 1px solid var(--border); color: var(--text-muted); padding: 0.05rem 0.4rem; border-radius: 5px; }
|
||||
.chip-price { margin-left: auto; font-size: 0.66rem; font-weight: 700; background: rgba(15,23,42,0.06); color: #334155; padding: 0 0.25rem; border-radius: 4px; white-space: nowrap; }
|
||||
.chip-price { margin-left: auto; font-size: 0.66rem; font-weight: 700; background: var(--chip-price-bg); color: var(--chip-text); padding: 0 0.25rem; border-radius: 4px; white-space: nowrap; }
|
||||
.entry-title-row { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; }
|
||||
.entry-price { font-size: 0.82rem; font-weight: 700; color: var(--brand-dark); white-space: nowrap; }
|
||||
.entry-cost { font-size: 0.78rem; margin-top: 0.15rem; }
|
||||
@@ -371,7 +383,7 @@ textarea.input { resize: vertical; }
|
||||
.costs-total { margin-bottom: 0.9rem; padding: 0.9rem; text-align: center; background: linear-gradient(135deg, #0f766e, #059669); color: #fff; border-radius: var(--radius-sm); display: flex; flex-direction: column; }
|
||||
.costs-total-value { font-size: 1.7rem; font-weight: 800; }
|
||||
.costs-total-label { font-size: 0.78rem; opacity: 0.9; }
|
||||
.costs-warn { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; font-size: 0.82rem; padding: 0.5rem 0.7rem; border-radius: var(--radius-sm); margin-bottom: 0.9rem; }
|
||||
.costs-warn { background: var(--warn-soft-bg); border: 1px solid var(--warn-soft-border); color: var(--warn-soft-text); font-size: 0.82rem; padding: 0.5rem 0.7rem; border-radius: var(--radius-sm); margin-bottom: 0.9rem; }
|
||||
.cost-block { margin-top: 1rem; }
|
||||
.cost-block h3 { margin-bottom: 0.5rem; }
|
||||
.bytype-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0; font-size: 0.88rem; }
|
||||
@@ -446,7 +458,7 @@ textarea.input { resize: vertical; }
|
||||
|
||||
/* Secondary "dropoff" calendar chip */
|
||||
.cal-chip-dropoff { background: transparent; border: 1px dashed var(--chip); border-left: 3px dashed var(--chip); color: var(--text-muted); cursor: pointer; font-style: italic; }
|
||||
.cal-chip-dropoff:hover { background: color-mix(in srgb, var(--chip) 10%, white); }
|
||||
.cal-chip-dropoff:hover { background: color-mix(in srgb, var(--chip) 10%, var(--surface)); }
|
||||
|
||||
/* Over-budget driven distance */
|
||||
.km-row.km-over .km-row-value { color: var(--danger); }
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/* Dark/light theme switcher. See docs/API.md "Theme switcher (dark/light,
|
||||
frontend-only)". document.documentElement carries data-theme="dark" or
|
||||
"light" (set by an inline script in index.html's <head>, before first
|
||||
paint). Token names/values mirror styles.css :root — every light token
|
||||
defined there (and the hygiene tokens added alongside it, plus --now from
|
||||
timeline.css) gets a dark counterpart here. Attribute-selector overrides
|
||||
win on specificity regardless of stylesheet link order. */
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--brand: #3b82f6;
|
||||
--brand-dark: #60a5fa;
|
||||
--brand-soft: rgba(59, 130, 246, 0.18);
|
||||
--accent: #38bdf8;
|
||||
|
||||
--bg: #0f172a;
|
||||
--surface: #1e293b;
|
||||
--surface-2: #253147;
|
||||
--border: #334155;
|
||||
--border-strong: #475569;
|
||||
|
||||
--text: #e2e8f0;
|
||||
--text-muted: #94a3b8;
|
||||
--text-faint: #64748b;
|
||||
|
||||
--danger: #f87171;
|
||||
--danger-soft: rgba(248, 113, 113, 0.16);
|
||||
--success: #34d399;
|
||||
--warn: #fbbf24;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.35);
|
||||
--shadow-md: 0 4px 14px rgba(0, 0, 0, 0.45);
|
||||
--shadow-lg: 0 18px 48px rgba(0, 0, 0, 0.55);
|
||||
|
||||
/* Theme-hygiene tokens (light values in styles.css) */
|
||||
--chip-text: #e2e8f0;
|
||||
--nav-bg: rgba(15, 23, 42, 0.85);
|
||||
--auth-tint: #1c2b4d;
|
||||
--stripe-a: #1a2438;
|
||||
--stripe-b: #17202f;
|
||||
--warn-soft-bg: rgba(251, 191, 36, 0.14);
|
||||
--warn-soft-border: rgba(251, 191, 36, 0.35);
|
||||
--warn-soft-text: #fbbf24;
|
||||
--role-owner-bg: rgba(251, 191, 36, 0.18);
|
||||
--chip-price-bg: rgba(255, 255, 255, 0.12);
|
||||
|
||||
/* timeline.css defines --now on plain :root; override it here too. */
|
||||
--now: #f87171;
|
||||
}
|
||||
|
||||
/* ---------- Theme toggle button (top-right of navbar) ---------- */
|
||||
.theme-toggle {
|
||||
border: none;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, transform 0.1s;
|
||||
}
|
||||
.theme-toggle:hover { background: var(--border); color: var(--text); }
|
||||
.theme-toggle:active { transform: scale(0.94); }
|
||||
.theme-toggle:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
|
||||
@@ -11,7 +11,7 @@
|
||||
.day-timeline { display: flex; flex-direction: column; gap: 0.6rem; padding-bottom: 0.6rem; border-bottom: 1px solid var(--border); }
|
||||
|
||||
.dt-allday { display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
||||
.dt-chip { display: inline-flex; align-items: center; gap: 0.3rem; max-width: 100%; border: none; border-left: 3px solid var(--chip); border-radius: 6px; background: color-mix(in srgb, var(--chip) 16%, white); color: #334155; font-size: 0.78rem; font-weight: 600; padding: 0.2rem 0.5rem; cursor: pointer; }
|
||||
.dt-chip { display: inline-flex; align-items: center; gap: 0.3rem; max-width: 100%; border: none; border-left: 3px solid var(--chip); border-radius: 6px; background: color-mix(in srgb, var(--chip) 16%, var(--surface)); color: var(--chip-text); font-size: 0.78rem; font-weight: 600; padding: 0.2rem 0.5rem; cursor: pointer; }
|
||||
.dt-chip-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 11rem; }
|
||||
|
||||
.dt-grid-wrap { position: relative; display: flex; }
|
||||
@@ -28,8 +28,8 @@
|
||||
border: none;
|
||||
border-left: 3px solid var(--chip);
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--chip) 22%, white);
|
||||
color: #334155;
|
||||
background: color-mix(in srgb, var(--chip) 22%, var(--surface));
|
||||
color: var(--chip-text);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
|
||||
+23
-1
@@ -3,9 +3,30 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Trip Plan</title>
|
||||
|
||||
<!-- Applies the theme attribute before the stylesheets paint, so there's
|
||||
no flash of the wrong theme. Resolution order: an explicit user
|
||||
choice in localStorage, else the OS preference. See docs/API.md
|
||||
"Theme switcher (dark/light, frontend-only)". -->
|
||||
<script>
|
||||
(function () {
|
||||
var theme = 'light';
|
||||
try {
|
||||
var stored = localStorage.getItem('theme');
|
||||
if (stored === 'light' || stored === 'dark') {
|
||||
theme = stored;
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
theme = 'dark';
|
||||
}
|
||||
} catch (e) {
|
||||
// localStorage may throw in privacy modes; fall back to light.
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Leaflet (map) from unpkg CDN. Classic scripts run before the deferred
|
||||
ES module below, so window.L is ready by the time app.js executes. -->
|
||||
<link
|
||||
@@ -21,6 +42,7 @@
|
||||
></script>
|
||||
|
||||
<link rel="stylesheet" href="./css/styles.css" />
|
||||
<link rel="stylesheet" href="./css/theme.css" />
|
||||
<link rel="stylesheet" href="./css/flipclock.css" />
|
||||
<link rel="stylesheet" href="./css/checklist.css" />
|
||||
<link rel="stylesheet" href="./css/expenses.css" />
|
||||
|
||||
@@ -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