From 1bcfca15863cb3e6b313a35bdc9b90e7ea5080d4 Mon Sep 17 00:00:00 2001 From: Mix Date: Mon, 21 Sep 2026 20:55:18 +0300 Subject: [PATCH] docs: turn the accepted panel design into an enforceable contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .cursor/rules/panel-design.mdc | 39 ++ CHANGELOG.md | 12 + docs/assets/panel-redesign/index.html | 4 +- docs/assets/panel-redesign/panel/check.py | 227 ++++++++++ .../panel-redesign/panel/components.html | 263 ++++++++++++ .../panel-redesign/panel/dmarc-domain.html | 2 +- docs/assets/panel-redesign/panel/health.html | 2 +- .../panel/in-domain-delete.html | 2 +- .../assets/panel-redesign/panel/in-queue.html | 8 +- .../panel-redesign/panel/out-app-created.html | 2 +- .../panel/out-domain-delete.html | 2 +- .../panel/out-domain-settings.html | 2 +- .../panel-redesign/panel/out-domain.html | 2 +- .../panel-redesign/panel/out-message.html | 12 +- .../panel-redesign/panel/out-queue.html | 8 +- .../assets/panel-redesign/panel/outlines.json | 401 ++++++++++++++++++ .../assets/panel-redesign/panel/overview.html | 4 +- .../assets/panel-redesign/panel/settings.html | 2 +- .../panel-redesign/panel/src/components.html | 194 +++++++++ .../panel/src/dmarc-domain.html | 2 +- .../panel-redesign/panel/src/health.html | 2 +- .../panel/src/in-domain-delete.html | 2 +- .../panel-redesign/panel/src/in-queue.html | 8 +- .../panel/src/out-app-created.html | 2 +- .../panel/src/out-domain-delete.html | 2 +- .../panel/src/out-domain-settings.html | 2 +- .../panel-redesign/panel/src/out-domain.html | 2 +- .../panel-redesign/panel/src/out-message.html | 12 +- .../panel-redesign/panel/src/out-queue.html | 8 +- .../panel-redesign/panel/src/overview.html | 4 +- .../panel-redesign/panel/src/settings.html | 2 +- .../panel-redesign/panel/src/system-log.html | 20 +- .../panel-redesign/panel/src/user-delete.html | 2 +- .../panel-redesign/panel/system-log.html | 20 +- docs/assets/panel-redesign/panel/theme.css | 34 +- .../panel-redesign/panel/user-delete.html | 2 +- docs/plans/panel-redesign.md | 306 ++++++------- docs/roadmap.md | 38 +- 38 files changed, 1402 insertions(+), 256 deletions(-) create mode 100644 .cursor/rules/panel-design.mdc create mode 100644 docs/assets/panel-redesign/panel/check.py create mode 100644 docs/assets/panel-redesign/panel/components.html create mode 100644 docs/assets/panel-redesign/panel/outlines.json create mode 100644 docs/assets/panel-redesign/panel/src/components.html diff --git a/.cursor/rules/panel-design.mdc b/.cursor/rules/panel-design.mdc new file mode 100644 index 0000000..5d04090 --- /dev/null +++ b/.cursor/rules/panel-design.mdc @@ -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). diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ff23cc..91de086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/docs/assets/panel-redesign/index.html b/docs/assets/panel-redesign/index.html index f77de1d..c2b7e58 100644 --- a/docs/assets/panel-redesign/index.html +++ b/docs/assets/panel-redesign/index.html @@ -13,7 +13,7 @@

The accepted direction, every screen

Direction C (Bulma) was picked as the base: the important things in view, details one or two clicks away. It takes the postal signature of A — the postmark verdict, numbered mono section - heads, the stamp's perforated edge — and the icons of B. Twenty-nine screens live in + heads, the stamp's perforated edge — and the icons of B. Twenty-nine screens and a components page live in panel/. Serve this folder from the repository root: the pages load the panel's own fonts and mark by relative path.

