docs: panel header, type scale, account e-mail and the fixes of a full review
test / test (push) Canceled after 0s

Header. The navbar carries the wordmark instead of the stamp - at nav
size the stamp dissolves into the brick bar - and under the bar runs the
stamp's paper margin with the perforation along its outer edge: round
holes, flat teeth, ink hairline, 8px under a 60px bar as on the mark.
docs/assets/selfpost-wordmark.svg and -ink are the stamp's own outlines;
the header comparison sheet is removed.

Type and tables. About 40 % of the text measured under 13px. Body is
16px with two tokens under it, 14px for supporting text and 13px for
upper-case mono labels and tags; Bulma's small sizes are overridden or
dropped (sp-small replaces is-size-7, is-small only on a progress bar).
Every table sits in a table-container, addresses break after the @, and
all screens fit without scrolling at 1280px and up.

Contrast. A sweep of every text node against its real ground found the
solid red button at 3:1, the muted grey at 4.1:1, the amber postmark and
Bulma's menu labels just under 4.5. All 33 screens now measure 4.5:1 or
better. The sweep is kept as panel/audit.js and its clean output joins
the evidence a restyle step must show.

Account and roles. A user's e-mail is its own field (users.email in the
2.0 baseline), edited under Account and seen by the global user on the
user form and list. The DMARC default report address is a user's
setting, not the server's: it moves to Account with its authorization
record, and a domain follows the default of a named user. Roles are
labelled by reach - global, domain - and each assignment list gains All,
a per-user flag that covers domains added later. Account gains
Notifications for the new roadmap candidate panel-notifications;
password-reset is added as a candidate too.

Also: the inbound spam filter is visible where a domain user looks (a
box on each inbound domain, a tag on the list, its socket on Health);
the health row is compact again and splits 6/3/2; a read-only value is
as tall as its Copy button; Preflight and Account lose boxes that only
filled space, and the plan's layout rule and contract now forbid them.

