From f2f52f851880c75eb892fba7a4be9ef3bb741034 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 28 Mar 2026 21:13:58 +0100 Subject: [PATCH] webui: sidebar fixes, content sectioning, and theme fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the useless 768–1024px icon-only sidebar mode; everything up to 1024px now uses the hamburger overlay and full-width sidebar. Fix the user dropdown on touch devices: click now toggles aria-expanded so CSS-driven visibility works without hover. A document click listener closes the menu when tapping outside. Give the sidebar proper light/dark tokens instead of hardcoded dark values. Introduce --sidebar-hover-fg (primary in light, white in dark) so nav-link hover text is legible in both themes. Fix dropdown hover background using --border instead of --border-subtle, which had zero contrast against the surface in dark mode. Keep active nav link in sync with the current URL via JS so it updates correctly after htmx navigation instead of staying on the initial server-rendered page. Also, add YouTube-style progress when laoding pages that take time, e.g., Dashboard. Signed-off-by: Joachim Wiberg --- src/webui/static/css/style.css | 193 +++++++++++++-------- src/webui/static/js/app.js | 210 ++++++++++++++++++++--- src/webui/templates/layouts/base.html | 14 +- src/webui/templates/layouts/sidebar.html | 113 ++++++------ src/webui/templates/pages/login.html | 10 +- 5 files changed, 379 insertions(+), 161 deletions(-) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 70416f1a..3d388e25 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -42,13 +42,17 @@ --warning: var(--amber-500); --danger: var(--red-500); - /* Sidebar (Always Dark) */ - --sidebar-bg: var(--slate-800); - --sidebar-fg: var(--slate-200); - --sidebar-hover: rgba(255, 255, 255, 0.1); - --sidebar-border: rgba(255, 255, 255, 0.1); + /* Sidebar — Light mode */ + --sidebar-bg: var(--slate-100); + --sidebar-fg: var(--slate-700); + --sidebar-hover: rgba(0, 0, 0, 0.07); + --sidebar-hover-fg: var(--primary); + --sidebar-border: rgba(0, 0, 0, 0.08); --sidebar-width: 240px; + /* Login page */ + --dot-color: rgba(0, 0, 0, 0.055); + /* Layout */ --radius: 8px; --radius-sm: 4px; @@ -70,6 +74,12 @@ --fg-muted: var(--slate-400); --border: var(--slate-700); --border-subtle: var(--slate-800); + --sidebar-bg: var(--slate-800); + --sidebar-fg: var(--slate-200); + --sidebar-hover: rgba(255, 255, 255, 0.1); + --sidebar-hover-fg: #fff; + --sidebar-border: rgba(255, 255, 255, 0.1); + --dot-color: rgba(255, 255, 255, 0.045); } } @@ -82,6 +92,12 @@ --border: var(--slate-700); --border-subtle: var(--slate-800); --zebra-stripe: rgba(255,255,255,0.03); + --sidebar-bg: var(--slate-800); + --sidebar-fg: var(--slate-200); + --sidebar-hover: rgba(255, 255, 255, 0.1); + --sidebar-hover-fg: #fff; + --sidebar-border: rgba(255, 255, 255, 0.1); + --dot-color: rgba(255, 255, 255, 0.045); } /* Force Light Mode (override system dark preference) */ @@ -92,6 +108,12 @@ --fg-muted: var(--slate-500); --border: var(--slate-200); --border-subtle: var(--slate-100); + --sidebar-bg: var(--slate-100); + --sidebar-fg: var(--slate-700); + --sidebar-hover: rgba(0, 0, 0, 0.07); + --sidebar-hover-fg: var(--primary); + --sidebar-border: rgba(0, 0, 0, 0.08); + --dot-color: rgba(0, 0, 0, 0.055); } .light .alert-error { background: #fef2f2; color: #991b1b; border-color: #fecaca; } .light .alert-info { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; } @@ -180,7 +202,6 @@ a:hover { .sidebar-header { padding: 1.5rem 1.25rem; - border-bottom: 1px solid var(--sidebar-border); } .sidebar-logo { @@ -195,45 +216,58 @@ a:hover { overflow-y: auto; } -/* Accordion Nav Groups */ -details.nav-group { - border-top: 1px solid var(--sidebar-border); -} -details.nav-group:first-child { - border-top: none; +/* Standalone nav link (Dashboard) */ +.nav-standalone { + margin: 0.25rem 0.5rem 0.5rem; } -details.nav-group > summary.nav-group-summary { +/* Top-level accordion groups (Status / Configure / Maintenance) */ +details.nav-group-top { +} + +details.nav-group-top > summary.nav-group-summary-top { list-style: none; cursor: pointer; - font-size: 0.65rem; - font-weight: 600; - letter-spacing: 0.08em; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.07em; text-transform: uppercase; color: var(--sidebar-fg); - opacity: 0.5; - padding: 0.875rem 1rem 0.375rem; + opacity: 0.7; + padding: 0.75rem 1rem 0.375rem; display: flex; justify-content: space-between; align-items: center; user-select: none; transition: opacity 0.15s; } -details.nav-group > summary.nav-group-summary:hover { opacity: 0.8; } -details.nav-group > summary.nav-group-summary::-webkit-details-marker { display: none; } -details.nav-group > summary.nav-group-summary::marker { display: none; } +details.nav-group-top > summary.nav-group-summary-top:hover { opacity: 1; } +details.nav-group-top > summary.nav-group-summary-top::-webkit-details-marker { display: none; } +details.nav-group-top > summary.nav-group-summary-top::marker { display: none; } -details.nav-group > summary.nav-group-summary::after { +details.nav-group-top > summary.nav-group-summary-top::after { content: '▸'; font-size: 0.7rem; } -details[open].nav-group > summary.nav-group-summary::after { +details[open].nav-group-top > summary.nav-group-summary-top::after { content: '▾'; } +/* Static sub-section labels within top-level accordion */ +.nav-section-label { + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--sidebar-fg); + opacity: 0.45; + padding: 0.75rem 1rem 0.2rem; + user-select: none; +} + .nav-group-items { list-style: none; - padding-bottom: 0.5rem; + padding-bottom: 0.25rem; } .nav-icon { @@ -262,13 +296,13 @@ details[open].nav-group > summary.nav-group-summary::after { font-size: 0.875rem; border-radius: 0.375rem; margin: 0 0.5rem; - transition: background-color 0.15s; + transition: background-color 0.15s, color 0.15s; font-weight: 500; } .nav-link:hover { background-color: var(--sidebar-hover); - color: #fff; + color: var(--sidebar-hover-fg); text-decoration: none; } @@ -278,6 +312,22 @@ details[open].nav-group > summary.nav-group-summary::after { text-decoration: none; } +/* Button styled as a nav-link (for actions like Reboot) */ +.nav-link-btn { + width: 100%; + background: none; + border: none; + cursor: pointer; + text-align: left; + font-family: inherit; + font-size: inherit; +} + +.nav-link-danger:hover { + color: var(--danger); + background-color: var(--sidebar-hover); +} + .sidebar-footer { padding: 1.25rem; border-top: 1px solid var(--sidebar-border); @@ -521,12 +571,19 @@ details[open].nav-group > summary.nav-group-summary::after { Authentication ========================================================================== */ +@keyframes login-rise { + from { opacity: 0; transform: translateY(14px); } + to { opacity: 1; transform: translateY(0); } +} + .login-wrapper { display: flex; align-items: center; justify-content: center; min-height: 100vh; - background: var(--bg); + background-color: var(--bg); + background-image: radial-gradient(var(--dot-color) 1.5px, transparent 1.5px); + background-size: 22px 22px; padding: 1rem; } @@ -534,10 +591,12 @@ details[open].nav-group > summary.nav-group-summary::after { background: var(--surface); padding: 2.5rem; border-radius: var(--radius); - box-shadow: var(--shadow); + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.07), + 0 0 0 1px var(--border); width: 100%; max-width: 400px; - border: 1px solid var(--border); + border-top: 2px solid var(--primary); + animation: login-rise 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; } .login-logo { @@ -551,6 +610,19 @@ details[open].nav-group > summary.nav-group-summary::after { Alerts & Banners ========================================================================== */ +/* Top progress bar — shown during htmx page navigations */ +.page-progress { + position: fixed; + top: 0; + left: 0; + height: 2px; + width: 0%; + background: var(--primary); + z-index: 9999; + opacity: 0; + pointer-events: none; +} + .conn-banner { background: var(--danger); color: #fff; @@ -1023,8 +1095,8 @@ details[open].nav-group > summary.nav-group-summary::after { justify-content: flex-end; padding: 0 1rem; height: 48px; - background: var(--surface); - border-bottom: 1px solid var(--border); + background: var(--sidebar-bg); + border-bottom: 1px solid var(--sidebar-border); flex-shrink: 0; z-index: 100; } @@ -1139,7 +1211,7 @@ details[open].nav-group > summary.nav-group-summary::after { } .dropdown-item:hover, .dropdown-item:focus { - background: var(--border-subtle); + background: var(--border); color: var(--fg); text-decoration: none; } @@ -1174,20 +1246,28 @@ details[open].nav-group > summary.nav-group-summary::after { } .login-theme-btn:hover { color: var(--fg); border-color: var(--fg-muted); } -/* Login lock icon */ -.login-icon { +.login-header { display: flex; + align-items: center; justify-content: center; - margin-bottom: 1.5rem; + gap: 0.5rem; + margin-bottom: 1.75rem; color: var(--fg-muted); } +.login-header h1 { + font-size: 1.25rem; + font-weight: 600; + color: var(--fg); + line-height: 1; +} + /* ========================================================================== Responsive Layout ========================================================================== */ -/* Mobile (≤768px): Sidebar hidden, hamburger visible */ -@media (max-width: 768px) { +/* Narrow (≤1024px): Sidebar hidden behind hamburger overlay */ +@media (max-width: 1024px) { #sidebar { position: fixed; top: 0; @@ -1221,43 +1301,8 @@ details[open].nav-group > summary.nav-group-summary::after { } } -/* Tablet (769px–1024px): Icon-only sidebar */ -@media (min-width: 769px) and (max-width: 1024px) { - :root { - --sidebar-width: 64px; - } - - .nav-link span:not(.nav-icon) { - display: none; - } - - /* Accordion in icon-only mode: always show items, hide section headers */ - details.nav-group > summary.nav-group-summary { - display: none; - } - details.nav-group:not([open]) > .nav-group-items { - display: block !important; - } - - .nav-link { - justify-content: center; - padding: 0.6rem; - } - - .sidebar-header { - display: flex; - align-items: center; - justify-content: center; - padding: 1rem; - } - - .sidebar-logo { - max-width: 32px; - } -} - -/* Desktop: hamburger hidden */ -@media (min-width: 769px) { +/* Desktop (>1024px): hamburger hidden, sidebar always visible */ +@media (min-width: 1025px) { .hamburger-btn { display: none; } diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index fe69109a..845397da 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -135,6 +135,93 @@ } })(); +// Active nav link — keep in sync with the current URL after htmx navigation. +// Also opens the top-level accordion section containing the active link, +// and immediately highlights the clicked link (optimistic active state). +(function() { + function updateActiveNav() { + var path = window.location.pathname; + var activeTopGroup = null; + + document.querySelectorAll('.nav-link').forEach(function(link) { + var href = link.getAttribute('href'); + var active = href === path; + link.classList.toggle('active', active); + if (active) { + var topGroup = link.closest('details.nav-group-top'); + if (topGroup) activeTopGroup = topGroup; + } + }); + + if (activeTopGroup) { + document.querySelectorAll('details.nav-group-top').forEach(function(d) { + if (d === activeTopGroup) { + d.setAttribute('open', ''); + } else { + d.removeAttribute('open'); + } + }); + } + } + + // Optimistic active state: highlight the link immediately on click so the + // sidebar doesn't lag while waiting for the server to respond. + document.addEventListener('click', function(e) { + var link = e.target.closest('a.nav-link[hx-get]'); + if (!link) return; + document.querySelectorAll('.nav-link').forEach(function(l) { l.classList.remove('active'); }); + link.classList.add('active'); + }); + + document.addEventListener('DOMContentLoaded', updateActiveNav); + document.addEventListener('htmx:pushedIntoHistory', updateActiveNav); + window.addEventListener('popstate', updateActiveNav); +})(); + +// Top progress bar during htmx navigations +(function() { + var bar, timer; + + function getBar() { + if (!bar) bar = document.getElementById('page-progress'); + return bar; + } + + function start() { + var b = getBar(); + if (!b) return; + if (timer) { clearTimeout(timer); timer = null; } + b.style.transition = 'none'; + b.style.width = '0%'; + b.style.opacity = '1'; + b.offsetWidth; // force reflow so the transition below fires from 0 + b.style.transition = 'width 8s cubic-bezier(0.05, 0.8, 0.4, 1)'; + b.style.width = '85%'; + } + + function finish() { + var b = getBar(); + if (!b) return; + if (timer) clearTimeout(timer); + b.style.transition = 'width 0.1s ease'; + b.style.width = '100%'; + timer = setTimeout(function() { + b.style.transition = 'opacity 0.25s ease'; + b.style.opacity = '0'; + timer = setTimeout(function() { + b.style.transition = 'none'; + b.style.width = '0%'; + timer = null; + }, 260); + }, 120); + } + + document.addEventListener('htmx:beforeSend', start); + document.addEventListener('htmx:afterSettle', finish); + document.addEventListener('htmx:responseError', finish); + document.addEventListener('htmx:sendError', finish); +})(); + // Theme (auto / light / dark) — shared by main app and login page (function() { function getTheme() { @@ -205,67 +292,127 @@ })(); // Accordion nav group persistence +// Top-level sections (Status / Configure / Maintenance) are mutually exclusive. +// State is persisted in localStorage under 'nav-top:Name' keys. (function() { document.addEventListener('DOMContentLoaded', function() { - document.querySelectorAll('details.nav-group').forEach(function(d) { - var label = d.querySelector('summary'); + var groups = document.querySelectorAll('details.nav-group-top'); + + // Restore saved state (HTML default: Status open, others closed) + groups.forEach(function(d) { + var label = d.querySelector(':scope > summary'); if (!label) return; - var key = 'nav-group:' + label.textContent.trim(); + var key = 'nav-top:' + label.textContent.trim(); var saved = localStorage.getItem(key); - if (saved === 'closed') { - d.removeAttribute('open'); - } else if (saved === 'open') { + if (saved === 'open') { d.setAttribute('open', ''); + } else if (saved === 'closed') { + d.removeAttribute('open'); } + }); + + // Mutual exclusion, persistence, and auto-navigation to first page link + groups.forEach(function(d) { + var label = d.querySelector(':scope > summary'); + if (!label) return; + var key = 'nav-top:' + label.textContent.trim(); d.addEventListener('toggle', function() { localStorage.setItem(key, d.open ? 'open' : 'closed'); + if (d.open) { + groups.forEach(function(other) { + if (other !== d && other.open) { + other.removeAttribute('open'); + var otherLabel = other.querySelector(':scope > summary'); + if (otherLabel) { + localStorage.setItem('nav-top:' + otherLabel.textContent.trim(), 'closed'); + } + } + }); + // Navigate to the first page link if none in this section is active + if (!d.querySelector('.nav-link.active')) { + var first = d.querySelector('a.nav-link[hx-get]'); + if (first) first.click(); + } + } }); }); }); })(); -// User menu — hover handled by CSS; JS manages aria-expanded and keyboard +// User menu — hover (desktop) + click-toggle (touch) + keyboard (function() { document.addEventListener('DOMContentLoaded', function() { var menu = document.getElementById('user-menu'); var btn = document.getElementById('user-menu-btn'); if (!menu || !btn) return; - menu.addEventListener('mouseenter', function() { - btn.setAttribute('aria-expanded', 'true'); + function setExpanded(val) { + btn.setAttribute('aria-expanded', val ? 'true' : 'false'); + } + + // Hover: keep aria-expanded in sync so CSS transition fires correctly + menu.addEventListener('mouseenter', function() { setExpanded(true); }); + menu.addEventListener('mouseleave', function() { setExpanded(false); }); + + // Click/tap: toggle (primary interaction on touch devices) + btn.addEventListener('click', function(e) { + e.stopPropagation(); + setExpanded(btn.getAttribute('aria-expanded') !== 'true'); }); - menu.addEventListener('mouseleave', function() { - btn.setAttribute('aria-expanded', 'false'); + + // Close when clicking outside (touch: tap outside) + document.addEventListener('click', function(e) { + if (!menu.contains(e.target)) setExpanded(false); }); + document.addEventListener('keydown', function(e) { - if (e.key === 'Escape') btn.setAttribute('aria-expanded', 'false'); + if (e.key === 'Escape') setExpanded(false); }); }); })(); // Sidebar toggle (mobile) (function() { + var MOBILE_BP = 1024; + + function closeSidebar(btn) { + document.body.classList.remove('sidebar-open'); + if (btn) btn.setAttribute('aria-expanded', 'false'); + } + document.addEventListener('DOMContentLoaded', function() { var btn = document.getElementById('hamburger-btn'); if (!btn) return; + + // Open/close on hamburger click btn.addEventListener('click', function() { var open = document.body.classList.toggle('sidebar-open'); btn.setAttribute('aria-expanded', open ? 'true' : 'false'); }); - // Close sidebar when clicking the overlay (::after pseudo) + + // Close when clicking the overlay (::after pseudo-element covers body) document.body.addEventListener('click', function(e) { if (document.body.classList.contains('sidebar-open') && e.target === document.body) { - document.body.classList.remove('sidebar-open'); - btn.setAttribute('aria-expanded', 'false'); + closeSidebar(btn); } }); - // Close sidebar when a nav link is clicked (htmx navigation) + + // Close when navigating via htmx document.addEventListener('htmx:beforeRequest', function() { - if (document.body.classList.contains('sidebar-open')) { - document.body.classList.remove('sidebar-open'); - if (btn) btn.setAttribute('aria-expanded', 'false'); - } + closeSidebar(btn); }); + + // Close when viewport grows beyond the mobile breakpoint so the + // sidebar-open class and overlay don't linger at desktop widths. + var mq = window.matchMedia('(max-width: ' + MOBILE_BP + 'px)'); + function onBreakpointChange(e) { + if (!e.matches) closeSidebar(btn); + } + if (mq.addEventListener) { + mq.addEventListener('change', onBreakpointChange); + } else { + mq.addListener(onBreakpointChange); // Safari <14 fallback + } }); })(); @@ -295,3 +442,26 @@ } document.addEventListener('DOMContentLoaded', focusAutofocusWhenVisible); })(); + +// ─── Login page: progress bar on submit ──────────────────────────────────── +// The login form is a native POST (not HTMX), so htmx:beforeSend never fires. +// Start the bar and disable the button to show the user something is happening. +(function() { + document.addEventListener('DOMContentLoaded', function() { + var form = document.querySelector('form[action="/login"]'); + if (!form) return; + form.addEventListener('submit', function() { + var bar = document.getElementById('page-progress'); + if (bar) { + bar.style.transition = 'none'; + bar.style.width = '0%'; + bar.style.opacity = '1'; + bar.offsetWidth; + bar.style.transition = 'width 8s cubic-bezier(0.05, 0.8, 0.4, 1)'; + bar.style.width = '85%'; + } + var btn = form.querySelector('button[type="submit"]'); + if (btn) { btn.disabled = true; btn.textContent = 'Logging in\u2026'; } + }); + }); +})(); diff --git a/src/webui/templates/layouts/base.html b/src/webui/templates/layouts/base.html index 18037c2b..3fb0cab7 100644 --- a/src/webui/templates/layouts/base.html +++ b/src/webui/templates/layouts/base.html @@ -14,6 +14,7 @@ +
{{template "sidebar" .}} @@ -56,19 +57,6 @@ - - - Download Config - - -