@@ -36,6 +36,8 @@ Backup · Users · user form · delete user · Settings User menuAccount · Help + ContractComponents — every building block in every state · + the plan · panel/check.py lints the mockups and writes panel/outlines.json diff --git a/docs/assets/panel-redesign/panel/check.py b/docs/assets/panel-redesign/panel/check.py new file mode 100644 index 0000000..d099d78 --- /dev/null +++ b/docs/assets/panel-redesign/panel/check.py @@ -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"(?")[0] + for bad in ("style=", "") + 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()) diff --git a/docs/assets/panel-redesign/panel/components.html b/docs/assets/panel-redesign/panel/components.html new file mode 100644 index 0000000..1569397 --- /dev/null +++ b/docs/assets/panel-redesign/panel/components.html @@ -0,0 +1,263 @@ + + + + + +Components · SelfPost + + + + + + + + + +
+
+
+
SELFPOSTREFDESIGN CONTRACT
+
+

Reference · page_head

+

Components

+

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.

+
+ +
+ +
+
00

Page layouts

A page is exactly one of these
+
+
+
list
page_head, then full-width boxes: the add form first, the table second. Example
+
detail
page_head, then columns: is-3 side menu + is-9 boxes. Example
+
form
page_head, then boxes in equal columns, one submit row under them. Example
+
signed out
The split brand / form screen, no navbar. Example
+
+
+
+ +
+
+
+
01

postmark

Worst status of the page's checks
+
+
+
SELFPOSTOK6 OF 6 CHECKS
+
SELFPOSTWARN1 OF 6 CHECKS
+
DNSFAIL1 OF 3 RECORDS
+
+

Only inside page_head, at most one per page, and only where the page has a verdict to give.

+
+
+ +
+
03

status_tag

One mapping, one function
+ + + + + + + + + +
StatusTagBulma classes
ok, published, running, sentoktag is-success is-light
warn, deferred, quietwarntag is-warning is-light
error, fail, mismatch, bouncedfailtag is-danger is-light
unknown, off, neutral countsofftag is-light
role: globalglobaltag is-primary is-light
+
+
+ +
+
+
02

box · box_head

The end slot: a note, a tag, a link or a filter form
+

box_body — padded content. A table or a run of sp-record blocks sits directly in the box, without box_body.

+
box_foot — a legend, pagingend slot
+
+
+

box · sp-danger-zone

+

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.

+
+
+

box · sp-credential

Shown once
+

Only for a secret the panel will never show again.

+
+
+
+ +
+
04

health_card

Overview only · each card links to its detail
+ +
+ +
+
+
+
05

dns_record · copy_field

One block per record
+
+

Record

publishedA one-line note
+
+
+
+
+
+
+
+
+
+

Record

mismatch"In DNS now" appears only when it differs
+
+
+

What is wrong and the exact fix.

+
+
+ +
+
07

Table conventions

+ + + + + +
Time, UTCNamesp-clipStatus
09-21 14:01:52the row's linkA long subject is clipped with an ellipsis instead of wrapping the rowsentDetailsDelete
+
Page 1 of 9Older →
+
+ +
+
09

log_pane

sp-d time · sp-w warning · sp-e error
+
13:58:07 an ordinary line
+13:58:09 a deferred delivery — sp-w
+13:57:41 a reject or a bounce — sp-e
+
+
+ +
+
+
06

facts

+
+
+
Label
Value
+
Mono value
4XcB7k2Jm9z1
+
sp-big
5 days
a supporting line
+
+
+
+ +
+
08

timeline

+
+
    +
  1. Done — default
  2. +
  3. Deferred — sp-warn
  4. +
  5. Bounced — sp-fail
  6. +
  7. Expected next — sp-pending
  8. +
+
+
+ +
+
10

empty_state

+
One sentence: what is absent and why that is fine.
+
+ +
+
11

confirm_list

+
+
    +
  • each thing that will be lost, one per line;
  • +
  • inside an sp-danger-zone box, above the danger button.
  • +
+
+
+
+
+ +
+
+ +
+
+
+
12

Form rows · route · help_topic

+
+
+
+
+
+
+
+
+

Help text lives in Bulma's help.

+
+
+

from@example.orgto@example.netdeferred

+
+

help_topic

A heading and a paragraph; topics stack inside one box on the Help page.

+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/dmarc-domain.html b/docs/assets/panel-redesign/panel/dmarc-domain.html index 1c2d8dd..ea94be3 100644 --- a/docs/assets/panel-redesign/panel/dmarc-domain.html +++ b/docs/assets/panel-redesign/panel/dmarc-domain.html @@ -63,7 +63,7 @@
-
DMARC99.6%PASS · 7 DAYS
+
DMARC99.6%PASS · 7 DAYS

