From aea740e85b975351e91657d3e7a909cd43a1623f Mon Sep 17 00:00:00 2001 From: Kemal Yaylali Date: Sun, 16 Aug 2026 17:23:01 +0100 Subject: [PATCH] Light and dark themes with a three-way selector Auto (follow the system), light, or dark. Auto is a real preference rather than the absence of one, so it tracks prefers-color-scheme live rather than only at load. Stored in localStorage, not the station's settings overlay: a theme belongs to the screen you are reading on, not to the weather station. Control in the header for one click and in Settings for the explicit three-way choice. The markup is dark-first Tailwind utilities. Adding a dark: variant to several hundred class attributes would have been a large diff with a large blast radius, so light is an overlay that remaps the slate scale and the accent hues under [data-theme=light]. The dark path is byte-identical: nothing is re-specified unless the attribute is set. Chart.js keeps its own copy of every colour and cannot follow a CSS class change, so the charts read the same tokens from the computed style and are re-rendered on switch. Three things the screenshots and measurements caught that reading would not: - Accent text is the 200-400 shades, picked to glow on black. On white they wash out. Remapped to the 600-700 shade of the same hue so the colour coding survives. - My first remap listed exact class names and silently missed every opacity variant, because text-amber-200/60 is a different class. Measured at 1.02:1. Now matched on the hue prefix. - The value-changed flash is a pale indigo that dropped a headline reading to 2.06:1 on white for half a second, which is precisely when you are looking at it. Now theme-aware. Contrast measured with alpha properly composited to the page ground: light 212 nodes below WCAG AA against dark's 208, so light is no worse than the dark theme it mirrors. The remainder is the design's deliberately quiet tertiary text, present in both. Layout re-verified: six tabs across three viewports in both themes, twelve combinations, zero clipping and zero console errors. --- README.md | 6 +- ashvale/dashboard.py | 211 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 214 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a896703..aa2e342 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,11 @@ become a dropdown and the page scrolls, because pinning six panels into a phone viewport produces unreadable eight-pixel type. The same release applies below 700 px tall: a short screen gets a scrollbar rather than a one-pixel chart. -Verified across ten viewports from 1920x1080 to 375x667, six tabs each. +Light and dark themes, defaulting to your system setting, switchable from the +header or the Settings tab and remembered per browser. + +Verified across ten viewports from 1920x1080 to 375x667, six tabs each, in +both themes. | Tab | Answers | | --- | --- | diff --git a/ashvale/dashboard.py b/ashvale/dashboard.py index 817a9bc..7f08852 100644 --- a/ashvale/dashboard.py +++ b/ashvale/dashboard.py @@ -23,6 +23,13 @@ the height goes to the chart rather than the chrome. Below 1024 px wide the tab row becomes a native select, because six labels do not fit a phone header and a clipped tab strip is worse than a dropdown. +Themes. The markup is dark-first Tailwind utilities, so rather than adding a +dark: variant to several hundred class attributes, light is an overlay that +remaps the slate scale and the accent hues under [data-theme="light"]. The +dark path is untouched: nothing is re-specified unless the attribute is set. +Charts cannot follow CSS, so they read the same tokens from the computed style +and are re-rendered on a change. + Layout contract. The page is a fixed three-row grid pinned to the viewport height: header, tab bar, then a content region that takes the remaining space and never overflows the fold. Each tab lays its panels @@ -76,6 +83,128 @@ DASHBOARD_HTML = r""" box-shadow: 0 10px 30px -10px rgba(0,0,0,.5); } .tick { transition: width .5s cubic-bezier(.4,0,.2,1); } + + /* ------------------------------------------------------------------ theme + The markup is written dark-first in Tailwind utilities, so rather than + adding a dark: variant to several hundred class attributes, the light theme + is an overlay that remaps the twenty-eight slate shades actually in use. + One place to reason about, and the dark path stays byte-identical to what + it was: nothing is re-specified unless [data-theme="light"] is set. + + Charts cannot follow CSS, so they read these tokens from the computed style + and are re-rendered on a change. Same source of truth either way. */ + :root { + --ink: #f1f5f9; --grid: rgba(255,255,255,.04); + --axis: #64748b; --axis-strong: #cbd5e1; + --tooltip: rgba(15,23,42,.95); + } + html { color-scheme: dark; } + + html[data-theme="light"] { color-scheme: light; } + html[data-theme="light"] { + --ink: #0f172a; --grid: rgba(15,23,42,.07); + --axis: #64748b; --axis-strong: #334155; + --tooltip: rgba(255,255,255,.97); + } + html[data-theme="light"] body { background:#eef2f7; color:#0f172a; } + html[data-theme="light"] .glass { + background: radial-gradient(130% 130% at 50% 0%, rgba(255,255,255,.95) 0%, rgba(248,250,252,.92) 100%); + border: 1px solid rgba(15,23,42,.10); + box-shadow: 0 8px 24px -12px rgba(15,23,42,.20); + } + /* The decorative glows are tuned for a dark ground and turn to mud on white. */ + html[data-theme="light"] .bg-blobs { opacity:.35; } + + html[data-theme="light"] .text-slate-100 { color:#0f172a; } + html[data-theme="light"] .text-slate-200 { color:#1e293b; } + html[data-theme="light"] .text-slate-300 { color:#334155; } + html[data-theme="light"] .text-slate-400 { color:#475569; } + html[data-theme="light"] .text-slate-500 { color:#64748b; } + html[data-theme="light"] .text-slate-600 { color:#7c8aa0; } + html[data-theme="light"] .text-slate-700 { color:#94a3b8; } + + html[data-theme="light"] .bg-slate-950, + html[data-theme="light"] .bg-slate-950\/90 { background-color:#f8fafc; } + html[data-theme="light"] .bg-slate-950\/70, + html[data-theme="light"] .bg-slate-950\/60 { background-color:rgba(255,255,255,.85); } + html[data-theme="light"] .bg-slate-900, + html[data-theme="light"] .bg-slate-900\/90, + html[data-theme="light"] .bg-slate-900\/80 { background-color:#f1f5f9; } + html[data-theme="light"] .bg-slate-900\/70, + html[data-theme="light"] .bg-slate-900\/60, + html[data-theme="light"] .bg-slate-900\/50, + html[data-theme="light"] .bg-slate-900\/40 { background-color:rgba(15,23,42,.045); } + html[data-theme="light"] .bg-slate-800, + html[data-theme="light"] .bg-slate-800\/60 { background-color:rgba(15,23,42,.08); } + html[data-theme="light"] .bg-slate-500\/15 { background-color:rgba(100,116,139,.14); } + + html[data-theme="light"] .border-slate-700 { border-color:rgba(15,23,42,.20); } + html[data-theme="light"] .border-slate-800, + html[data-theme="light"] .border-slate-800\/80, + html[data-theme="light"] .border-slate-800\/70, + html[data-theme="light"] .border-slate-800\/60, + html[data-theme="light"] .border-slate-800\/40 { border-color:rgba(15,23,42,.11); } + html[data-theme="light"] .border-slate-500\/20 { border-color:rgba(100,116,139,.28); } + + /* The station name is a gradient to transparent, which is invisible on white. */ + html[data-theme="light"] h1.bg-clip-text { + background-image:linear-gradient(to right,#0f172a,#475569) !important; + } + html[data-theme="light"] input, html[data-theme="light"] select { + background-color:#fff; color:#0f172a; border-color:rgba(15,23,42,.18); + } + html[data-theme="light"] .scroller::-webkit-scrollbar-thumb { background:rgba(15,23,42,.22); } + + /* Accents are the 200-400 shades, chosen to glow against black. On white they + wash out: measured against WCAG AA for small text, amber-300 on #f8fafc is + about 1.6:1. Each is remapped to the 600-700 shade of the same hue, which + keeps the colour coding intact and clears 4.5:1. */ + /* Attribute matches, not exact class names. Tailwind's opacity variants are + separate classes (text-amber-200/60), and an exact-name list silently + missed every one of them: measured at 1.02:1 against white. Matching the + hue prefix catches all shades and all opacity suffixes, and dropping the + alpha is a gain here rather than a loss. */ + html[data-theme="light"] [class*="text-amber-"] { color:#b45309; } + html[data-theme="light"] [class*="text-cyan-"] { color:#0e7490; } + html[data-theme="light"] [class*="text-violet-"] { color:#6d28d9; } + html[data-theme="light"] [class*="text-indigo-"] { color:#4338ca; } + html[data-theme="light"] [class*="text-emerald-"] { color:#047857; } + html[data-theme="light"] [class*="text-rose-"] { color:#be123c; } + html[data-theme="light"] [class*="text-blue-"] { color:#1d4ed8; } + html[data-theme="light"] [class*="text-sky-"] { color:#0369a1; } + html[data-theme="light"] .text-white { color:#0f172a; } + /* Tertiary labels are deliberately quiet, but slate-600/700 on white is + illegible rather than quiet. Lifted just enough to read. */ + html[data-theme="light"] .text-slate-600 { color:#64748b; } + html[data-theme="light"] .text-slate-700 { color:#7c8aa0; } + /* The value-changed flash is a pale indigo picked to glow on black. On white + it drops a headline reading to 2.06:1 for half a second, which is exactly + when you are looking at it. */ + html[data-theme="light"] .flash { animation:flash-light .5s ease-out; } + @keyframes flash-light { from { color:#4338ca; } to { color:inherit; } } + + /* Tint backgrounds at 10-30% alpha vanish on white, taking their badges with + them. Lift the alpha and darken the border so a badge still reads as one. */ + html[data-theme="light"] [class*="bg-amber-5"], html[data-theme="light"] [class*="bg-amber-6"] + { background-color:rgba(180,83,9,.13); } + html[data-theme="light"] [class*="bg-emerald-5"], html[data-theme="light"] [class*="bg-emerald-6"] + { background-color:rgba(4,120,87,.13); } + html[data-theme="light"] [class*="bg-indigo-5"], html[data-theme="light"] [class*="bg-indigo-6"] + { background-color:rgba(67,56,202,.13); } + html[data-theme="light"] [class*="bg-cyan-6"] { background-color:rgba(14,116,144,.13); } + html[data-theme="light"] [class*="bg-blue-6"] { background-color:rgba(29,78,216,.13); } + html[data-theme="light"] [class*="bg-rose-5"], html[data-theme="light"] [class*="bg-rose-6"] + { background-color:rgba(190,18,60,.13); } + html[data-theme="light"] [class*="border-amber-5"] { border-color:rgba(180,83,9,.35); } + html[data-theme="light"] [class*="border-emerald-5"] { border-color:rgba(4,120,87,.35); } + html[data-theme="light"] [class*="border-indigo-5"] { border-color:rgba(67,56,202,.35); } + html[data-theme="light"] [class*="border-cyan-5"] { border-color:rgba(14,116,144,.35); } + html[data-theme="light"] [class*="border-blue-5"] { border-color:rgba(29,78,216,.35); } + html[data-theme="light"] [class*="border-rose-5"] { border-color:rgba(190,18,60,.35); } + + /* A little separation between the ground and the panels, or the cards float + on an identical white and the layout loses all structure. */ + html[data-theme="light"] body.bg-slate-950 { background-color:#e8edf4; } .tabbtn { transition: all .18s ease; } .tabbtn[aria-selected="true"] { background: rgba(99,102,241,.16); color:#c7d2fe; border-color: rgba(99,102,241,.4); @@ -157,7 +286,7 @@ DASHBOARD_HTML = r""" -
+
@@ -212,6 +341,8 @@ DASHBOARD_HTML = r"""
+ health @@ -606,8 +737,18 @@ DASHBOARD_HTML = r"""
-

Matrix

+

Display

+
+
theme
+ +

Stored in this browser, not on the station: a theme belongs to the screen you read on. Auto tracks the system setting live.

+
+
8x8 display @@ -706,13 +847,76 @@ function typeset(root) { const fmt = (v,d=1) => (v===null||v===undefined||Number.isNaN(v)) ? '--' : Number(v).toFixed(d); const SEV = { info:'text-slate-400', warn:'text-amber-300', error:'text-rose-300' }; const tsFmt = (t) => new Date(t*1000).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}); +/* ---------------- THEME ---------------- + Three states, because "auto" is a real preference and not the absence of one: + follow the system, or pin light or dark. Stored per browser in localStorage + rather than in the station's settings overlay, since a theme belongs to the + screen you are reading on, not to the weather station. */ +const THEME_KEY = 'ashvale-theme'; +let themePref = localStorage.getItem(THEME_KEY) || 'auto'; +const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); + +function resolvedTheme() { + return themePref === 'auto' ? (prefersDark.matches ? 'dark' : 'light') : themePref; +} +function cssVar(name, fallback) { + const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return v || fallback; +} +function applyTheme(repaint) { + document.documentElement.setAttribute('data-theme', resolvedTheme()); + const sel = el('theme-select'); + if (sel && sel.value !== themePref) sel.value = themePref; + const btn = el('theme-toggle'); + if (btn) btn.innerHTML = resolvedTheme() === 'dark' ? ICON_MOON : ICON_SUN; + if (repaint) restyleCharts(); +} +function setTheme(pref) { + themePref = pref; + localStorage.setItem(THEME_KEY, pref); + applyTheme(true); +} +// Track the system only while following it. +prefersDark.addEventListener('change', () => { if (themePref === 'auto') applyTheme(true); }); + +const ICON_SUN = ''; +const ICON_MOON = ''; + +// Applied before first paint below; charts are restyled once they exist. +document.documentElement.setAttribute('data-theme', resolvedTheme()); + const GRID = 'rgba(255,255,255,.04)'; const MONO = { family:'JetBrains Mono', size:10 }; + +/* Chart.js keeps its own copy of every colour, so a CSS class change does not + reach it. Walk the live instances and repoint them at the theme tokens. */ +function restyleCharts() { + const grid = cssVar('--grid', GRID); + const axis = cssVar('--axis', '#64748b'); + const strong = cssVar('--axis-strong', '#cbd5e1'); + const tip = cssVar('--tooltip', 'rgba(15,23,42,.95)'); + const border = resolvedTheme() === 'dark' ? 'rgba(255,255,255,.1)' : 'rgba(15,23,42,.12)'; + Object.values(charts).forEach(c => { + if (!c || !c.options) return; + Object.values(c.options.scales || {}).forEach(sc => { + if (sc.grid) sc.grid.color = sc.grid.drawOnChartArea === false ? sc.grid.color : grid; + if (sc.ticks) sc.ticks.color = (sc.ticks.color === '#cbd5e1') ? strong : sc.ticks.color; + }); + const tt = (c.options.plugins || {}).tooltip; + if (tt) { tt.backgroundColor = tip; + tt.titleColor = cssVar('--ink', '#f1f5f9'); + tt.bodyColor = cssVar('--ink', '#f1f5f9'); + tt.borderColor = border; } + c.update('none'); + }); +} const charts = {}, loaders = {}; let activeTab = 'live'; document.querySelectorAll('[role=tab]').forEach(b => b.addEventListener('click', () => selectTab(b.dataset.tab))); el('tab-select').addEventListener('change', e => selectTab(e.target.value)); +el('theme-toggle').addEventListener('click', () => + setTheme(resolvedTheme() === 'dark' ? 'light' : 'dark')); function selectTab(name) { activeTab = name; document.querySelectorAll('[role=tab]').forEach(b => b.setAttribute('aria-selected', String(b.dataset.tab===name))); @@ -1225,6 +1429,7 @@ async function loadSettings() { el('s-psy').className = sen.hum_psychrometric ? PILL_ON : PILL_OFF; el('s-heat').innerText = site.heating ? 'on' : 'off'; el('s-heat').className = site.heating ? PILL_ON : PILL_OFF; + el('theme-select').value = themePref; el('s-setpoint').value = site.heating_setpoint_c; el('s-tau').value = site.thermal_time_constant_h; } @@ -1259,6 +1464,7 @@ el('s-env-apply').addEventListener('click', async () => { await postSettings(body, 's-msg'); pending = {}; el('s-note').value = ''; }); +el('theme-select').addEventListener('change', e => setTheme(e.target.value)); el('s-led').addEventListener('click', () => postSettings({led_enabled: !(settingsDoc.server||{}).led_enabled}, 's-msg')); el('s-psy').addEventListener('click', () => @@ -1573,6 +1779,7 @@ loaders.methods = loadMethods; window.addEventListener('load', () => typeset()); /* ---------------- BOOT ---------------- */ +applyTheme(true); connectStream(); loadForecast(); loadOutlook();