docs: turn the accepted panel design into an enforceable contract

panel-redesign.md is rewritten for the accepted direction: three layers
(Bulma as shipped, adapters, own sp- components), the page layouts, what
an implementer must not do, and how that is enforced — guard tests that
land before any template, a design-first CI step, and acceptance by
evidence (test output plus a screenshot of every page beside its mockup,
reviewed by a model that did not write the step).

A components page shows every building block in every state; a screen
may be built from nothing else. panel/check.py lints the mockups against
the contract (class vocabulary, CSS bans on !important, id and page
selectors and stray widths, page structure) and writes
panel/outlines.json, the per-screen component skeleton an implementation
is compared with, so a different design cannot pass as this one.

Own modifier classes now carry the sp- prefix (sp-warn, sp-fail, sp-big,
sp-danger-zone, ...) so the vocabulary is machine-checkable; the lint
also caught a width set outside the layout in the mockup's own CSS.

Agent rule .cursor/rules/panel-design.mdc; roadmap row and section
updated (0/15, still candidate).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
mix
2026-09-21 20:55:18 +03:00
co-authored by Claude Fable 5.1
parent d1f22fbcc8
commit 1bcfca1586
38 changed files with 1402 additions and 256 deletions
+39
View File
@@ -0,0 +1,39 @@
---
description: The panel's design is fixed by accepted mockups; implement it, do not reinterpret it.
globs: internal/web/view/**,docs/assets/panel-redesign/**
alwaysApply: false
---
# Panel design contract
Source of truth, in this order: the mockups in
[docs/assets/panel-redesign/panel/](docs/assets/panel-redesign/index.html),
the [components page](docs/assets/panel-redesign/panel/components.html),
`panel/outlines.json`, then
[docs/plans/panel-redesign.md](docs/plans/panel-redesign.md). Read the plan's
**The contract** and **Enforcement** sections before touching a template.
You are implementing a finished design. You are not designing.
- Build pages only from Bulma classes in the `BULMA` list of `panel/check.py`
and from the `sp-` components on the components page, through their
partials. No other class, no hand-written component markup, no inline style.
- No selector in `panel.css` that names a page or an id, uses `!important`, or
sets a width outside `WIDTH_OK`. If a page looks wrong, the layout choice is
wrong — do not patch it locally.
- If a screen seems to need a component, state or layout that does not exist:
**stop and ask.** Do not approximate it, and do not substitute a look of
your own.
- Never edit a mockup, `outlines.json`, `check.py`, the vendored Bulma or a
guard test to make your work pass. A failing guard means the implementation
is wrong. Design changes go mockup-first in a separate `design:` commit on
the owner's explicit instruction.
- "Done" is evidence, not a description: passing `go test ./internal/web/...`
output plus a screenshot of each restyled page from the running container,
saved under `docs/assets/panel-redesign/evidence/` beside its mockup's name.
Report every difference from the mockup you could not remove; do not
describe a different result as the requested one.
After editing a fragment in `panel/src/`: `python panel/build.py`, then
`python panel/check.py` (add `--write` only when the structure change is the
point of the commit).
+12
View File
@@ -23,6 +23,18 @@ document is not part of the release.
### Added
- Panel redesign, design contract:
[docs/plans/panel-redesign.md](docs/plans/panel-redesign.md) rewritten for
the accepted direction — three layers (Bulma as shipped, adapters, own `sp-`
components), three page layouts, what an implementer must not do, the guard
tests that land before any template, a `design-first` CI step and
evidence-based acceptance. A
[components page](docs/assets/panel-redesign/panel/components.html) shows
every building block in every state; `panel/check.py` lints the mockups
against the contract and writes `panel/outlines.json`, the per-screen
component skeleton an implementation is compared with. Own modifier classes
renamed to carry the `sp-` prefix so the vocabulary is machine-checkable.
Agent rule `.cursor/rules/panel-design.mdc`; roadmap row updated (`0/15`).
- Panel redesign, stage 2: the accepted direction drawn on every screen —
29 pages under [docs/assets/panel-redesign/panel/](docs/assets/panel-redesign/index.html).
Direction C (Bulma) is the base, with A's postal signature (postmark verdict,
+3 -1
View File
@@ -13,7 +13,7 @@
<h1>The accepted direction, every screen</h1>
<p class="lead">Direction <strong>C</strong> (Bulma) was picked as the base: the important things in view, details one
or two clicks away. It takes the postal signature of <strong>A</strong> — the postmark verdict, numbered mono section
heads, the stamp's perforated edge — and the icons of <strong>B</strong>. Twenty-nine screens live in
heads, the stamp's perforated edge — and the icons of <strong>B</strong>. Twenty-nine screens and a components page live in
<a href="panel/overview.html">panel/</a>. Serve this folder from the repository root: the pages load the panel's own
fonts and mark by relative path.</p>
@@ -36,6 +36,8 @@
<a href="panel/backup.html">Backup</a> · <a href="panel/users.html">Users</a> · <a href="panel/user.html">user form</a> ·
<a href="panel/user-delete.html">delete user</a> · <a href="panel/settings.html">Settings</a></td></tr>
<tr><th>User menu</th><td><a href="panel/account.html">Account</a> · <a href="panel/help.html">Help</a></td></tr>
<tr><th>Contract</th><td><a href="panel/components.html">Components</a> — every building block in every state ·
<a href="../../plans/panel-redesign.md">the plan</a> · <code>panel/check.py</code> lints the mockups and writes <code>panel/outlines.json</code></td></tr>
</tbody>
</table>
</div>
+227
View File
@@ -0,0 +1,227 @@
"""Design-contract lint for the panel mockups, and the source of outlines.json.
The contract (docs/plans/panel-redesign.md) is only worth something if a
machine can say "no". This script is that machine for the mockups; the same
rules are ported to a Go test over internal/web templates when the redesign is
implemented, and that test compares rendered pages with outlines.json.
python docs/assets/panel-redesign/panel/check.py # lint
python docs/assets/panel-redesign/panel/check.py --write # lint + rewrite outlines.json
Exit code 1 on any violation.
"""
import json
import pathlib
import re
import sys
from html.parser import HTMLParser
HERE = pathlib.Path(__file__).parent
VOID = {"input", "img", "br", "hr", "meta", "link"}
# The subset of Bulma the panel uses. Reaching for a Bulma class outside this
# list is a design decision, not an implementation detail: add it here and to
# the components page in the same change, or do without it.
BULMA = set("""
box button buttons checkbox column columns control field file file-cta file-input file-label
file-name has-addons has-icons-left has-name has-text-danger help icon input label menu menu-label
menu-list progress select table tag textarea title
is-3 is-4 is-5 is-6 is-7 is-9 is-active is-danger is-expanded is-fullwidth is-gapless is-half
is-hoverable is-left is-light is-primary is-size-7 is-small is-success is-text is-warning
mb-1 mb-2 mb-3 mb-4 ml-2 mt-4 mt-5
""".split())
# Classes the shell (build.py, later layout.html) owns; screens never write them.
SHELL = {"sp-page", "sp-perf", "sp-subnav", "sp-current", "sp-foot"}
# The signed-out screens have no shell, so they carry their own footer.
SIGNED_OUT = {"sp-signin", "sp-signin-brand", "sp-signin-form", "sp-foot"}
# Selectors allowed to set a width. Everything else gets its width from the
# layout it sits in — that is the whole point of the redesign.
WIDTH_OK = (".navbar-brand img", ".sp-head .sp-lead", ".sp-head .sp-grow", ".sp-postmark", ".table td.sp-clip",
".sp-h-icon", ".sp-signin", ".sp-timeline li::before", ".sp-perf")
class Node:
def __init__(self, tag, attrs, parent):
self.tag, self.attrs, self.parent, self.kids, self.text = tag, dict(attrs), parent, [], ""
@property
def classes(self):
return (self.attrs.get("class") or "").split()
def has(self, cls):
return cls in self.classes
def walk(self):
yield self
for k in self.kids:
yield from k.walk()
def find(self, cls):
return [n for n in self.walk() if n.has(cls)]
class Tree(HTMLParser):
def __init__(self):
super().__init__()
self.root = Node("root", [], None)
self.cur = self.root
def handle_starttag(self, tag, attrs):
n = Node(tag, attrs, self.cur)
self.cur.kids.append(n)
if tag not in VOID:
self.cur = n
def handle_endtag(self, tag):
n = self.cur
while n is not self.root and n.tag != tag:
n = n.parent
if n is not self.root:
self.cur = n.parent
def handle_data(self, data):
self.cur.text += data
def parse(text):
t = Tree()
t.feed(text)
return t.root
def text_of(node):
return re.sub(r"\s+", " ", "".join(n.text for n in node.walk())).strip()
def css_rules(css):
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
css = re.sub(r"@media[^{]*\{", "", css)
for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css):
yield sel.strip(), body
def box_kind(box):
kinds = []
for k in box.kids:
if k.has("sp-box-head") or k.has("sp-box-foot"):
continue
for cls, name in (("sp-record", "record"), ("sp-health", "health"), ("sp-empty", "empty"),
("sp-log", "log"), ("sp-help-topic", "help"), ("sp-box-body", "body")):
if k.has(cls):
kinds.append(name)
break
else:
kinds.append("table" if k.tag == "table" else k.tag)
out = []
for k in kinds:
if not out or out[-1].split("×")[0] != k:
out.append(k)
else:
n = int(out[-1].split("×")[1]) + 1 if "×" in out[-1] else 2
out[-1] = f"{k}×{n}"
return "+".join(out)
def outline(node):
"""The component skeleton of a page: what a reviewer sees with the text blurred."""
out = []
for k in node.kids:
if k.has("sp-head"):
parts = ["head"] + [p for p, c in (("postmark", "sp-postmark"), ("actions", "buttons")) if k.find(c)]
if not k.find("buttons") and any(n.tag in ("a", "form") and n.parent is k for n in k.walk()):
parts.append("actions")
out.append("+".join(parts))
elif k.has("box"):
variant = next((v for v in ("sp-danger-zone", "sp-credential") if k.has(v)), "")
head = k.find("sp-box-head")
title = text_of(head[0].find("sp-no")[0].parent.kids[1]) if head else "?"
out.append(f"box{'.' + variant if variant else ''}[{title}]:{box_kind(k)}")
elif k.has("columns"):
cols = []
for c in k.kids:
size = next((x for x in c.classes if re.fullmatch(r"is-\d+", x)), "auto")
cols.append({size: outline(c)})
out.append({"columns": cols})
elif k.tag == "form" or k.has("menu") or k.tag == "aside":
out.append({"form": outline(k)} if k.tag == "form" else "side_menu")
elif k.has("buttons"):
out.append("submit_row")
return out
def main():
errors = []
css = (HERE / "theme.css").read_text(encoding="utf-8")
defined = set(re.findall(r"\.(sp-[a-z0-9-]+)", css))
for sel, body in css_rules(css):
if "!important" in body:
errors.append(f"theme.css: !important in `{sel}`")
if re.search(r"(^|[\s,>+~])#[a-z]", sel):
errors.append(f"theme.css: id selector `{sel}` — style components, not one element on one page")
if re.search(r"(?<![-\w])(max-width|min-width|width)\s*:", body) and not any(sel.startswith(w) or w in sel for w in WIDTH_OK):
errors.append(f"theme.css: `{sel}` sets a width; widths come from the page layout (WIDTH_OK)")
used, documented, outlines = set(), set(), {}
for src in sorted((HERE / "src").glob("*.html")):
text = src.read_text(encoding="utf-8")
name = src.stem
bare = "shell: bare" in text.split("-->")[0]
for bad in ("style=", "<style", "<script", " onclick=", " id=\"page-"):
if bad in text:
errors.append(f"{name}: `{bad}` is forbidden in a screen")
root = parse(text)
for n in root.walk():
for c in n.classes:
if c.startswith("sp-"):
(documented if name == "components" else used).add(c)
if c in SHELL and not (bare and c in SIGNED_OUT):
errors.append(f"{name}: `{c}` belongs to the shell")
elif c not in defined:
errors.append(f"{name}: `{c}` is not defined in theme.css")
elif c == "ti" or c.startswith("ti-"):
continue
elif c not in BULMA:
errors.append(f"{name}: class `{c}` is neither in the Bulma subset nor an sp- component")
if bare:
continue
top = [k for k in root.kids if k.tag != "root"]
if not top or not top[0].has("sp-head"):
errors.append(f"{name}: the page must open with page_head (.sp-head)")
if len([n for n in root.walk() if n.tag == "h1"]) != 1:
errors.append(f"{name}: exactly one <h1>")
marks = root.find("sp-postmark")
if name != "components" and (len(marks) > 1 or any(not m.parent.has("sp-head") for m in marks)):
errors.append(f"{name}: at most one postmark, inside page_head")
for box in root.find("box"):
if not box.kids or not box.kids[0].has("sp-box-head"):
errors.append(f"{name}: a box must start with box_head")
for k in top[1:]:
ok = k.has("box") or k.has("columns") or (k.tag == "form" and k.has("sp-form"))
if not ok:
errors.append(f"{name}: top-level <{k.tag} class=\"{' '.join(k.classes)}\"> — a page is boxes, columns or one form")
if name != "components":
outlines[name] = outline(root)
for c in sorted(used - documented - SIGNED_OUT):
errors.append(f"components page does not show `{c}` — document it or stop using it")
shell_used = set(re.findall(r"sp-[a-z0-9-]+", (HERE / "build.py").read_text(encoding="utf-8")))
for c in sorted(defined - used - documented - shell_used):
errors.append(f"theme.css defines `{c}` but nothing uses it")
if "--write" in sys.argv and not errors:
(HERE / "outlines.json").write_text(json.dumps(outlines, ensure_ascii=False, indent=1) + "\n",
encoding="utf-8", newline="\n")
golden = HERE / "outlines.json"
if golden.exists() and "--write" not in sys.argv:
if json.loads(golden.read_text(encoding="utf-8")) != outlines:
errors.append("outlines.json is stale — a screen's structure changed; re-run with --write and say so in the commit")
for e in errors:
print("FAIL", e)
print(f"{len(outlines) + 1} screens, {len(used | documented)} sp- classes, {len(errors)} violation(s)")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,263 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Components · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-postmark"><span>SELFPOST</span><b>REF</b><span>DESIGN CONTRACT</span></div>
<div class="sp-grow">
<p class="sp-kicker">Reference · page_head</p>
<h1 class="title is-3">Components</h1>
<p class="sp-lead">Every building block of the panel, in every state it has. A screen is assembled from these and from plain Bulma — nothing else. The title of each box is the name of its template partial.</p>
</div>
<div class="buttons"><a class="button" href="../index.html"><i class="ti ti-arrow-left"></i>Cover sheet</a></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">00</span><h2>Page layouts</h2><div class="sp-end">A page is exactly one of these</div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>list</dt><dd>page_head, then full-width boxes: the add form first, the table second. <a href="out-domains.html">Example</a></dd></div>
<div><dt>detail</dt><dd>page_head, then <code>columns</code>: <code>is-3</code> side menu + <code>is-9</code> boxes. <a href="out-domain.html">Example</a></dd></div>
<div><dt>form</dt><dd>page_head, then boxes in equal <code>columns</code>, one submit row under them. <a href="out-app.html">Example</a></dd></div>
<div><dt>signed out</dt><dd>The split brand / form screen, no navbar. <a href="login.html">Example</a></dd></div>
</dl>
</div>
</div>
<div class="columns">
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>postmark</h2><div class="sp-end">Worst status of the page's checks</div></div>
<div class="sp-box-body">
<div class="buttons">
<div class="sp-postmark"><span>SELFPOST</span><b>OK</b><span>6 OF 6 CHECKS</span></div>
<div class="sp-postmark sp-warn"><span>SELFPOST</span><b>WARN</b><span>1 OF 6 CHECKS</span></div>
<div class="sp-postmark sp-fail"><span>DNS</span><b>FAIL</b><span>1 OF 3 RECORDS</span></div>
</div>
<p class="sp-muted">Only inside page_head, at most one per page, and only where the page has a verdict to give.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>status_tag</h2><div class="sp-end">One mapping, one function</div></div>
<table class="table is-fullwidth">
<thead><tr><th>Status</th><th>Tag</th><th>Bulma classes</th></tr></thead>
<tbody>
<tr><td>ok, published, running, sent</td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono is-size-7">tag is-success is-light</td></tr>
<tr><td>warn, deferred, quiet</td><td><span class="tag is-warning is-light">warn</span></td><td class="sp-mono is-size-7">tag is-warning is-light</td></tr>
<tr><td>error, fail, mismatch, bounced</td><td><span class="tag is-danger is-light">fail</span></td><td class="sp-mono is-size-7">tag is-danger is-light</td></tr>
<tr><td>unknown, off, neutral counts</td><td><span class="tag is-light">off</span></td><td class="sp-mono is-size-7">tag is-light</td></tr>
<tr><td>role: global</td><td><span class="tag is-primary is-light">global</span></td><td class="sp-mono is-size-7">tag is-primary is-light</td></tr>
</tbody>
</table>
</div>
</div>
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>box · box_head</h2><div class="sp-end">The end slot: a note, a tag, a link or a filter form</div></div>
<div class="sp-box-body"><p>box_body — padded content. A <code>table</code> or a run of <code>sp-record</code> blocks sits directly in the box, without box_body.</p></div>
<div class="sp-box-foot"><span>box_foot — a legend, paging</span><span class="sp-end">end slot</span></div>
</div>
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>box · sp-danger-zone</h2></div>
<div class="sp-box-body"><p>For destructive confirmations and the "delete" entry of a settings page. The number may be an icon when the box is not part of a sequence.</p></div>
</div>
<div class="box sp-credential">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-eye"></i></span><h2>box · sp-credential</h2><div class="sp-end">Shown once</div></div>
<div class="sp-box-body"><p>Only for a secret the panel will never show again.</p></div>
</div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>health_card</h2><div class="sp-end">Overview only · each card links to its detail</div></div>
<div class="sp-health">
<a href="#"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Name</span><br><span class="sp-h-value">Verdict in words</span><br><span class="sp-h-sub">ok · one supporting fact</span></span></a>
<a href="#" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">Name</span><br><span class="sp-h-value">Expires in 12 days</span><br><span class="sp-h-sub">sp-warn</span></span></a>
<a href="#" class="sp-fail"><span class="sp-h-icon"><i class="ti ti-plug-connected-x"></i></span><span><span class="sp-h-name">Name</span><br><span class="sp-h-value">1 of 2 answering</span><br><span class="sp-h-sub">sp-fail</span></span></a>
</div>
</div>
<div class="columns">
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><span class="sp-no">05</span><h2>dns_record · copy_field</h2><div class="sp-end">One block per record</div></div>
<div class="sp-record">
<div class="sp-record-head"><h3>Record</h3><span class="tag is-success is-light">published</span><small>A one-line note</small></div>
<div class="sp-row">
<div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="copy_field — single line"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="2" readonly>copy_field — multi-line, for a DKIM key</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>Record</h3><span class="tag is-danger is-light">mismatch</span><small>"In DNS now" appears only when it differs</small></div>
<div class="field"><label class="label">In DNS now</label>
<div class="control"><input class="input is-danger" readonly value="what the resolver returned"></div>
<p class="help is-danger">What is wrong and the exact fix.</p></div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">07</span><h2>Table conventions</h2></div>
<table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-muted sp-nowrap is-size-7">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><a class="has-text-danger" href="#">Delete</a></td></tr>
</tbody>
</table>
<div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">09</span><h2>log_pane</h2><div class="sp-end">sp-d time · sp-w warning · sp-e error</div></div>
<pre class="sp-log"><span class="sp-d">13:58:07</span> an ordinary line
<span class="sp-d">13:58:09</span> <span class="sp-w">a deferred delivery — sp-w</span>
<span class="sp-d">13:57:41</span> <span class="sp-e">a reject or a bounce — sp-e</span></pre>
</div>
</div>
<div class="column is-5">
<div class="box">
<div class="sp-box-head"><span class="sp-no">06</span><h2>facts</h2></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Label</dt><dd>Value</dd></div>
<div><dt>Mono value</dt><dd class="sp-mono">4XcB7k2Jm9z1</dd></div>
<div><dt>sp-big</dt><dd class="sp-big">5 days</dd><dd class="sp-muted">a supporting line</dd></div>
</dl>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">08</span><h2>timeline</h2></div>
<div class="sp-box-body">
<ol class="sp-timeline">
<li><time>13:58:07 UTC</time><strong>Done</strong> — default</li>
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — sp-warn</li>
<li class="sp-fail"><time>14:40:00 UTC</time><strong>Bounced</strong> — sp-fail</li>
<li class="sp-pending"><time>not yet</time>Expected next — sp-pending</li>
</ol>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">10</span><h2>empty_state</h2></div>
<div class="sp-empty"><i class="ti ti-stack-2"></i>One sentence: what is absent and why that is fine.</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">11</span><h2>confirm_list</h2></div>
<div class="sp-box-body">
<ul class="sp-list-check">
<li><i class="ti ti-x"></i>each thing that will be lost, one per line;</li>
<li><i class="ti ti-x"></i>inside an sp-danger-zone box, above the danger button.</li>
</ul>
</div>
</div>
</div>
</div>
<div class="columns">
<div class="column is-3">
<aside class="menu sp-side">
<p class="menu-label">side_menu</p>
<ul class="menu-list">
<li><a class="is-active" href="#"><i class="ti ti-world-check"></i>Current <span class="tag is-danger is-light">1</span></a></li>
<li><a href="#"><i class="ti ti-apps"></i>Section <span class="tag is-light">2</span></a></li>
</ul>
<p class="menu-label">Rarely changed</p>
<ul class="menu-list">
<li><a class="has-text-danger" href="#"><i class="ti ti-trash has-text-danger"></i>Delete</a></li>
</ul>
</aside>
</div>
<div class="column is-9">
<div class="box">
<div class="sp-box-head"><span class="sp-no">12</span><h2>Form rows · route · help_topic</h2></div>
<div class="sp-box-body">
<div class="sp-form">
<div class="sp-row">
<div class="field"><label class="label">sp-row — a wide field</label><div class="control"><input class="input" value="host"></div></div>
<div class="field"><label class="label">and a short one</label><div class="control"><input class="input" value="25"></div></div>
</div>
<div class="sp-pair">
<div class="field"><label class="label">sp-pair — two equal fields</label><div class="control"><input class="input" type="password"></div></div>
<div class="field"><label class="label">Repeat</label><div class="control"><input class="input" type="password"></div><p class="help">Help text lives in Bulma's <code>help</code>.</p></div>
</div>
</div>
<p class="sp-route"><span>from@example.org</span><i class="ti ti-arrow-right sp-muted"></i><span>to@example.net</span><span class="tag is-warning is-light">deferred</span></p>
</div>
<div class="sp-help-topic"><h3>help_topic</h3><p>A heading and a paragraph; topics stack inside one box on the Help page.</p></div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -63,7 +63,7 @@
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-postmark warn"><span>DMARC</span><b>99.6%</b><span>PASS · 7 DAYS</span></div>
<div class="sp-postmark sp-warn"><span>DMARC</span><b>99.6%</b><span>PASS · 7 DAYS</span></div>
<div class="sp-grow">
<p class="sp-kicker"><a href="dmarc.html">DMARC reports</a> /</p>
<h1 class="title is-3">shop.example.org</h1>
+1 -1
View File
@@ -111,7 +111,7 @@
<div><dt>Expires</dt><dd class="sp-mono">2026-10-03 09:14 UTC</dd></div>
<div><dt>Names</dt><dd class="sp-mono">mail.example.org</dd></div>
</dl>
<p class="mt-4 has-text-danger-40">Expires in 12 days — renewal has not run.</p>
<p class="mt-4 has-text-danger">Expires in 12 days — renewal has not run.</p>
<p class="sp-muted">Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for <code>acme</code> lines in the <a href="system-log.html">system log</a>.</p>
</div>
</div>
@@ -71,7 +71,7 @@
<div class="columns">
<div class="column is-7">
<div class="box is-danger-zone">
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>This cannot be undone</h2></div>
<div class="sp-box-body">
<p class="mb-3">Deleting the inbound domain <strong>lists.example.org</strong> will:</p>
@@ -79,10 +79,10 @@
<div class="sp-box-head"><span class="sp-no">02</span><h2>How retries work</h2><div class="sp-end">This Postfix's policy, read at panel start</div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>First retry</dt><dd class="is-big">5 min</dd></div>
<div><dt>Later retries</dt><dd class="is-big">doubling</dd><dd class="sp-muted">capped at 66 min</dd></div>
<div><dt>Kept in queue</dt><dd class="is-big">5 days</dd></div>
<div><dt>Then</dt><dd class="is-big">bounced</dd><dd class="sp-muted">back to the original sender</dd></div>
<div><dt>First retry</dt><dd class="sp-big">5 min</dd></div>
<div><dt>Later retries</dt><dd class="sp-big">doubling</dd><dd class="sp-muted">capped at 66 min</dd></div>
<div><dt>Kept in queue</dt><dd class="sp-big">5 days</dd></div>
<div><dt>Then</dt><dd class="sp-big">bounced</dd><dd class="sp-muted">back to the original sender</dd></div>
</dl>
</div>
</div>
@@ -71,7 +71,7 @@
<div class="columns">
<div class="column is-7">
<div class="box is-credential">
<div class="box sp-credential">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-eye"></i></span><h2>Shown once</h2><div class="sp-end">Not stored — copy it now</div></div>
<div class="sp-record">
<div class="field"><label class="label">Login</label>
@@ -71,7 +71,7 @@
<div class="columns">
<div class="column is-7">
<div class="box is-danger-zone">
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>This cannot be undone</h2></div>
<div class="sp-box-body">
<p class="mb-3">Deleting <strong>example.org</strong> will:</p>
@@ -126,7 +126,7 @@
</div>
</div>
<div class="box is-danger-zone">
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Delete domain</h2></div>
<div class="sp-box-body">
<p class="mb-3">Removes the DKIM key and both applications. Asks once more before doing it.</p>
@@ -63,7 +63,7 @@
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-postmark fail"><span>DNS</span><b>FAIL</b><span>1 OF 3 RECORDS</span></div>
<div class="sp-postmark sp-fail"><span>DNS</span><b>FAIL</b><span>1 OF 3 RECORDS</span></div>
<div class="sp-grow">
<p class="sp-kicker"><a href="out-domains.html">Outbound domains</a> /</p>
<h1 class="title is-3">example.org</h1>
@@ -92,8 +92,8 @@
<div class="sp-box-body">
<ol class="sp-timeline">
<li><time>13:58:07 UTC</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li>
<li class="warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — the receiving server asked to retry later</li>
<li class="pending"><time>next attempt ≈ 14:08 UTC</time>Still in the <a href="out-queue.html">queue</a>; kept for 5 days, then bounced</li>
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — the receiving server asked to retry later</li>
<li class="sp-pending"><time>next attempt ≈ 14:08 UTC</time>Still in the <a href="out-queue.html">queue</a>; kept for 5 days, then bounced</li>
</ol>
</div>
</div>
@@ -102,10 +102,10 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Delivery log</h2><div class="sp-end">Lines from <code>mail.log</code> with this queue id</div></div>
<pre class="sp-log"><span class="d">13:58:07</span> postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts
<span class="d">13:58:07</span> postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=&lt;a81f0c@alertmanager&gt;
<span class="d">13:58:07</span> opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org)
<span class="d">13:58:09</span> <span class="w">postfix/smtp[2123]: 4XcB7k2Jm9z1: to=&lt;noc@example.org&gt;, relay=mx1.example.org[192.0.2.10]:25, delay=1.9, status=deferred (host mx1.example.org said: 451 4.7.1 Greylisted, try again in 5 minutes)</span></pre>
<pre class="sp-log"><span class="sp-d">13:58:07</span> postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts
<span class="sp-d">13:58:07</span> postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=&lt;a81f0c@alertmanager&gt;
<span class="sp-d">13:58:07</span> opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org)
<span class="sp-d">13:58:09</span> <span class="sp-w">postfix/smtp[2123]: 4XcB7k2Jm9z1: to=&lt;noc@example.org&gt;, relay=mx1.example.org[192.0.2.10]:25, delay=1.9, status=deferred (host mx1.example.org said: 451 4.7.1 Greylisted, try again in 5 minutes)</span></pre>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
@@ -84,10 +84,10 @@
<div class="sp-box-head"><span class="sp-no">02</span><h2>How retries work</h2><div class="sp-end">This Postfix's policy, read at panel start</div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>First retry</dt><dd class="is-big">5 min</dd></div>
<div><dt>Later retries</dt><dd class="is-big">doubling</dd><dd class="sp-muted">capped at 66 min</dd></div>
<div><dt>Kept in queue</dt><dd class="is-big">5 days</dd></div>
<div><dt>Then</dt><dd class="is-big">bounced</dd><dd class="sp-muted">no attempt counter, only time</dd></div>
<div><dt>First retry</dt><dd class="sp-big">5 min</dd></div>
<div><dt>Later retries</dt><dd class="sp-big">doubling</dd><dd class="sp-muted">capped at 66 min</dd></div>
<div><dt>Kept in queue</dt><dd class="sp-big">5 days</dd></div>
<div><dt>Then</dt><dd class="sp-big">bounced</dd><dd class="sp-muted">no attempt counter, only time</dd></div>
</dl>
</div>
</div>
@@ -0,0 +1,401 @@
{
"account": [
"head",
{
"columns": [
{
"is-6": [
"box[Panel credentials]:body"
]
},
{
"is-6": [
"box[What happens]:body"
]
}
]
}
],
"backup": [
"head",
{
"columns": [
{
"is-6": [
"box[Full backup]:body"
]
},
{
"is-6": [
"box[Import a domain]:body"
]
}
]
}
],
"dmarc-domain": [
"head+postmark+actions",
{
"columns": [
{
"is-5": [
"box[Sources · 7 days]:table"
]
},
{
"is-7": [
"box[Reports]:table"
]
}
]
}
],
"dmarc-report": [
"head",
{
"columns": [
{
"is-6": [
"box[Report]:body"
]
},
{
"is-6": [
"box[Published policy]:body"
]
}
]
},
"box[Records]:table"
],
"dmarc": [
"head",
"box[Ingest]:body",
"box[Recent reports]:table"
],
"health": [
"head+actions",
{
"columns": [
{
"is-6": [
"box[Machine]:table"
]
},
{
"is-6": [
"box[Processes]:table"
]
}
]
},
{
"columns": [
{
"is-4": [
"box[TLS certificate]:body"
]
},
{
"is-4": [
"box[Milter sockets]:table"
]
},
{
"is-4": [
"box[Hostname and reverse DNS]:body"
]
}
]
}
],
"help": [
"head",
{
"columns": [
{
"is-3": [
"side_menu"
]
},
{
"is-9": [
"box[Overview]:help×3",
"box[Outbound]:help×3"
]
}
]
}
],
"in-domain-delete": [
"head",
{
"columns": [
{
"is-7": [
"box.sp-danger-zone[This cannot be undone]:body"
]
}
]
}
],
"in-domain": [
"head+postmark+actions",
{
"columns": [
{
"is-3": [
"side_menu"
]
},
{
"is-9": [
"box[MX record]:record",
{
"columns": [
{
"is-6": [
"box[Upstream]:body"
]
},
{
"is-6": [
"box[Valid recipients]:body"
]
}
]
}
]
}
]
}
],
"in-domains": [
"head",
"box[Add a domain]:body",
"box[Domains]:table"
],
"in-log": [
"head",
"box[Messages]:table"
],
"in-queue": [
"head",
"box[Waiting]:empty",
"box[How retries work]:body"
],
"out-app-created": [
"head",
{
"columns": [
{
"is-7": [
"box.sp-credential[Shown once]:record"
]
},
{
"is-5": [
"box[Connect with]:body"
]
}
]
}
],
"out-app": [
"head+actions",
{
"form": [
{
"columns": [
{
"is-4": [
"box[Sender]:body"
]
},
{
"is-4": [
"box[Client IP allow-list]:body"
]
},
{
"is-4": [
"box[Rate limit]:body"
]
}
]
},
"submit_row"
]
}
],
"out-domain-delete": [
"head",
{
"columns": [
{
"is-7": [
"box.sp-danger-zone[This cannot be undone]:body"
]
},
{
"is-5": [
"box[Moving it instead?]:body"
]
}
]
}
],
"out-domain-settings": [
"head",
{
"columns": [
{
"is-6": [
"box[DMARC report address]:body",
"box[Export domain]:body"
]
},
{
"is-6": [
"box[Rate limit]:body",
"box.sp-danger-zone[Delete domain]:body"
]
}
]
}
],
"out-domain": [
"head+postmark+actions",
{
"columns": [
{
"is-3": [
"side_menu"
]
},
{
"is-9": [
"box[DNS records]:record×3",
"box[Applications]:table",
"box[Connection]:body"
]
}
]
}
],
"out-domains": [
"head",
"box[Add a domain]:body",
"box[Domains]:table"
],
"out-log": [
"head",
"box[Messages]:table"
],
"out-message": [
"head",
{
"columns": [
{
"is-7": [
"box[Message]:body"
]
},
{
"is-5": [
"box[History]:body"
]
}
]
},
"box[Delivery log]:log"
],
"out-queue": [
"head",
"box[Waiting]:table",
"box[How retries work]:body"
],
"overview": [
"head+postmark",
"box[Server health]:health",
{
"columns": [
{
"is-7": [
"box[Outbound domains]:table"
]
},
{
"is-5": [
"box[Inbound domains]:table"
]
}
]
}
],
"settings": [
"head",
{
"form": [
{
"columns": [
{
"is-7": [
"box[DMARC aggregate reports]:record×2"
]
},
{
"is-5": [
"box[Log retention]:body",
"box[Sending rate limits]:body"
]
}
]
},
"submit_row"
]
}
],
"system-log": [
"head",
"box[Recent lines]:log"
],
"user-delete": [
"head",
{
"columns": [
{
"is-7": [
"box.sp-danger-zone[This cannot be undone]:body"
]
}
]
}
],
"user": [
"head+actions",
{
"form": [
{
"columns": [
{
"is-6": [
"box[Sign-in]:body"
]
},
{
"is-6": [
"box[Assigned domains]:body"
]
}
]
},
"submit_row"
]
}
],
"users": [
"head+actions",
"box[Panel users]:table"
]
}
@@ -63,7 +63,7 @@
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-postmark warn"><span>SELFPOST</span><b>WARN</b><span>1 OF 6 CHECKS</span></div>
<div class="sp-postmark sp-warn"><span>SELFPOST</span><b>WARN</b><span>1 OF 6 CHECKS</span></div>
<div class="sp-grow">
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3">Mail is flowing. One thing needs attention.</h1>
@@ -77,7 +77,7 @@
<div class="sp-health">
<a href="health.html#machine"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><br><span class="sp-h-value">CPU 12 % · RAM 41 %</span><br><span class="sp-h-sub">↓ 18 kB/s · ↑ 44 kB/s</span></span></a>
<a href="health.html#processes"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><br><span class="sp-h-value">5 of 5 running</span><br><span class="sp-h-sub">up 18 days</span></span></a>
<a href="health.html#certificate" class="warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><br><span class="sp-h-value">Expires in 12 days</span><br><span class="sp-h-sub">renewal has not run</span></span></a>
<a href="health.html#certificate" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><br><span class="sp-h-value">Expires in 12 days</span><br><span class="sp-h-sub">renewal has not run</span></span></a>
<a href="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><br><span class="sp-h-value">Both empty</span><br><span class="sp-h-sub">outbound and inbound</span></span></a>
<a href="health.html#sockets"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><br><span class="sp-h-value">2 of 2 answering</span><br><span class="sp-h-sub">opendkim, journal</span></span></a>
<a href="health.html#hostname"><span class="sp-h-icon"><i class="ti ti-arrows-exchange"></i></span><span><span class="sp-h-name">Reverse DNS</span><br><span class="sp-h-value">Forward = reverse</span><br><span class="sp-h-sub">203.0.113.25</span></span></a>
@@ -107,7 +107,7 @@
<div class="sp-box-head"><span class="sp-no">03</span><h2>Sending rate limits</h2><div class="sp-end">Read-only</div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Level 1 · per client IP</dt><dd class="is-big">600 / h</dd><dd class="sp-muted">Set in <code>.env</code>; restart to change. Nothing below may exceed it.</dd></div>
<div><dt>Level 1 · per client IP</dt><dd class="sp-big">600 / h</dd><dd class="sp-muted">Set in <code>.env</code>; restart to change. Nothing below may exceed it.</dd></div>
<div><dt>Level 2 · domain, application</dt><dd>Set on each <a href="out-domain-settings.html#limit">domain</a> and <a href="out-app.html">application</a>.</dd></div>
</dl>
</div>
@@ -0,0 +1,194 @@
<!-- title: Components | section: reference -->
<div class="sp-head">
<div class="sp-postmark"><span>SELFPOST</span><b>REF</b><span>DESIGN CONTRACT</span></div>
<div class="sp-grow">
<p class="sp-kicker">Reference · page_head</p>
<h1 class="title is-3">Components</h1>
<p class="sp-lead">Every building block of the panel, in every state it has. A screen is assembled from these and from plain Bulma — nothing else. The title of each box is the name of its template partial.</p>
</div>
<div class="buttons"><a class="button" href="../index.html"><i class="ti ti-arrow-left"></i>Cover sheet</a></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">00</span><h2>Page layouts</h2><div class="sp-end">A page is exactly one of these</div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>list</dt><dd>page_head, then full-width boxes: the add form first, the table second. <a href="out-domains.html">Example</a></dd></div>
<div><dt>detail</dt><dd>page_head, then <code>columns</code>: <code>is-3</code> side menu + <code>is-9</code> boxes. <a href="out-domain.html">Example</a></dd></div>
<div><dt>form</dt><dd>page_head, then boxes in equal <code>columns</code>, one submit row under them. <a href="out-app.html">Example</a></dd></div>
<div><dt>signed out</dt><dd>The split brand / form screen, no navbar. <a href="login.html">Example</a></dd></div>
</dl>
</div>
</div>
<div class="columns">
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>postmark</h2><div class="sp-end">Worst status of the page's checks</div></div>
<div class="sp-box-body">
<div class="buttons">
<div class="sp-postmark"><span>SELFPOST</span><b>OK</b><span>6 OF 6 CHECKS</span></div>
<div class="sp-postmark sp-warn"><span>SELFPOST</span><b>WARN</b><span>1 OF 6 CHECKS</span></div>
<div class="sp-postmark sp-fail"><span>DNS</span><b>FAIL</b><span>1 OF 3 RECORDS</span></div>
</div>
<p class="sp-muted">Only inside page_head, at most one per page, and only where the page has a verdict to give.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>status_tag</h2><div class="sp-end">One mapping, one function</div></div>
<table class="table is-fullwidth">
<thead><tr><th>Status</th><th>Tag</th><th>Bulma classes</th></tr></thead>
<tbody>
<tr><td>ok, published, running, sent</td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono is-size-7">tag is-success is-light</td></tr>
<tr><td>warn, deferred, quiet</td><td><span class="tag is-warning is-light">warn</span></td><td class="sp-mono is-size-7">tag is-warning is-light</td></tr>
<tr><td>error, fail, mismatch, bounced</td><td><span class="tag is-danger is-light">fail</span></td><td class="sp-mono is-size-7">tag is-danger is-light</td></tr>
<tr><td>unknown, off, neutral counts</td><td><span class="tag is-light">off</span></td><td class="sp-mono is-size-7">tag is-light</td></tr>
<tr><td>role: global</td><td><span class="tag is-primary is-light">global</span></td><td class="sp-mono is-size-7">tag is-primary is-light</td></tr>
</tbody>
</table>
</div>
</div>
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>box · box_head</h2><div class="sp-end">The end slot: a note, a tag, a link or a filter form</div></div>
<div class="sp-box-body"><p>box_body — padded content. A <code>table</code> or a run of <code>sp-record</code> blocks sits directly in the box, without box_body.</p></div>
<div class="sp-box-foot"><span>box_foot — a legend, paging</span><span class="sp-end">end slot</span></div>
</div>
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>box · sp-danger-zone</h2></div>
<div class="sp-box-body"><p>For destructive confirmations and the "delete" entry of a settings page. The number may be an icon when the box is not part of a sequence.</p></div>
</div>
<div class="box sp-credential">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-eye"></i></span><h2>box · sp-credential</h2><div class="sp-end">Shown once</div></div>
<div class="sp-box-body"><p>Only for a secret the panel will never show again.</p></div>
</div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>health_card</h2><div class="sp-end">Overview only · each card links to its detail</div></div>
<div class="sp-health">
<a href="#"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Name</span><br><span class="sp-h-value">Verdict in words</span><br><span class="sp-h-sub">ok · one supporting fact</span></span></a>
<a href="#" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">Name</span><br><span class="sp-h-value">Expires in 12 days</span><br><span class="sp-h-sub">sp-warn</span></span></a>
<a href="#" class="sp-fail"><span class="sp-h-icon"><i class="ti ti-plug-connected-x"></i></span><span><span class="sp-h-name">Name</span><br><span class="sp-h-value">1 of 2 answering</span><br><span class="sp-h-sub">sp-fail</span></span></a>
</div>
</div>
<div class="columns">
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><span class="sp-no">05</span><h2>dns_record · copy_field</h2><div class="sp-end">One block per record</div></div>
<div class="sp-record">
<div class="sp-record-head"><h3>Record</h3><span class="tag is-success is-light">published</span><small>A one-line note</small></div>
<div class="sp-row">
<div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="copy_field — single line"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="2" readonly>copy_field — multi-line, for a DKIM key</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>Record</h3><span class="tag is-danger is-light">mismatch</span><small>"In DNS now" appears only when it differs</small></div>
<div class="field"><label class="label">In DNS now</label>
<div class="control"><input class="input is-danger" readonly value="what the resolver returned"></div>
<p class="help is-danger">What is wrong and the exact fix.</p></div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">07</span><h2>Table conventions</h2></div>
<table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-muted sp-nowrap is-size-7">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><a class="has-text-danger" href="#">Delete</a></td></tr>
</tbody>
</table>
<div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">09</span><h2>log_pane</h2><div class="sp-end">sp-d time · sp-w warning · sp-e error</div></div>
<pre class="sp-log"><span class="sp-d">13:58:07</span> an ordinary line
<span class="sp-d">13:58:09</span> <span class="sp-w">a deferred delivery — sp-w</span>
<span class="sp-d">13:57:41</span> <span class="sp-e">a reject or a bounce — sp-e</span></pre>
</div>
</div>
<div class="column is-5">
<div class="box">
<div class="sp-box-head"><span class="sp-no">06</span><h2>facts</h2></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Label</dt><dd>Value</dd></div>
<div><dt>Mono value</dt><dd class="sp-mono">4XcB7k2Jm9z1</dd></div>
<div><dt>sp-big</dt><dd class="sp-big">5 days</dd><dd class="sp-muted">a supporting line</dd></div>
</dl>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">08</span><h2>timeline</h2></div>
<div class="sp-box-body">
<ol class="sp-timeline">
<li><time>13:58:07 UTC</time><strong>Done</strong> — default</li>
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — sp-warn</li>
<li class="sp-fail"><time>14:40:00 UTC</time><strong>Bounced</strong> — sp-fail</li>
<li class="sp-pending"><time>not yet</time>Expected next — sp-pending</li>
</ol>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">10</span><h2>empty_state</h2></div>
<div class="sp-empty"><i class="ti ti-stack-2"></i>One sentence: what is absent and why that is fine.</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">11</span><h2>confirm_list</h2></div>
<div class="sp-box-body">
<ul class="sp-list-check">
<li><i class="ti ti-x"></i>each thing that will be lost, one per line;</li>
<li><i class="ti ti-x"></i>inside an sp-danger-zone box, above the danger button.</li>
</ul>
</div>
</div>
</div>
</div>
<div class="columns">
<div class="column is-3">
<aside class="menu sp-side">
<p class="menu-label">side_menu</p>
<ul class="menu-list">
<li><a class="is-active" href="#"><i class="ti ti-world-check"></i>Current <span class="tag is-danger is-light">1</span></a></li>
<li><a href="#"><i class="ti ti-apps"></i>Section <span class="tag is-light">2</span></a></li>
</ul>
<p class="menu-label">Rarely changed</p>
<ul class="menu-list">
<li><a class="has-text-danger" href="#"><i class="ti ti-trash has-text-danger"></i>Delete</a></li>
</ul>
</aside>
</div>
<div class="column is-9">
<div class="box">
<div class="sp-box-head"><span class="sp-no">12</span><h2>Form rows · route · help_topic</h2></div>
<div class="sp-box-body">
<div class="sp-form">
<div class="sp-row">
<div class="field"><label class="label">sp-row — a wide field</label><div class="control"><input class="input" value="host"></div></div>
<div class="field"><label class="label">and a short one</label><div class="control"><input class="input" value="25"></div></div>
</div>
<div class="sp-pair">
<div class="field"><label class="label">sp-pair — two equal fields</label><div class="control"><input class="input" type="password"></div></div>
<div class="field"><label class="label">Repeat</label><div class="control"><input class="input" type="password"></div><p class="help">Help text lives in Bulma's <code>help</code>.</p></div>
</div>
</div>
<p class="sp-route"><span>from@example.org</span><i class="ti ti-arrow-right sp-muted"></i><span>to@example.net</span><span class="tag is-warning is-light">deferred</span></p>
</div>
<div class="sp-help-topic"><h3>help_topic</h3><p>A heading and a paragraph; topics stack inside one box on the Help page.</p></div>
</div>
</div>
</div>
@@ -1,6 +1,6 @@
<!-- title: shop.example.org · DMARC reports | section: outbound | page: dmarc -->
<div class="sp-head">
<div class="sp-postmark warn"><span>DMARC</span><b>99.6%</b><span>PASS · 7 DAYS</span></div>
<div class="sp-postmark sp-warn"><span>DMARC</span><b>99.6%</b><span>PASS · 7 DAYS</span></div>
<div class="sp-grow">
<p class="sp-kicker"><a href="dmarc.html">DMARC reports</a> /</p>
<h1 class="title is-3">shop.example.org</h1>
@@ -48,7 +48,7 @@
<div><dt>Expires</dt><dd class="sp-mono">2026-10-03 09:14 UTC</dd></div>
<div><dt>Names</dt><dd class="sp-mono">mail.example.org</dd></div>
</dl>
<p class="mt-4 has-text-danger-40">Expires in 12 days — renewal has not run.</p>
<p class="mt-4 has-text-danger">Expires in 12 days — renewal has not run.</p>
<p class="sp-muted">Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for <code>acme</code> lines in the <a href="system-log.html">system log</a>.</p>
</div>
</div>
@@ -8,7 +8,7 @@
<div class="columns">
<div class="column is-7">
<div class="box is-danger-zone">
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>This cannot be undone</h2></div>
<div class="sp-box-body">
<p class="mb-3">Deleting the inbound domain <strong>lists.example.org</strong> will:</p>
@@ -16,10 +16,10 @@
<div class="sp-box-head"><span class="sp-no">02</span><h2>How retries work</h2><div class="sp-end">This Postfix's policy, read at panel start</div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>First retry</dt><dd class="is-big">5 min</dd></div>
<div><dt>Later retries</dt><dd class="is-big">doubling</dd><dd class="sp-muted">capped at 66 min</dd></div>
<div><dt>Kept in queue</dt><dd class="is-big">5 days</dd></div>
<div><dt>Then</dt><dd class="is-big">bounced</dd><dd class="sp-muted">back to the original sender</dd></div>
<div><dt>First retry</dt><dd class="sp-big">5 min</dd></div>
<div><dt>Later retries</dt><dd class="sp-big">doubling</dd><dd class="sp-muted">capped at 66 min</dd></div>
<div><dt>Kept in queue</dt><dd class="sp-big">5 days</dd></div>
<div><dt>Then</dt><dd class="sp-big">bounced</dd><dd class="sp-muted">back to the original sender</dd></div>
</dl>
</div>
</div>
@@ -8,7 +8,7 @@
<div class="columns">
<div class="column is-7">
<div class="box is-credential">
<div class="box sp-credential">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-eye"></i></span><h2>Shown once</h2><div class="sp-end">Not stored — copy it now</div></div>
<div class="sp-record">
<div class="field"><label class="label">Login</label>
@@ -8,7 +8,7 @@
<div class="columns">
<div class="column is-7">
<div class="box is-danger-zone">
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>This cannot be undone</h2></div>
<div class="sp-box-body">
<p class="mb-3">Deleting <strong>example.org</strong> will:</p>
@@ -63,7 +63,7 @@
</div>
</div>
<div class="box is-danger-zone">
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Delete domain</h2></div>
<div class="sp-box-body">
<p class="mb-3">Removes the DKIM key and both applications. Asks once more before doing it.</p>
@@ -1,6 +1,6 @@
<!-- title: example.org · Outbound domains | section: outbound | page: domains -->
<div class="sp-head">
<div class="sp-postmark fail"><span>DNS</span><b>FAIL</b><span>1 OF 3 RECORDS</span></div>
<div class="sp-postmark sp-fail"><span>DNS</span><b>FAIL</b><span>1 OF 3 RECORDS</span></div>
<div class="sp-grow">
<p class="sp-kicker"><a href="out-domains.html">Outbound domains</a> /</p>
<h1 class="title is-3">example.org</h1>
@@ -29,8 +29,8 @@
<div class="sp-box-body">
<ol class="sp-timeline">
<li><time>13:58:07 UTC</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li>
<li class="warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — the receiving server asked to retry later</li>
<li class="pending"><time>next attempt ≈ 14:08 UTC</time>Still in the <a href="out-queue.html">queue</a>; kept for 5 days, then bounced</li>
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — the receiving server asked to retry later</li>
<li class="sp-pending"><time>next attempt ≈ 14:08 UTC</time>Still in the <a href="out-queue.html">queue</a>; kept for 5 days, then bounced</li>
</ol>
</div>
</div>
@@ -39,8 +39,8 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Delivery log</h2><div class="sp-end">Lines from <code>mail.log</code> with this queue id</div></div>
<pre class="sp-log"><span class="d">13:58:07</span> postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts
<span class="d">13:58:07</span> postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=&lt;a81f0c@alertmanager&gt;
<span class="d">13:58:07</span> opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org)
<span class="d">13:58:09</span> <span class="w">postfix/smtp[2123]: 4XcB7k2Jm9z1: to=&lt;noc@example.org&gt;, relay=mx1.example.org[192.0.2.10]:25, delay=1.9, status=deferred (host mx1.example.org said: 451 4.7.1 Greylisted, try again in 5 minutes)</span></pre>
<pre class="sp-log"><span class="sp-d">13:58:07</span> postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts
<span class="sp-d">13:58:07</span> postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=&lt;a81f0c@alertmanager&gt;
<span class="sp-d">13:58:07</span> opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org)
<span class="sp-d">13:58:09</span> <span class="sp-w">postfix/smtp[2123]: 4XcB7k2Jm9z1: to=&lt;noc@example.org&gt;, relay=mx1.example.org[192.0.2.10]:25, delay=1.9, status=deferred (host mx1.example.org said: 451 4.7.1 Greylisted, try again in 5 minutes)</span></pre>
</div>
@@ -21,10 +21,10 @@
<div class="sp-box-head"><span class="sp-no">02</span><h2>How retries work</h2><div class="sp-end">This Postfix's policy, read at panel start</div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>First retry</dt><dd class="is-big">5 min</dd></div>
<div><dt>Later retries</dt><dd class="is-big">doubling</dd><dd class="sp-muted">capped at 66 min</dd></div>
<div><dt>Kept in queue</dt><dd class="is-big">5 days</dd></div>
<div><dt>Then</dt><dd class="is-big">bounced</dd><dd class="sp-muted">no attempt counter, only time</dd></div>
<div><dt>First retry</dt><dd class="sp-big">5 min</dd></div>
<div><dt>Later retries</dt><dd class="sp-big">doubling</dd><dd class="sp-muted">capped at 66 min</dd></div>
<div><dt>Kept in queue</dt><dd class="sp-big">5 days</dd></div>
<div><dt>Then</dt><dd class="sp-big">bounced</dd><dd class="sp-muted">no attempt counter, only time</dd></div>
</dl>
</div>
</div>
@@ -1,6 +1,6 @@
<!-- title: Overview | section: overview -->
<div class="sp-head">
<div class="sp-postmark warn"><span>SELFPOST</span><b>WARN</b><span>1 OF 6 CHECKS</span></div>
<div class="sp-postmark sp-warn"><span>SELFPOST</span><b>WARN</b><span>1 OF 6 CHECKS</span></div>
<div class="sp-grow">
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3">Mail is flowing. One thing needs attention.</h1>
@@ -14,7 +14,7 @@
<div class="sp-health">
<a href="health.html#machine"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><br><span class="sp-h-value">CPU 12 % · RAM 41 %</span><br><span class="sp-h-sub">↓ 18 kB/s · ↑ 44 kB/s</span></span></a>
<a href="health.html#processes"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><br><span class="sp-h-value">5 of 5 running</span><br><span class="sp-h-sub">up 18 days</span></span></a>
<a href="health.html#certificate" class="warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><br><span class="sp-h-value">Expires in 12 days</span><br><span class="sp-h-sub">renewal has not run</span></span></a>
<a href="health.html#certificate" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><br><span class="sp-h-value">Expires in 12 days</span><br><span class="sp-h-sub">renewal has not run</span></span></a>
<a href="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><br><span class="sp-h-value">Both empty</span><br><span class="sp-h-sub">outbound and inbound</span></span></a>
<a href="health.html#sockets"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><br><span class="sp-h-value">2 of 2 answering</span><br><span class="sp-h-sub">opendkim, journal</span></span></a>
<a href="health.html#hostname"><span class="sp-h-icon"><i class="ti ti-arrows-exchange"></i></span><span><span class="sp-h-name">Reverse DNS</span><br><span class="sp-h-value">Forward = reverse</span><br><span class="sp-h-sub">203.0.113.25</span></span></a>
@@ -44,7 +44,7 @@
<div class="sp-box-head"><span class="sp-no">03</span><h2>Sending rate limits</h2><div class="sp-end">Read-only</div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Level 1 · per client IP</dt><dd class="is-big">600 / h</dd><dd class="sp-muted">Set in <code>.env</code>; restart to change. Nothing below may exceed it.</dd></div>
<div><dt>Level 1 · per client IP</dt><dd class="sp-big">600 / h</dd><dd class="sp-muted">Set in <code>.env</code>; restart to change. Nothing below may exceed it.</dd></div>
<div><dt>Level 2 · domain, application</dt><dd>Set on each <a href="out-domain-settings.html#limit">domain</a> and <a href="out-app.html">application</a>.</dd></div>
</dl>
</div>
@@ -9,14 +9,14 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Recent lines</h2><div class="sp-end">Last 200 · refreshes every 10 s</div></div>
<pre class="sp-log"><span class="d">Sep 21 14:01:52</span> postfix/smtp[2131]: 4XcB9q0Lw2z1: to=&lt;m.keller@gmx.de&gt;, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
<span class="d">Sep 21 14:01:52</span> postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
<span class="d">Sep 21 14:00:18</span> postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
<span class="d">Sep 21 14:00:18</span> postfix/smtp[2130]: 4XcB8z5Tn1z1: to=&lt;dev@lists.example.org&gt;, relay=mx.internal[10.0.4.2]:25, status=sent (250 2.0.0 Ok: queued)
<span class="d">Sep 21 13:58:09</span> <span class="w">postfix/smtp[2123]: 4XcB7k2Jm9z1: to=&lt;noc@example.org&gt;, relay=mx1.example.org[192.0.2.10]:25, status=deferred (451 4.7.1 Greylisted, try again in 5 minutes)</span>
<span class="d">Sep 21 13:57:41</span> <span class="e">postfix/smtpd[2119]: NOQUEUE: reject: RCPT from bulk.example[198.51.100.90]: 550 5.1.1 &lt;sales@lists.example.org&gt;: Recipient address rejected: unknown recipient</span>
<span class="d">Sep 21 13:44:02</span> <span class="w">postfix/smtp[2101]: 4XcB2m7Hd4z1: to=&lt;jobs@acme.io&gt;, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)</span>
<span class="d">Sep 21 13:41:19</span> <span class="e">postfix/smtp[2098]: 4XcB1c3Rk8z1: to=&lt;j.doe@nonexistent.example&gt;, status=bounced (Host or domain name not found)</span>
<span class="d">Sep 21 13:40:02</span> <span class="w">journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM</span>
<span class="d">Sep 21 13:00:00</span> opendkim[39]: key table reloaded (3 domains)</pre>
<pre class="sp-log"><span class="sp-d">Sep 21 14:01:52</span> postfix/smtp[2131]: 4XcB9q0Lw2z1: to=&lt;m.keller@gmx.de&gt;, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
<span class="sp-d">Sep 21 14:01:52</span> postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
<span class="sp-d">Sep 21 14:00:18</span> postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
<span class="sp-d">Sep 21 14:00:18</span> postfix/smtp[2130]: 4XcB8z5Tn1z1: to=&lt;dev@lists.example.org&gt;, relay=mx.internal[10.0.4.2]:25, status=sent (250 2.0.0 Ok: queued)
<span class="sp-d">Sep 21 13:58:09</span> <span class="sp-w">postfix/smtp[2123]: 4XcB7k2Jm9z1: to=&lt;noc@example.org&gt;, relay=mx1.example.org[192.0.2.10]:25, status=deferred (451 4.7.1 Greylisted, try again in 5 minutes)</span>
<span class="sp-d">Sep 21 13:57:41</span> <span class="sp-e">postfix/smtpd[2119]: NOQUEUE: reject: RCPT from bulk.example[198.51.100.90]: 550 5.1.1 &lt;sales@lists.example.org&gt;: Recipient address rejected: unknown recipient</span>
<span class="sp-d">Sep 21 13:44:02</span> <span class="sp-w">postfix/smtp[2101]: 4XcB2m7Hd4z1: to=&lt;jobs@acme.io&gt;, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)</span>
<span class="sp-d">Sep 21 13:41:19</span> <span class="sp-e">postfix/smtp[2098]: 4XcB1c3Rk8z1: to=&lt;j.doe@nonexistent.example&gt;, status=bounced (Host or domain name not found)</span>
<span class="sp-d">Sep 21 13:40:02</span> <span class="sp-w">journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM</span>
<span class="sp-d">Sep 21 13:00:00</span> opendkim[39]: key table reloaded (3 domains)</pre>
</div>
@@ -8,7 +8,7 @@
<div class="columns">
<div class="column is-7">
<div class="box is-danger-zone">
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>This cannot be undone</h2></div>
<div class="sp-box-body">
<p class="mb-3">Deleting the panel user <strong>shop-team</strong> will:</p>
@@ -72,16 +72,16 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Recent lines</h2><div class="sp-end">Last 200 · refreshes every 10 s</div></div>
<pre class="sp-log"><span class="d">Sep 21 14:01:52</span> postfix/smtp[2131]: 4XcB9q0Lw2z1: to=&lt;m.keller@gmx.de&gt;, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
<span class="d">Sep 21 14:01:52</span> postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
<span class="d">Sep 21 14:00:18</span> postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
<span class="d">Sep 21 14:00:18</span> postfix/smtp[2130]: 4XcB8z5Tn1z1: to=&lt;dev@lists.example.org&gt;, relay=mx.internal[10.0.4.2]:25, status=sent (250 2.0.0 Ok: queued)
<span class="d">Sep 21 13:58:09</span> <span class="w">postfix/smtp[2123]: 4XcB7k2Jm9z1: to=&lt;noc@example.org&gt;, relay=mx1.example.org[192.0.2.10]:25, status=deferred (451 4.7.1 Greylisted, try again in 5 minutes)</span>
<span class="d">Sep 21 13:57:41</span> <span class="e">postfix/smtpd[2119]: NOQUEUE: reject: RCPT from bulk.example[198.51.100.90]: 550 5.1.1 &lt;sales@lists.example.org&gt;: Recipient address rejected: unknown recipient</span>
<span class="d">Sep 21 13:44:02</span> <span class="w">postfix/smtp[2101]: 4XcB2m7Hd4z1: to=&lt;jobs@acme.io&gt;, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)</span>
<span class="d">Sep 21 13:41:19</span> <span class="e">postfix/smtp[2098]: 4XcB1c3Rk8z1: to=&lt;j.doe@nonexistent.example&gt;, status=bounced (Host or domain name not found)</span>
<span class="d">Sep 21 13:40:02</span> <span class="w">journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM</span>
<span class="d">Sep 21 13:00:00</span> opendkim[39]: key table reloaded (3 domains)</pre>
<pre class="sp-log"><span class="sp-d">Sep 21 14:01:52</span> postfix/smtp[2131]: 4XcB9q0Lw2z1: to=&lt;m.keller@gmx.de&gt;, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
<span class="sp-d">Sep 21 14:01:52</span> postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
<span class="sp-d">Sep 21 14:00:18</span> postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
<span class="sp-d">Sep 21 14:00:18</span> postfix/smtp[2130]: 4XcB8z5Tn1z1: to=&lt;dev@lists.example.org&gt;, relay=mx.internal[10.0.4.2]:25, status=sent (250 2.0.0 Ok: queued)
<span class="sp-d">Sep 21 13:58:09</span> <span class="sp-w">postfix/smtp[2123]: 4XcB7k2Jm9z1: to=&lt;noc@example.org&gt;, relay=mx1.example.org[192.0.2.10]:25, status=deferred (451 4.7.1 Greylisted, try again in 5 minutes)</span>
<span class="sp-d">Sep 21 13:57:41</span> <span class="sp-e">postfix/smtpd[2119]: NOQUEUE: reject: RCPT from bulk.example[198.51.100.90]: 550 5.1.1 &lt;sales@lists.example.org&gt;: Recipient address rejected: unknown recipient</span>
<span class="sp-d">Sep 21 13:44:02</span> <span class="sp-w">postfix/smtp[2101]: 4XcB2m7Hd4z1: to=&lt;jobs@acme.io&gt;, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)</span>
<span class="sp-d">Sep 21 13:41:19</span> <span class="sp-e">postfix/smtp[2098]: 4XcB1c3Rk8z1: to=&lt;j.doe@nonexistent.example&gt;, status=bounced (Host or domain name not found)</span>
<span class="sp-d">Sep 21 13:40:02</span> <span class="sp-w">journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM</span>
<span class="sp-d">Sep 21 13:00:00</span> opendkim[39]: key table reloaded (3 domains)</pre>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
+17 -17
View File
@@ -74,8 +74,8 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
display: grid; place-content: center; text-align: center; color: var(--sp-ok);
font-family: var(--sp-mono); font-weight: 600; letter-spacing: .08em; line-height: 1.15;
}
.sp-postmark.warn { color: #a66a0e; }
.sp-postmark.fail { color: var(--sp-fail); }
.sp-postmark.sp-warn { color: #a66a0e; }
.sp-postmark.sp-fail { color: var(--sp-fail); }
.sp-postmark b { font-size: 1.2rem; display: block; }
.sp-postmark span { font-size: .52rem; display: block; }
@@ -93,10 +93,10 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
.sp-box-foot .sp-end { margin-left: auto; }
.sp-box-head form.sp-end { margin-block: -.2rem; }
.box > .sp-box-foot {padding: .7rem 1.25rem; border-top: 1px solid var(--sp-rule); color: var(--sp-muted); font-size: .85rem; display: flex; gap: 1rem; align-items: center; }
.box.is-danger-zone { border-color: var(--sp-fail-rule); }
.box.is-danger-zone .sp-box-head { background: var(--sp-fail-bg); border-color: var(--sp-fail-rule); }
.box.is-credential { border-color: #e0c874; }
.box.is-credential .sp-box-head { background: #fcf6e4; border-color: #e0c874; }
.box.sp-danger-zone { border-color: var(--sp-fail-rule); }
.box.sp-danger-zone .sp-box-head { background: var(--sp-fail-bg); border-color: var(--sp-fail-rule); }
.box.sp-credential { border-color: #e0c874; }
.box.sp-credential .sp-box-head { background: #fcf6e4; border-color: #e0c874; }
.table { background: transparent; }
.table thead th { font-family: var(--sp-mono); font-size: .7rem; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); }
@@ -119,10 +119,10 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
.sp-health > a:hover { background: var(--sp-paper); }
.sp-h-icon { flex: none; width: 2.6rem; height: 2.6rem; border-radius: 8px; display: grid; place-content: center; background: var(--sp-ok-bg); color: var(--sp-ok); }
.sp-h-icon .ti { font-size: 1.5rem; vertical-align: 0; }
.sp-health > a.warn { background: var(--sp-warn-bg); }
.sp-health > a.warn .sp-h-icon { background: #f6e2b8; color: #8a5510; }
.sp-health > a.fail { background: var(--sp-fail-bg); }
.sp-health > a.fail .sp-h-icon { background: #f5d3cc; color: var(--sp-fail); }
.sp-health > a.sp-warn { background: var(--sp-warn-bg); }
.sp-health > a.sp-warn .sp-h-icon { background: #f6e2b8; color: #8a5510; }
.sp-health > a.sp-fail { background: var(--sp-fail-bg); }
.sp-health > a.sp-fail .sp-h-icon { background: #f5d3cc; color: var(--sp-fail); }
.sp-h-name { font-family: var(--sp-mono); font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); }
.sp-h-value { font-weight: 600; line-height: 1.25; }
.sp-h-sub { font-size: .8rem; color: var(--sp-muted); }
@@ -131,7 +131,7 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
.sp-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 1rem 1.5rem; margin: 0; }
.sp-facts dt { font-family: var(--sp-mono); font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); }
.sp-facts dd { margin: 0; overflow-wrap: anywhere; }
.sp-facts dd.is-big { font-size: 1.5rem; font-weight: 600; line-height: 1.2; }
.sp-facts dd.sp-big { font-size: 1.5rem; font-weight: 600; line-height: 1.2; }
.sp-side { position: sticky; top: 1.5rem; }
.menu-label { font-family: var(--sp-mono); letter-spacing: .1em; }
.menu-list a { display: flex; align-items: center; gap: .55rem; }
@@ -156,14 +156,14 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
margin: 0; padding: 1rem 1.25rem; background: #191c21; color: #d9d5cc; border-radius: 0;
font-family: var(--sp-mono); font-size: .78rem; line-height: 1.6; overflow-x: auto; white-space: pre;
}
.sp-log .w { color: #e0b15c; } .sp-log .e { color: #ef8f82; } .sp-log .d { color: #7f8894; }
.sp-log .sp-w { color: #e0b15c; } .sp-log .sp-e { color: #ef8f82; } .sp-log .sp-d { color: #7f8894; }
.sp-timeline { list-style: none; margin: 0; padding: 0; }
.sp-timeline li { position: relative; padding: 0 0 1.1rem 1.6rem; border-left: 2px solid var(--sp-rule); margin-left: .4rem; }
.sp-timeline li:last-child { padding-bottom: 0; border-left-color: transparent; }
.sp-timeline li::before { content: ""; position: absolute; left: -7px; top: .25rem; width: 12px; height: 12px; border-radius: 50%; background: var(--sp-ok); box-shadow: 0 0 0 3px #fff; }
.sp-timeline li.warn::before { background: #d9962b; }
.sp-timeline li.fail::before { background: var(--sp-fail); }
.sp-timeline li.pending::before { background: #fff; border: 2px solid var(--sp-rule-strong); }
.sp-timeline li.sp-warn::before { background: #d9962b; }
.sp-timeline li.sp-fail::before { background: var(--sp-fail); }
.sp-timeline li.sp-pending::before { background: #fff; border: 2px solid var(--sp-rule-strong); }
.sp-timeline time { font-family: var(--sp-mono); font-size: .75rem; color: var(--sp-muted); display: block; }
.sp-route { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; font-family: var(--sp-mono); font-size: .9rem; margin-top: .3rem; }
.sp-help-topic + .sp-help-topic { border-top: 1px solid var(--sp-rule); }
@@ -183,7 +183,7 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
.sp-signin-brand img { width: 20rem; max-width: 100%; height: auto; }
.sp-signin-brand p { font-family: var(--sp-mono); letter-spacing: .18em; font-size: .8rem; color: var(--sp-on-brick); margin-top: 1.6rem; }
.sp-signin-form { display: flex; flex-direction: column; justify-content: center; padding: 3rem; }
.sp-signin-form form, .sp-signin-form .sp-signin-note { width: 22rem; max-width: 100%; margin-inline: auto; }
.sp-signin-form form { width: 22rem; max-width: 100%; margin-inline: auto; }
.sp-foot { color: var(--sp-muted); font-size: .8rem; padding-block: 1.5rem 0; }
.sp-foot a { color: inherit; text-decoration: underline; }
.sp-signin-form .sp-foot { text-align: center; margin-top: 2.5rem; }
@@ -191,6 +191,6 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
@media (max-width: 768px) {
.sp-row, .sp-pair { grid-template-columns: 1fr; }
.sp-record-head { flex-wrap: wrap; }
.sp-record-head small { margin-left: 0; text-align: left; width: 100%; }
.sp-record-head small { margin-left: 0; text-align: left; flex-basis: 100%; }
.sp-postmark { display: none; }
}
@@ -71,7 +71,7 @@
<div class="columns">
<div class="column is-7">
<div class="box is-danger-zone">
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>This cannot be undone</h2></div>
<div class="sp-box-body">
<p class="mb-3">Deleting the panel user <strong>shop-team</strong> will:</p>
+154 -152
View File
@@ -1,197 +1,199 @@
# Plan: panel-redesign
**Status:** candidate
**Date:** 2026-09-08
**Version:** `1.x` MINOR once implementation is agreed; this file is givens, not a
build plan.
**Date:** 2026-09-21 (replaces the 2026-09-08 givens)
**Version:** `1.x` MINOR once agreed.
---
## What this file is
Source data for a visual redesign of the control panel. Mockups have not been
drawn yet. Implementation in `internal/web` waits on those mockups and on
roadmap status **agreed**.
The design contract for the control panel and the plan to implement it. The
design itself is drawn and accepted:
HTML click-throughs in [docs/assets/panel-ui/](../assets/panel-ui/index.html)
are **not** the brief. They were an earlier layout experiment; new screens are
drawn from this file's vocabulary, not copied from that folder.
| Artefact | Role |
|---|---|
| [docs/assets/panel-redesign/panel/](../assets/panel-redesign/index.html) | 29 screens — **the** reference for what each page looks like |
| [panel/components.html](../assets/panel-redesign/panel/components.html) | every component in every state — the only parts a page may be built from |
| [panel/outlines.json](../assets/panel-redesign/panel/outlines.json) | the component skeleton of each screen, generated from the mockups |
| [panel/check.py](../assets/panel-redesign/panel/check.py) | the contract as a program; its rules are ported to Go tests below |
When this file and a mockup disagree, the mockup wins and this file is fixed.
When an implementation and a mockup disagree, the implementation is wrong.
The 2026-09-08 givens ("no component library, Web Awesome layout CSS only") are
withdrawn. `docs/assets/panel-ui/` and the stage-1 directions `a/`, `b/`, `c/`
are history, not a brief.
## Goal
Stop hand-tuning every card on every page to hide meaningless empty space.
Operators should get a panel whose width rules are the same on Status, a
delete-confirm, and a six-column log: a form stays at a reading measure, ops
content uses the remaining column, and a card fills its cell.
A panel an operator opens a few times a year and still finds their way in: the
important things in view, details one or two clicks away, one visual language
on every page, and no page that tunes its own width.
This is a visual and CSS-grammar change. Routes, POST behaviour, and htmx
fragments stay as they are unless a later mockup proves a control must move.
## Pain (why)
[panel.css](../../internal/web/view/static/panel.css) already juggles several
widths (`64rem` column, `48rem` measure, `main.wide`, `.card.narrow`,
`page-login`) and two names for one pairing (`.split` everywhere, `.pair` on
DMARC). Each new screen picks a width by eye. On a wide monitor that leaves
voids; stretching a single input to the window is unreadable. The fix is a
layout grammar, not another button library.
## Delivery constraints (fixed unless product says otherwise)
The panel is not an SPA. Changing any of these needs an explicit decision, not
a mockup flourish:
## Delivery constraints (unchanged)
| Constraint | Where |
|---|---|
| Go `html/template`, server-rendered pages | [internal/web/view/](../../internal/web/view/) |
| Vendored htmx 2.x for polling fragments | [layout.html](../../internal/web/view/templates/layout.html), [development.md](../development.md) § Vendored front-end |
| Go `html/template`, server-rendered; htmx 2.x for polled fragments | [internal/web/view/](../../internal/web/view/) |
| Assets embedded (`//go:embed`), no CDN at runtime | [view.go](../../internal/web/view/view.go) |
| CSP `default-src 'self'` — no inline script or `style=""` | [security.go](../../internal/web/security.go), `TestNoTemplateUsesInlineScriptOrStyle` |
| State changes are native HTML POST | templates under `internal/web/view/templates/` |
| AGPL-3.0; new front-end must be MIT-or-better and listed in NOTICE | [NOTICE](../../NOTICE), [development.md](../development.md) |
| CSP `default-src 'self'` — no inline script, no `style=""` | [security.go](../../internal/web/security.go) |
| State changes are native HTML POST; no script is the only way to submit | templates |
| New front-end is MIT-or-better and listed in NOTICE | [NOTICE](../../NOTICE) |
JavaScript is not a product requirement. [security.md](../security.md) records
progressive enhancement so **authorization does not depend on JS** (origin
check, session, RBAC). `data-confirm` is deliberately not a security boundary.
The redesign may use JS for chrome (copy, `<dialog>`). It must not make a
script the only way to submit a form.
Bulma was chosen partly because it needs nothing from this table relaxed: no
JavaScript, no `data:` URIs.
Brand carriers today: stamp, brick, IBM Plex — [product.md](../product.md) and
[docs/assets/selfpost-proof.html](../assets/selfpost-proof.html). New mockups
may restyle; they must not silently drop the mark or invent a second product
look.
## Information architecture
## Screens in scope (information architecture)
Navigation is grouped by mail direction. Page titles carry the group
(**Outbound domains**, **Inbound log**); menu entries do not repeat it.
Same pages as the live panel. Visibility flags (inbound, DMARC, global vs
domain-admin) stay as in [web.go](../../internal/web/web.go).
| Group | Pages | Today |
|---|---|---|
| Overview | Overview | `/status` (verdicts only) |
| Outbound | Domains · Log · Queue · DMARC reports | `/domains`, `/deliveries`, `/mail-queue`, `/dmarc` |
| Inbound | Domains · Log · Queue | `/inbound`; Log and Queue are new |
| Server | Health · System log · Backup · Users · Settings | Health is new (the tables that left `/status`) |
| User menu | Account · Help · Sign out | Account is the credentials half of `/settings` |
| Kind | Pages |
Visibility flags (global vs domain-admin, inbound, DMARC) stay as in
[web.go](../../internal/web/web.go): a hidden page leaves no gap in the menu.
Whether paths are renamed to match (`/outbound/log`, …) with redirects from
the old ones is decided in checklist step 3, not by the implementer.
**Needs its own roadmap decision before it is built** (the mockups show the
target; none of this is smuggled in with the restyle):
- Queue as a table — parse `postqueue -j` instead of printing `postqueue -p`.
- Inbound queue — the same queue filtered by recipient domain.
- Inbound log — rides on `inbound-antispam-panel` (`1.10.0`).
- Server › Health — a route for the tables removed from Overview.
Until a row above is agreed, its page ships in the old content inside the new
shell, and its menu entry is hidden if the page does not exist.
## Three layers
1. **Bulma, as shipped.** Vendored `bulma.min.css`, never edited. Only the
classes listed in `check.py` (`BULMA`) are used; reaching for another is a
design change.
2. **Adapters** — where display logic lives once:
`status_tag` (status → `tag is-… is-light`) as a template function, and
`copy_field` (the read-only value with its Copy button).
3. **Own components** — everything with an `sp-` class, styled in one
`panel.css`, each rendered by one partial in `components.html`:
| Partial | What it is |
|---|---|
| Signed-out | login, setup |
| Global ops | Status (plus polled fragment), mail queue, system log, backup, users |
| Shared | domains, domain detail, deliveries, delivery, help, settings |
| Optional | inbound (+ domain, delete), DMARC hub / domain / report |
| Confirms / forms | domain delete, inbound delete, user create/edit/delete |
| `page_head` | path, `<h1>`, lead, actions; optionally a `postmark` |
| `postmark` | the round verdict stamp: ok / `sp-warn` / `sp-fail` |
| `box`, `box_head`, `box_foot` | numbered mono head with an end slot; variants `sp-danger-zone`, `sp-credential` |
| `health_card` | Overview only; ok / `sp-warn` / `sp-fail`; links to its detail |
| `dns_record` | one block per record: status, what to publish, "in DNS now" only on a mismatch |
| `facts` | label / value grid, `sp-big` for headline numbers |
| `side_menu` | Bulma `menu` with icons and count tags; sticky |
| `timeline`, `log_pane`, `empty_state`, `confirm_list`, `help_topic` | as on the components page |
| form rows | `sp-row` (wide + short), `sp-pair` (two equal) |
## Layout layer (decided)
The shell — brick navbar with hover menus, perforated edge, sibling-page
strip, footer — belongs to `layout.html` alone.
**Hybrid.** Page-level regions are ours. In-page composition is vendored Web
Awesome **layout CSS only** (MIT Core), not the component library and not the
full `utilities.css` barrel.
## Layout rules
Every Layout ([every-layout.dev](https://every-layout.dev/)) is the *school*
(Stack, Cluster, Sidebar, Grid). It is a paid book; redistribution is
forbidden. Do not vendor it, do not buy it for the image, do not put their
custom elements in the tree. Web Awesome `layout.css` is the MIT packaging of
those recipes (`wa-stack` ≈ Stack, `wa-cluster` ≈ Cluster, `wa-flank` ≈
Sidebar, `wa-grid` ≈ Grid/Switcher).
A page is exactly one of:
### Regions (our CSS)
- **list** — `page_head`, then full-width boxes: the add form, then the table.
- **detail** — `page_head`, then `columns`: `is-3` `side_menu` + `is-9` boxes.
- **form** — `page_head`, then boxes in equal `columns`, one submit row below.
- **signed out** — the split brand / form screen.
Shell: navigation column plus content that takes the rest (today's `.shell`,
without a centred `64rem` well that leaves side voids).
Width comes from the layout, never from the page. A narrow form does not get a
`max-width`; it gets a column and a neighbour that explains it.
Inside the content column, **two modes only**. A page must not invent a third
width:
## The contract
- **`measure`** — one readable form (login, setup, delete confirm, user form).
Cap around `42rem`, **left-aligned**. Fields fill the measure, not the
window. Empty space to the right of a form on a wide monitor is acceptable.
- **`fill`** — ops: Status, tables, logs, queue, DNS, paired cards. One column
cap for all fill pages (pick a single value in mockups, e.g. ~`90rem`; do
not keep both `48rem` and `64rem`). A card fills its grid cell.
An implementer — human or model — **must not**:
There is no `main.wide` or `.card.narrow` as a width exception. A narrow card
lives in `measure`. A wide table lives in `fill`.
1. Write an `sp-` class, a Bulma class outside the subset, or any other class
that the components page does not show.
2. Add a component, a state or a layout. If a screen seems to need one, **stop
and ask**; do not approximate it with ad-hoc markup.
3. Put a selector in `panel.css` that names a page or an element id, uses
`!important`, or sets a width outside the `WIDTH_OK` list.
4. Write component markup by hand in a page template instead of calling the
partial.
5. Edit the vendored Bulma, or load a second CSS framework, icon set or any
JavaScript widget.
6. Change a mockup, `outlines.json`, `check.py` or a guard test in the same
commit as `internal/web`, or at all without the owner's explicit
instruction. **The guards are not the implementer's to adjust.** A failing
guard means the implementation is wrong, not the guard.
7. Report a step as done on the strength of a description. Done means the
evidence below exists.
### In-page (vendored WA CSS)
**Changing the design** is allowed and has one route: mockup first. Edit the
fragment in `panel/src/`, the components page if a part changed, run
`build.py` and `check.py --write`, commit as `design: …` with the reason, get
it accepted — and only then touch `internal/web`.
Embed under `/static`, same as htmx — never a runtime CDN.
## Enforcement
| Take | Do not take |
Guard tests live in `internal/web/view` and run in `go test ./...` and CI.
They land **before** any template is restyled (checklist step 2), written and
reviewed by models other than the one that implements the templates.
| Test | Fails when |
|---|---|
| `utilities/layout.css` | barrel `utilities.css` |
| `utilities/gap.css` | `themes/default.css`, `native.css` |
| `align-items.css`, `justify-content.css`, `flex-wrap.css` if a mockup needs them | variants, prose, FOUCE, JS, icons, Pro |
| A short `:root` stub for `--wa-space-*` only | their `layers.css` (it styles `wa-page`) |
| `TestPanelClassVocabulary` | a template uses a class outside the Bulma subset + the `sp-` classes of `panel.css`; an `sp-` class is defined but unused, or used but absent from `components.html` |
| `TestPanelCSSContract` | `panel.css` has `!important`, an id or page-name selector, or a width outside `WIDTH_OK` |
| `TestTemplatesUseComponents` | a page template contains `sp-box-head`, `sp-postmark`, `sp-record`, `sp-facts`, … literally instead of `{{template "…"}}` |
| `TestPanelOutlinesMatchMockups` | a page rendered with fixtures has a different component skeleton than `outlines.json` — a different design cannot pass as this one |
| `TestVendoredBulmaChecksum` | `bulma.min.css` differs from the pinned SHA-256 |
| `TestNoTemplateUsesInlineScriptOrStyle` | (exists) inline `style` / script |
Declare `@layer wa-utilities` locally. One spacing scale for the whole layer
(map to today's `1.2rem` gaps if that looks right; do not keep a second scale).
CI adds one step, `design-first`: a commit that touches
`internal/web/view/**` must not touch `docs/assets/panel-redesign/panel/**`
or the guard tests.
`.card` with slots (head / body / actions) stays ours so a `wa-grid` cell can
stretch. Do not use `wa-card`.
### Evidence for "done"
### Class vocabulary
Every checklist step that restyles pages ends with, in the commit:
**Paired cards are `wa-grid`, not `wa-split`.** In Web Awesome, `wa-split` is
space-between (title ‖ action). `wa-grid` is `auto-fit` + `--min-column-size`,
which is today's `.split { minmax(22rem, 1fr) }`. Freeze `--min-column-size`
once (same order as `22rem`); do not retune it per page.
- `go test ./internal/web/...` output in the commit message body (pass);
- a screenshot of each restyled page at 1440×900, saved next to its mockup's
name under `docs/assets/panel-redesign/evidence/`, rendered from the running
container with the e2e fixtures — not from the mockup;
- a review by a model that did not write the step
([development.md](../development.md) § Model routing): mockup and screenshot
side by side, every difference either fixed or listed for the owner.
| Class | Use |
|---|---|
| `measure` / `fill` | page region only, never inside a card |
| `wa-stack` | form or card body; children stretch on the block axis |
| `wa-cluster` | button rows, tags, Copy beside a value |
| `wa-grid` | two or more equal cards or field columns |
| `wa-split` | card header: title ‖ help/action; label ‖ value |
| `wa-flank` | rare: fixed + fluid (icon beside a field) |
| `wa-gap-*` | change a gap; not a substitute for a region |
The owner accepts a step by looking at the pairs. A step without them is not
reviewed.
`wa-frame` is for media boxes; the panel has none — omit from mockups.
## Out of scope
One child in `wa-grid` occupies the row (`auto-fit`, not `auto-fill`): a
domain-admin Settings card must not leave an empty second column.
**Forbidden in mockups and later in templates:** a one-off `max-width` “for
this page”; a second name for the same pairing (today `.pair` vs `.split`);
`margin-inline: auto` on a card inside a grid (that already collapsed `.split`
tracks).
**Ready when:** Status (`fill` + `wa-grid` pairs) and login (`measure` +
`wa-stack`) can be drawn with no extra width rule. If a third screen wants a
new `max-width`, the vocabulary is unfinished.
## Components (decided)
**No JavaScript component library.** Pico, Bootstrap JS, DaisyUI, FAST,
Shoelace, and Web Awesome custom elements (`wa-button`, `wa-dialog`,
`wa-drawer`, `wa-card`) are out. They do not fix empty cards; they add Lit or
another runtime, Shadow DOM, CSP risk, and friction with htmx swaps.
What the live panel actually uses — and what mockups should assume:
| Need | Source |
|---|---|
| Button, input, select, textarea, table | native HTML, our CSS (brick / Plex) |
| Card | our `.card` + slots |
| Flash, status tint | our CSS |
| Help / application panels | CSS disclosure (checkbox today); or `<details>` / Popover if a mockup prefers |
| Destructive confirm | `data-confirm` / `window.confirm`, plus existing confirm pages for high blast radius |
| Overlay, if a mockup truly needs one | HTML [`<dialog>`](https://developer.mozilla.org/docs/Web/HTML/Element/dialog), not `wa-dialog` |
| Copy-to-clipboard | existing `panel.js` |
There is no combobox, date picker, tab set, tree, or chart in the panel. If a
future mockup invents a widget native HTML cannot express, choose a library
then, for that widget only.
## Out of scope for this item
- New panel features, routes, or copy (those are other roadmap rows).
- Weakening CSP to accommodate a widget library.
- Web Awesome Pro, Font Awesome Kit, a JS bundler for Lit.
- Replacing htmx with a front-end framework.
- Implementing `docs/assets/panel-ui/` as-is.
## Next (not this file's job)
1. Draw new mockups using only the vocabulary above.
2. After mockups are accepted and this item is **agreed**: vendor the WA CSS
files, NOTICE / development.md rows, then restyle templates without changing
behaviour.
- New features beyond the four rows listed under Information architecture.
- Dark scheme and the narrow-screen shell: Bulma ships both; they are drawn
and agreed as a `design:` change before anyone implements them.
- Weakening CSP; a JS bundler; a front-end framework.
## Implementation checklist
None until mockups exist and the roadmap status is **agreed**. Do not start
vendoring or rewriting `internal/web` from this file alone.
No code until the roadmap status is **agreed**.
- [ ] Vendor Bulma 1.0.4 and the used Tabler icons (MIT) under `/static`, NOTICE and development.md rows, pinned checksum — **Haiku**
- [ ] Guard tests from the table above, red against today's templates where expected; `design-first` CI step — **Opus**
- [ ] Owner decision recorded here: keep today's paths or rename with redirects — **owner**
- [ ] `panel.css` from `panel/theme.css`; `components.html` partials; `status_tag`; a `/components` page behind the global role rendering every partial — **Sonnet**
- [ ] `layout.html`: navbar, perforated edge, sibling strip, user menu, footer; visibility flags — **Sonnet**
- [ ] Signed-out pages: login, setup — **Sonnet**
- [ ] Overview (verdicts) and Account / Settings split — **Sonnet**
- [ ] Outbound: domains, domain, domain settings, application form, shown-once password, delete — **Sonnet**
- [ ] Outbound: log, message, DMARC hub / domain / report — **Sonnet**
- [ ] Inbound: domains, domain, delete — **Sonnet**
- [ ] Server: system log, backup, users, user form, delete; Help — **Sonnet**
- [ ] Remove the old `panel.css` rules and classes nothing references; vocabulary test green with no allow-list — **Sonnet**
- [ ] Technical review of the whole against the mockups, evidence pairs complete — **Opus**
- [ ] guide.md screenshots and wording for the new navigation — **Sonnet**
- [ ] `go vet`, `go test ./...`, e2e suite — **Haiku**
+22 -16
View File
@@ -36,7 +36,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — |
| structured-logging | `log/slog` with levels and fields | candidate | — | — |
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | candidate | — | — |
| panel-redesign | Panel visual redesign (layout grammar) | candidate | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
| panel-redesign | Panel redesign (accepted mockups, design contract) | candidate | 0/15 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — |
**Recommended order** (not binding): the next feature is
@@ -297,26 +297,32 @@ and tagging it would only delete cross-platform test coverage.
## panel-redesign
**Goal:** a panel layout grammar so screens stop inventing per-page widths to
hide empty space. Forms stay on a reading measure; ops pages use the remaining
column; cards fill their cells. Givens (hybrid WA layout CSS + own `measure` /
`fill`, no JS component library, new mockups rather than
[docs/assets/panel-ui/](assets/panel-ui/index.html)):
**Goal:** a panel an operator opens a few times a year and still finds their
way in — the important things in view, details one or two clicks away, one
visual language, no page tuning its own width. The design is drawn and
accepted: 29 screens and a components page under
[docs/assets/panel-redesign/](assets/panel-redesign/index.html) (Bulma base,
postmark / numbered heads / perforated edge, icons). Contract and checklist:
[plans/panel-redesign.md](plans/panel-redesign.md).
**Boundary:** visual CSS and templates after mockups. No new routes or mail-path
behaviour. Not a front-end framework, not Web Awesome components, not Pico /
Bootstrap. The older HTML mockups in `docs/assets/panel-ui/` are not the brief.
**Boundary:** templates, one `panel.css`, shared partials, vendored Bulma and
icons. Navigation regroups by mail direction (Overview · Outbound · Inbound ·
Server). Four things the mockups show need their own decision and are not
smuggled in with the restyle: queue as a table (`postqueue -j`), inbound
queue, inbound log (rides on inbound-antispam-panel), Server › Health. No CSP
change, no JavaScript widgets, no second framework.
**Done when:** mockups exist that use only the frozen vocabulary, then — once
this item is agreed — the live panel matches them without one-off `max-width`
exceptions. The givens file itself is not “done”.
**Done when:** every page renders with the component skeleton recorded in
`panel/outlines.json`, the guard tests in the plan are green with no
allow-list, and each page has an evidence screenshot accepted against its
mockup.
**Dependencies / risks:** competes with feature work for attention; easy to
start restyling `internal/web` before the vocabulary is proven on paper.
Vendoring WA CSS needs NOTICE / licence rows. CSP stays `default-src 'self'`.
**Dependencies / risks:** an implementer substituting local patches or a look
of their own — answered by guard tests that land before the templates, a
`design-first` CI step and evidence-based acceptance (plan § Enforcement).
Competes with feature work; Bulma is ~680 kB minified, embedded once.
**Version:** `1.x` MINOR when implementation is agreed; `candidate` until then.
**Version:** `1.x` MINOR when agreed; `candidate` until then.
---