DMARC reports /

shop.example.org

diff --git a/docs/assets/panel-redesign/panel/health.html b/docs/assets/panel-redesign/panel/health.html index ce87a13..a614601 100644 --- a/docs/assets/panel-redesign/panel/health.html +++ b/docs/assets/panel-redesign/panel/health.html @@ -111,7 +111,7 @@
Expires
2026-10-03 09:14 UTC
Names
mail.example.org
-

Expires in 12 days — renewal has not run.

+

Expires in 12 days — renewal has not run.

Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for acme lines in the system log.

diff --git a/docs/assets/panel-redesign/panel/in-domain-delete.html b/docs/assets/panel-redesign/panel/in-domain-delete.html index 0dc2e31..fdd0260 100644 --- a/docs/assets/panel-redesign/panel/in-domain-delete.html +++ b/docs/assets/panel-redesign/panel/in-domain-delete.html @@ -71,7 +71,7 @@
-
+

This cannot be undone

Deleting the inbound domain lists.example.org will:

diff --git a/docs/assets/panel-redesign/panel/in-queue.html b/docs/assets/panel-redesign/panel/in-queue.html index 0f41fb5..ce38225 100644 --- a/docs/assets/panel-redesign/panel/in-queue.html +++ b/docs/assets/panel-redesign/panel/in-queue.html @@ -79,10 +79,10 @@
02

How retries work

This Postfix's policy, read at panel start
-
First retry
5 min
-
Later retries
doubling
capped at 66 min
-
Kept in queue
5 days
-
Then
bounced
back to the original sender
+
First retry
5 min
+
Later retries
doubling
capped at 66 min
+
Kept in queue
5 days
+
Then
bounced
back to the original sender
diff --git a/docs/assets/panel-redesign/panel/out-app-created.html b/docs/assets/panel-redesign/panel/out-app-created.html index c6966c3..7d09b33 100644 --- a/docs/assets/panel-redesign/panel/out-app-created.html +++ b/docs/assets/panel-redesign/panel/out-app-created.html @@ -71,7 +71,7 @@
-
+

Shown once

Not stored — copy it now
diff --git a/docs/assets/panel-redesign/panel/out-domain-delete.html b/docs/assets/panel-redesign/panel/out-domain-delete.html index 5989907..d6837e1 100644 --- a/docs/assets/panel-redesign/panel/out-domain-delete.html +++ b/docs/assets/panel-redesign/panel/out-domain-delete.html @@ -71,7 +71,7 @@
-
+

This cannot be undone

Deleting example.org will:

diff --git a/docs/assets/panel-redesign/panel/out-domain-settings.html b/docs/assets/panel-redesign/panel/out-domain-settings.html index c5bbcf7..f860550 100644 --- a/docs/assets/panel-redesign/panel/out-domain-settings.html +++ b/docs/assets/panel-redesign/panel/out-domain-settings.html @@ -126,7 +126,7 @@
-
+
04

Delete domain

Removes the DKIM key and both applications. Asks once more before doing it.

diff --git a/docs/assets/panel-redesign/panel/out-domain.html b/docs/assets/panel-redesign/panel/out-domain.html index 8f73f2b..d18e786 100644 --- a/docs/assets/panel-redesign/panel/out-domain.html +++ b/docs/assets/panel-redesign/panel/out-domain.html @@ -63,7 +63,7 @@
-
DNSFAIL1 OF 3 RECORDS
+
DNSFAIL1 OF 3 RECORDS

Outbound domains /

example.org

diff --git a/docs/assets/panel-redesign/panel/out-message.html b/docs/assets/panel-redesign/panel/out-message.html index 49dbc0e..1891105 100644 --- a/docs/assets/panel-redesign/panel/out-message.html +++ b/docs/assets/panel-redesign/panel/out-message.html @@ -92,8 +92,8 @@
  1. Accepted from alerts at 198.51.100.7
  2. -
  3. Deferred — the receiving server asked to retry later
  4. -
  5. Still in the queue; kept for 5 days, then bounced
  6. +
  7. Deferred — the receiving server asked to retry later
  8. +
  9. Still in the queue; kept for 5 days, then bounced
@@ -102,10 +102,10 @@
03

Delivery log

