From 610f74d6a41ddcc308791acf40ea6f959caa4eb9 Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Mon, 3 Aug 2026 22:11:51 +0300 Subject: [PATCH] panel: give each nav entry an icon The bar was six similar-length words; nothing distinguished them at a glance. Each entry now leads with a 16px inline SVG. Inline rather than an icon font or a sprite file: the icons inherit the entry's colour through currentColor, so the active pill's darker text and a link's blue need no second rule, they cost no request, and they need no exemption from the panel's default-src 'self' policy. Each is aria-hidden because the entry's text is already its accessible name. Account is included -- it is a page like the others and would otherwise be the one bare word left in the bar -- so the highlight and padding rules now cover the session block too, and the bar centres its items instead of aligning them on the text baseline now that an entry is an icon-plus-label row rather than a run of text. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 6 ++++++ internal/web/static/panel.css | 20 +++++++++++++++----- internal/web/templates/layout.html | 28 +++++++++++++++++++++------- internal/web/templates_test.go | 6 ++++-- 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a81e62..04cd774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version `/mail-queue` (was `/queue`), System log at `/system-log` (was `/logtail`). Bookmarks to the old paths stop working. +- panel: each entry in the navigation bar now carries an icon beside its label, + so the bar is scannable at a glance instead of a row of similar-length words. + The icons are inline SVG drawn in the entry's own colour — no extra request, + no exemption from the panel's Content-Security-Policy — and are hidden from + screen readers, which still announce the label alone. + ## [0.2.0] - 2026-08-03 - panel: every authenticated page now ends with the running version diff --git a/internal/web/static/panel.css b/internal/web/static/panel.css index e1c4b8d..123964a 100644 --- a/internal/web/static/panel.css +++ b/internal/web/static/panel.css @@ -89,19 +89,29 @@ details form { margin-top: 0.6rem; } /* Panel navigation: rendered once from the layout, so it is present on every authenticated page without each content template having to include it. */ .nav { - display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; + display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 0.4rem 1rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem; border-bottom: 1px solid #e2e5e9; } @media (prefers-color-scheme: dark) { .nav { border-color: #2b3138 !important; } } -.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: baseline; } -.nav .links a, .nav .links [aria-current] { padding: 0.2rem 0.5rem; border-radius: 6px; } -.nav .links [aria-current] { +.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: center; } +/* Each entry pairs an icon with its label, so the entry itself is a flex row + rather than a run of text — that is also why the bar centres its items + instead of aligning them on the text baseline. Account is included: it is a + page like the others and would otherwise be the one bare word in the bar. */ +.nav a, .nav [aria-current] { + display: inline-flex; align-items: center; gap: 0.4rem; + padding: 0.2rem 0.5rem; border-radius: 6px; +} +.nav [aria-current] { font-weight: 600; color: #1b1f24; background: #e6ebf5; box-shadow: inset 0 -2px 0 #2563eb; } @media (prefers-color-scheme: dark) { - .nav .links [aria-current] { color: #e6e8eb !important; background: #22303f !important; } + .nav [aria-current] { color: #e6e8eb !important; background: #22303f !important; } } +/* The icons draw in the entry's own colour, so the active entry's darker text + and a link's blue carry through without a second rule per state. */ +.nav .icon { width: 1rem; height: 1rem; flex: none; } /* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server status page and the per-domain DNS checks, so a colour means the same thing everywhere. The class suffix is the check's own status value. */ diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 808a40f..da2d362 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -36,19 +36,33 @@ {{define "nav"}} {{end}} + +{{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they + inherit the link's colour through currentColor, cost no extra request, and + need no exemption from the panel's "default-src 'self'" policy. Each is + aria-hidden because the entry's own text is already the accessible name; + the icon is a landmark for the eye, not a second label. Kept as separate + templates so the nav above stays one readable line per page. */}} +{{define "icon-status"}}{{end}} +{{define "icon-domains"}}{{end}} +{{define "icon-deliveries"}}{{end}} +{{define "icon-mail-queue"}}{{end}} +{{define "icon-system-log"}}{{end}} +{{define "icon-backup"}}{{end}} +{{define "icon-account"}}{{end}} diff --git a/internal/web/templates_test.go b/internal/web/templates_test.go index 7db70c0..0d1083e 100644 --- a/internal/web/templates_test.go +++ b/internal/web/templates_test.go @@ -105,7 +105,9 @@ func TestNavMarksActivePage(t *testing.T) { t.Fatalf("execute nav: %v", err) } out := buf.String() - if !strings.Contains(out, `Mail queue`) { + // The label is checked apart from the opening tag because each entry now + // carries an icon between the two. + if !strings.Contains(out, ``) || !strings.Contains(out, `Mail queue`) { t.Errorf("active page is not marked:\n%s", out) } if strings.Contains(out, `href="/mail-queue"`) { @@ -129,7 +131,7 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) { t.Fatalf("execute nav: %v", err) } out := buf.String() - if !strings.Contains(out, `Status`) { + if !strings.Contains(out, ``) || !strings.Contains(out, `Status`) { t.Errorf("the status page is not marked active:\n%s", out) } if !strings.Contains(out, `href="/domains"`) {