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