Lines from mail.log with this queue id
-
13:58:07 postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts
-13:58:07 postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=<a81f0c@alertmanager>
-13:58:07 opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org)
-13:58:09 postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, 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)
+
13:58:07 postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts
+13:58:07 postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=<a81f0c@alertmanager>
+13:58:07 opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org)
+13:58:09 postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, 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)
diff --git a/docs/assets/panel-redesign/panel/out-queue.html b/docs/assets/panel-redesign/panel/out-queue.html index ff7e0e3..544b263 100644 --- a/docs/assets/panel-redesign/panel/out-queue.html +++ b/docs/assets/panel-redesign/panel/out-queue.html @@ -84,10 +84,10 @@
02

How retries work

This Postfix's policy, read at panel start
-
First retry
5 min
-
Later retries
doubling
capped at 66 min
-
Kept in queue
5 days
-
Then
bounced
no attempt counter, only time
+
First retry
5 min
+
Later retries
doubling
capped at 66 min
+
Kept in queue
5 days
+
Then
bounced
no attempt counter, only time
diff --git a/docs/assets/panel-redesign/panel/outlines.json b/docs/assets/panel-redesign/panel/outlines.json new file mode 100644 index 0000000..5d14443 --- /dev/null +++ b/docs/assets/panel-redesign/panel/outlines.json @@ -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" + ] +} diff --git a/docs/assets/panel-redesign/panel/overview.html b/docs/assets/panel-redesign/panel/overview.html index e118e0b..760ec30 100644 --- a/docs/assets/panel-redesign/panel/overview.html +++ b/docs/assets/panel-redesign/panel/overview.html @@ -63,7 +63,7 @@
-
SELFPOSTWARN1 OF 6 CHECKS
+
SELFPOSTWARN1 OF 6 CHECKS

mail.example.org

Mail is flowing. One thing needs attention.

@@ -77,7 +77,7 @@
Machine
CPU 12 % · RAM 41 %
↓ 18 kB/s · ↑ 44 kB/s
Processes
5 of 5 running
up 18 days
- TLS certificate
Expires in 12 days
renewal has not run
+ TLS certificate
Expires in 12 days
renewal has not run
Queues
Both empty
outbound and inbound
Milter sockets
2 of 2 answering
opendkim, journal
Reverse DNS
Forward = reverse
203.0.113.25
diff --git a/docs/assets/panel-redesign/panel/settings.html b/docs/assets/panel-redesign/panel/settings.html index 6d8a340..ed78014 100644 --- a/docs/assets/panel-redesign/panel/settings.html +++ b/docs/assets/panel-redesign/panel/settings.html @@ -107,7 +107,7 @@
03

Sending rate limits

Read-only
-
Level 1 · per client IP
600 / h
Set in .env; restart to change. Nothing below may exceed it.
+
Level 1 · per client IP
600 / h
Set in .env; restart to change. Nothing below may exceed it.
Level 2 · domain, application
Set on each domain and application.
diff --git a/docs/assets/panel-redesign/panel/src/components.html b/docs/assets/panel-redesign/panel/src/components.html new file mode 100644 index 0000000..dff015e --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/components.html @@ -0,0 +1,194 @@ + +
+
SELFPOSTREFDESIGN CONTRACT
+
+

Reference · page_head

+

Components

+

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.

+
+ +
+ +
+
00

Page layouts

A page is exactly one of these
+
+
+
list
page_head, then full-width boxes: the add form first, the table second. Example
+
detail
page_head, then columns: is-3 side menu + is-9 boxes. Example
+
form
page_head, then boxes in equal columns, one submit row under them. Example
+
signed out
The split brand / form screen, no navbar. Example
+
+
+
+ +
+
+
+
01

postmark

Worst status of the page's checks
+
+
+
SELFPOSTOK6 OF 6 CHECKS
+
SELFPOSTWARN1 OF 6 CHECKS
+
DNSFAIL1 OF 3 RECORDS
+
+

Only inside page_head, at most one per page, and only where the page has a verdict to give.

+
+
+ +
+
03

status_tag

One mapping, one function
+ + + + + + + + + +
StatusTagBulma classes
ok, published, running, sentoktag is-success is-light
warn, deferred, quietwarntag is-warning is-light
error, fail, mismatch, bouncedfailtag is-danger is-light
unknown, off, neutral countsofftag is-light
role: globalglobaltag is-primary is-light
+
+
+ +
+
+
02

box · box_head

The end slot: a note, a tag, a link or a filter form
+

