fix(panel): lay every page out in one column width
test / test (push) Has been cancelled

The page column was the 48rem reading measure, which the send log, the mail
queue and the system log widened to 64rem for their tables. The navigation and
the page are centred as a pair, so that difference did not only change the
page's width: it moved the navigation column and the left edge of every card
on the way between two pages. The column is now 64rem throughout and the
measure lives inside it — a page's heading, cards, back link and version footer
are held to 48rem and centred in the column, and the pages made of data opt out
and take the column whole.

Which pages those are is declared by the page (a "wide" block in its template,
the same mechanism as the section index) rather than derived from the
navigation entry: .Active cannot tell the send log from a single delivery's
page, which is prose and now keeps the measure instead of inheriting the log's
width.

Two rules follow from centring the children rather than the cards. The heading
takes its margins as longhands, since the shorthand re-zeroed the auto side
margins and pinned it a measure's width left of the card under it; and the back
link is a block, since auto margins centre a block and do nothing for an
inline-block.

The scrollbar's width is reserved on every page as well. Without it a short
page and a long one are laid out in viewports differing by that width, which
moved the same things again, in the same direction, for a second reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-08-08 09:17:13 +03:00
parent bea482e30d
commit 94ab207752
7 changed files with 120 additions and 20 deletions
+16
View File
@@ -52,6 +52,22 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Changed ### Changed
- Every panel page is laid out in one column of the same width, so moving
between them no longer shifts the navigation and the cards sideways. The
column used to be the 48rem reading measure, which the send log, the mail
queue and the system log widened to 64rem for their tables — and since the
navigation and the page are centred as a pair, that difference moved
everything on screen on the way between two pages. The column is now 64rem
throughout, with the reading measure kept inside it: a page's heading, cards,
back link and version footer are held to 48rem and centred in the column,
and the three pages made of data opt out and take the column whole. Which
pages those are is declared by the page itself (a `wide` block in its
template, the same mechanism as the section index) rather than derived from
the navigation entry, so a single delivery's page — prose, but filed under
the send log — keeps the measure. The scrollbar's width is now reserved on
every page as well: without it a short page and a long one were laid out in
viewports differing by that width, which moved the same things again.
- The mark's small-size variant — the tab icon and the `SP` initials it - The mark's small-size variant — the tab icon and the `SP` initials it
carries — sets its S in Medium where the wordmark sets it in ExtraLight. carries — sets its S in Medium where the wordmark sets it in ExtraLight.
Against the P's SemiBold the ExtraLight S is a 0.90 stem against 3.40, Against the P's SemiBold the ExtraLight S is a 0.90 stem against 3.40,
+48 -15
View File
@@ -46,6 +46,13 @@
} }
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
/* The scrollbar's width is reserved whether or not the page is long enough to
need one. Without it a short page (Account) and a long one (Status) are laid
out in viewports differing by the scrollbar's width, and everything on the
page — the navigation column included — shifts sideways on the way between
them. The panel is a set of pages an operator moves through, so that shift
read as the layout jumping. */
html { scrollbar-gutter: stable; }
body { body {
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--fg); margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--fg);
@@ -54,13 +61,37 @@ body {
pair. The navigation is a fixed width and the page keeps the measure it had: 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. */ 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; } .shell { display: flex; justify-content: center; align-items: flex-start; gap: 1.75rem; }
/* One column for every page. It used to be the reading measure, 48rem, which
the three pages holding data widened to 64rem — and since the shell centres
the navigation and the page as a pair, that difference moved the navigation
column and the left edge of every card on the way between two pages. The
column is now the wider of the two everywhere, so nothing moves; the reading
measure is kept inside it by the rule below rather than by the column.
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: 64rem; }
/* 48rem is a reading measure: right for the forms and prose that make up most /* 48rem is a reading measure: right for the forms and prose that make up most
of the panel. Page-specific overrides below widen or narrow it for the pages of the panel, and too narrow for the pages that are tables of data. Every
that need something else. No auto margins: inside the shell they would eat page therefore keeps its content at that measure, centred in the column, and
the free space and push the navigation column off to the far edge, so the the pages made of data opt out (main.wide below) and take the column whole.
centring is the shell's job (justify-content above). */ The cap is on <main>'s children rather than on .card so a page's heading, its
main { flex: 1 1 auto; min-width: 0; width: 100%; max-width: 48rem; } back link and the version footer travel with the cards: capping the cards
h1 { font-size: 1.4rem; margin: 0 0 1rem; } alone would leave the heading spanning the column with the card it belongs to
floating in the middle — the misalignment that gave login and setup their own
narrow column (main.page-login further down). */
main > * { max-width: 48rem; margin-left: auto; margin-right: auto; }
/* The three monitoring pages are the ones that opt out: six columns of send
log, and raw mail.log lines that are long by nature, which at the measure
left Subject and Status fighting over the last inch and wrapped every second
log line. The class is declared by the page itself (the "wide" block in
layout.html) rather than derived from the page name, since a delivery's own
page is prose and shares the send log's name in the navigation. */
main.wide > * { max-width: none; }
/* Longhands, not the margin shorthand: the shorthand would re-zero the auto
side margins above and pin the heading to the column's edge, a measure's
worth to the left of the card under it. */
h1 { font-size: 1.4rem; margin-top: 0; margin-bottom: 1rem; }
/* The full mark, on the two pages that have no navigation bar to carry the /* 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 compact one. It takes the column's width so its edges line up with the card
below it, capped at that column's own 24rem; height stays automatic because below it, capped at that column's own 24rem; height stays automatic because
@@ -100,13 +131,6 @@ button:hover, a.btn:hover { background: #1d4ed8; }
/* form.inline only exists so a POST can sit next to other content without a /* form.inline only exists so a POST can sit next to other content without a
form's block layout; its button is styled like any other. */ form's block layout; its button is styled like any other. */
form.inline { display: inline; margin: 0; } form.inline { display: inline; margin: 0; }
/* The three monitoring pages hold data instead of prose — seven columns of
send-log, and raw mail.log lines that are long by nature — and at the
default measure the send-log's Subject and Status were fighting over the
last inch while the log pages wrapped every second line. They get a wider
measure; every other page keeps the default one. The class comes from the
layout, which stamps the page name onto <main>. */
main.page-deliveries, main.page-mail_queue, main.page-system_log { max-width: 64rem; }
/* The signed-out pages are a single card and nothing else, and .card.narrow /* The signed-out pages are a single card and nothing else, and .card.narrow
centres itself inside whatever holds it — so at the panel's usual width the centres itself inside whatever holds it — so at the panel's usual width the
card floated in the middle while the mark and the heading stayed at the far card floated in the middle while the mark and the heading stayed at the far
@@ -141,7 +165,13 @@ td.subject span {
.code { display: block; white-space: pre-wrap; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; .code { display: block; white-space: pre-wrap; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; } font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
h2 { font-size: 1.05rem; margin: 0 0 0.4rem; } h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
.back { display: inline-block; margin-bottom: 1rem; } /* The back link is a block so it takes the measure and lines up with the
heading above it and the card below: auto side margins centre a block, but
they do nothing for an inline-block, which would have stayed at the column's
edge while everything around it moved in. Its own text stays at the left of
that block — only the click target grows to the line, as it does for the
navigation entries. */
.back { display: block; margin-bottom: 1rem; }
/* Build version, closing every authenticated page. Quiet on purpose: it is /* Build version, closing every authenticated page. Quiet on purpose: it is
reference material, not something to read on the way past. */ reference material, not something to read on the way past. */
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; } .version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; }
@@ -300,7 +330,10 @@ button.danger:hover, a.danger:hover { background: #912018; }
/* Jumping to a card should not leave it touching the top edge of the window. */ /* Jumping to a card should not leave it touching the top edge of the window. */
.card[id] { scroll-margin-top: 1rem; } .card[id] { scroll-margin-top: 1rem; }
/* Below the width the two columns need (13.5rem of navigation, 1.75rem of gap /* 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 and the 48rem measure, plus the body's padding the measure, not the column
it sits in: a page column short of its 64rem is only a page column that has
not reached its cap, while a measure that no longer fits is a page that has),
the column lies back down
into a bar above the page — the same wrapping rows it used to be. A drawer 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, behind a hamburger would save more height, but it would need script to open,
and the panel's navigation is six entries: they fit. */ and the panel's navigation is six entries: they fit. */
+6
View File
@@ -1,3 +1,9 @@
{{/* The send log is six columns of addresses, subjects and timestamps, so this
page takes the whole column rather than the reading measure (see the "wide"
block in layout.html). A single delivery's page carries prose and keeps the
measure, which is why this is declared per page and not per page name. */}}
{{define "wide"}}wide{{end}}
{{define "content"}} {{define "content"}}
<h1>Deliveries</h1> <h1>Deliveries</h1>
+16 -5
View File
@@ -28,11 +28,14 @@
<div class="shell"> <div class="shell">
{{if .User}}{{template "nav" .}}{{end}} {{if .User}}{{template "nav" .}}{{end}}
{{/* The page name travels onto <main> as a class so the stylesheet can size a {{/* The page name travels onto <main> as a class so the stylesheet can size a
page to what it holds — the monitoring pages are wider than the panel's page to what it holds — the signed-out pages are narrower than the rest
form pages (see main.page-* in panel.css). It is .Active, already carried (see main.page-* in panel.css). It is .Active, already carried for the
for the navigation, rather than a second field every handler would have to navigation, rather than a second field every handler would have to
remember to set. */}} remember to set. The "wide" block beside it is how a page made of data
<main class="page-{{.Active}}"> asks for the whole column instead of the reading measure; it is the page's
own declaration rather than a name in the stylesheet, since .Active cannot
tell the send log from a single delivery's page. */}}
<main class="page-{{.Active}} {{template "wide" .}}">
{{template "content" .}} {{template "content" .}}
{{/* The running version, on every authenticated page: it is what a backup {{/* The running version, on every authenticated page: it is what a backup
manifest is checked against on restore and the first thing to establish manifest is checked against on restore and the first thing to establish
@@ -85,6 +88,14 @@
nothing keeps it and renders no index. */}} nothing keeps it and renders no index. */}}
{{define "sections"}}{{end}} {{define "sections"}}{{end}}
{{/* Whether the page takes the column whole. Empty here, so a page is held to
the reading measure unless it says otherwise; the three pages that are
tables of data or raw log lines redefine this as the word "wide", which
lands in <main>'s class list (see main.wide in panel.css). Same mechanism
as "sections" above: each page's template file is parsed after this one,
so its definition replaces the empty one. */}}
{{define "wide"}}{{end}}
{{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they {{/* 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 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 need no exemption from the panel's "default-src 'self'" policy. Each is
+4
View File
@@ -1,3 +1,7 @@
{{/* A table of queue ids, sizes, senders and recipients: the whole column
rather than the reading measure (see the "wide" block in layout.html). */}}
{{define "wide"}}wide{{end}}
{{define "content"}} {{define "content"}}
<h1>Mail queue</h1> <h1>Mail queue</h1>
+4
View File
@@ -1,3 +1,7 @@
{{/* Raw mail.log lines, long by nature: the whole column rather than the
reading measure (see the "wide" block in layout.html). */}}
{{define "wide"}}wide{{end}}
{{define "content"}} {{define "content"}}
<h1>System log</h1> <h1>System log</h1>
+26
View File
@@ -215,6 +215,32 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
} }
} }
// Whether a page takes the whole column or the reading measure is declared by
// the page's own "wide" block (see layout.html), which the layout stamps into
// <main>'s class list. A page that loses the block does not fail to render — it
// silently comes back at the measure, with its table squeezed into two thirds
// of the column — so the set is asserted here, in both directions.
func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
tmpl, err := loadTemplates()
if err != nil {
t.Fatalf("loadTemplates: %v", err)
}
wide := map[string]bool{"deliveries": true, "mail_queue": true, "system_log": true}
for name, page := range tmpl.pages {
var buf bytes.Buffer
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
t.Fatalf("execute the wide block of %s: %v", name, err)
}
got := strings.TrimSpace(buf.String())
switch {
case wide[name] && got != "wide":
t.Errorf("page %q no longer declares itself wide (%q); its data falls back to the reading measure", name, got)
case !wide[name] && got != "":
t.Errorf("page %q declares itself %q; only the pages that are tables of data or raw log lines take the whole column", name, got)
}
}
}
// Since the panel root redirects to the status page, a link left pointing at // Since the panel root redirects to the status page, a link left pointing at
// "/" silently lands on the wrong screen instead of failing — so no template may // "/" silently lands on the wrong screen instead of failing — so no template may
// contain one. // contain one.