From f9e259a66dfbb0549d0d611ca4d5fbf89ee7fed8 Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Wed, 12 Aug 2026 22:16:03 +0300 Subject: [PATCH] panel: refine domain detail layout and drop section index Co-authored-by: Cursor --- CHANGELOG.md | 13 +- internal/web/view/static/panel.css | 90 ++--- internal/web/view/static/panel.js | 86 ++--- .../web/view/templates/domain_detail.html | 358 ++++++++++-------- internal/web/view/templates/layout.html | 27 +- internal/web/view/templates_test.go | 109 ++---- 6 files changed, 299 insertions(+), 384 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 609b348..55c80b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Changed -- panel: the domain detail page is wide with paired cards (DKIM ‖ DNS status; - SPF ‖ DMARC; sending settings ‖ add application; rate limit ‖ export). - Applications and Danger stay full-width; SPF and DMARC are separate cards; - section blurbs are shorter. +- panel: the domain detail page is wide with paired cards (DKIM ‖ SPF+DMARC; + sending settings ‖ add application; export ‖ danger). DNS status, + Applications and Domain settings are full-width. DNS status is two rows + (DKIM ‖ SPF, DMARC ‖ report authorization); Domain settings pairs DMARC + report mode with the domain rate limit; application Edit opens mode and + rate limit side by side; the custom rua address field is shown only for + Custom address. The in-nav “On this page” section index is removed (Status + already dropped it; the paired domain layout no longer needs it). Section + blurbs are shorter. - panel: page URLs, browser titles, and headings are aligned — **Settings** is now `/settings` (legacy `/account` redirects with 308); the domains list title is `SelfPost — domains`; Status, Users, and user create/edit titles match their diff --git a/internal/web/view/static/panel.css b/internal/web/view/static/panel.css index 4a2b2e4..94f8e04 100644 --- a/internal/web/view/static/panel.css +++ b/internal/web/view/static/panel.css @@ -259,9 +259,9 @@ th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var( td.time, td.status { white-space: nowrap; } /* Column headings are set in the mono face: they are labels for machine data rather than prose, and the wider tracking a small monospaced capital wants - also holds them apart from the first row of values. Same treatment on the - other two small all-caps labels in the panel (.sections-title, .fact-label), - so a heading of that size reads as one thing wherever it appears. */ + also holds them apart from the first row of values. Same treatment on + .fact-label, so a heading of that size reads as one thing wherever it + appears. */ th { font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); @@ -329,23 +329,19 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); } .app .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem; } /* Mode and rate limit open under the whole row rather than inside it. As
the panel sat where its summary was, so opening one cut the row of - four controls in half and pushed the rest below a block of fields — the - buttons moved every time a panel opened or closed. The panels are therefore - the last children of the row, and the checkbox that opens each one is - visually hidden earlier in the row with its label drawn as the button. The - checkbox stays in the tab order and keeps its focus ring on the label, so it - works from the keyboard, and being pure CSS it also works with JavaScript - blocked, as
did. */ + controls in half and pushed the rest below a block of fields — the buttons + moved every time a panel opened or closed. The panel is therefore the last + child of the row, and the checkbox that opens it is visually hidden earlier + in the row with its label drawn as the button. The checkbox stays in the tab + order and keeps its focus ring on the label, so it works from the keyboard, + and being pure CSS it also works with JavaScript blocked, as
did. */ .app .actions > .panel-toggle { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; } .app .actions > .panel { display: none; flex: 1 0 100%; } -.app .actions > .t-mode:checked ~ .panel-mode, -.app .actions > .t-limit:checked ~ .panel-limit { display: block; } -.app .actions > .t-mode:checked ~ .for-mode, -.app .actions > .t-limit:checked ~ .for-limit { background: var(--surface-open-bg); } -.app .actions > .t-mode:focus-visible ~ .for-mode, -.app .actions > .t-limit:focus-visible ~ .for-limit { outline: 2px solid var(--accent-fill); outline-offset: 2px; } +.app .actions > .t-edit:checked ~ .panel-edit { display: block; } +.app .actions > .t-edit:checked ~ .for-edit { background: var(--surface-open-bg); } +.app .actions > .t-edit:focus-visible ~ .for-edit { outline: 2px solid var(--accent-fill); outline-offset: 2px; } .panel form { margin-top: 0.6rem; } /* A panel's own submit is a form button, not one of the controls in the row above, so it takes back the spacing the compact .actions rule zeroes out: @@ -362,6 +358,8 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); } } .app .actions > .panel .panel-buttons button, .app .actions > .panel .panel-buttons form { margin-top: 0; } +.app .actions > .panel .check-cols { margin-top: 0.6rem; } +.app .actions > .panel .check-col > form { margin-top: 0; } .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. */ @@ -370,11 +368,9 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); } 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`. */ + it is out of the way. Sticky, so the list stays in view while the page + scrolls past it. 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 { position: sticky; top: 2rem; align-self: flex-start; flex: none; width: 13.5rem; @@ -439,32 +435,7 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); } /* 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 is long enough that 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 or a short paired Status layout 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-family: var(--font-mono); - font-size: 0.7rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.1em; - color: var(--muted); -} -/* 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: var(--muted); 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. */ +/* Jumping to a card (in-page links) should not leave it touching the top edge. */ .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 measure, not the column @@ -488,11 +459,10 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); } .nav .brand img { width: 110px; } /* 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 { + .nav .links, .nav .session { 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 @@ -562,9 +532,31 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re grid keep their spacing from it instead. */ .split > .card + .card { margin-top: 0; } .split + .card { margin-top: 1.2rem; } +.card + .split { margin-top: 1.2rem; } /* Configuration sits outside the polled fragment; without this it would touch the last .split inside #status-body (a nested .split is not a sibling). */ #status-body + .card { margin-top: 1.2rem; } +/* Peer checks or paired forms inside one card (domain DNS status: two rows of + two; domain settings; application edit). Same auto-fit idea as .split, so a + wide card gets two columns — not three — and falls to one when narrow. */ +.check-cols { + display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr)); + gap: 1rem 1.2rem; margin-top: 1rem; +} +.check-col { min-width: 0; } +.check-col > label:first-child { margin-top: 0; } +.check-col-title { + margin: 1rem 0 0.35rem; font-size: 1.05rem; font-weight: 600; +} +.card > h2 + .check-col-title { margin-top: 0.55rem; } +/* Two short fields on one row (rate-limit message count ‖ window). Falls to + one column when the parent is too narrow for both. */ +.field-pair { + display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); + gap: 0 1rem; margin-top: 0.45rem; +} +.field-pair > div { min-width: 0; } +.field-pair label { margin-top: 0.45rem; } /* The subject heads a delivery's page, and it is the one heading in the panel whose text we do not control: it may be a hundred characters with nothing to break on. It wraps to as many lines as it needs (this is the page's name, not diff --git a/internal/web/view/static/panel.js b/internal/web/view/static/panel.js index 146c688..2e8993c 100644 --- a/internal/web/view/static/panel.js +++ b/internal/web/view/static/panel.js @@ -72,6 +72,28 @@ }); } + // --- Custom DMARC rua address shown only for "custom" mode ------------ + // Same idea as the address list: the email field only applies when the + // operator picks Custom address. With JavaScript blocked the field stays + // visible and the server still ignores it for inherit/none. + function syncCustomAddressField(select) { + var form = select.closest("form"); + var field = form && form.querySelector("[data-custom-address]"); + if (!field) { + return; + } + field.hidden = select.value !== select.dataset.customMode; + } + + function initCustomAddressFields(root) { + root.querySelectorAll("select[data-custom-mode]").forEach(function (select) { + syncCustomAddressField(select); + select.addEventListener("change", function () { + syncCustomAddressField(select); + }); + }); + } + // --- Domain pick shown only for domain administrators ------------------ // Global administrators manage every domain, so the assignment checkboxes // are irrelevant for that role. The toggle runs on load too, because the @@ -157,74 +179,12 @@ }); } - // --- 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 (adaptive polling 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); + initCustomAddressFields(document); initDomainPickFields(document); initEncryptFields(document); initImportPasswordField(document); - initSectionIndex(); }); // --- Adaptive monitoring polling --------------------------------------- diff --git a/internal/web/view/templates/domain_detail.html b/internal/web/view/templates/domain_detail.html index bd20a05..2322977 100644 --- a/internal/web/view/templates/domain_detail.html +++ b/internal/web/view/templates/domain_detail.html @@ -28,11 +28,63 @@ {{end}} +{{/* Two rows of two checks (.check-cols). */}} +
+

DNS status {{.DNS.Overall}}

+

Cached a few minutes — use Re-check after + publishing.

+ +
+
+ +

{{.Record.Name}}

+ {{if ne .DNS.DKIM.Status "ok"}} +

{{.DNS.DKIM.Detail}}

+ {{end}} + {{if .DNS.DKIM.Records}}{{range .DNS.DKIM.Records}}{{.}} +{{end}}{{end}} +
+ +
+ +

{{.Domain.Name}}

+ {{if ne .DNS.SPF.Status "ok"}} +

{{.DNS.SPF.Detail}}

+ {{end}} +

Shallow check: literal address only, no include: / + redirect=.

+ {{if .DNS.SPF.Records}}{{range .DNS.SPF.Records}}{{.}} +{{end}}{{end}} +
+ +
+ +

{{.DNS.DMARC.Detail}}

+ {{if .DNS.DMARC.Records}}{{range .DNS.DMARC.Records}}{{.}} +{{end}}{{end}} +
+ +
+ {{if .DNS.DMARCReportAuth.Status}} + +

{{.DNS.DMARCReportAuth.Detail}}

+ {{if .DNS.DMARCReportAuth.Records}}{{range .DNS.DMARCReportAuth.Records}}{{.}} +{{end}}{{end}} + {{else}} + +

Not required (no external rua=).

+ {{end}} +
+
+ +
+ +
+
+
-

DKIM DNS record

-

Publish this TXT for {{.Domain.Name}}. - It is not a secret.

+

DKIM record

@@ -49,91 +101,42 @@
-

Signed with selector {{.Domain.DKIMSelector}}.

+

Not a secret. Signed with selector + {{.Domain.DKIMSelector}}.

-
-

DNS status {{.DNS.Overall}}

-

Cached a few minutes — use Re-check after - publishing.

- - -

{{.DNS.DKIM.Detail}}

- {{if .DNS.DKIM.Records}}{{range .DNS.DKIM.Records}}{{.}} -{{end}}{{end}} - - -

{{.DNS.SPF.Detail}}

- {{if .DNS.SPF.Records}}{{range .DNS.SPF.Records}}{{.}} -{{end}}{{end}} -

Shallow check: literal address only, no - include: / redirect=.

- - -

{{.DNS.DMARC.Detail}}

- {{if .DNS.DMARC.Records}}{{range .DNS.DMARC.Records}}{{.}} -{{end}}{{end}} - - {{if .DNS.DMARCReportAuth.Status}} - -

{{.DNS.DMARCReportAuth.Detail}}

- {{if .DNS.DMARCReportAuth.Records}}{{range .DNS.DMARCReportAuth.Records}}{{.}} -{{end}}{{end}} - {{end}} - -
- -
-
-
- -
-
-

SPF record

-

Suggested TXT. If the domain already has SPF, merge this - server's mechanism into it — do not add a second record.

+
+

SPF and DMARC records

+

SPF

{{.Domain.Name}}
+ + TXT +
{{.SPFExample}}
-
-
-

DMARC record

-

Policy TXT. SelfPost is send-only — omit rua= - or point it at a mailbox elsewhere (Settings default - or custom below).

- -
- - - - - - - -
+

Merge into an existing SPF if the domain already has one — + do not publish a second record.

+

DMARC

{{.DMARCName}}
+ + TXT +
{{.DMARCExample}} @@ -147,13 +150,17 @@ {{end}} {{if .NeedsReportAuth}} - +

Report authorization

+
{{.ReportAuthName}}
- + + TXT + +
{{.ReportAuthValue}} @@ -161,7 +168,8 @@ {{end}}

p=none does not affect delivery. Tighten to - p=quarantine then p=reject once reports look clean.

+ p=quarantine then p=reject once reports look clean. + Report address is set under Domain settings.

@@ -229,17 +237,12 @@ {{if eq .AddressMode $.Wildcard}}Any address of the domain — *@{{$.Domain.Name}} {{else}}Fixed list — {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}{{end}}

- +
- - - - + +
@@ -248,44 +251,55 @@ data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
-
-
- - -
- - +
+ +
+
+
+

Rate limit

+
+ + +

The limit counts only connections from these + IPs. Leave empty to leave the limit inactive.

+
+
+ + +
+
+ + +
+
+
+ +
+ + {{if .HasLimit}} +
+ + +
+ {{end}} +
- - -
-
-
- - - - - - -
- -
- - {{if .HasLimit}} -
- - -
- {{end}}
@@ -297,42 +311,73 @@ {{end}}
-
-
-

Sending rate limit (domain)

-

Optional level-2 cap across this domain's applications from - the listed client IPs. Empty IP list = inactive (level-1 only).

+
+

Domain settings

-

Status: - {{if .DomainHasRL}}active{{else}}inactive (level-1 only){{end}}.

+
+
+

DMARC reports

+

Default comes from Settings; + override per domain here.

+
+ + - - - +
+ + +
- - + +
+
- - +
+

Optional level-2 sending rate limit

+

Empty IP list = inactive (level-1 only). Status: + {{if .DomainHasRL}}active{{else}}inactive{{end}}.

- - - {{if .DomainHasRL}} -
- - -
- {{end}} +
+ + +

The limit counts only connections from these IPs. + Leave empty to leave the limit inactive.

+ +
+
+ + +
+
+ + +
+
+ + +
+ {{if .DomainHasRL}} +
+ + +
+ {{end}} +
+
+

Export domain

-

DKIM key, selector and application passwords for another - SelfPost instance. DNS stays the same on import.

Secret file — transfer securely, or encrypt below as .spde.

{{if .ExportErr}}

{{.ExportErr}}

{{end}} @@ -341,30 +386,11 @@
-
-
-

Danger zone

-

Deletes the DKIM key and every application on this domain.

- Delete domain -
-{{end}} - -{{/* Section index in the navigation column (layout.html). The freshly generated - password is only listed while it is on the page. */}} -{{define "sections"}} -
-

On this page

- {{if .NewCred}}New application password{{end}} - DKIM DNS record - DNS status - SPF record - DMARC record - Sending server settings - Add an application - Applications - Sending rate limit - Export domain - Danger zone +
+

Danger zone

+

Deletes the DKIM key and every application on this domain.

+ Delete domain +
{{end}} diff --git a/internal/web/view/templates/layout.html b/internal/web/view/templates/layout.html index 002d38c..9e8593b 100644 --- a/internal/web/view/templates/layout.html +++ b/internal/web/view/templates/layout.html @@ -61,9 +61,9 @@ {{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}} - {{/* 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
'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. */}} + the reading measure unless it says otherwise; the pages that are tables of + data, raw log lines or side-by-side cards redefine this as the word "wide", + which lands in
's class list (see main.wide in panel.css). Each + page's template file is parsed after this one, so its definition replaces + the empty one. */}} {{define "wide"}}{{end}} {{/* back_link — up-navigation on drill-down pages. Invoke with the "back" diff --git a/internal/web/view/templates_test.go b/internal/web/view/templates_test.go index 51d69a4..2e3ddd4 100644 --- a/internal/web/view/templates_test.go +++ b/internal/web/view/templates_test.go @@ -29,80 +29,6 @@ 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) { - engine, err := New("test") - if err != nil { - t.Fatalf("New: %v", err) - } - // Anchors the index links to, taken from the page's own cards. Status used - // to carry one too; the paired layout is short enough without it. - wantAnchors := map[string]string{ - "domain_detail": `href="#danger"`, - } - for name, page := range engine.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) { - engine, err := New("test") - if err != nil { - t.Fatalf("New: %v", err) - } - // The pages that carry an index; checked with a credential shown, which is - // the domain page's one conditional entry. Status dropped its index once - // the paired layout was short enough. - for _, name := range []string{"domain_detail"} { - var index bytes.Buffer - if err := engine.Page(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. @@ -280,29 +206,46 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) { } } -// The domain page pairs cards the same way Status does: four .split rows -// (DKIM|DNS, SPF|DMARC, settings|add-app, rate-limit|export) with Applications -// and Danger full-width. Losing a row silently stacks the page again. +// The domain page pairs cards the same way Status does: three .split rows +// (DKIM|SPF+DMARC, settings|add-app, export|danger). DNS status, Applications +// and Domain settings are full-width; DNS status and Domain settings (and the +// application Edit panel) use .check-cols. Losing a row silently stacks again. func TestDomainDetailPageHasPairedCards(t *testing.T) { body, err := fs.ReadFile(assetsFS, "templates/domain_detail.html") if err != nil { t.Fatalf("read domain_detail: %v", err) } src := string(body) - if got := strings.Count(src, `class="split"`); got != 4 { - t.Errorf("domain detail has %d .split rows, want 4", got) + if got := strings.Count(src, `class="split"`); got != 3 { + t.Errorf("domain detail has %d .split rows, want 3", got) + } + if !strings.Contains(src, `class="check-cols"`) { + t.Error("domain detail is missing the check-cols grid") + } + if !strings.Contains(src, `class="panel-toggle t-edit"`) { + t.Error("application Edit should be a single panel-toggle") + } + if strings.Contains(src, `panel-toggle t-mode`) || strings.Contains(src, `panel-toggle t-limit`) || + strings.Contains(src, `panel-mode`) || strings.Contains(src, `panel-limit`) { + t.Error("application Edit mode and Rate limit should be one Edit button") } for _, id := range []string{ - `id="dkim"`, `id="dns-status"`, `id="spf"`, `id="dmarc"`, + `id="dkim"`, `id="dns-status"`, `id="spf-dmarc"`, `id="settings"`, `id="add-application"`, `id="applications"`, - `id="rate-limit"`, `id="export"`, `id="danger"`, + `id="domain-settings"`, `id="export"`, `id="danger"`, } { if !strings.Contains(src, id) { t.Errorf("domain detail is missing %s", id) } } - if strings.Contains(src, `id="spf-dmarc"`) { - t.Error("domain detail still has the combined spf-dmarc card; SPF and DMARC are separate") + if strings.Contains(src, `id="rate-limit"`) { + t.Error("domain rate limit should live inside domain-settings, not its own card") + } + if strings.Contains(src, `id="spf"`) && !strings.Contains(src, `id="spf-dmarc"`) { + t.Error("standalone SPF card should be merged into spf-dmarc") + } + if regexp.MustCompile(`id="dmarc"`).MatchString(src) { + t.Error("standalone DMARC card should be merged into spf-dmarc") } }