box_body — padded content. A table or a run of sp-record blocks sits directly in the box, without box_body.

+
box_foot — a legend, pagingend slot
+
+
+

box · sp-danger-zone

+

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.

+
+
+

box · sp-credential

Shown once
+

Only for a secret the panel will never show again.

+
+
+
+ +
+
04

health_card

Overview only · each card links to its detail
+ +
+ +
+
+
+
05

dns_record · copy_field

One block per record
+
+

Record

publishedA one-line note
+
+
+
+
+
+
+
+
+
+

Record

mismatch"In DNS now" appears only when it differs
+
+
+

What is wrong and the exact fix.

+
+
+ +
+
07

Table conventions

+ + + + + +
Time, UTCNamesp-clipStatus
09-21 14:01:52the row's linkA long subject is clipped with an ellipsis instead of wrapping the rowsentDetailsDelete
+
Page 1 of 9Older →
+
+ +
+
09

log_pane

sp-d time · sp-w warning · sp-e error
+
13:58:07 an ordinary line
+13:58:09 a deferred delivery — sp-w
+13:57:41 a reject or a bounce — sp-e
+
+
+ +
+
+
06

facts

+
+
+
Label
Value
+
Mono value
4XcB7k2Jm9z1
+
sp-big
5 days
a supporting line
+
+
+
+ +
+
08

timeline

+
+
    +
  1. Done — default
  2. +
  3. Deferred — sp-warn
  4. +
  5. Bounced — sp-fail
  6. +
  7. Expected next — sp-pending
  8. +
+
+
+ +
+
10

empty_state

+
One sentence: what is absent and why that is fine.
+
+ +
+
11

confirm_list

+
+
    +
  • each thing that will be lost, one per line;
  • +
  • inside an sp-danger-zone box, above the danger button.
  • +
+
+
+
+
+ +
+
+ +
+
+
+
12

Form rows · route · help_topic

+
+
+
+
+
+
+
+
+

Help text lives in Bulma's help.

+
+
+

from@example.orgto@example.netdeferred

+
+

help_topic

A heading and a paragraph; topics stack inside one box on the Help page.

+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/dmarc-domain.html b/docs/assets/panel-redesign/panel/src/dmarc-domain.html index 631b2a7..685c682 100644 --- a/docs/assets/panel-redesign/panel/src/dmarc-domain.html +++ b/docs/assets/panel-redesign/panel/src/dmarc-domain.html @@ -1,6 +1,6 @@
-
DMARC99.6%PASS · 7 DAYS
+
DMARC99.6%PASS · 7 DAYS

DMARC reports /

shop.example.org

diff --git a/docs/assets/panel-redesign/panel/src/health.html b/docs/assets/panel-redesign/panel/src/health.html index 9d76580..0a75ad7 100644 --- a/docs/assets/panel-redesign/panel/src/health.html +++ b/docs/assets/panel-redesign/panel/src/health.html @@ -48,7 +48,7 @@
Expires
2026-10-03 09:14 UTC
Names
mail.example.org
-

Expires in 12 days — renewal has not run.

+

Expires in 12 days — renewal has not run.

Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for acme lines in the system log.

diff --git a/docs/assets/panel-redesign/panel/src/in-domain-delete.html b/docs/assets/panel-redesign/panel/src/in-domain-delete.html index fdaa486..ff0d89a 100644 --- a/docs/assets/panel-redesign/panel/src/in-domain-delete.html +++ b/docs/assets/panel-redesign/panel/src/in-domain-delete.html @@ -8,7 +8,7 @@
-
+

This cannot be undone

Deleting the inbound domain lists.example.org will:

diff --git a/docs/assets/panel-redesign/panel/src/in-queue.html b/docs/assets/panel-redesign/panel/src/in-queue.html index 42bffff..6e721c4 100644 --- a/docs/assets/panel-redesign/panel/src/in-queue.html +++ b/docs/assets/panel-redesign/panel/src/in-queue.html @@ -16,10 +16,10 @@
02

How retries work

This Postfix's policy, read at panel start
-
First retry
5 min
-
Later retries
doubling
capped at 66 min
-
Kept in queue
5 days
-
Then
bounced
back to the original sender
+
First retry
5 min
+
Later retries
doubling
capped at 66 min
+
Kept in queue
5 days
+
Then
bounced
back to the original sender
diff --git a/docs/assets/panel-redesign/panel/src/out-app-created.html b/docs/assets/panel-redesign/panel/src/out-app-created.html index 294e2de..11bccdb 100644 --- a/docs/assets/panel-redesign/panel/src/out-app-created.html +++ b/docs/assets/panel-redesign/panel/src/out-app-created.html @@ -8,7 +8,7 @@
-
+

