Add light mode theme with dark/light toggle

- Add sun/moon toggle in navbar (top-right) using DaisyUI swap component
- Store user theme preference in localStorage, default to server config
- Add WEB_THEME env var to configure default theme (dark/light)
- Add light mode color palette with adjusted section colors for contrast
- Use CSS filter to invert white SVG logos in light mode
- Add section-colored hover/active backgrounds for navbar items
- Style hero buttons with thicker outlines and white text on hover
- Soften hero heading color in light mode
- Change member callsign badges from green to neutral
- Update AGENTS.md, .env.example with WEB_THEME documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Louis King
2026-02-10 18:11:11 +00:00
parent 565e0ffc7b
commit 70ecb5e4da
8 changed files with 100 additions and 7 deletions

View File

@@ -187,6 +187,11 @@ API_ADMIN_KEY=
# External web port
WEB_PORT=8080
# Default theme for the web dashboard (dark or light)
# Users can override via the theme toggle; their preference is saved in localStorage
# Default: dark
# WEB_THEME=dark
# Timezone for displaying dates/times on the web dashboard
# Uses standard IANA timezone names (e.g., America/New_York, Europe/London)
# Default: UTC

View File

@@ -492,6 +492,7 @@ Key variables:
- `MQTT_TLS` - Enable TLS/SSL for MQTT (default: `false`)
- `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys
- `WEB_ADMIN_ENABLED` - Enable admin interface at /a/ (default: `false`, requires auth proxy)
- `WEB_THEME` - Default theme for the web dashboard (default: `dark`, options: `dark`, `light`). Users can override via the theme toggle in the navbar, which persists their preference in browser localStorage.
- `TZ` - Timezone for web dashboard date/time display (default: `UTC`, e.g., `America/New_York`, `Europe/London`)
- `FEATURE_DASHBOARD`, `FEATURE_NODES`, `FEATURE_ADVERTISEMENTS`, `FEATURE_MESSAGES`, `FEATURE_MAP`, `FEATURE_MEMBERS`, `FEATURE_PAGES` - Feature flags to enable/disable specific web dashboard pages (default: all `true`). Dependencies: Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled.
- `LOG_LEVEL` - Logging verbosity

View File

@@ -256,6 +256,12 @@ class WebSettings(CommonSettings):
# Timezone for date/time display (uses standard TZ environment variable)
tz: str = Field(default="UTC", description="Timezone for displaying dates/times")
# Theme (dark or light, default dark)
web_theme: str = Field(
default="dark",
description="Default theme for the web dashboard (dark or light)",
)
# Admin interface (disabled by default for security)
web_admin_enabled: bool = Field(
default=False,

View File

@@ -113,6 +113,7 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
"timezone": app.state.timezone_abbr,
"timezone_iana": app.state.timezone,
"is_authenticated": bool(request.headers.get("X-Forwarded-User")),
"default_theme": app.state.web_theme,
}
return json.dumps(config)
@@ -174,6 +175,9 @@ def create_app(
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
# Store configuration in app state (use args if provided, else settings)
app.state.web_theme = (
settings.web_theme if settings.web_theme in ("dark", "light") else "dark"
)
app.state.api_url = api_url or settings.api_base_url
app.state.api_key = api_key or settings.api_key
app.state.admin_enabled = (
@@ -638,6 +642,7 @@ def create_app(
"custom_pages": custom_pages,
"logo_url": request.app.state.logo_url,
"version": __version__,
"default_theme": request.app.state.web_theme,
"config_json": config_json,
},
)

View File

@@ -27,6 +27,16 @@
--color-members: oklch(0.72 0.17 50); /* orange */
}
/* Light mode: darker section colors for contrast on light backgrounds */
[data-theme="light"] {
--color-dashboard: oklch(0.55 0.15 210);
--color-nodes: oklch(0.50 0.24 265);
--color-adverts: oklch(0.55 0.17 330);
--color-messages: oklch(0.55 0.18 180);
--color-map: oklch(0.58 0.16 45);
--color-members: oklch(0.55 0.18 25);
}
/* ==========================================================================
Navbar Styling
========================================================================== */
@@ -34,6 +44,16 @@
/* Spacing between horizontal nav items */
.menu-horizontal { gap: 0.125rem; }
/* Invert white logos/images to dark for light mode */
[data-theme="light"] .theme-logo {
filter: brightness(0.15);
}
/* Soften hero heading to dark grey in light mode */
[data-theme="light"] .hero-title {
color: #6b7280;
}
/* Nav icon colors */
.nav-icon-dashboard { color: var(--color-dashboard); }
.nav-icon-nodes { color: var(--color-nodes); }
@@ -42,6 +62,31 @@
.nav-icon-map { color: var(--color-map); }
.nav-icon-members { color: var(--color-members); }
/* Propagate section color to parent li for hover/active backgrounds */
.navbar .menu li:has(.nav-icon-dashboard) { --nav-color: var(--color-dashboard); }
.navbar .menu li:has(.nav-icon-nodes) { --nav-color: var(--color-nodes); }
.navbar .menu li:has(.nav-icon-adverts) { --nav-color: var(--color-adverts); }
.navbar .menu li:has(.nav-icon-messages) { --nav-color: var(--color-messages); }
.navbar .menu li:has(.nav-icon-map) { --nav-color: var(--color-map); }
.navbar .menu li:has(.nav-icon-members) { --nav-color: var(--color-members); }
/* Section-tinted hover and active backgrounds (!important to override DaisyUI CDN) */
.navbar .menu li > a:hover {
background-color: color-mix(in oklch, var(--nav-color, oklch(var(--bc))) 12%, transparent) !important;
}
.navbar .menu li > a.active {
background-color: color-mix(in oklch, var(--nav-color, oklch(var(--bc))) 20%, transparent) !important;
color: inherit !important;
}
/* Homepage hero buttons: slightly thicker outline, white text on hover */
#app .btn-outline {
border-width: 2px;
}
#app .btn-outline:hover {
color: #fff !important;
}
/* ==========================================================================
Scrollbar Styling
========================================================================== */

View File

@@ -70,9 +70,9 @@ export async function render(container, params, router) {
<div class="${showStats ? 'grid grid-cols-1 lg:grid-cols-3 gap-6' : ''} bg-base-100 rounded-box p-6">
<div class="${showStats ? 'lg:col-span-2' : ''} flex flex-col items-center text-center">
<div class="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 mb-4">
<img src="${logoUrl}" alt="${networkName}" class="h-24 w-24 sm:h-36 sm:w-36" />
<img src="${logoUrl}" alt="${networkName}" class="theme-logo h-24 w-24 sm:h-36 sm:w-36" />
<div class="flex flex-col justify-center">
<h1 class="text-3xl sm:text-5xl lg:text-6xl font-black tracking-tight">${networkName}</h1>
<h1 class="hero-title text-3xl sm:text-5xl lg:text-6xl font-black tracking-tight">${networkName}</h1>
${cityCountry}
</div>
</div>
@@ -159,7 +159,7 @@ export async function render(container, params, router) {
<div class="card-body flex flex-col items-center justify-center">
<p class="text-sm opacity-70 mb-4 text-center">Our local off-grid mesh network is made possible by</p>
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="hover:opacity-80 transition-opacity">
<img src="/static/img/meshcore.svg" alt="MeshCore" class="h-8" />
<img src="/static/img/meshcore.svg" alt="MeshCore" class="theme-logo h-8" />
</a>
<p class="text-xs opacity-50 mt-4 text-center">Connecting people and things, without using the internet</p>
<div class="flex gap-2 mt-4">

View File

@@ -53,7 +53,7 @@ function renderMemberCard(member, nodes) {
: nothing;
const callsignBadge = member.callsign
? html`<span class="badge badge-success">${member.callsign}</span>`
? html`<span class="badge badge-neutral">${member.callsign}</span>`
: nothing;
const descBlock = member.description

View File

@@ -1,10 +1,18 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<html lang="en" data-theme="{{ default_theme }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ network_name }}</title>
<!-- Theme initialization (before CSS to prevent flash) -->
<script>
(function() {
var theme = localStorage.getItem('meshcore-theme');
if (theme) document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<!-- SEO Meta Tags -->
<meta name="description" content="{{ network_name }}{% if network_welcome_text %} - {{ network_welcome_text }}{% else %} - MeshCore off-grid LoRa mesh network dashboard.{% endif %}">
<meta name="generator" content="MeshCore Hub {{ version }}">
@@ -79,7 +87,7 @@
</ul>
</div>
<a href="/" class="btn btn-ghost text-xl">
<img src="{{ logo_url }}" alt="{{ network_name }}" class="h-6 w-6 mr-2" />
<img src="{{ logo_url }}" alt="{{ network_name }}" class="theme-logo h-6 w-6 mr-2" />
{{ network_name }}
</a>
</div>
@@ -111,8 +119,15 @@
{% endif %}
</ul>
</div>
<div class="navbar-end">
<div class="navbar-end gap-1 pr-2">
<span id="nav-loading" class="loading loading-spinner loading-sm hidden"></span>
<label class="swap swap-rotate btn btn-ghost btn-circle btn-sm">
<input type="checkbox" id="theme-toggle" />
<!-- sun icon - shown in dark mode (click to switch to light) -->
<svg class="swap-off fill-current w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/></svg>
<!-- moon icon - shown in light mode (click to switch to dark) -->
<svg class="swap-on fill-current w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/></svg>
</label>
</div>
</div>
@@ -167,6 +182,22 @@
window.__APP_CONFIG__ = {{ config_json|safe }};
</script>
<!-- Theme toggle initialization -->
<script>
(function() {
var toggle = document.getElementById('theme-toggle');
if (toggle) {
var current = document.documentElement.getAttribute('data-theme');
toggle.checked = current === 'light';
toggle.addEventListener('change', function() {
var theme = this.checked ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('meshcore-theme', theme);
});
}
})();
</script>
<!-- SPA Application (ES Module) -->
<script type="module" src="/static/js/spa/app.js"></script>
</body>