v0.166.0: mobile nav off-canvas drawer + sidebar cleanup + versioned logo/favicon URLs

Mobile nav was broken — the <=768px block predated the v0.146.0 accordion and
flattened .nav-links into a horizontal overflow-x strip, clipping the accordion's
nested sub-lists. Replaced with a sticky top bar + off-canvas left drawer that
reuses the vertical sidebar (accordion untouched), plus a no-js static fallback.
Removed the sidebar customer-name span (kept on login). Added ?v={{.Version}}
cache-bust to logo/favicon URLs (Cloudflare 4h edge-cache; 0.126.1 failure mode).

Part 4 (outlined-logo constant swap) gated out per §3a: live felhom.eu main still
serves a logo.svg with live <text>/font-family; constants unchanged.

5 new tests via the real layout/CSS render; nav_accordion invariants unchanged.
This commit is contained in:
2026-07-24 13:26:22 +02:00
parent 1fd070615d
commit bf44216e79
8 changed files with 361 additions and 19 deletions
+1
View File
@@ -327,6 +327,7 @@ func (s *Server) renderLogin(w http.ResponseWriter, errorMsg, flashMsg string) {
data := map[string]interface{}{
"Title": "Bejelentkezés",
"CustomerName": s.cfg.Customer.Name,
"Version": s.version, // logo ?v= cache-bust (v0.166.0)
"Error": errorMsg,
"Flash": flashMsg,
}
+181
View File
@@ -0,0 +1,181 @@
package web
import (
"bytes"
"strings"
"testing"
)
// v0.166.0 mobile navigation rework. The old @media(max-width:768px) block predated the v0.146.0
// accordion: it flattened .nav-links into a horizontal, overflow-x strip, which clipped the
// accordion's nested sub-lists (they share the .nav-links class). This suite pins the replacement:
// a hamburger + off-canvas drawer that REUSES the existing vertical sidebar, plus a no-JS static
// fallback so navigation never dead-ends. Desktop (>768px) is untouched — the accordion invariants
// stay in nav_accordion_test.go and must pass unchanged.
// mediaBlock768 returns the full brace-balanced body of the @media(max-width: 768px) rule so CSS
// pins are scoped to the mobile block rather than matching an unrelated rule elsewhere in the sheet.
func mediaBlock768(t *testing.T, css string) string {
t.Helper()
marker := "@media(max-width: 768px)"
i := strings.Index(css, marker)
if i < 0 {
t.Fatalf("no %q media block found — the mobile breakpoint moved or was renamed", marker)
}
open := strings.IndexByte(css[i:], '{')
if open < 0 {
t.Fatal("no opening brace after the 768px media marker")
}
start := i + open
depth := 0
for j := start; j < len(css); j++ {
switch css[j] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return css[start : j+1]
}
}
}
t.Fatal("unbalanced braces in the 768px media block")
return ""
}
// Group B — CSS pins (Scenario A/B/C).
//
// Red-proof: run against the pre-edit style.css and the strip-absence assertions FAIL — the old
// block still carries `.nav-links { display:flex; ... overflow-x:auto }`.
func TestMobileCSS_StripRemovedDrawerPresent(t *testing.T) {
data, err := StyleCSS()
if err != nil {
t.Fatalf("StyleCSS(): %v", err)
}
css := string(data)
block := mediaBlock768(t, css)
// The horizontal strip that clipped the accordion is gone from the mobile block.
if strings.Contains(block, "overflow-x") {
t.Error("768px block still contains an overflow-x rule — the horizontal .nav-links strip was not removed")
}
if strings.Contains(block, ".nav-links {") || strings.Contains(block, ".nav-links{") {
t.Error("768px block still flattens .nav-links — the strip pattern must be deleted, not patched")
}
// The off-canvas drawer + no-JS fallback are present in the mobile block.
for _, want := range []string{
".js .sidebar", // JS path: sidebar becomes an off-canvas drawer
"translateX(-100%)", // parked off-canvas until opened
".no-js .sidebar", // no-JS path: sidebar renders static inline
"body.nav-open", // scroll lock while the drawer is open
".mobile-topbar", // the sticky top bar is styled inside the mobile block
} {
if !strings.Contains(block, want) {
t.Errorf("768px block missing %q — the drawer/fallback rules are incomplete", want)
}
}
// The desktop defaults that keep >768px pixel-identical live OUTSIDE the media query.
for _, want := range []string{".mobile-topbar", ".nav-backdrop", ".nav-burger"} {
if !strings.Contains(css, want) {
t.Errorf("stylesheet missing base rule for %q", want)
}
}
if !strings.Contains(css, ".mobile-topbar { display: none") &&
!strings.Contains(css, ".mobile-topbar{display:none") {
t.Error("no desktop-default `.mobile-topbar { display:none }` — the top bar would leak onto desktop")
}
}
// Group A — drawer markup (Scenario A/C), asserted through the real layout render.
//
// Red-proof: remove aria-controls from the burger button in layout.html → the aria-controls
// assertion FAILS. (Recorded in REPORT.)
func TestMobileNav_TopbarAndDrawerMarkup(t *testing.T) {
html := renderNavFor(t, "dashboard")
// Progressive-enhancement hook: the html element starts as no-js and the head script swaps it.
if !strings.Contains(html, `<html lang="hu" class="no-js">`) {
t.Error("<html> element does not carry class=\"no-js\" — the no-JS CSS fallback would never engage")
}
if !strings.Contains(html, `replace('no-js','js')`) {
t.Error("head script does not swap no-js → js")
}
// Sticky top bar with a single burger toggle wired to the sidebar.
if !strings.Contains(html, `class="mobile-topbar"`) {
t.Error("no .mobile-topbar element rendered")
}
if !strings.Contains(html, `class="nav-burger"`) {
t.Error("no .nav-burger button rendered")
}
if !strings.Contains(html, `aria-expanded="false"`) {
t.Error("burger missing aria-expanded=\"false\" initial state")
}
if !strings.Contains(html, `aria-controls="sidebar"`) {
t.Error("burger missing aria-controls=\"sidebar\" — the button is not associated with the drawer")
}
// The sidebar is the drawer target, and the backdrop starts hidden.
if !strings.Contains(html, `id="sidebar"`) {
t.Error("sidebar has no id=\"sidebar\" — aria-controls points at nothing")
}
if !strings.Contains(html, `class="nav-backdrop"`) {
t.Error("no .nav-backdrop element rendered")
}
// The backdrop must default hidden (both the attribute and its element must be present together).
if !strings.Contains(html, `class="nav-backdrop" hidden`) {
t.Error("nav-backdrop is not initially hidden")
}
}
// Group D — customer name (Scenario D).
//
// Red-proof: re-add <span class="customer-name">{{.CustomerName}}</span> to the sidebar header →
// TestSidebar_NoCustomerName FAILS.
func TestSidebar_NoCustomerName(t *testing.T) {
html := renderNavFor(t, "dashboard")
if strings.Contains(html, "customer-name") {
t.Error("sidebar still renders the customer-name element — it must live only on the login page")
}
}
func TestLogin_CustomerNameKept(t *testing.T) {
s := testServer(t)
s.loadTemplates()
var buf bytes.Buffer
data := map[string]interface{}{
"Title": "Bejelentkezés",
"CustomerName": "Teszt Ügyfél",
"Version": "9.9.9",
}
if err := s.tmpl.ExecuteTemplate(&buf, "login", data); err != nil {
t.Fatalf("render login: %v", err)
}
out := buf.String()
// The login page still identifies whose box this is.
if !strings.Contains(out, "Teszt Ügyfél") {
t.Error("login page dropped the CustomerName subtitle — it identifies the box owner and must stay")
}
if !strings.Contains(out, `class="login-subtitle"`) {
t.Error("login-subtitle element missing")
}
}
// Group E — versioned asset URLs (Scenario E). The logo-constant no-live-text test ships WITH
// Part 4 (the outlined-asset swap), which is gated on Viktor pushing the outlined master; it is
// intentionally not part of this suite while the constants still carry live <text>.
//
// Red-proof: run against the pre-edit layout.html → both assertions FAIL (the /static/ URLs lack
// the ?v= cache-bust). Cloudflare edge-caches /static/* for 4h, so an unversioned logo/favicon keeps
// serving the previous release after a deploy (same failure mode as the 0.126.1 CSS incident).
func TestLayout_VersionedAssetURLs(t *testing.T) {
html := renderNavFor(t, "dashboard")
if !strings.Contains(html, "/static/felhom-logo.svg?v=") {
t.Error("sidebar logo URL is not cache-busted with ?v= — a deploy would keep serving the stale edge copy")
}
if !strings.Contains(html, "/static/favicon.svg?v=") {
t.Error("favicon URL is not cache-busted with ?v=")
}
}
@@ -31,4 +31,5 @@
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></symbol>
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="3" width="5" height="18" rx="1" /> <rect x="5" y="3" width="5" height="18" rx="1" /></symbol>
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16" /> <path d="M4 6h16" /> <path d="M4 18h16" /></symbol>
</svg>{{end}}
+36 -7
View File
@@ -1,17 +1,20 @@
{{define "layout_start"}}
<!DOCTYPE html>
<html lang="hu">
<html lang="hu" class="no-js">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
{{/* ?v= cache-bust: Cloudflare edge-caches /static/* for 4h — an unversioned URL keeps
serving the PREVIOUS release's CSS after a deploy (0.126.1 live QA hit this). The
controller version busts it automatically on every release. */}}
serving the PREVIOUS release's asset after a deploy (0.126.1 live QA hit this on CSS;
the logo/favicon share the failure mode). The controller version busts it every release. */}}
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v={{.Version}}">
<link rel="stylesheet" href="/static/style.css?v={{.Version}}">
<meta name="csrf-token" content="{{.CSRFToken}}">
<script>
// Progressive-enhancement hook (v0.166.0): flip no-js → js before any CSS-dependent paint so the
// mobile off-canvas drawer engages only when the drawer JS below can actually open it.
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
// Inline two-step confirm (drill F-11): the trigger swaps IN PLACE to "<question> Igen/Mégse".
// Never native confirm() — it is an OS-modal that blocks browser automation — and no overlay:
@@ -62,10 +65,16 @@
</head>
<body>
{{template "icon_sprite"}}
<nav class="sidebar">
<header class="mobile-topbar">
<a href="/" class="mobile-topbar-logo"><img src="/static/felhom-logo.svg?v={{.Version}}" alt="Felhom.eu"></a>
<button type="button" class="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Menü">
<svg class="ico"><use href="#i-menu"/></svg>
</button>
</header>
<div class="nav-backdrop" hidden></div>
<nav class="sidebar" id="sidebar">
<div class="sidebar-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="sidebar-logo">
<span class="customer-name">{{.CustomerName}}</span>
<img src="/static/felhom-logo.svg?v={{.Version}}" alt="Felhom.eu" class="sidebar-logo">
</div>
<ul class="nav-links">
<li><a href="/launcher" class="{{if eq .Page "launcher"}}active{{end}}"><svg class="ico"><use href="#i-rocket"/></svg>Indítópult</a></li>
@@ -139,6 +148,26 @@
{{define "layout_end"}}
</main>
<script>
// Mobile off-canvas drawer (v0.166.0). Reuses the existing sidebar as the drawer; the accordion
// handler in the head script is untouched and keeps working inside it. Only wires up on ≤768px
// chrome — on desktop the burger/backdrop are display:none so these listeners never fire.
(function(){
var burger=document.querySelector('.nav-burger');
var sidebar=document.getElementById('sidebar');
var backdrop=document.querySelector('.nav-backdrop');
if(!burger||!sidebar||!backdrop)return;
function setOpen(open){
sidebar.classList.toggle('is-open',open);
document.body.classList.toggle('nav-open',open);
backdrop.hidden=!open;
burger.setAttribute('aria-expanded',open?'true':'false');
}
burger.addEventListener('click',function(){setOpen(!sidebar.classList.contains('is-open'));});
backdrop.addEventListener('click',function(){setOpen(false);});
document.addEventListener('keydown',function(e){
if(e.key==='Escape'&&sidebar.classList.contains('is-open'))setOpen(false);
});
})();
document.addEventListener('click', function(e) {
if (e.target.closest('a, button, .btn, input, select, textarea, .app-row-actions, .stack-detail-actions')) return;
var card = e.target.closest('[data-href]');
+1 -1
View File
@@ -9,7 +9,7 @@
</head>
<body class="login-body">
<div class="login-card">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
<img src="/static/felhom-logo.svg?v={{.Version}}" alt="Felhom.eu" class="login-logo">
<h1 class="login-title">Otthoni <span class="title-accent">vezérlőpult</span></h1>
<p class="login-subtitle">{{.CustomerName}}</p>
{{if .Flash}}<div class="alert alert-info">{{.Flash}}</div>{{end}}
+72 -10
View File
@@ -100,12 +100,6 @@ body {
display: block;
margin: 0 auto 0.5rem;
}
.customer-name {
display: block;
font-size: .85rem;
color: var(--text-2);
margin-top: .25rem;
}
.nav-links {
list-style: none;
padding: 1rem 0;
@@ -142,6 +136,26 @@ body {
.version { color: var(--text-3); }
.logout-link { color: var(--text-3); text-decoration: none; transition: color 0.2s ease; }
.logout-link:hover { color: var(--blue-bright); }
/* Mobile chrome (v0.166.0) hidden by default so desktop (>768px) stays pixel-identical; the
768px block below turns the top bar on and converts the sidebar into an off-canvas drawer. */
.mobile-topbar { display: none; }
.nav-backdrop { display: none; }
.nav-burger {
display: flex;
align-items: center;
padding: .375rem;
background: none;
border: none;
color: var(--text-2);
cursor: pointer;
transition: color 0.2s ease;
}
.nav-burger:hover { color: var(--blue-bright); }
.nav-burger:focus-visible {
outline: 2px solid var(--blue-bright);
outline-offset: -2px;
}
.nav-burger .ico { width: 24px; height: 24px; }
/* Main content */
.content {
@@ -2532,10 +2546,54 @@ a.stat-card:hover {
/* Responsive */
@media(max-width: 768px) {
.sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--line); }
.nav-links { display: flex; padding: 0; overflow-x: auto; }
.nav-links a { padding: .5rem 1rem; white-space: nowrap; }
.nav-links a.active { border-left: none; border-bottom: 2px solid var(--blue); }
/* v0.166.0: the old horizontal .nav-links strip was deleted it predated the v0.146.0
accordion and, because the nested sub-lists share the .nav-links class, laid them out
horizontally inside an overflow:hidden row and clipped every sub-item past the first.
Replaced by a sticky top bar + off-canvas drawer (JS) with a static-sidebar fallback (no-JS). */
.mobile-topbar {
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 800;
background: var(--bg-2);
border-bottom: 1px solid var(--line);
padding: .5rem 1rem;
}
.mobile-topbar-logo img { height: 36px; width: auto; display: block; }
/* JS path: the vertical sidebar becomes an off-canvas left drawer (accordion untouched). */
.js .sidebar {
position: fixed;
top: 0;
left: 0;
height: 100vh;
height: 100dvh;
width: min(280px, 85vw);
transform: translateX(-100%);
transition: transform .2s ease;
z-index: 950;
border-right: 1px solid var(--line);
border-bottom: none;
}
.js .sidebar.is-open { transform: none; }
.js .nav-backdrop:not([hidden]) {
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .5);
z-index: 900;
}
body.nav-open { overflow: hidden; }
/* No-JS fallback: sidebar renders static inline above the content so no destination dead-ends. */
.no-js .sidebar {
position: static;
height: auto;
width: 100%;
border-right: none;
border-bottom: 1px solid var(--line);
}
.no-js .nav-burger { display: none; }
.content { margin-left: 0; padding: 1rem; }
body { flex-direction: column; }
.app-row { flex-wrap: wrap; }
@@ -2552,6 +2610,10 @@ a.stat-card:hover {
.restore-label { min-width: auto; }
.restore-select { max-width: 100%; }
}
/* v0.166.0: honor reduced-motion for the drawer slide, matching the accordion/chevron convention. */
@media (prefers-reduced-motion: reduce) {
.js .sidebar { transition: none; }
}
/* ===================================================================
Storage Init Wizard & Migration UI