Shown once

Not stored — copy it now
diff --git a/docs/assets/panel-redesign/panel/src/out-domain-delete.html b/docs/assets/panel-redesign/panel/src/out-domain-delete.html index 5da34d6..f3156a7 100644 --- a/docs/assets/panel-redesign/panel/src/out-domain-delete.html +++ b/docs/assets/panel-redesign/panel/src/out-domain-delete.html @@ -8,7 +8,7 @@
-
+

This cannot be undone

Deleting example.org will:

diff --git a/docs/assets/panel-redesign/panel/src/out-domain-settings.html b/docs/assets/panel-redesign/panel/src/out-domain-settings.html index 9dc076e..f8b8d24 100644 --- a/docs/assets/panel-redesign/panel/src/out-domain-settings.html +++ b/docs/assets/panel-redesign/panel/src/out-domain-settings.html @@ -63,7 +63,7 @@
-
+
04

Delete domain

Removes the DKIM key and both applications. Asks once more before doing it.

diff --git a/docs/assets/panel-redesign/panel/src/out-domain.html b/docs/assets/panel-redesign/panel/src/out-domain.html index 66eddf4..7827b1e 100644 --- a/docs/assets/panel-redesign/panel/src/out-domain.html +++ b/docs/assets/panel-redesign/panel/src/out-domain.html @@ -1,6 +1,6 @@
-
DNSFAIL1 OF 3 RECORDS
+
DNSFAIL1 OF 3 RECORDS

Outbound domains /

example.org

diff --git a/docs/assets/panel-redesign/panel/src/out-message.html b/docs/assets/panel-redesign/panel/src/out-message.html index cd479e0..cdb30bc 100644 --- a/docs/assets/panel-redesign/panel/src/out-message.html +++ b/docs/assets/panel-redesign/panel/src/out-message.html @@ -29,8 +29,8 @@
  1. Accepted from alerts at 198.51.100.7
  2. -
  3. Deferred — the receiving server asked to retry later
  4. -
  5. Still in the queue; kept for 5 days, then bounced
  6. +
  7. Deferred — the receiving server asked to retry later
  8. +
  9. Still in the queue; kept for 5 days, then bounced
@@ -39,8 +39,8 @@
03

Delivery log

Lines from mail.log with this queue id
-
13:58:07 postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts
-13:58:07 postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=<a81f0c@alertmanager>
-13:58:07 opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org)
-13:58:09 postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, 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)
+
13:58:07 postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts
+13:58:07 postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=<a81f0c@alertmanager>
+13:58:07 opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org)
+13:58:09 postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, 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)
diff --git a/docs/assets/panel-redesign/panel/src/out-queue.html b/docs/assets/panel-redesign/panel/src/out-queue.html index 0e7847d..65c8195 100644 --- a/docs/assets/panel-redesign/panel/src/out-queue.html +++ b/docs/assets/panel-redesign/panel/src/out-queue.html @@ -21,10 +21,10 @@
02

How retries work

This Postfix's policy, read at panel start
-
First retry
5 min
-
Later retries
doubling
capped at 66 min
-
Kept in queue
5 days
-
Then
bounced
no attempt counter, only time
+
First retry
5 min
+
Later retries
doubling
capped at 66 min
+
Kept in queue
5 days
+
Then
bounced
no attempt counter, only time
diff --git a/docs/assets/panel-redesign/panel/src/overview.html b/docs/assets/panel-redesign/panel/src/overview.html index 07838a7..4fadf8f 100644 --- a/docs/assets/panel-redesign/panel/src/overview.html +++ b/docs/assets/panel-redesign/panel/src/overview.html @@ -1,6 +1,6 @@
-
SELFPOSTWARN1 OF 6 CHECKS
+
SELFPOSTWARN1 OF 6 CHECKS

mail.example.org

Mail is flowing. One thing needs attention.