check.py gains the type floor, the table-container rule and the is-small
rule, and no longer counts min-width: 0 as setting a width.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
mix
2026-09-22 00:05:32 +03:00
co-authored by Claude Fable 5.1
parent 4921416384
commit d83d326217
72 changed files with 970 additions and 624 deletions
+90 -15
View File
@@ -17,6 +17,7 @@ design itself is drawn and accepted:
| [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 |
| [panel/audit.js](../assets/panel-redesign/panel/audit.js) | what only a browser can measure: text size, contrast, overflow, clipped values — pasted into the console, on the mockups or on the running panel |
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.
@@ -55,7 +56,7 @@ Navigation is grouped by mail direction. Page titles carry the group
| 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` |
| User menu | Account · Help · Sign out | Account is the user's half of `/settings`: credentials, e-mail, DMARC default |
A hidden page leaves no gap in the menu. The inbound and DMARC feature flags
stay as in [web.go](../../internal/web/web.go); who sees what changes — see
@@ -86,7 +87,7 @@ too, and that is granted separately from outbound.** Today every inbound
handler is `requireGlobal()` and `user_domains` references outbound domains
only.
| | Global administrator | Domain administrator |
| | `global` | `domain` |
|---|---|---|
| Overview, Server | yes | no (404) |
| Outbound: domain, applications, settings, log, DMARC | all domains | assigned outbound domains |
@@ -100,13 +101,53 @@ only.
- New table `user_inbound_domains (user_id, inbound_domain_id)`, mirroring
`user_domains`; both live in the 2.0 baseline. The same name on both lists
is two assignments — the two kinds of domain stay separate entities.
- The user form has two lists (mockup `user.html`); a domain administrator
needs at least one assignment on either.
- The user form has two lists (mockup `user.html`); the *domain* role needs at
least one assignment on either.
- **Each list has *All*, which includes domains added later** — a flag per
user and direction (`users.all_domains`, `users.all_inbound_domains`), not a
tick on every row: otherwise tomorrow's domain is silently unassigned. *All*
widens the list, not the role: adding and deleting domains, the queues and
Server stay with the global role. The form works without scripts — with
*All* ticked the server ignores the rows below it.
- **The role is a reach, not a rank.** Everyone in the panel is an
administrator, so the label is `global` or `domain` — in the users list, the
role select and the page kickers; never "admin".
- A group with nothing assigned is absent from that user's menu.
- **The spam filter is visible to whoever it affects.** Whether the instance
filters inbound mail is an environment setting, so a domain administrator
cannot look it up: every inbound domain page has a *Spam filter* box (on /
off, engine, what happens to suspicious mail, who keeps the lists), the
inbound domains list shows it in its head, and for the global administrator
the filter's socket is one of the milter sockets on Health and Overview.
- Authorization helpers come in pairs: `requireDomain(id)` and
`requireInboundDomain(id)`; lists and logs are filtered by assignment in the
query, not in the template.
## Account e-mail
**Decided 2026-09-21 (owner): a user's e-mail is theirs, not a DMARC field.**
Today the column is `users.dmarc_report_email` and the only place it shows is
the DMARC card, so it reads as if it existed for that alone.
- The 2.0 baseline has `users.email`. It is what the panel writes to: event
notifications ([panel-notifications](../roadmap.md#panel-notifications)) and
a reset link ([password-reset](../roadmap.md#password-reset)).
- **Account** edits it; the global administrator sees and sets it on the user
form and sees it in the users list.
- DMARC only *may* use it. **The default report address is a user's setting,
not the server's** (owner, 2026-09-21): it lives on **Account** with its
report-authorization record, as a choice — SelfPost hosted, *my account
e-mail*, another address, none. Historically it was the administrator's
profile field, later mirrored into an instance setting only the global user
could edit; Server › Settings keeps nothing of it.
- A domain can have several users, so "the default" must be someone's. A
domain's report address is *the default of a named user* (shown as
`admin's default — mix@example.org`), the hosted address, a custom one or
none. A new domain follows whoever created it; any user of the domain may
switch it to their own default. If that user is deleted the domain falls
to *none* and its DMARC check says so. Baseline: `users.dmarc_default_mode`,
`users.dmarc_default_address`, `domains.dmarc_rua_user_id`.
## Routes
**Decided 2026-09-21 (owner): paths are renamed to follow the new names and
@@ -135,8 +176,8 @@ list, an action under the thing it changes.
| system-log | `GET /server/log` | `/system-log` |
| backup | `GET, POST /server/backup` · `POST /server/backup/import` | `/backup` · `POST /domains/import` |
| users · user · user-delete | `/server/users` · `…/new` · `…/{uid}` · `…/{uid}/delete` | `/users/…` |
| settings | `GET, POST /server/settings` | the instance half of `/settings` |
| account | `GET, POST /account` | the credentials half of `/settings` (today `/account` redirects there) |
| settings | `GET, POST /server/settings` | the instance half of `/settings`: log retention, rate limits; plus the notifications switch |
| account | `GET /account` · `POST /account/profile`, `/password`, `/notifications`, `/dmarc` | `/settings`: credentials and the DMARC default (today `/account` redirects there) |
| help · components | `GET /help` · `GET /server/components` | `/help` · — |
Unchanged: `/healthz`, `/license`, `/static/`, `/setup/{token}`, `/login`,
@@ -191,6 +232,8 @@ the first step of that feature's plan — not invented during implementation.
| inbound-antispam-panel (agreed, `1.10.0`) | journal = **Inbound › Log** (add client IP and engine to the mockup's columns; `quarantine` joins the decisions); lists = new **Inbound › Filter lists**, global-only because the lists are instance-wide | list layout: add form + two tables, `status_tag`, filter form in `box_head`; the log links *Allow / Deny sender* to the lists | `in-log`, `in-filter-lists` |
| inbound-quarantine (candidate) | new **Inbound › Quarantine**, list + detail with *Release* / *Discard* | list layout; detail like `out-message` with `facts`, `log_pane` for headers only, Release beside a `sp-danger-zone` Discard | `in-quarantine`, `in-quarantine-message` — the open questions of its plan (where mail lives, what release means, retention, RBAC) stay open; the mockup assumes SelfPost-held mail, release to upstream, 14 days, delegated like the rest of Inbound |
| preflight (candidate) | new **Server › Preflight**, next to Health: Health is what runs now, Preflight is the deeper on-demand installation check with a test e-mail form | `postmark` verdict, a table of checks with `status_tag` and a *What to do* column, one form box for the test e-mail | `preflight` |
| panel-notifications (candidate) | per-user event choice on **Account**; one switch under **Server › Settings** | checkboxes in a box, `help` | `account`, `settings` |
| password-reset (candidate) | a link on sign-in, two signed-out screens (request, set new password) | the signed-out layout | **no** |
| csrf-tokens | no screen; every form gains a hidden field — forms are rendered through one helper so the token cannot be forgotten | — | n/a |
| template-data-typing | no screen; the component partials are the natural first users of typed data | — | n/a |
| structured-logging, contributing, review follow-ups | none | — | n/a |
@@ -226,8 +269,31 @@ migration to the 1.x chain as planned; the 2.0 baseline then folds it in.
| `timeline`, `log_pane`, `empty_state`, `confirm_list`, `help_topic` | as on the components page |
| form rows | `sp-row` (wide + short), `sp-pair` (two equal) |
The shell — brick navbar with hover menus, perforated edge, sibling-page
strip, footer — belongs to `layout.html` alone.
The shell belongs to `layout.html` alone: a brick navbar with hover menus
that carries the **wordmark, not the stamp** (at nav size the stamp dissolves
into the bar; it stays on sign-in, setup and the favicon); under it the
stamp's paper margin with the perforation along its outer edge — round holes,
flat teeth, the mark's ink hairline, one tile `perf-edge.svg`; then the
sibling-page strip; and the footer.
## Type and tables
- **Three sizes.** Body 16px; `--sp-fs-s` 14px for supporting text (labels,
`help`, notes, mono values, logs); `--sp-fs-xs` 13px for upper-case mono
micro-labels and tags only. Nothing a person has to read is under 13px.
Bulma's own small sizes are overridden (`help`, `tag`, `label`,
`menu-label`, the navbar dropdown) or not used (`is-small` on a control,
`is-size-7` — `sp-small` instead). `check.py` enforces the floor.
- **Contrast.** Text is at least 4.5:1 against the ground it actually sits on
(3:1 for large text). Two traps are closed in the theme: Bulma picks a solid
button's text colour from the fill's lightness, and the brick and the red
land on the wrong side — solid buttons are white on colour; and the muted
grey is `#5c6774`, not the 4.1:1 `#6b7684` of the old panel.
- **A table sits in a `table-container`**, so a wide one scrolls inside its
box instead of being cut off. Addresses in table cells get a `<wbr>` after
the `@` from one template function, so a column can shrink without breaking a
name mid-word. Screens fit without scrolling at 1280px and up; below that a
dense log scrolls inside its box.
## Layout rules
@@ -239,7 +305,11 @@ A page is exactly one of:
- **signed out** — the split brand / form screen.
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.
`max-width`; it gets a column. **What stands beside it must be real** — the
form's own result, the facts it changes, a second form. A box written to fill
the space beside another is the same defect as a width tuned by hand, and the
first draft of Preflight had one. If nothing real belongs there, the form
shares its box with its result, or the page uses fewer columns.
## The contract
@@ -249,17 +319,20 @@ An implementer — human or model — **must not**:
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
3. Add a box whose only job is to fill space, or text that restates the page.
Every box answers a question the operator has on that page.
4. Put a selector in `panel.css` that names a page or an element id, uses
`!important`, sets a width outside the `WIDTH_OK` list, or a font size under
the 13px floor.
5. 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
6. 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
7. 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
8. Report a step as done on the strength of a description. Done means the
evidence below exists.
**Changing the design** is allowed and has one route: mockup first. Edit the
@@ -291,6 +364,8 @@ or the guard tests.
Every checklist step that restyles pages ends with, in the commit:
- `go test ./internal/web/...` output in the commit message body (pass);
- the output of `panel/audit.js` run against the restyled pages of the running
container, with an empty `problems` list;
- 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;