From c0c66dfcdbea2dc66be45260a5f6569d27e3285a Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Fri, 7 Aug 2026 04:11:32 +0300 Subject: [PATCH] feat(panel): navigation as a left column, with a section index on the long pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The navigation was a bar across the top that did not fit on one row — six page entries and the session block against the panel's width — and had to be split into two, costing the top of every page. It is now a column down the left edge: one left edge to scan, the current entry marked down its leading edge, sticky so it stays in view, and room under the entries for the current page's own sections. Below the width the two columns need it lies back down into the same wrapping rows as before; six entries need no drawer. The section index is for the two pages long enough to need one — the domain page (nine cards) and the status page (eight). Each card carries an id and the page's template defines the list by overriding an empty "sections" block in the layout, so a page that defines nothing renders no index. panel.js marks the section in view, looking targets up by id on each pass so the status page swapping its cards out every five seconds cannot leave it measuring boxes that have left the document; the links themselves are plain fragment links and need no script. Verified against the real pages rendered by a local panel at 1300px, 924px and 481px wide. --- CHANGELOG.md | 24 ++++ internal/web/static/panel.css | 136 ++++++++++++++++------ internal/web/static/panel.js | 63 ++++++++++ internal/web/templates/domain_detail.html | 43 +++++-- internal/web/templates/layout.html | 45 ++++--- internal/web/templates/status.html | 23 +++- internal/web/templates/status_body.html | 12 +- internal/web/templates_test.go | 73 ++++++++++++ 8 files changed, 349 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee66264..dc3ea45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,30 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version — no `/proc` outside Linux — leave the card in place showing "unknown". The usage bars are `` elements: the panel's CSP has no inline-style exemption, so a bar's length has to travel on an attribute. +- An index of the current page's own sections in the navigation column, for the + two pages long enough to need one: the domain page (nine cards, from the DNS + records to publish down to the danger zone) and the status page (eight). Each + card carries an id and the page's template defines the list + (`{{define "sections"}}`); every other page defines nothing and shows no + index. `panel.js` marks the section currently in view, looking its targets up + by id on each pass so the status page swapping its cards out every five + seconds cannot leave it measuring boxes that have left the document. The + links are plain fragment links and work with JavaScript blocked; only the + highlight needs it. + +### Changed + +- The panel's navigation is a column down the left edge instead of a bar across + the top. As a bar it did not fit on one row — six page entries and the + session block against the panel's width — and had to be split into two, + costing the top of every page; standing it up removes the compromise, gives + the entries one left edge to scan down, and leaves room under them for the + section index above. It is sticky, so both lists stay in view on the long + pages, and the current entry is marked down its leading edge rather than + underlined. Below the width the two columns need, it lies back down into the + wrapping rows it used to be — no drawer and no hamburger, since six entries + fit. The markup now lists the blocks in the order they are drawn, so the tab + order follows the eye instead of starting at Sign out. ## [0.5.0] - 2026-08-06 diff --git a/internal/web/static/panel.css b/internal/web/static/panel.css index e3f3517..77207ab 100644 --- a/internal/web/static/panel.css +++ b/internal/web/static/panel.css @@ -50,11 +50,16 @@ body { font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--fg); } +/* The two columns — navigation on the left, page on the right — centred as a + pair. The navigation is a fixed width and the page keeps the measure it had: + the column is added beside the content, not taken out of it. */ +.shell { display: flex; justify-content: center; align-items: flex-start; gap: 1.75rem; } /* 48rem is a reading measure: right for the forms and prose that make up most - of the panel, and the width the navigation bar's two rows were sized for. - Page-specific overrides below widen or narrow it for the pages that need - something else. */ -main { max-width: 48rem; margin: 0 auto; } + of the panel. Page-specific overrides below widen or narrow it for the pages + that need something else. No auto margins: inside the shell they would eat + the free space and push the navigation column off to the far edge, so the + centring is the shell's job (justify-content above). */ +main { flex: 1 1 auto; min-width: 0; width: 100%; max-width: 48rem; } h1 { font-size: 1.4rem; margin: 0 0 1rem; } /* The full mark, on the two pages that have no navigation bar to carry the compact one. It takes the column's width so its edges line up with the card @@ -173,56 +178,115 @@ details form { margin-top: 0.6rem; } .credential { border-color: var(--credential-border); background: var(--credential-bg); } /* Panel navigation: rendered once from the layout, so it is present on every authenticated page without each content template having to include it. */ -/* Two deliberate rows, not one. The six page entries need about 660px and the - session block another 260px, against the 738px the panel is wide — so the bar - was wrapping on its own, and the session landed left-aligned directly under - the entries, reading as a third row of navigation. Shortening labels would buy - the ~200px back but undo the point of naming each entry after its page. Rows - rather than columns: a two-column grid of entries came out a third taller - (107px against 83px) and no easier to read. - The session sits on top, at the right edge, and the page entries below — the - usual arrangement, and the layout template puts the session first so the - reading and tab order follow what the eye sees rather than being flipped by a - CSS `order`. The cost is that Sign out is the bar's first tab stop; nothing - activates on focus, so that is a reordering, not a hazard. */ +/* A column down the left edge rather than a bar across the top. The six page + entries need about 660px and the session block another 260px, against the + 738px the panel was wide, so as a bar it had to be split over two rows — and + even then it cost the top of every page. Standing it up removes that: the + entries share one left edge to scan down, the session sits at the foot where + it is out of the way, and there is room between them for the current page's + own sections (.sections below), which is what makes the long pages navigable. + Sticky, so both lists stay in view while the page scrolls past them. The + layout template lists the blocks in the order they are drawn, so reading and + tab order follow the eye without a CSS `order`. */ .nav { - display: flex; flex-direction: column; align-items: stretch; - gap: 0.5rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem; - border-bottom: 1px solid var(--border); + position: sticky; top: 2rem; align-self: flex-start; + flex: none; width: 13.5rem; + display: flex; flex-direction: column; align-items: stretch; gap: 0.75rem; + /* A viewport shorter than the column would otherwise cut off whatever hangs + below the fold — with no page scroll left to reach it, since the column is + stuck to the viewport. */ + max-height: calc(100vh - 4rem); overflow-y: auto; } -.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: center; } -/* The top row: mark at one edge, session block at the other. Splitting them to - the edges is what makes the two rows read as two blocks rather than as one - ragged list that happened to wrap. */ -.nav .top { display: flex; align-items: center; justify-content: space-between; gap: 0.9rem; } -.nav .session { justify-content: flex-end; } -/* The mark is a link, but not one of the bar's entries: it takes none of the +.nav .links, .nav .session { display: flex; flex-direction: column; gap: 0.1rem; } +/* The session is the column's last block and the only one that is not + navigation, so it is ruled off from the entries above it. */ +.nav .session { padding-top: 0.75rem; border-top: 1px solid var(--border); } +/* The signed-in administrator's name: a label above the two controls, not one + of them. It may be long and there is nothing to break it on, so it is allowed + to break mid word rather than widen the column. */ +.nav .session .muted { padding: 0 0.6rem; font-size: 0.85rem; overflow-wrap: anywhere; } +/* The mark is a link, but not one of the column's entries: it takes none of the padding and rounding the entry rule below applies, so its own edge lines up - with the page rather than sitting half a step inside it. The stamp carries - about 4px of field inside the file at this size, which is what puts it level - with the icons of the row underneath. */ + with the entries' icons rather than sitting half a step inside them. */ .nav .brand { padding: 0; } /* width/height are on the element too, so the row reserves the space before the SVG has loaded; these keep the ratio if the box is ever squeezed. The stamp's proportions are part of the mark — it is never scaled unevenly. */ .nav .brand img { display: block; width: 110px; height: auto; } /* 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. */ + rather than a run of text — that is also why the entries centre their items + instead of aligning them on the text baseline. In the column the entry fills + the width, so the whole strip is the click target and the hover and active + backgrounds read as one stack of bars. Account is included: it is a page like + the others and would otherwise be the one bare word in the column. */ +/* No underline: in a row the entries were separated by space alone and the + underline was what marked them as links, but a column of them reads as a + list of links already, and eight underlines down the left edge is a lot of + line for no added meaning. The hover background carries the affordance. */ .nav a, .nav [aria-current] { - display: inline-flex; align-items: center; gap: 0.4rem; - padding: 0.2rem 0.5rem; border-radius: 6px; + display: flex; align-items: center; gap: 0.5rem; + padding: 0.35rem 0.6rem; border-radius: 6px; text-decoration: none; } +.nav a:hover { background: var(--surface-bg); } +/* The marker moved with the bar: a rule under the entry made sense in a row, + where the entries sat side by side; standing up, the current entry is marked + down its leading edge, which is the edge every entry shares. */ .nav [aria-current] { - font-weight: 600; color: var(--fg); background: var(--nav-active-bg); box-shadow: inset 0 -2px 0 #2563eb; + font-weight: 600; color: var(--fg); background: var(--nav-active-bg); box-shadow: inset 2px 0 0 #2563eb; } /* Sign out carries an icon too, so it needs the same row layout; its padding and colours come from the compact button rule further down. */ -.nav button { display: inline-flex; align-items: center; gap: 0.4rem; } +.nav button { display: flex; align-items: center; gap: 0.5rem; } /* The icons draw in the entry's own colour, so the active entry's darker text, a link's blue and Sign out's red all carry through without a rule apiece. */ .nav .icon { width: 1rem; height: 1rem; flex: none; } +/* The current page's own sections, listed under the page entries. The domain + page and the status page are eight and nine cards tall, and the only way to + the card you came for was to scroll past all the ones you did not. Only pages + that long carry an index — it comes from the page's own "sections" template + (see layout.html), so a page with two cards renders nothing here. */ +.sections { + display: flex; flex-direction: column; gap: 0.1rem; + padding-top: 0.75rem; border-top: 1px solid var(--border); +} +.sections-title { + margin: 0 0 0.25rem 0.6rem; + font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; + color: #6b7280; +} +/* Quieter and a step in from the page entries: this is an index of one page, + subordinate to the list of pages above it. */ +.nav .sections a { + padding: 0.2rem 0.6rem 0.2rem 0.9rem; + font-size: 0.85rem; color: #6b7280; text-decoration: none; +} +.nav .sections a:hover { color: var(--fg); } +/* panel.js marks the section the page is scrolled to. Without JavaScript + nothing is marked and the list is still a working index. */ +.nav .sections a.current { color: var(--fg); font-weight: 600; background: var(--nav-active-bg); } +/* Jumping to a card should not leave it touching the top edge of the window. */ +.card[id] { scroll-margin-top: 1rem; } +/* Below the width the two columns need (13.5rem of navigation, 1.75rem of gap + and the 48rem measure, plus the body's padding), the column lies back down + into a bar above the page — the same wrapping rows it used to be. A drawer + behind a hamburger would save more height, but it would need script to open, + and the panel's navigation is six entries: they fit. */ +@media (max-width: 66rem) { + .shell { flex-direction: column; align-items: stretch; gap: 1.2rem; } + main { margin: 0 auto; } + .nav { + position: static; width: auto; max-height: none; overflow-y: visible; + flex-direction: row; flex-wrap: wrap; align-items: center; gap: 0.4rem 1.2rem; + padding-bottom: 0.6rem; border-bottom: 1px solid var(--border); + } + /* Each block keeps its own group of entries together and wraps as one; the + rules that separated the blocks vertically become the space between them. */ + .nav .links, .nav .session, .nav .sections { + flex-direction: row; flex-wrap: wrap; align-items: center; + gap: 0.2rem 0.6rem; padding-top: 0; border-top: 0; + } + .sections-title { margin: 0; } +} /* 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/static/panel.js b/internal/web/static/panel.js index da4697f..c4450c1 100644 --- a/internal/web/static/panel.js +++ b/internal/web/static/panel.js @@ -102,9 +102,72 @@ }); } + // --- Section index follows the page ----------------------------------- + // The long pages list their own sections in the navigation column (the + // "sections" template). Marking the one currently in view turns that list + // from an index into a position, which is the whole point of it on a page + // nine cards tall. The links work without any of this; only the highlight + // depends on it. + // + // Each pass looks its targets up by id rather than holding on to elements + // found once: the status page replaces its cards wholesale every five + // seconds (hx-swap on #status-body), and anything remembered here would be + // measuring boxes that had left the document. + var sectionLinks = []; + + function markCurrentSection() { + var current = null; + sectionLinks.forEach(function (link) { + var target = document.getElementById(link.hash.slice(1)); + // The section in view is the last one whose top has passed the reading + // line; the links are in document order, so the last match wins. + if (target && target.getBoundingClientRect().top <= 100) { + current = link; + } + }); + if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2) { + // At the foot of the page there is no scroll left to bring the last + // cards up to the reading line, so without this they could never be + // marked however far down you are — and the last card of the domain + // page is the one that deletes it. + current = sectionLinks[sectionLinks.length - 1]; + } else if (!current) { + // Above the first heading nothing has been passed yet, and the page is + // still on its first section. + current = sectionLinks[0]; + } + sectionLinks.forEach(function (link) { + link.classList.toggle("current", link === current); + }); + } + + function initSectionIndex() { + sectionLinks = Array.prototype.slice.call( + document.querySelectorAll(".sections a[href^='#']") + ); + if (!sectionLinks.length) { + return; + } + var pending = false; + // Scroll fires far more often than the highlight can change, so the work + // is collapsed onto the next frame. + window.addEventListener("scroll", function () { + if (pending) { + return; + } + pending = true; + window.requestAnimationFrame(function () { + pending = false; + markCurrentSection(); + }); + }, { passive: true }); + markCurrentSection(); + } + document.addEventListener("DOMContentLoaded", function () { initAddressFields(document); initEncryptFields(document); + initSectionIndex(); }); // --- Skip polling while the tab is hidden ------------------------------ diff --git a/internal/web/templates/domain_detail.html b/internal/web/templates/domain_detail.html index c1bb454..f41b7d6 100644 --- a/internal/web/templates/domain_detail.html +++ b/internal/web/templates/domain_detail.html @@ -7,7 +7,7 @@ {{if .RateLimitErr}}
{{.RateLimitErr}}
{{end}} {{if .NewCred}} -
+

New application password

This password is shown once only and is not stored. Copy it now — if it is lost, regenerate a new one.

@@ -24,7 +24,7 @@
{{end}} -
+

DKIM DNS record

Publish this TXT record in the DNS for {{.Domain.Name}}. It is not a secret and can be viewed at any time.

@@ -47,7 +47,7 @@

Mail is signed with selector {{.Domain.DKIMSelector}}.

-
+

SPF and DMARC records

These two are not generated the way the DKIM record above is — they are policy, and the domain may already publish an SPF record for other @@ -91,7 +91,7 @@ p=reject once the reports come back clean.

-
+

DNS status {{.DNS.Overall}}

What DNS publishes for {{.Domain.Name}} right now, checked against the key this server signs with. Results are cached for a @@ -121,7 +121,7 @@

-
+

Sending server settings

Point the mail client or script at these settings and authenticate with an application login and password from the @@ -146,7 +146,7 @@ {{/* Create form above the list, the same order the domains page uses for "Add a sending domain" above "Domains". */}} -

+

Add an application

@@ -173,7 +173,7 @@ be unique across all domains and may contain letters, digits, '.', '-' and '_'.

-
+

Applications

Each application is a SASL login/password an app or script uses to send mail as this domain. A login may send from any address of the @@ -241,7 +241,7 @@ {{end}}

-
+

Sending rate limit (domain)

Optional level-2 limit (spec 7.4): cap how many messages this domain may send from its expected client IP(s) within a time window, summed @@ -276,7 +276,7 @@ {{end}}

-
+

Export domain

Download this domain to move it to another SelfPost instance: its DKIM key, selector and every application with its working password. On @@ -292,10 +292,33 @@

-
+

Danger zone

Deleting this domain also deletes its DKIM key and every application bound to it.

Delete domain
{{end}} + +{{/* The domain page's section index, shown in the navigation column (see the + "sections" block in layout.html). This is the panel's longest page — the + DNS records to publish, the checks on them, the applications and two rate + limits — and setting a domain up means going back and forth between them. + The freshly generated password is only listed while it is on the page: it + is the one card that is not always there, and the one nothing should scroll + away from silently. */}} +{{define "sections"}} + +{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 9cadf22..915d349 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -22,13 +22,17 @@ +{{/* Two columns: the navigation on the left and the page beside it, centred + together (see .shell in panel.css). The signed-out pages have no navigation + and the shell holds their card alone. */}} +
+{{if .User}}{{template "nav" .}}{{end}} {{/* The page name travels onto
as a class so the stylesheet can size a page to what it holds — the monitoring pages are wider than the panel's form pages (see main.page-* in panel.css). It is .Active, already carried for the navigation, rather than a second field every handler would have to remember to set. */}}
-{{if .User}}{{template "nav" .}}{{end}} {{template "content" .}} {{/* The running version, on every authenticated page: it is what a backup manifest is checked against on restore and the first thing to establish @@ -36,6 +40,7 @@ the login and setup pages must not advertise it to the internet. */}} {{if .User}}
SelfPost {{.Version}}
{{end}}
+
{{end}} @@ -46,21 +51,11 @@ .User and get no nav. */}} {{define "nav"}} {{end}} +{{/* The index of the current page's own sections, shown in the navigation + column under the page entries. Most pages are one or two cards and need no + index, so the block is empty here and only the pages that are genuinely + long — the domain page, the status page — redefine it with their own list + (see .sections in panel.css for what it looks like, and panel.js for the + marking of the section in view). Each page's template file is parsed after + this one, so its definition replaces this empty one; a page that defines + nothing keeps it and renders no index. */}} +{{define "sections"}}{{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 diff --git a/internal/web/templates/status.html b/internal/web/templates/status.html index 21472e6..fa2f145 100644 --- a/internal/web/templates/status.html +++ b/internal/web/templates/status.html @@ -5,7 +5,7 @@ {{template "status_body" .}} -
+

Hostname and reverse DNS {{.PTR.Status}}

Receiving servers check that the name this server announces resolves to its address and that the address resolves back to the same @@ -29,7 +29,7 @@

-
+

Configuration

Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it if you edited the files by hand, @@ -41,3 +41,22 @@

{{end}} + +{{/* The status page's section index, shown in the navigation column (see the + "sections" block in layout.html). The first six cards are the ones the + polling fragment replaces every five seconds; their ids are part of + status_body.html and do not change with the reading, so the links here hold + across a refresh. */}} +{{define "sections"}} + +{{end}} diff --git a/internal/web/templates/status_body.html b/internal/web/templates/status_body.html index 81ce2e6..a5b9a67 100644 --- a/internal/web/templates/status_body.html +++ b/internal/web/templates/status_body.html @@ -1,11 +1,11 @@ {{define "status_body"}}
-
+

Overall {{.OverallStatus}}

{{.OverallHeading}}

-
+

Processes {{.ProcessStatus}}

{{if .ProcessError}}

Could not ask supervisord for the process list.

@@ -25,7 +25,7 @@ {{end}}
-
+

Machine {{.Machine.Status}}

Processor, memory and network of the machine this container runs on, read from the kernel's counters. CPU and throughput are rates, so @@ -72,7 +72,7 @@

-
+

Mail queue {{.QueueStatus}}

{{if .QueueError}}

{{.QueueError}}

@@ -82,7 +82,7 @@ {{end}}
-
+

TLS certificate {{.Cert.Status}}

The certificate Postfix serves on port 465{{if .Cert.Subject}} ({{.Cert.Subject}}){{end}}. It is supplied by the reverse proxy through a read-only mount; SelfPost only reads it.

@@ -93,7 +93,7 @@

{{.Cert.Detail}}

-
+

Milter sockets {{.SocketStatus}}

diff --git a/internal/web/templates_test.go b/internal/web/templates_test.go index 0adfa6f..f167faf 100644 --- a/internal/web/templates_test.go +++ b/internal/web/templates_test.go @@ -29,6 +29,79 @@ func TestEveryPageResolvesNav(t *testing.T) { } } +// The section index each long page shows in the navigation column works by +// overriding an empty "sections" block defined in the layout, which only holds +// as long as the layout is parsed before the page's own files (see pageFiles). +// Reverse that order and every index would silently disappear — the empty +// definition would win and no page would fail to render — so the two ends are +// asserted here: the long pages produce a list, and a page that defines nothing +// produces nothing at all. +func TestSectionIndexIsOnTheLongPagesOnly(t *testing.T) { + tmpl, err := loadTemplates() + if err != nil { + t.Fatalf("loadTemplates: %v", err) + } + // Anchors the index links to, taken from the page's own cards. + wantAnchors := map[string]string{ + "status": `href="#certificate"`, + "domain_detail": `href="#danger"`, + } + for name, page := range tmpl.pages { + var buf bytes.Buffer + // The domain page's index hides the freshly generated credential entry + // unless one is on the page, so the data map carries the key it reads. + if err := page.ExecuteTemplate(&buf, "sections", map[string]any{"NewCred": nil}); err != nil { + t.Fatalf("execute sections for %q: %v", name, err) + } + out := buf.String() + anchor, wanted := wantAnchors[name] + switch { + case wanted && !strings.Contains(out, anchor): + t.Errorf("page %q shows no section index (expected %s):\n%s", name, anchor, out) + case !wanted && strings.TrimSpace(out) != "": + t.Errorf("page %q is not long enough to carry a section index:\n%s", name, out) + } + } +} + +// A section link that points at no card is a link that does nothing, and +// nothing about rendering the page says so. Every anchor the index offers must +// name an element the same page defines an id for. +func TestSectionLinksPointAtCardsThatExist(t *testing.T) { + tmpl, err := loadTemplates() + if err != nil { + t.Fatalf("loadTemplates: %v", err) + } + // The pages that carry an index; both are checked with a credential shown, + // which is the domain page's one conditional entry. + for _, name := range []string{"status", "domain_detail"} { + var index bytes.Buffer + if err := tmpl.pages[name].ExecuteTemplate(&index, "sections", map[string]any{"NewCred": true}); err != nil { + t.Fatalf("execute sections for %q: %v", name, err) + } + // The cards are spread over the page's template files, so the ids are + // collected from the files rather than from a rendered page — rendering + // one would need the whole of a handler's data map. + ids := map[string]bool{} + for _, file := range pageFiles[name] { + body, err := fs.ReadFile(assetsFS, file) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + // Cards only: a form field's id is not somewhere a section link may + // land, so matching those too would weaken the check. + for _, m := range regexp.MustCompile(`class="card[^"]*" id="([a-z-]+)"`).FindAllStringSubmatch(string(body), -1) { + ids[m[1]] = true + } + } + for _, m := range regexp.MustCompile(`href="#([a-z-]+)"`).FindAllStringSubmatch(index.String(), -1) { + if !ids[m[1]] { + t.Errorf("page %q indexes #%s, which no card on it carries", name, m[1]) + } + } + } +} + // The version comes from render(), not from each handler's data map, so the // footer is only correct as long as every page composes with the layout and // render keeps supplying the key. Both are asserted here rather than trusted.
MilterSocketState