@@ -14,7 +14,7 @@
Machine
CPU 12 % · RAM 41 %
↓ 18 kB/s · ↑ 44 kB/s
Processes
5 of 5 running
up 18 days
- TLS certificate
Expires in 12 days
renewal has not run
+ TLS certificate
Expires in 12 days
renewal has not run
Queues
Both empty
outbound and inbound
Milter sockets
2 of 2 answering
opendkim, journal
Reverse DNS
Forward = reverse
203.0.113.25
diff --git a/docs/assets/panel-redesign/panel/src/settings.html b/docs/assets/panel-redesign/panel/src/settings.html index 2bbb481..ea3da03 100644 --- a/docs/assets/panel-redesign/panel/src/settings.html +++ b/docs/assets/panel-redesign/panel/src/settings.html @@ -44,7 +44,7 @@
03

Sending rate limits

Read-only
-
Level 1 · per client IP
600 / h
Set in .env; restart to change. Nothing below may exceed it.
+
Level 1 · per client IP
600 / h
Set in .env; restart to change. Nothing below may exceed it.
Level 2 · domain, application
Set on each domain and application.
diff --git a/docs/assets/panel-redesign/panel/src/system-log.html b/docs/assets/panel-redesign/panel/src/system-log.html index 81a66ff..c0a37ee 100644 --- a/docs/assets/panel-redesign/panel/src/system-log.html +++ b/docs/assets/panel-redesign/panel/src/system-log.html @@ -9,14 +9,14 @@
01

Recent lines

Last 200 · refreshes every 10 s
-
Sep 21 14:01:52 postfix/smtp[2131]: 4XcB9q0Lw2z1: to=<m.keller@gmx.de>, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
-Sep 21 14:01:52 postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
-Sep 21 14:00:18 postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
-Sep 21 14:00:18 postfix/smtp[2130]: 4XcB8z5Tn1z1: to=<dev@lists.example.org>, relay=mx.internal[10.0.4.2]:25, status=sent (250 2.0.0 Ok: queued)
-Sep 21 13:58:09 postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, relay=mx1.example.org[192.0.2.10]:25, status=deferred (451 4.7.1 Greylisted, try again in 5 minutes)
-Sep 21 13:57:41 postfix/smtpd[2119]: NOQUEUE: reject: RCPT from bulk.example[198.51.100.90]: 550 5.1.1 <sales@lists.example.org>: Recipient address rejected: unknown recipient
-Sep 21 13:44:02 postfix/smtp[2101]: 4XcB2m7Hd4z1: to=<jobs@acme.io>, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)
-Sep 21 13:41:19 postfix/smtp[2098]: 4XcB1c3Rk8z1: to=<j.doe@nonexistent.example>, status=bounced (Host or domain name not found)
-Sep 21 13:40:02 journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM
-Sep 21 13:00:00 opendkim[39]: key table reloaded (3 domains)
+
Sep 21 14:01:52 postfix/smtp[2131]: 4XcB9q0Lw2z1: to=<m.keller@gmx.de>, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
+Sep 21 14:01:52 postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
+Sep 21 14:00:18 postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
+Sep 21 14:00:18 postfix/smtp[2130]: 4XcB8z5Tn1z1: to=<dev@lists.example.org>, relay=mx.internal[10.0.4.2]:25, status=sent (250 2.0.0 Ok: queued)
+Sep 21 13:58:09 postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, relay=mx1.example.org[192.0.2.10]:25, status=deferred (451 4.7.1 Greylisted, try again in 5 minutes)
+Sep 21 13:57:41 postfix/smtpd[2119]: NOQUEUE: reject: RCPT from bulk.example[198.51.100.90]: 550 5.1.1 <sales@lists.example.org>: Recipient address rejected: unknown recipient
+Sep 21 13:44:02 postfix/smtp[2101]: 4XcB2m7Hd4z1: to=<jobs@acme.io>, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)
+Sep 21 13:41:19 postfix/smtp[2098]: 4XcB1c3Rk8z1: to=<j.doe@nonexistent.example>, status=bounced (Host or domain name not found)
+Sep 21 13:40:02 journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM
+Sep 21 13:00:00 opendkim[39]: key table reloaded (3 domains)
diff --git a/docs/assets/panel-redesign/panel/src/user-delete.html b/docs/assets/panel-redesign/panel/src/user-delete.html index 8ec8e58..ae7a7eb 100644 --- a/docs/assets/panel-redesign/panel/src/user-delete.html +++ b/docs/assets/panel-redesign/panel/src/user-delete.html @@ -8,7 +8,7 @@
-
+

This cannot be undone

Deleting the panel user shop-team will:

diff --git a/docs/assets/panel-redesign/panel/system-log.html b/docs/assets/panel-redesign/panel/system-log.html index 57c76f6..584c5d4 100644 --- a/docs/assets/panel-redesign/panel/system-log.html +++ b/docs/assets/panel-redesign/panel/system-log.html @@ -72,16 +72,16 @@
01

Recent lines

Last 200 · refreshes every 10 s
-
Sep 21 14:01:52 postfix/smtp[2131]: 4XcB9q0Lw2z1: to=<m.keller@gmx.de>, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
-Sep 21 14:01:52 postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
-Sep 21 14:00:18 postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
-Sep 21 14:00:18 postfix/smtp[2130]: 4XcB8z5Tn1z1: to=<dev@lists.example.org>, relay=mx.internal[10.0.4.2]:25, status=sent (250 2.0.0 Ok: queued)
-Sep 21 13:58:09 postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, relay=mx1.example.org[192.0.2.10]:25, status=deferred (451 4.7.1 Greylisted, try again in 5 minutes)
-Sep 21 13:57:41 postfix/smtpd[2119]: NOQUEUE: reject: RCPT from bulk.example[198.51.100.90]: 550 5.1.1 <sales@lists.example.org>: Recipient address rejected: unknown recipient
-Sep 21 13:44:02 postfix/smtp[2101]: 4XcB2m7Hd4z1: to=<jobs@acme.io>, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)
-Sep 21 13:41:19 postfix/smtp[2098]: 4XcB1c3Rk8z1: to=<j.doe@nonexistent.example>, status=bounced (Host or domain name not found)
-Sep 21 13:40:02 journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM
-Sep 21 13:00:00 opendkim[39]: key table reloaded (3 domains)
+
Sep 21 14:01:52 postfix/smtp[2131]: 4XcB9q0Lw2z1: to=<m.keller@gmx.de>, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
+Sep 21 14:01:52 postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
+Sep 21 14:00:18 postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
+Sep 21 14:00:18 postfix/smtp[2130]: 4XcB8z5Tn1z1: to=<dev@lists.example.org>, relay=mx.internal[10.0.4.2]:25, status=sent (250 2.0.0 Ok: queued)
+Sep 21 13:58:09 postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, relay=mx1.example.org[192.0.2.10]:25, status=deferred (451 4.7.1 Greylisted, try again in 5 minutes)
+Sep 21 13:57:41 postfix/smtpd[2119]: NOQUEUE: reject: RCPT from bulk.example[198.51.100.90]: 550 5.1.1 <sales@lists.example.org>: Recipient address rejected: unknown recipient
+Sep 21 13:44:02 postfix/smtp[2101]: 4XcB2m7Hd4z1: to=<jobs@acme.io>, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)
+Sep 21 13:41:19 postfix/smtp[2098]: 4XcB1c3Rk8z1: to=<j.doe@nonexistent.example>, status=bounced (Host or domain name not found)
+Sep 21 13:40:02 journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM
+Sep 21 13:00:00 opendkim[39]: key table reloaded (3 domains)
diff --git a/docs/assets/panel-redesign/panel/theme.css b/docs/assets/panel-redesign/panel/theme.css index 555f783..3a83c0c 100644 --- a/docs/assets/panel-redesign/panel/theme.css +++ b/docs/assets/panel-redesign/panel/theme.css @@ -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; } } diff --git a/docs/assets/panel-redesign/panel/user-delete.html b/docs/assets/panel-redesign/panel/user-delete.html index c2229e0..e0f5783 100644 --- a/docs/assets/panel-redesign/panel/user-delete.html +++ b/docs/assets/panel-redesign/panel/user-delete.html @@ -71,7 +71,7 @@
-
+

This cannot be undone

Deleting the panel user shop-team will:

diff --git a/docs/plans/panel-redesign.md b/docs/plans/panel-redesign.md index 2713e5d..d9c28ff 100644 --- a/docs/plans/panel-redesign.md +++ b/docs/plans/panel-redesign.md @@ -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, ``). 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, `

`, 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 `
` / 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 [``](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** diff --git a/docs/roadmap.md b/docs/roadmap.md index 2d3a7ec..f43441f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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. ---