panel-redesign: the component kit - panel.css, partials, shell, kit page
Stage 0, steps 3-5, in one commit: the three were built together (the kit
page is what exercises the partials and the shell) and do not split into
states that build on their own. Written by Sonnet, reviewed by Opus.
- static/panel.css: shared/brand.css + panel/theme.css, rule for rule.
wordmark.svg and perf-edge.svg beside it; the stamp is logo.svg.
- templates/components.html: every partial of the plan's Three layers,
with its typed input in components.go. Wrappers are open/close pairs
(box_open/box_close, body_open/body_close, record_open/record_close).
Rich text is a Text value built from escaped runs - no field is
trusted HTML. status_tag and wbr_at are template functions; copy_field
is a partial whose button panel.js wires through data-copy.
- templates/layout.html: the shell as build.py draws it, the menu paths
written out literally, visibility from a typed Shell (role, feature
flags, the user's reach); the signed-out screen as a second define.
Not in the 2.0 menu: Inbound Log / Queue / Quarantine / Filter lists
and Preflight.
- GET /server/components (global role, 404 otherwise): the mockup
components page rebuilt from fixtures (kit.go) by the real templates.
- The old panel steps aside unchanged: panel.css -> legacy.css,
layout.html -> layout_legacy.html; a page uses the new layout iff it
is off legacy_pages.txt.
- legacy_pages.txt: @kit removed - the kit is under the guards for good.
Differences from the mockup components page, for the owner:
- the head button is Help, not "Cover sheet"; two "Example" links are
dropped (no fixed URL), two point at /outbound/domains and /login;
- the page sits in the Server group, so the sibling strip shows;
- Sign out is a POST form whose button carries navbar-item: same size
and type as the links, but without Bulma's hover tint;
- a head's actions are always wrapped in div.buttons.
Evidence
- docs/assets/panel-redesign/evidence/components.png: 1440 px wide, full
height, headless Edge. Taken from the real templates served locally,
NOT from the container (no Docker on this machine); the page reads
only its own fixtures and embedded assets.
- panel/audit.js on the kit page, 1440 and 1280:
{"pages":1,"widths":[1440,1280],"textsChecked":516,"problems":[]}
- go test ./internal/web/... (with @kit removed)
ok github.com/mixeme/selfpost/internal/web
ok github.com/mixeme/selfpost/internal/web/auth
ok github.com/mixeme/selfpost/internal/web/handlers
ok github.com/mixeme/selfpost/internal/web/validate
ok github.com/mixeme/selfpost/internal/web/view
TestRoutesFollowNavigation stays skipped behind @routes (stage 1).
Roadmap 10/27.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,509 @@
|
||||
package view
|
||||
|
||||
// The typed input of every partial in templates/components.html, and of the
|
||||
// shell in templates/layout.html (docs/plans/panel-redesign.md § Component kit).
|
||||
// A partial's input is part of its definition: components.html documents the
|
||||
// look, this file the data. A page template passes one of these structs to its
|
||||
// partial — it never assembles the input with dict — and the handler (or a
|
||||
// fixture) fills it. Anything a person could type is a plain string and is
|
||||
// escaped; markup a field can carry — a link, a code span — is a Text, never
|
||||
// trusted HTML.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Levels a postmark, a health card or a timeline step can have. The empty
|
||||
// string is the default look (ok); "pending" is a timeline step that has not
|
||||
// happened yet.
|
||||
const (
|
||||
LevelOK = "ok"
|
||||
LevelWarn = "warn"
|
||||
LevelFail = "fail"
|
||||
LevelPending = "pending"
|
||||
)
|
||||
|
||||
// Box variants.
|
||||
const (
|
||||
// BoxDanger is the sp-danger-zone box: a destructive confirmation or the
|
||||
// "delete" entry of a settings page.
|
||||
BoxDanger = "danger"
|
||||
// BoxCredential is the sp-credential box: a secret the panel will never
|
||||
// show again.
|
||||
BoxCredential = "credential"
|
||||
)
|
||||
|
||||
// Inline is one piece of a Text. Exactly one kind applies, in this order: a
|
||||
// line break, a tag, then text — which a link wraps when Href is set, and which
|
||||
// is code, a mono value, strong or emphasised when the matching flag is.
|
||||
type Inline struct {
|
||||
Text string
|
||||
Href string
|
||||
Code bool
|
||||
Mono bool
|
||||
Strong bool
|
||||
Em bool
|
||||
Tag *Tag
|
||||
Break bool
|
||||
}
|
||||
|
||||
// Text is a run of inline content: what a lead, a box's end slot, a note, a
|
||||
// fact's value or a help paragraph holds when it is more than one plain string.
|
||||
// It is rendered by the "inline" partial. Build it with Rich.
|
||||
type Text []Inline
|
||||
|
||||
// Rich builds a Text from strings (plain text), Inlines (Code, Link, …) and
|
||||
// other Texts. Anything else is printed as text, so a number can be passed.
|
||||
func Rich(parts ...any) Text {
|
||||
var t Text
|
||||
for _, p := range parts {
|
||||
switch v := p.(type) {
|
||||
case string:
|
||||
t = append(t, Inline{Text: v})
|
||||
case Inline:
|
||||
t = append(t, v)
|
||||
case Text:
|
||||
t = append(t, v...)
|
||||
default:
|
||||
t = append(t, Inline{Text: fmt.Sprint(v)})
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Plain is a Text of one plain string — the common case.
|
||||
func Plain(s string) Text { return Text{{Text: s}} }
|
||||
|
||||
// Code is a <code> span.
|
||||
func Code(s string) Inline { return Inline{Text: s, Code: true} }
|
||||
|
||||
// Mono is a value set in the mono face.
|
||||
func Mono(s string) Inline { return Inline{Text: s, Mono: true} }
|
||||
|
||||
// Strong is a <strong> span.
|
||||
func Strong(s string) Inline { return Inline{Text: s, Strong: true} }
|
||||
|
||||
// Em is an <em> span.
|
||||
func Em(s string) Inline { return Inline{Text: s, Em: true} }
|
||||
|
||||
// Link is a link with plain text.
|
||||
func Link(href, label string) Inline { return Inline{Text: label, Href: href} }
|
||||
|
||||
// Br is a line break.
|
||||
func Br() Inline { return Inline{Break: true} }
|
||||
|
||||
// TagOf is a status tag inside a Text.
|
||||
func TagOf(status, label string) Inline { return Inline{Tag: &Tag{Status: status, Label: label}} }
|
||||
|
||||
// Tag is a status tag (partial "tag", function status_tag): the colour comes
|
||||
// from Status, the text from Label — the status itself when Label is empty. It
|
||||
// is the small coloured word in a table cell, a box's end slot or a menu entry.
|
||||
type Tag struct {
|
||||
Status string
|
||||
Label string
|
||||
}
|
||||
|
||||
// statusClass is the one mapping of status to Bulma tag classes — box 03 of the
|
||||
// kit page. A page passes the status it knows (a DKIM check says ok, a message
|
||||
// says deferred) and never picks a colour.
|
||||
var statusClass = map[string]string{
|
||||
"ok": "tag is-success is-light", "published": "tag is-success is-light",
|
||||
"running": "tag is-success is-light", "sent": "tag is-success is-light",
|
||||
|
||||
"warn": "tag is-warning is-light", "deferred": "tag is-warning is-light",
|
||||
"quiet": "tag is-warning is-light",
|
||||
|
||||
"error": "tag is-danger is-light", "fail": "tag is-danger is-light",
|
||||
"mismatch": "tag is-danger is-light", "bounced": "tag is-danger is-light",
|
||||
|
||||
"quarantine": "tag is-primary is-light", "held": "tag is-primary is-light",
|
||||
"global": "tag is-primary is-light",
|
||||
}
|
||||
|
||||
// neutralTag is a status without a colour: unknown, off, a plain count.
|
||||
const neutralTag = "tag is-light"
|
||||
|
||||
// StatusTag returns the Bulma classes of a status tag (template function
|
||||
// status_tag). A status the table does not know — "off", "unknown", a count, a
|
||||
// word a page invented — is neutral rather than an error: a tag that is merely
|
||||
// grey is a visible prompt to add the word to statusClass (and to the kit page),
|
||||
// where a failing render would take the whole page down for it. Case and
|
||||
// surrounding space do not matter.
|
||||
func StatusTag(status string) string {
|
||||
if c, ok := statusClass[strings.ToLower(strings.TrimSpace(status))]; ok {
|
||||
return c
|
||||
}
|
||||
return neutralTag
|
||||
}
|
||||
|
||||
// wbrAt escapes s and puts a <wbr> after every @ (template function wbr_at):
|
||||
// an address in a table cell breaks where it reads naturally, so the column can
|
||||
// shrink without splitting a name mid-word.
|
||||
func wbrAt(s string) template.HTML {
|
||||
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(s), "@", "@<wbr>"))
|
||||
}
|
||||
|
||||
// Head is the input of page_head, which opens every page: the line above the
|
||||
// title (a section name or the path to this page), the title, a lead or a route,
|
||||
// the postmark when the page gives a verdict, a note on the right (when it was
|
||||
// updated) and the page's buttons.
|
||||
type Head struct {
|
||||
Postmark *Postmark
|
||||
// Kicker is a plain word or two above the title ("Outbound"); Crumbs
|
||||
// replace it with the trail to this page.
|
||||
Kicker string
|
||||
Crumbs []Crumb
|
||||
Title string
|
||||
// Code follows the title in the mono face ("New password for prod-server");
|
||||
// Mono sets the whole title in it; Compact is the smaller h1 of a page
|
||||
// whose title is data (a subject line).
|
||||
Code string
|
||||
Mono bool
|
||||
Compact bool
|
||||
// Lead is the sentence under the title. A Route takes its place on the page
|
||||
// of one message.
|
||||
Lead Text
|
||||
Route Route
|
||||
Note []string // right-hand note, one line each
|
||||
Actions []Action
|
||||
}
|
||||
|
||||
// Crumb is one step of a Head's trail; a step without Href is plain text (the
|
||||
// level that has no page of its own).
|
||||
type Crumb struct {
|
||||
Href string
|
||||
Label string
|
||||
}
|
||||
|
||||
// Action is a button in a Head or a Foot: a link to Href, or a POST form
|
||||
// submitting to Post. Icon is a Tabler class ("ti-refresh").
|
||||
type Action struct {
|
||||
Label string
|
||||
Icon string
|
||||
Href string
|
||||
Post string
|
||||
Primary bool
|
||||
Danger bool
|
||||
}
|
||||
|
||||
// Postmark is the round verdict stamp inside a Head: three lines of imprint
|
||||
// and the Level (ok, warn or fail) that colours it.
|
||||
type Postmark struct {
|
||||
Top string
|
||||
Word string
|
||||
Bottom string
|
||||
Level string
|
||||
}
|
||||
|
||||
// RoutePart is one piece of a Route: a plain value, an arrow, or a tag.
|
||||
type RoutePart struct {
|
||||
Text string
|
||||
Arrow bool
|
||||
Tag *Tag
|
||||
}
|
||||
|
||||
// Route is the from → to line under the title of a message, with its status;
|
||||
// partial "route", used in a Head and in the body of a box.
|
||||
type Route []RoutePart
|
||||
|
||||
// Flash is the input of flash: the result of the last action, between the page
|
||||
// head and the first box. Error makes it the red one; Text may open with a Strong.
|
||||
type Flash struct {
|
||||
Error bool
|
||||
Text Text
|
||||
}
|
||||
|
||||
// Box is the input of box_open and box_head: a box and its numbered mono head.
|
||||
// No is "01"; Icon replaces it when the box is not part of a sequence. End is
|
||||
// the slot at the right of the head (a note, a tag, a link) and Filter takes it
|
||||
// instead as a filter form. Help links the section's topic on the Help page.
|
||||
// ID is the anchor a side menu points at. Variant is BoxDanger or BoxCredential.
|
||||
type Box struct {
|
||||
No string
|
||||
Icon string
|
||||
Title string
|
||||
ID string
|
||||
End Text
|
||||
Filter *Filter
|
||||
Help *HelpLink
|
||||
Variant string
|
||||
}
|
||||
|
||||
// HelpLink is a Box's link to its Help topic.
|
||||
type HelpLink struct {
|
||||
Href string
|
||||
Title string
|
||||
}
|
||||
|
||||
// Filter is the input of filter_form, the filter of a list in a box head: it is
|
||||
// sent with GET to Action.
|
||||
type Filter struct {
|
||||
Action string
|
||||
Selects []Select
|
||||
Dates []DateInput
|
||||
Button string // "Filter" when empty
|
||||
}
|
||||
|
||||
// Select is one select of a Filter. Label is its accessible name.
|
||||
type Select struct {
|
||||
Name string
|
||||
Label string
|
||||
Options []Option
|
||||
Selected string
|
||||
}
|
||||
|
||||
// Option is one choice of a Select; an empty Value means "any".
|
||||
type Option struct {
|
||||
Value string
|
||||
Label string
|
||||
}
|
||||
|
||||
// DateInput is one date field of a Filter.
|
||||
type DateInput struct {
|
||||
Name string
|
||||
Label string
|
||||
Value string
|
||||
}
|
||||
|
||||
// Foot is the input of box_foot, the line under a box's content: Text (a legend
|
||||
// or "Page 1 of 9"), a Link beside it ("Older →"), an End slot at the right, or
|
||||
// instead of all of it one Button that finishes the page. Bare puts Text
|
||||
// directly in the foot instead of in a span, as the legends under tables do.
|
||||
type Foot struct {
|
||||
Text Text
|
||||
Bare bool
|
||||
Link *Anchor
|
||||
End Text
|
||||
Button *Action
|
||||
}
|
||||
|
||||
// Anchor is a plain link: where it goes and what it says.
|
||||
type Anchor struct {
|
||||
Href string
|
||||
Label string
|
||||
}
|
||||
|
||||
// HealthCard is the input of health_card and, as a slice, of health_cards: one
|
||||
// tile of the Overview grid. Name is the check, Value its verdict in words, Sub
|
||||
// one supporting fact; Level is ok, warn or fail; Href is its detail.
|
||||
type HealthCard struct {
|
||||
Name string
|
||||
Value string
|
||||
Sub string
|
||||
Icon string
|
||||
Href string
|
||||
Level string
|
||||
}
|
||||
|
||||
// Record is the input of dns_record: one DNS record. Host and Type say where
|
||||
// and what, Value is what to publish (a textarea of ValueRows lines when it is
|
||||
// long, as a DKIM key is), InDNS what the resolver returned — set only on a
|
||||
// mismatch, with Problem saying what is wrong and the fix.
|
||||
type Record struct {
|
||||
Name string
|
||||
Status Tag
|
||||
Note Text
|
||||
Host string
|
||||
Type string
|
||||
Value string
|
||||
ValueRows int
|
||||
ValueHelp Text
|
||||
InDNS string
|
||||
Problem Text
|
||||
}
|
||||
|
||||
// HostField is the Copy field of the record's host.
|
||||
func (r Record) HostField() CopyField { return CopyField{Label: "Host / name", Value: r.Host} }
|
||||
|
||||
// ValueField is the Copy field of what to publish.
|
||||
func (r Record) ValueField() CopyField {
|
||||
return CopyField{Label: "Value to publish", Value: r.Value, Rows: r.ValueRows, Help: r.ValueHelp}
|
||||
}
|
||||
|
||||
// CopyField is the input of copy_field: a read-only value with its Copy button.
|
||||
// Rows makes it a textarea; Help sits under it. Used in a dns_record and, in a
|
||||
// record_open block, for a secret shown once.
|
||||
type CopyField struct {
|
||||
Label string
|
||||
Value string
|
||||
Rows int
|
||||
Help Text
|
||||
}
|
||||
|
||||
// Fact is the input of facts (as a slice): a label and its value. Mono sets the
|
||||
// value in the mono face; Big is a headline number, with Note as the line under it.
|
||||
type Fact struct {
|
||||
Label string
|
||||
Value Text
|
||||
Mono bool
|
||||
Big bool
|
||||
Note Text
|
||||
}
|
||||
|
||||
// Facts is a shorthand for plain label / value pairs: Facts("Domain",
|
||||
// "example.org", "Port", "587").
|
||||
func Facts(pairs ...string) []Fact {
|
||||
var out []Fact
|
||||
for i := 0; i+1 < len(pairs); i += 2 {
|
||||
out = append(out, Fact{Label: pairs[i], Value: Plain(pairs[i+1])})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SideMenu is the input of side_menu, the left column of a detail page: groups
|
||||
// of entries with icons and count tags.
|
||||
type SideMenu struct {
|
||||
Groups []MenuGroup
|
||||
}
|
||||
|
||||
// MenuGroup is a labelled list of a SideMenu.
|
||||
type MenuGroup struct {
|
||||
Label string
|
||||
Items []MenuItem
|
||||
}
|
||||
|
||||
// MenuItem is one entry of a SideMenu. Active marks the section on screen;
|
||||
// Danger is the entry that deletes; Tag is a count.
|
||||
type MenuItem struct {
|
||||
Label string
|
||||
Icon string
|
||||
Href string
|
||||
Active bool
|
||||
Danger bool
|
||||
Tag *Tag
|
||||
}
|
||||
|
||||
// Step is one entry of a timeline (as a slice): a time, a strong word and the
|
||||
// rest of the sentence. Level is "", warn, fail or pending.
|
||||
type Step struct {
|
||||
Time string
|
||||
Strong string
|
||||
Text Text
|
||||
Level string
|
||||
}
|
||||
|
||||
// LogLine is one line of a log_pane (as a slice). Level is "", warn or error;
|
||||
// Time is the dimmed prefix when the line has one.
|
||||
type LogLine struct {
|
||||
Time string
|
||||
Text string
|
||||
Level string
|
||||
}
|
||||
|
||||
// EmptyState is the input of empty_state: what is absent and why that is fine.
|
||||
type EmptyState struct {
|
||||
Icon string
|
||||
Text Text
|
||||
}
|
||||
|
||||
// HelpTopic is the input of help_topic: one topic of the Help page, a heading
|
||||
// and its paragraphs. ID is the anchor the boxes' help links point at.
|
||||
type HelpTopic struct {
|
||||
ID string
|
||||
Title string
|
||||
Body []Text
|
||||
}
|
||||
|
||||
// Meta is what a page says about itself to the shell. A page's data embeds it
|
||||
// (Engine.Render reads it through ShellMeta); a handler that still passes a map
|
||||
// gives the same names as keys: Title, User, IsGlobal, HasOutbound, HasInbound,
|
||||
// Section and Page.
|
||||
//
|
||||
// Section is the group the page belongs to — overview, outbound, inbound,
|
||||
// server, or user for Account and Help — and Page the entry within it
|
||||
// (domains, log, queue, dmarc, health, backup, users, settings…): the first
|
||||
// highlights the menu, the second the entry and the sibling strip. A domain
|
||||
// administrator's reach is HasOutbound and HasInbound: whether anything of that
|
||||
// direction is assigned to them (ignored for the global role, who has it all).
|
||||
type Meta struct {
|
||||
Title string
|
||||
User string
|
||||
IsGlobal bool
|
||||
HasOutbound bool
|
||||
HasInbound bool
|
||||
Section string
|
||||
Page string
|
||||
}
|
||||
|
||||
// ShellMeta lets a page's data that embeds Meta be read by the engine.
|
||||
func (m Meta) ShellMeta() Meta { return m }
|
||||
|
||||
// shellMeta is implemented by Meta, and so by every page that embeds it.
|
||||
type shellMeta interface{ ShellMeta() Meta }
|
||||
|
||||
// metaOf reads a page's Meta from its data: a struct that embeds Meta, or the
|
||||
// map a handler builds.
|
||||
func metaOf(data any) Meta {
|
||||
switch d := data.(type) {
|
||||
case shellMeta:
|
||||
return d.ShellMeta()
|
||||
case map[string]any:
|
||||
str := func(k string) string { s, _ := d[k].(string); return s }
|
||||
flag := func(k string) bool { b, _ := d[k].(bool); return b }
|
||||
return Meta{
|
||||
Title: str("Title"), User: str("User"), IsGlobal: flag("IsGlobal"),
|
||||
HasOutbound: flag("HasOutbound"), HasInbound: flag("HasInbound"),
|
||||
Section: str("Section"), Page: str("Page"),
|
||||
}
|
||||
}
|
||||
return Meta{}
|
||||
}
|
||||
|
||||
// Shell is the input of layout.html: what the navbar, the sibling strip and the
|
||||
// footer show. The menu's paths are in the template; whether an entry is there
|
||||
// is decided here, once, from the role and the feature flags (plan § Who sees
|
||||
// what): Overview and Server only for the global role, Queue likewise (a queue
|
||||
// is not filtered by domain), Inbound only when the feature is on and there is
|
||||
// something in it for this user, DMARC only when it is on.
|
||||
type Shell struct {
|
||||
Title string
|
||||
User string // empty: signed out, no navigation
|
||||
Section string
|
||||
Page string
|
||||
Home string // where the wordmark leads
|
||||
Version string
|
||||
Copyright string
|
||||
SourceURL string
|
||||
|
||||
ShowOverview bool
|
||||
ShowOutbound bool
|
||||
ShowQueue bool
|
||||
ShowDMARC bool
|
||||
ShowInbound bool
|
||||
ShowServer bool
|
||||
}
|
||||
|
||||
// Frame is what layout.html is executed with: the shell and the page's own
|
||||
// data, which the page's "content" template receives.
|
||||
type Frame struct {
|
||||
Shell Shell
|
||||
Page any
|
||||
}
|
||||
|
||||
// shell derives the Shell for a page from its Meta and the engine's flags.
|
||||
func (e *Engine) shell(m Meta, copyright, sourceURL string) Shell {
|
||||
s := Shell{
|
||||
Title: m.Title, User: m.User, Section: m.Section, Page: m.Page,
|
||||
Version: e.version, Copyright: copyright, SourceURL: sourceURL,
|
||||
|
||||
ShowOverview: m.IsGlobal,
|
||||
ShowServer: m.IsGlobal,
|
||||
ShowQueue: m.IsGlobal,
|
||||
ShowOutbound: m.IsGlobal || m.HasOutbound,
|
||||
ShowInbound: e.inboundEnabled && (m.IsGlobal || m.HasInbound),
|
||||
}
|
||||
s.ShowDMARC = e.dmarcEnabled && s.ShowOutbound
|
||||
switch {
|
||||
case s.ShowOverview:
|
||||
s.Home = "/overview"
|
||||
case s.ShowOutbound:
|
||||
s.Home = "/outbound/domains"
|
||||
case s.ShowInbound:
|
||||
s.Home = "/inbound/domains"
|
||||
default:
|
||||
s.Home = "/account"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Tests of the component kit's own code: the template functions, each partial
|
||||
// in each of its states, the shell's visibility rules and the agreement between
|
||||
// kitPages and legacy_pages.txt. The design contract itself (vocabulary, outlines,
|
||||
// the stylesheet) is held by the guard tests, which this file does not repeat.
|
||||
|
||||
func kitEngine(t *testing.T) *Engine {
|
||||
t.Helper()
|
||||
e, err := New("9.9.9-test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
e.SetInboundEnabled(true)
|
||||
e.SetDMARCEnabled(true)
|
||||
return e
|
||||
}
|
||||
|
||||
// partial executes one named partial of components.html with data.
|
||||
func partial(t *testing.T, name string, data any) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := kitEngine(t).Page("components").ExecuteTemplate(&buf, name, data); err != nil {
|
||||
t.Fatalf("partial %s: %v", name, err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// squash removes the layout whitespace between tags, so a test can look for the
|
||||
// markup without caring about the line breaks the template source carries.
|
||||
func squash(s string) string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if line = strings.TrimSpace(line); line != "" {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "")
|
||||
}
|
||||
|
||||
func mustContain(t *testing.T, out string, wants ...string) {
|
||||
t.Helper()
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("missing %q in:\n%s", w, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustNotContain(t *testing.T, out string, unwanted ...string) {
|
||||
t.Helper()
|
||||
for _, u := range unwanted {
|
||||
if strings.Contains(out, u) {
|
||||
t.Errorf("unexpected %q in:\n%s", u, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// status_tag is the one mapping of a status to Bulma's classes: the table of
|
||||
// box 03 of the kit page. A word it does not know is neutral, never an error.
|
||||
func TestStatusTagMapsEveryStatusOfTheKitTable(t *testing.T) {
|
||||
for status, want := range map[string]string{
|
||||
"ok": "tag is-success is-light", "published": "tag is-success is-light",
|
||||
"running": "tag is-success is-light", "sent": "tag is-success is-light",
|
||||
"warn": "tag is-warning is-light", "deferred": "tag is-warning is-light",
|
||||
"quiet": "tag is-warning is-light",
|
||||
"error": "tag is-danger is-light", "fail": "tag is-danger is-light",
|
||||
"mismatch": "tag is-danger is-light", "bounced": "tag is-danger is-light",
|
||||
"quarantine": "tag is-primary is-light", "global": "tag is-primary is-light",
|
||||
"off": "tag is-light", "unknown": "tag is-light",
|
||||
} {
|
||||
if got := StatusTag(status); got != want {
|
||||
t.Errorf("StatusTag(%q) = %q, want %q", status, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusTagTreatsAnUnknownStatusAsNeutral(t *testing.T) {
|
||||
for _, status := range []string{"", "rate-limited", "3", "DKIM", "something new"} {
|
||||
if got := StatusTag(status); got != "tag is-light" {
|
||||
t.Errorf("StatusTag(%q) = %q, want the neutral tag", status, got)
|
||||
}
|
||||
}
|
||||
// Case and surrounding space are not part of the word.
|
||||
if got := StatusTag(" Deferred "); got != "tag is-warning is-light" {
|
||||
t.Errorf("StatusTag(\" Deferred \") = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The kit page's status table is drawn from the function, so the words shown
|
||||
// there cannot drift from what the function does.
|
||||
func TestKitStatusTableAgreesWithStatusTag(t *testing.T) {
|
||||
for _, row := range KitPage().StatusRows {
|
||||
out := partial(t, "tag", row.Tag)
|
||||
mustContain(t, out, `class="`+StatusTag(row.Tag.Status)+`"`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWbrAtBreaksAfterTheAtSignAndEscapesTheRest(t *testing.T) {
|
||||
if got, want := string(wbrAt("shop@shop.example.org")), "shop@<wbr>shop.example.org"; got != want {
|
||||
t.Errorf("wbrAt = %q, want %q", got, want)
|
||||
}
|
||||
got := string(wbrAt(`<b>"a"</b>@x&y`))
|
||||
if strings.Contains(got, "<b>") || !strings.Contains(got, "@<wbr>x&y") {
|
||||
t.Errorf("wbrAt did not escape the address: %q", got)
|
||||
}
|
||||
if got := string(wbrAt("no-address")); got != "no-address" {
|
||||
t.Errorf("wbrAt changed text without an @: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Both functions are reachable from a page template, which is how a table cell
|
||||
// uses them.
|
||||
func TestStatusTagAndWbrAtAreTemplateFunctions(t *testing.T) {
|
||||
funcs := templateFuncs()
|
||||
for _, name := range []string{"status_tag", "wbr_at"} {
|
||||
if _, ok := funcs[name]; !ok {
|
||||
t.Errorf("template function %s is missing", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichBuildsATextFromMixedParts(t *testing.T) {
|
||||
got := Rich("a ", Code("b"), Text{{Text: " c"}}, 4)
|
||||
if len(got) != 4 || got[0].Text != "a " || !got[1].Code || got[2].Text != " c" || got[3].Text != "4" {
|
||||
t.Errorf("Rich = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineRendersEveryKindAndEscapesText(t *testing.T) {
|
||||
out := partial(t, "inline", Rich(
|
||||
"1<2 ", Code("c"), Mono("m"), Strong("s"), Em("e"), Br(), Link("/x?a=1&b=2", "go"), TagOf("ok", "fine"),
|
||||
Inline{Text: "both", Href: "/y", Strong: true},
|
||||
))
|
||||
mustContain(t, out,
|
||||
"1<2 ", "<code>c</code>", `<span class="sp-mono">m</span>`, "<strong>s</strong>", "<em>e</em>", "<br>",
|
||||
`<a href="/x?a=1&b=2">go</a>`, `<span class="tag is-success is-light">fine</span>`,
|
||||
`<a href="/y"><strong>both</strong></a>`)
|
||||
}
|
||||
|
||||
func TestTagLabelDefaultsToTheStatus(t *testing.T) {
|
||||
mustContain(t, partial(t, "tag", Tag{Status: "deferred"}), `<span class="tag is-warning is-light">deferred</span>`)
|
||||
mustContain(t, partial(t, "tag", Tag{Status: "ok", Label: "DKIM"}), `<span class="tag is-success is-light">DKIM</span>`)
|
||||
}
|
||||
|
||||
func TestPostmarkLevels(t *testing.T) {
|
||||
for level, class := range map[string]string{
|
||||
LevelOK: `class="sp-postmark"`, "": `class="sp-postmark"`,
|
||||
LevelWarn: `class="sp-postmark sp-warn"`, LevelFail: `class="sp-postmark sp-fail"`,
|
||||
} {
|
||||
out := partial(t, "postmark", Postmark{Top: "DNS", Word: "FAIL", Bottom: "1 OF 3", Level: level})
|
||||
mustContain(t, out, class, "<span>DNS</span><b>FAIL</b><span>1 OF 3</span>")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageHeadStates(t *testing.T) {
|
||||
// Everything a head can carry.
|
||||
out := squash(partial(t, "page_head", Head{
|
||||
Postmark: &Postmark{Top: "DNS", Word: "FAIL", Bottom: "1 OF 3 RECORDS", Level: LevelFail},
|
||||
Crumbs: []Crumb{{Href: "/outbound/domains", Label: "Outbound domains"}, {Label: "Applications"}},
|
||||
Title: "New password for", Code: "prod-server",
|
||||
Lead: Rich("Lead with ", Code("code")),
|
||||
Note: []string{"Updated 14:02:31 UTC", "refreshes every 10 s"},
|
||||
Actions: []Action{{Label: "Re-check DNS", Icon: "ti-refresh", Post: "/x/dns-recheck"}, {Label: "Add", Icon: "ti-plus", Href: "/x/new", Primary: true}, {Label: "Delete", Href: "/x/delete", Danger: true}},
|
||||
}))
|
||||
mustContain(t, out,
|
||||
`<div class="sp-head"><div class="sp-postmark sp-fail">`,
|
||||
`<p class="sp-kicker"><a href="/outbound/domains">Outbound domains</a> / Applications /</p>`,
|
||||
`<h1 class="title is-3">New password for <span class="sp-mono">prod-server</span></h1>`,
|
||||
`<p class="sp-lead">Lead with <code>code</code></p>`,
|
||||
`<div class="sp-muted sp-small">Updated 14:02:31 UTC<br>refreshes every 10 s</div>`,
|
||||
`<form method="post" action="/x/dns-recheck"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>`,
|
||||
`<a class="button is-primary" href="/x/new"><i class="ti ti-plus"></i>Add</a>`,
|
||||
`<a class="button is-danger is-light" href="/x/delete">Delete</a>`)
|
||||
|
||||
// The plainest head: a kicker, a title, a lead — and nothing else.
|
||||
out = squash(partial(t, "page_head", Head{Kicker: "Outbound", Title: "Outbound domains", Lead: Plain("Lead.")}))
|
||||
mustContain(t, out, `<p class="sp-kicker">Outbound</p>`, `<h1 class="title is-3">Outbound domains</h1>`)
|
||||
mustNotContain(t, out, "sp-postmark", `class="buttons"`, "sp-muted sp-small")
|
||||
|
||||
// A message: a compact mono title and a route in the place of the lead.
|
||||
out = squash(partial(t, "page_head", Head{
|
||||
Title: "[FIRING] disk", Compact: true, Mono: true,
|
||||
Route: Route{{Text: "a@example.org"}, {Arrow: true}, {Text: "b@example.org"}, {Tag: &Tag{Status: "deferred"}}},
|
||||
Lead: Plain("never shown beside a route"),
|
||||
}))
|
||||
mustContain(t, out, `<h1 class="title is-4 sp-mono">[FIRING] disk</h1>`,
|
||||
`<p class="sp-route"><span>a@example.org</span><i class="ti ti-arrow-right sp-muted"></i><span>b@example.org</span><span class="tag is-warning is-light">deferred</span></p>`)
|
||||
mustNotContain(t, out, "sp-lead")
|
||||
}
|
||||
|
||||
func TestFlashSuccessAndError(t *testing.T) {
|
||||
mustContain(t, partial(t, "flash", Flash{Text: Rich(Strong("Saved"), " — done")}),
|
||||
`class="notification is-success is-light"`, `<i class="ti ti-circle-check"></i><strong>Saved</strong> — done`)
|
||||
mustContain(t, partial(t, "flash", Flash{Error: true, Text: Plain("It failed")}),
|
||||
`class="notification is-danger is-light"`, `<i class="ti ti-alert-circle"></i>It failed`)
|
||||
}
|
||||
|
||||
func TestBoxOpensWithItsHeadInEveryVariant(t *testing.T) {
|
||||
out := squash(partial(t, "box_open", Box{No: "02", Title: "Applications", ID: "apps", End: Plain("note"),
|
||||
Help: &HelpLink{Href: "/help#apps", Title: "What an application is"}}))
|
||||
mustContain(t, out,
|
||||
`<div class="box" id="apps"><div class="sp-box-head"><span class="sp-no">02</span><h2>Applications</h2><div class="sp-end">note</div>`,
|
||||
`<a class="sp-help" href="/help#apps" title="What an application is"><i class="ti ti-help-circle"></i></a></div>`)
|
||||
|
||||
mustContain(t, partial(t, "box_open", Box{No: "01", Title: "x", Variant: BoxDanger}), `<div class="box sp-danger-zone">`)
|
||||
out = partial(t, "box_open", Box{Icon: "ti-eye", Title: "Shown once", Variant: BoxCredential})
|
||||
mustContain(t, out, `<div class="box sp-credential">`, `<span class="sp-no"><i class="ti ti-eye"></i></span>`)
|
||||
mustNotContain(t, out, "id=")
|
||||
|
||||
if got := partial(t, "box_close", nil); got != "</div>" {
|
||||
t.Errorf("box_close = %q", got)
|
||||
}
|
||||
if got := partial(t, "body_open", nil); got != `<div class="sp-box-body">` {
|
||||
t.Errorf("body_open = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoxHeadFilterFormReplacesTheEndSlot(t *testing.T) {
|
||||
out := squash(partial(t, "box_head", Box{No: "01", Title: "Messages", End: Plain("ignored"), Filter: &Filter{
|
||||
Action: "/outbound/log",
|
||||
Selects: []Select{{Name: "status", Label: "Status", Selected: "sent",
|
||||
Options: []Option{{Value: "", Label: "Any status"}, {Value: "sent", Label: "sent"}}}},
|
||||
Dates: []DateInput{{Name: "from", Label: "From date", Value: "2026-09-14"}},
|
||||
}}))
|
||||
mustContain(t, out,
|
||||
`<form class="sp-end" method="get" action="/outbound/log">`,
|
||||
`<div class="select"><select name="status" aria-label="Status"><option value="">Any status</option><option value="sent" selected>sent</option></select></div>`,
|
||||
`<input class="input" type="date" name="from" value="2026-09-14" aria-label="From date">`,
|
||||
`<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button></form>`)
|
||||
mustNotContain(t, out, "ignored")
|
||||
}
|
||||
|
||||
func TestBoxFootVariants(t *testing.T) {
|
||||
mustContain(t, partial(t, "box_foot", Foot{Text: Plain("Page 1 of 9"), Link: &Anchor{Href: "/p?n=2", Label: "Older →"}, End: Plain("live")}),
|
||||
`<div class="sp-box-foot"><span>Page 1 of 9</span><a href="/p?n=2">Older →</a><span class="sp-end">live</span></div>`)
|
||||
mustContain(t, partial(t, "box_foot", Foot{Bare: true, Text: Plain("A legend.")}),
|
||||
`<div class="sp-box-foot">A legend.</div>`)
|
||||
mustContain(t, partial(t, "box_foot", Foot{Text: Plain("ignored"), Button: &Action{Label: "Done", Href: "/x", Primary: true}}),
|
||||
`<div class="sp-box-foot"><a class="button is-primary" href="/x">Done</a></div>`)
|
||||
}
|
||||
|
||||
func TestHealthCards(t *testing.T) {
|
||||
out := squash(partial(t, "health_cards", []HealthCard{
|
||||
{Name: "Machine", Value: "CPU 12 %", Sub: "RAM 41 %", Icon: "ti-cpu", Href: "/server/health#machine"},
|
||||
{Name: "TLS certificate", Value: "Expires in 12 days", Sub: "renewal has not run", Icon: "ti-certificate", Href: "/server/health#certificate", Level: LevelWarn},
|
||||
{Name: "Sockets", Value: "1 of 2", Sub: "x", Icon: "ti-plug-connected-x", Href: "/server/health#sockets", Level: LevelFail},
|
||||
}))
|
||||
mustContain(t, out, `<div class="sp-health"><a href="/server/health#machine"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><span class="sp-h-value">CPU 12 %</span><span class="sp-h-sub">RAM 41 %</span></span></a>`,
|
||||
`<a href="/server/health#certificate" class="sp-warn">`, `<a href="/server/health#sockets" class="sp-fail">`)
|
||||
}
|
||||
|
||||
func TestDNSRecordShowsInDNSNowOnlyOnAMismatch(t *testing.T) {
|
||||
ok := squash(partial(t, "dns_record", Record{
|
||||
Name: "DKIM", Status: Tag{Status: "published"}, Note: Plain("selector"),
|
||||
Host: "sp2026._domainkey.example.org", Type: "TXT", Value: "v=DKIM1; p=AAA", ValueRows: 3,
|
||||
}))
|
||||
mustContain(t, ok,
|
||||
`<div class="sp-record"><div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>selector</small></div>`,
|
||||
`<div class="sp-row">`, `<input class="input" readonly value="sp2026._domainkey.example.org">`,
|
||||
`<label class="label">Type</label><div class="control"><input class="input" readonly value="TXT">`,
|
||||
`<textarea class="textarea" rows="3" readonly>v=DKIM1; p=AAA</textarea>`)
|
||||
mustNotContain(t, ok, "In DNS now", "is-danger")
|
||||
|
||||
bad := squash(partial(t, "dns_record", Record{
|
||||
Name: "SPF", Status: Tag{Status: "mismatch"}, Host: "example.org", Type: "TXT", Value: "v=spf1 ip4:203.0.113.25 ~all",
|
||||
InDNS: "v=spf1 include:_spf.google.com ~all", Problem: Rich("Add ", Code("ip4:203.0.113.25"), " before ~all."),
|
||||
}))
|
||||
mustContain(t, bad, `<label class="label">In DNS now</label>`,
|
||||
`<input class="input is-danger" readonly value="v=spf1 include:_spf.google.com ~all">`,
|
||||
`<p class="help is-danger">Add <code>ip4:203.0.113.25</code> before ~all.</p>`,
|
||||
`<input class="input" readonly value="v=spf1 ip4:203.0.113.25 ~all">`)
|
||||
}
|
||||
|
||||
func TestCopyFieldSingleLineAndMultiLine(t *testing.T) {
|
||||
one := squash(partial(t, "copy_field", CopyField{Label: "Login", Value: "prod-server", Help: Plain("Keep it.")}))
|
||||
mustContain(t, one,
|
||||
`<label class="label">Login</label>`,
|
||||
`<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="prod-server"></div>`,
|
||||
`<button type="button" class="button" data-copy><i class="ti ti-copy"></i>Copy</button>`,
|
||||
`<p class="help">Keep it.</p>`)
|
||||
many := squash(partial(t, "copy_field", CopyField{Label: "Key", Value: "line <1>\nline 2", Rows: 2}))
|
||||
mustContain(t, many, `<textarea class="textarea" rows="2" readonly>line <1>`, "data-copy")
|
||||
mustNotContain(t, many, "<input", `class="help"`)
|
||||
}
|
||||
|
||||
func TestRecordBlockWrapsASecretShownOnce(t *testing.T) {
|
||||
out := squash(partial(t, "record_open", nil) + partial(t, "copy_field", CopyField{Label: "Password", Value: "x"}) + partial(t, "record_close", nil))
|
||||
mustContain(t, out, `<div class="sp-record"><div class="field"><label class="label">Password</label>`)
|
||||
if !strings.HasSuffix(out, "</div></div>") {
|
||||
t.Errorf("record_close does not end the block: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactsBigMonoAndNote(t *testing.T) {
|
||||
out := squash(partial(t, "facts", []Fact{
|
||||
{Label: "Domain", Value: Rich(Link("/outbound/domains/1", "example.org"))},
|
||||
{Label: "Queue id", Value: Plain("4XcB7k2Jm9z1"), Mono: true},
|
||||
{Label: "Peak", Value: Plain("5 days"), Big: true, Note: Plain("a supporting line")},
|
||||
}))
|
||||
mustContain(t, out,
|
||||
`<dl class="sp-facts"><div><dt>Domain</dt><dd><a href="/outbound/domains/1">example.org</a></dd></div>`,
|
||||
`<dd class="sp-mono">4XcB7k2Jm9z1</dd>`,
|
||||
`<dd class="sp-big">5 days</dd><dd class="sp-muted">a supporting line</dd>`)
|
||||
if got := Facts("A", "1", "B", "2", "odd"); len(got) != 2 || got[1].Label != "B" {
|
||||
t.Errorf("Facts(pairs) = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSideMenu(t *testing.T) {
|
||||
out := squash(partial(t, "side_menu", SideMenu{Groups: []MenuGroup{
|
||||
{Label: "This domain", Items: []MenuItem{
|
||||
{Label: "DNS records", Icon: "ti-world-check", Href: "#dns", Active: true, Tag: &Tag{Status: "fail", Label: "1"}},
|
||||
{Label: "Connection", Icon: "ti-plug", Href: "#connection"},
|
||||
}},
|
||||
{Label: "Rarely changed", Items: []MenuItem{{Label: "Delete domain", Icon: "ti-trash", Href: "/outbound/domains/1/delete", Danger: true}}},
|
||||
}}))
|
||||
mustContain(t, out,
|
||||
`<aside class="menu sp-side"><p class="menu-label">This domain</p><ul class="menu-list">`,
|
||||
`<li><a class="is-active" href="#dns"><i class="ti ti-world-check"></i>DNS records <span class="tag is-danger is-light">1</span></a></li>`,
|
||||
`<li><a href="#connection"><i class="ti ti-plug"></i>Connection</a></li>`,
|
||||
`<li><a class="has-text-danger" href="/outbound/domains/1/delete"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li>`)
|
||||
}
|
||||
|
||||
func TestTimelineLevels(t *testing.T) {
|
||||
out := squash(partial(t, "timeline", []Step{
|
||||
{Time: "13:58:07 UTC", Strong: "Accepted", Text: Rich(" from ", Mono("alerts"))},
|
||||
{Time: "13:58:09 UTC", Strong: "Deferred", Text: Plain(" — later"), Level: LevelWarn},
|
||||
{Time: "14:40:00 UTC", Strong: "Bounced", Level: LevelFail},
|
||||
{Time: "next attempt", Text: Plain("Still queued"), Level: LevelPending},
|
||||
}))
|
||||
mustContain(t, out,
|
||||
`<ol class="sp-timeline"><li><time>13:58:07 UTC</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span></li>`,
|
||||
`<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — later</li>`,
|
||||
`<li class="sp-fail"><time>14:40:00 UTC</time><strong>Bounced</strong></li>`,
|
||||
`<li class="sp-pending"><time>next attempt</time>Still queued</li>`)
|
||||
}
|
||||
|
||||
// The log is a pre: its lines are separated by a newline of their own, not by
|
||||
// the template's layout whitespace, and a line is escaped.
|
||||
func TestLogPaneIsAPreWithEscapedLines(t *testing.T) {
|
||||
out := partial(t, "log_pane", []LogLine{
|
||||
{Time: "13:58:07", Text: "message-id=<a81f0c@alertmanager>"},
|
||||
{Time: "13:58:09", Text: "status=deferred", Level: "warn"},
|
||||
{Text: "no time on this one", Level: "error"},
|
||||
})
|
||||
want := `<pre class="sp-log"><span class="sp-d">13:58:07</span> message-id=<a81f0c@alertmanager>` + "\n" +
|
||||
`<span class="sp-d">13:58:09</span> <span class="sp-w">status=deferred</span>` + "\n" +
|
||||
`<span class="sp-e">no time on this one</span></pre>`
|
||||
if out != want {
|
||||
t.Errorf("log_pane =\n%s\nwant\n%s", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyStateConfirmListAndHelpTopic(t *testing.T) {
|
||||
mustContain(t, partial(t, "empty_state", EmptyState{Icon: "ti-stack-2", Text: Plain("Nothing waiting.")}),
|
||||
`<div class="sp-empty"><i class="ti ti-stack-2"></i>Nothing waiting.</div>`)
|
||||
mustContain(t, squash(partial(t, "confirm_list", []Text{Plain("the key"), Rich("every ", Code("login"))})),
|
||||
`<ul class="sp-list-check"><li><i class="ti ti-x"></i>the key</li><li><i class="ti ti-x"></i>every <code>login</code></li></ul>`)
|
||||
mustContain(t, partial(t, "help_topic", HelpTopic{ID: "dns", Title: "DNS records", Body: []Text{Plain("One."), Rich("Two ", Code("x"))}}),
|
||||
`<div class="sp-help-topic" id="dns"><h3>DNS records</h3><p>One.</p><p>Two <code>x</code></p></div>`)
|
||||
mustNotContain(t, partial(t, "help_topic", HelpTopic{Title: "t", Body: []Text{Plain("b")}}), "id=")
|
||||
mustContain(t, partial(t, "kicker", "Upper-case"), `<span class="sp-kicker">Upper-case</span>`)
|
||||
}
|
||||
|
||||
// The kit page shows every partial: if one stops rendering, or a state is lost
|
||||
// from kit.go, its marker disappears here.
|
||||
func TestKitPageRendersEveryPartial(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
kitEngine(t).Render(rec, http.StatusOK, "components", KitPage())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
out := rec.Body.String()
|
||||
for partial, marker := range map[string]string{
|
||||
"page_head": `class="sp-head"`, "postmark": `class="sp-postmark sp-warn"`, "box_head": `class="sp-box-head"`,
|
||||
"box_foot": `class="sp-box-foot"`, "flash": `class="notification is-danger is-light"`,
|
||||
"health_card": `class="sp-fail"><span class="sp-h-icon">`, "dns_record": `class="sp-record-head"`,
|
||||
"copy_field": `data-copy`, "facts": `class="sp-facts"`, "side_menu": `class="menu sp-side"`,
|
||||
"timeline": `class="sp-pending"`, "log_pane": `class="sp-log"`, "empty_state": `class="sp-empty"`,
|
||||
"confirm_list": `class="sp-list-check"`, "help_topic": `class="sp-help-topic"`, "route": `class="sp-route"`,
|
||||
"danger box": `class="box sp-danger-zone"`, "credential box": `class="box sp-credential"`,
|
||||
"in DNS now": `In DNS now`, "status_tag": `tag is-primary is-light`,
|
||||
} {
|
||||
if !strings.Contains(out, marker) {
|
||||
t.Errorf("the kit page does not show %s (looked for %s)", partial, marker)
|
||||
}
|
||||
}
|
||||
if n := strings.Count(out, `class="box`); n != 17 {
|
||||
t.Errorf("the kit page has %d boxes, the mockup components page has 17", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellVisibilityFollowsRoleAndFlags(t *testing.T) {
|
||||
on := kitEngine(t)
|
||||
off, err := New("t")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
e *Engine
|
||||
m Meta
|
||||
want Shell // only the flags and Home are compared
|
||||
wantIn bool
|
||||
}{
|
||||
{"global, everything on", on, Meta{IsGlobal: true},
|
||||
Shell{Home: "/overview", ShowOverview: true, ShowOutbound: true, ShowQueue: true, ShowDMARC: true, ShowInbound: true, ShowServer: true}, true},
|
||||
{"global, inbound and DMARC off", off, Meta{IsGlobal: true},
|
||||
Shell{Home: "/overview", ShowOverview: true, ShowOutbound: true, ShowQueue: true, ShowServer: true}, true},
|
||||
{"domain admin with both reaches", on, Meta{HasOutbound: true, HasInbound: true},
|
||||
Shell{Home: "/outbound/domains", ShowOutbound: true, ShowDMARC: true, ShowInbound: true}, true},
|
||||
{"domain admin, outbound only", on, Meta{HasOutbound: true},
|
||||
Shell{Home: "/outbound/domains", ShowOutbound: true, ShowDMARC: true}, true},
|
||||
{"domain admin, inbound only", on, Meta{HasInbound: true},
|
||||
Shell{Home: "/inbound/domains", ShowInbound: true}, true},
|
||||
{"domain admin, inbound only, feature off", off, Meta{HasInbound: true},
|
||||
Shell{Home: "/account"}, true},
|
||||
{"domain admin with nothing", on, Meta{}, Shell{Home: "/account"}, true},
|
||||
} {
|
||||
got := c.e.shell(c.m, "c", "s")
|
||||
got.Title, got.User, got.Section, got.Page, got.Version, got.Copyright, got.SourceURL = "", "", "", "", "", "", ""
|
||||
if got != c.want {
|
||||
t.Errorf("%s:\n got %+v\nwant %+v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderShell(t *testing.T, e *Engine, m Meta) string {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
page := KitPage()
|
||||
page.Meta = m
|
||||
e.Render(rec, http.StatusOK, "components", page)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
// The menu has the 2.0 entries and none of the ones that are not in 2.0 (plan
|
||||
// § Routes): no Inbound Log, Queue, Quarantine or Filter lists, no Preflight.
|
||||
func TestMenuHoldsTheTwoDotZeroEntries(t *testing.T) {
|
||||
out := renderShell(t, kitEngine(t), Meta{Title: "t", User: "admin", IsGlobal: true})
|
||||
for _, href := range []string{
|
||||
"/overview", "/outbound/domains", "/outbound/log", "/outbound/queue", "/outbound/dmarc",
|
||||
"/inbound/domains", "/server/health", "/server/log", "/server/backup", "/server/users", "/server/settings",
|
||||
"/account", "/help", "/license",
|
||||
} {
|
||||
mustContain(t, out, `href="`+href+`"`)
|
||||
}
|
||||
for _, gone := range []string{
|
||||
"/inbound/log", "/inbound/queue", "/inbound/quarantine", "/inbound/filter-lists", "/server/preflight",
|
||||
`href="/status"`, `href="/domains"`, `href="/users"`, `href="/settings"`,
|
||||
} {
|
||||
mustNotContain(t, out, gone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuHighlightsTheCurrentGroupEntryAndSibling(t *testing.T) {
|
||||
out := renderShell(t, kitEngine(t), Meta{Title: "t", User: "admin", IsGlobal: true, Section: "outbound", Page: "log"})
|
||||
mustContain(t, out,
|
||||
`<div class="navbar-item has-dropdown is-hoverable sp-current">`+"\n"+` <a class="navbar-link" href="/outbound/domains">`,
|
||||
`<a class="navbar-item is-active" href="/outbound/log">`,
|
||||
`<a href="/outbound/log" aria-current="page">`)
|
||||
if n := strings.Count(out, "sp-current"); n != 1 {
|
||||
t.Errorf("%d current groups, want 1", n)
|
||||
}
|
||||
if n := strings.Count(out, "aria-current"); n != 1 {
|
||||
t.Errorf("%d current siblings, want 1", n)
|
||||
}
|
||||
|
||||
// The user menu is a group of its own and has no sibling strip.
|
||||
out = renderShell(t, kitEngine(t), Meta{Title: "t", User: "admin", IsGlobal: true, Section: "user", Page: "account"})
|
||||
mustContain(t, out, `<div class="navbar-item has-dropdown is-hoverable sp-current">`+"\n"+` <a class="navbar-link" href="/account">`)
|
||||
mustNotContain(t, out, "sp-subnav")
|
||||
|
||||
// Overview is a single link, not a dropdown.
|
||||
out = renderShell(t, kitEngine(t), Meta{Title: "t", User: "admin", IsGlobal: true, Section: "overview"})
|
||||
mustContain(t, out, `<a class="navbar-item sp-current" href="/overview">Overview</a>`)
|
||||
mustNotContain(t, out, "sp-subnav")
|
||||
}
|
||||
|
||||
func TestMenuOfADomainAdministratorLeavesNoGap(t *testing.T) {
|
||||
out := renderShell(t, kitEngine(t), Meta{Title: "t", User: "ops", HasOutbound: true, Section: "outbound", Page: "domains"})
|
||||
mustContain(t, out, `href="/outbound/domains"`, `href="/outbound/log"`, `href="/outbound/dmarc"`, `href="/account"`)
|
||||
mustNotContain(t, out, `href="/overview"`, `href="/outbound/queue"`, `href="/inbound/domains"`, `href="/server/`)
|
||||
// The wordmark leads to what they can open.
|
||||
mustContain(t, out, `<a class="navbar-item" href="/outbound/domains"><img src="/static/wordmark.svg"`)
|
||||
|
||||
// With DMARC off the entry goes too.
|
||||
e, _ := New("t")
|
||||
out = renderShell(t, e, Meta{Title: "t", User: "ops", HasOutbound: true})
|
||||
mustNotContain(t, out, "/outbound/dmarc")
|
||||
}
|
||||
|
||||
// Signing out is a POST, as it is in the old layout and as the handler demands;
|
||||
// it has to work without scripts.
|
||||
func TestSignOutStaysAPostForm(t *testing.T) {
|
||||
out := renderShell(t, kitEngine(t), Meta{Title: "t", User: "admin", IsGlobal: true})
|
||||
mustContain(t, out, `<form method="post" action="/logout"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>`)
|
||||
mustNotContain(t, out, `href="/logout"`)
|
||||
}
|
||||
|
||||
func TestFooterShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
|
||||
signedIn := renderShell(t, kitEngine(t), Meta{Title: "t", User: "admin", IsGlobal: true})
|
||||
mustContain(t, signedIn,
|
||||
`<footer class="sp-foot">SelfPost 9.9.9-test · Copyright © 2026 Mikhail Yenuchenko · <a href="/license">License (AGPL-3.0)</a> · <a href="https://github.com/mixeme/selfpost">Source</a> · No warranty</footer>`)
|
||||
|
||||
// Signed out: the split screen, the notice, and no version, no navigation.
|
||||
out := renderShell(t, kitEngine(t), Meta{Title: "Sign in"})
|
||||
mustContain(t, out, `class="columns is-gapless sp-signin"`, `<img src="/static/logo.svg" alt="SelfPost">`,
|
||||
"Copyright © 2026 Mikhail Yenuchenko", `href="/license"`, `href="https://github.com/mixeme/selfpost"`, "No warranty")
|
||||
mustNotContain(t, out, "9.9.9-test", `class="navbar`, "sp-perf", "sp-subnav", `class="container sp-page"`)
|
||||
}
|
||||
|
||||
// Every page of the kit loads exactly the three stylesheets and two scripts.
|
||||
func TestKitLayoutLoadsOnlyTheKit(t *testing.T) {
|
||||
for _, m := range []Meta{{Title: "t", User: "admin", IsGlobal: true}, {Title: "Sign in"}} {
|
||||
out := renderShell(t, kitEngine(t), m)
|
||||
mustContain(t, out,
|
||||
`<link rel="stylesheet" href="/static/bulma.min.css">`+"\n"+`<link rel="stylesheet" href="/static/tabler-icons.css">`+"\n"+`<link rel="stylesheet" href="/static/panel.css">`,
|
||||
`<script src="/static/htmx.min.js" defer></script>`, `<script src="/static/panel.js" defer></script>`,
|
||||
`<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>`)
|
||||
mustNotContain(t, out, "legacy.css", "http://", "https://cdn")
|
||||
}
|
||||
}
|
||||
|
||||
// Meta can come from a struct that embeds it or from the map a handler builds.
|
||||
func TestMetaOfReadsAStructAndAMap(t *testing.T) {
|
||||
want := Meta{Title: "t", User: "u", IsGlobal: true, HasOutbound: true, HasInbound: true, Section: "server", Page: "users"}
|
||||
if got := metaOf(&Kit{Meta: want}); got != want {
|
||||
t.Errorf("metaOf(struct) = %+v", got)
|
||||
}
|
||||
got := metaOf(map[string]any{"Title": "t", "User": "u", "IsGlobal": true, "HasOutbound": true, "HasInbound": true, "Section": "server", "Page": "users", "Other": 1})
|
||||
if got != want {
|
||||
t.Errorf("metaOf(map) = %+v", got)
|
||||
}
|
||||
if got := metaOf(42); got != (Meta{}) {
|
||||
t.Errorf("metaOf(other) = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A page is on the new layout exactly when legacy_pages.txt does not list it.
|
||||
func TestKitPagesAreTheOnesOffTheRatchet(t *testing.T) {
|
||||
b, err := os.ReadFile("legacy_pages.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := map[string]bool{}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if line = strings.TrimSpace(line); line != "" && !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "@") {
|
||||
legacy[line] = true
|
||||
}
|
||||
}
|
||||
for name := range pageFiles {
|
||||
if kitPages[name] == legacy[name] {
|
||||
t.Errorf("page %q: kitPages=%v and legacy_pages.txt lists it=%v; it must be in exactly one", name, kitPages[name], legacy[name])
|
||||
}
|
||||
}
|
||||
for name := range kitPages {
|
||||
if _, ok := pageFiles[name]; !ok {
|
||||
t.Errorf("kitPages names %q, which is not a page", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The old pages keep rendering through the old layout and stylesheet.
|
||||
func TestLegacyPagesStayOnTheOldLayout(t *testing.T) {
|
||||
e := kitEngine(t)
|
||||
rec := httptest.NewRecorder()
|
||||
e.Render(rec, http.StatusOK, "backup", map[string]any{"Title": "t", "User": "admin", "IsGlobal": true})
|
||||
out := rec.Body.String()
|
||||
mustContain(t, out, `href="/static/legacy.css"`, `class="shell"`)
|
||||
mustNotContain(t, out, "bulma.min.css", "tabler-icons.css", "/static/panel.css", "navbar")
|
||||
}
|
||||
@@ -14,4 +14,6 @@ package view
|
||||
//
|
||||
// This file belongs to the implementation, not to the guards: the step that
|
||||
// restyles a page adds its fixture here in the same commit.
|
||||
var pageFixtures = map[string]func() any{}
|
||||
var pageFixtures = map[string]func() any{
|
||||
"components": func() any { return KitPage() },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package view
|
||||
|
||||
// The data of the kit page (GET /server/components): every partial of
|
||||
// components.html in every state it has, filled the way the mockup components
|
||||
// page (docs/assets/panel-redesign/panel/src/components.html) is filled, same
|
||||
// boxes, same order, same words. It is production code, not a test fixture,
|
||||
// because the handler serves it; the guard tests render the same value
|
||||
// (fixtures_test.go).
|
||||
|
||||
// Kit is the data of the kit page, rendered by templates/kit.html. The fields
|
||||
// are named after the boxes of the page they fill.
|
||||
type Kit struct {
|
||||
Meta
|
||||
Head Head
|
||||
Flash Flash
|
||||
FlashError Flash
|
||||
|
||||
Layouts Box
|
||||
LayoutFacts []Fact
|
||||
Postmark Box
|
||||
Postmarks []Postmark
|
||||
StatusTag Box
|
||||
StatusRows []KitStatusRow
|
||||
BoxHead Box
|
||||
BoxFoot Foot
|
||||
DangerZone Box
|
||||
Credential Box
|
||||
HealthCard Box
|
||||
HealthCards []HealthCard
|
||||
DNSRecord Box
|
||||
Records []Record
|
||||
Tables Box
|
||||
TablesFoot Foot
|
||||
LogPane Box
|
||||
LogLines []LogLine
|
||||
Facts Box
|
||||
FactList []Fact
|
||||
Timeline Box
|
||||
Steps []Step
|
||||
EmptyState Box
|
||||
Empty EmptyState
|
||||
ConfirmList Box
|
||||
Confirm []Text
|
||||
SideMenu SideMenu
|
||||
FormRows Box
|
||||
Route Route
|
||||
HelpTopic HelpTopic
|
||||
Shell Box
|
||||
ShellFacts []Fact
|
||||
TypeScale Box
|
||||
TypeScaleFoot Foot
|
||||
KickerSample string
|
||||
}
|
||||
|
||||
// KitStatusRow is one row of the status_tag table: the words that map to one
|
||||
// tag, and the tag.
|
||||
type KitStatusRow struct {
|
||||
Words string
|
||||
Tag Tag
|
||||
}
|
||||
|
||||
// KitPage returns the kit page's data. The page is shown to the global role, so
|
||||
// the shell is theirs; it sits under Server in the address and in the menu.
|
||||
func KitPage() *Kit {
|
||||
return &Kit{
|
||||
Meta: Meta{Title: "Components", User: "admin", IsGlobal: true, Section: "server"},
|
||||
Head: Head{
|
||||
Postmark: &Postmark{Top: "SELFPOST", Word: "REF", Bottom: "DESIGN CONTRACT", Level: LevelOK},
|
||||
Kicker: "Reference · page_head",
|
||||
Title: "Components",
|
||||
Lead: Plain("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."),
|
||||
Actions: []Action{{Label: "Help", Icon: "ti-help-circle", Href: "/help"}},
|
||||
},
|
||||
Flash: Flash{Text: Rich(Strong("flash"), " — the result of the last action, between page_head and the first box. Success, or…")},
|
||||
FlashError: Flash{Error: true, Text: Rich("…", Strong("flash, error"), " — a failure that belongs to the page, not to one field. A field's error stays under the field in Bulma's ", Code("help is-danger"), ".")},
|
||||
|
||||
Layouts: Box{No: "00", Title: "Page layouts", End: Plain("A page is exactly one of these")},
|
||||
LayoutFacts: []Fact{
|
||||
{Label: "list", Value: Rich("page_head, then full-width boxes: the add form first, the table second. ", Link("/outbound/domains", "Example"))},
|
||||
{Label: "detail", Value: Rich("page_head, then ", Code("columns"), ": ", Code("is-3"), " side menu + ", Code("is-9"), " boxes.")},
|
||||
{Label: "form", Value: Rich("page_head, then boxes in equal ", Code("columns"), ", one submit row under them.")},
|
||||
{Label: "signed out", Value: Rich("The split brand / form screen, no navbar. ", Link("/login", "Example"))},
|
||||
},
|
||||
|
||||
Postmark: Box{No: "01", Title: "postmark", End: Plain("Worst status of the page's checks")},
|
||||
Postmarks: []Postmark{
|
||||
{Top: "SELFPOST", Word: "OK", Bottom: "6 OF 6 CHECKS", Level: LevelOK},
|
||||
{Top: "SELFPOST", Word: "WARN", Bottom: "1 OF 6 CHECKS", Level: LevelWarn},
|
||||
{Top: "DNS", Word: "FAIL", Bottom: "1 OF 3 RECORDS", Level: LevelFail},
|
||||
},
|
||||
|
||||
StatusTag: Box{No: "03", Title: "status_tag", End: Plain("One mapping, one function")},
|
||||
StatusRows: []KitStatusRow{
|
||||
{"ok, published, running, sent", Tag{Status: "ok"}},
|
||||
{"warn, deferred, quiet", Tag{Status: "warn"}},
|
||||
{"error, fail, mismatch, bounced", Tag{Status: "fail"}},
|
||||
{"unknown, off, neutral counts", Tag{Status: "off"}},
|
||||
{"held: quarantine · role: global", Tag{Status: "quarantine"}},
|
||||
},
|
||||
|
||||
BoxHead: Box{No: "02", Title: "box · box_head",
|
||||
End: Plain("The end slot: a note, a tag, a link or a filter form"),
|
||||
Help: &HelpLink{Href: "/help#checks", Title: "Optional: the section's Help topic"}},
|
||||
BoxFoot: Foot{Text: Plain("box_foot — a legend, paging"), End: Plain("end slot")},
|
||||
DangerZone: Box{Icon: "ti-alert-triangle", Title: "box · sp-danger-zone", Variant: BoxDanger},
|
||||
Credential: Box{Icon: "ti-eye", Title: "box · sp-credential", End: Plain("Shown once"), Variant: BoxCredential},
|
||||
|
||||
HealthCard: Box{No: "04", Title: "health_card", End: Plain("Overview only · each card links to its detail")},
|
||||
HealthCards: []HealthCard{
|
||||
{Name: "Name", Value: "Verdict in words", Sub: "ok · one supporting fact", Icon: "ti-server-cog", Href: "#"},
|
||||
{Name: "Name", Value: "Expires in 12 days", Sub: "sp-warn", Icon: "ti-certificate", Href: "#", Level: LevelWarn},
|
||||
{Name: "Name", Value: "1 of 2 answering", Sub: "sp-fail", Icon: "ti-plug-connected-x", Href: "#", Level: LevelFail},
|
||||
},
|
||||
|
||||
DNSRecord: Box{No: "05", Title: "dns_record · copy_field", End: Plain("One block per record")},
|
||||
Records: []Record{
|
||||
{Name: "Record", Status: Tag{Status: "published"}, Note: Plain("A one-line note"),
|
||||
Host: "copy_field — single line", Type: "TXT",
|
||||
Value: "copy_field — multi-line, for a DKIM key", ValueRows: 2},
|
||||
{Name: "Record", Status: Tag{Status: "mismatch"}, Note: Plain(`"In DNS now" appears only when it differs`),
|
||||
InDNS: "what the resolver returned", Problem: Plain("What is wrong and the exact fix.")},
|
||||
},
|
||||
|
||||
Tables: Box{No: "07", Title: "Table conventions", End: Rich("Always inside ", Code("table-container"), " · an address breaks after its @")},
|
||||
TablesFoot: Foot{Text: Plain("Page 1 of 9"), Link: &Anchor{Href: "#", Label: "Older →"}},
|
||||
|
||||
LogPane: Box{No: "09", Title: "log_pane", End: Plain("sp-d time · sp-w warning · sp-e error")},
|
||||
LogLines: []LogLine{
|
||||
{Time: "13:58:07", Text: "an ordinary line"},
|
||||
{Time: "13:58:09", Text: "a deferred delivery — sp-w", Level: "warn"},
|
||||
{Time: "13:57:41", Text: "a reject or a bounce — sp-e", Level: "error"},
|
||||
},
|
||||
|
||||
Facts: Box{No: "06", Title: "facts"},
|
||||
FactList: []Fact{
|
||||
{Label: "Label", Value: Plain("Value")},
|
||||
{Label: "Mono value", Value: Plain("4XcB7k2Jm9z1"), Mono: true},
|
||||
{Label: "sp-big", Value: Plain("5 days"), Big: true, Note: Plain("a supporting line")},
|
||||
},
|
||||
|
||||
Timeline: Box{No: "08", Title: "timeline"},
|
||||
Steps: []Step{
|
||||
{Time: "13:58:07 UTC", Strong: "Done", Text: Plain(" — default")},
|
||||
{Time: "13:58:09 UTC", Strong: "Deferred", Text: Plain(" — sp-warn"), Level: LevelWarn},
|
||||
{Time: "14:40:00 UTC", Strong: "Bounced", Text: Plain(" — sp-fail"), Level: LevelFail},
|
||||
{Time: "not yet", Text: Plain("Expected next — sp-pending"), Level: LevelPending},
|
||||
},
|
||||
|
||||
EmptyState: Box{No: "10", Title: "empty_state"},
|
||||
Empty: EmptyState{Icon: "ti-stack-2", Text: Plain("One sentence: what is absent and why that is fine.")},
|
||||
|
||||
ConfirmList: Box{No: "11", Title: "confirm_list"},
|
||||
Confirm: []Text{
|
||||
Plain("each thing that will be lost, one per line;"),
|
||||
Plain("inside an sp-danger-zone box, above the danger button."),
|
||||
},
|
||||
|
||||
SideMenu: SideMenu{Groups: []MenuGroup{
|
||||
{Label: "side_menu", Items: []MenuItem{
|
||||
{Label: "Current", Icon: "ti-world-check", Href: "#", Active: true, Tag: &Tag{Status: "fail", Label: "1"}},
|
||||
{Label: "Section", Icon: "ti-apps", Href: "#", Tag: &Tag{Status: "off", Label: "2"}},
|
||||
}},
|
||||
{Label: "Rarely changed", Items: []MenuItem{
|
||||
{Label: "Delete", Icon: "ti-trash", Href: "#", Danger: true},
|
||||
}},
|
||||
}},
|
||||
|
||||
FormRows: Box{No: "12", Title: "Form rows · route · help_topic"},
|
||||
Route: Route{
|
||||
{Text: "from@example.org"}, {Arrow: true}, {Text: "to@example.net"}, {Tag: &Tag{Status: "deferred"}},
|
||||
},
|
||||
HelpTopic: HelpTopic{Title: "help_topic", Body: []Text{Plain("A heading and a paragraph; topics stack inside one box on the Help page.")}},
|
||||
|
||||
Shell: Box{No: "13", Title: "shell", End: Plain("Owned by the layout — a screen never writes it")},
|
||||
ShellFacts: []Fact{
|
||||
{Label: "Name", Value: Plain("The wordmark, not the stamp: at nav size the stamp's perforation is under a pixel and its brick field dissolves into the bar. The stamp lives on sign-in, setup and the favicon.")},
|
||||
{Label: "Edge", Value: Rich("Built like the mark: the brick field, then the stamp's paper margin, and the perforation along the outer edge of that margin — round holes, flat teeth, the ink hairline. One tile, ", Code("perf-edge.svg"), ".")},
|
||||
{Label: "Sibling strip", Value: Plain("The pages of the current group, under the edge. Five or six entries; past that the group is split.")},
|
||||
{Label: "Menus", Value: Plain("Bulma hover dropdowns, no JavaScript. The user menu holds Account, Help and Sign out.")},
|
||||
},
|
||||
|
||||
TypeScale: Box{No: "14", Title: "Type scale", End: Plain("Three sizes · nothing to be read is under 13px")},
|
||||
TypeScaleFoot: Foot{Bare: true, Text: Rich("Bulma's ", Code("is-small"), " and ", Code("is-size-7"), " are not used: a control is never small, and ", Code("sp-small"), " replaces the helper. The postmark's fine print is an imprint, not text.")},
|
||||
KickerSample: "Upper-case mono label",
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
# components.html, components.go, layout.html, GET /server/components
|
||||
# @routes the pre-2.0 route table is still in place (stage 1)
|
||||
# <name> a page of the view engine that is still the old template
|
||||
@kit
|
||||
@routes
|
||||
backup
|
||||
dashboard
|
||||
|
||||
@@ -0,0 +1,862 @@
|
||||
/* Panel stylesheet. It lives in a file rather than in a <style> block in the
|
||||
layout so the panel's Content-Security-Policy can be a plain
|
||||
"default-src 'self'" with no inline-style exemption. Any rule
|
||||
added here must therefore stay here: an inline style="..." attribute in a
|
||||
template is blocked by that policy and silently does nothing.
|
||||
|
||||
The palette and the type below come from the mark's own approval sheet
|
||||
(docs/assets/selfpost-proof.html) — brick, warm paper, IBM Plex. Before it
|
||||
the panel was a default blue-on-cool-grey admin theme with the stamp pasted
|
||||
onto it, and the two read as different pieces of work. */
|
||||
|
||||
/* The fonts ship with the panel rather than being asked for by name. Two
|
||||
reasons, and the second is the one that matters. A named family resolves to
|
||||
whatever the operator's machine has, so the mark — Plex converted to
|
||||
outlines — would be the only Plex on the page. And ui-monospace is Consolas,
|
||||
SF Mono or DejaVu Sans Mono depending on that machine, each with its own
|
||||
advance width: the six-column send log and the mail.log tables would lay out
|
||||
differently for every operator. Served from the panel's own origin, so the
|
||||
policy above already covers them and needs no font-src of its own.
|
||||
One variable file for the sans (100–700 in 46 KB, less than the four static
|
||||
weights it replaces); mono has no variable release, so it is two files.
|
||||
swap, so a cold load shows the system stack for a frame instead of nothing. */
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans"; font-style: normal; font-weight: 100 700;
|
||||
font-display: swap; src: url("/static/ibm-plex-sans.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono"; font-style: normal; font-weight: 400;
|
||||
font-display: swap; src: url("/static/ibm-plex-mono-400.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono"; font-style: normal; font-weight: 600;
|
||||
font-display: swap; src: url("/static/ibm-plex-mono-600.woff2") format("woff2");
|
||||
}
|
||||
|
||||
/* Colour tokens. Light values live on :root; the dark media query below
|
||||
reassigns the same names rather than re-declaring every rule that uses
|
||||
them, so a rule needs !important nowhere in this file — the custom
|
||||
property already carries the right value for the active scheme. */
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--font-sans: "IBM Plex Sans", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--bg: #f4f2ed; --fg: #12161c;
|
||||
--muted: #6b7684; /* secondary text: .muted, th, timestamps, footers */
|
||||
--card-bg: #fff;
|
||||
--border: #dedcd7; /* dividers: card, table, app, nav, code, encrypt-fields */
|
||||
--control-border: #cbc8c1; /* input, select, textarea, action buttons */
|
||||
--input-bg: #fff;
|
||||
--code-bg: #efede9;
|
||||
--surface-bg: #eae7e0; --surface-bg-hover: #e0dcd3; --surface-open-bg: #d8d3c8;
|
||||
--nav-active-bg: #ede4de;
|
||||
/* Brick, the mark's own colour, is the panel's accent — but as two values,
|
||||
not one. A filled control carries --accent-fill with white on it; a link or
|
||||
an active entry carries --accent-text, because the fill on a page
|
||||
background is a colour to sit behind text, not to be text. In the light
|
||||
scheme the two coincide. */
|
||||
--accent-fill: #7a3b2e; --accent-fill-hover: #632f25; --accent-text: #7a3b2e;
|
||||
--on-accent: #fff;
|
||||
--flash-bg: #edf6ef; --flash-border: #c2e0cc; --flash-fg: #2c6b43;
|
||||
--credential-bg: #fcf6e4; --credential-border: #e0c874;
|
||||
--danger-fill: #b42318; --danger-fill-hover: #912018;
|
||||
--danger-bg: #fbedea; --danger-border: #efccc4; --danger-fg: #b42318; --danger-bg-hover: #f7dfda;
|
||||
--st-ok-bg: #edf6ef; --st-ok-fg: #2c6b43; --st-ok-border: #c2e0cc;
|
||||
--st-warn-bg: #fbf2e2; --st-warn-fg: #8a5510; --st-warn-border: #ebd5a6;
|
||||
--st-error-bg: #fbedea; --st-error-fg: #b42318; --st-error-border: #efccc4;
|
||||
--st-unknown-bg: #efede9; --st-unknown-fg: #6b7684; --st-unknown-border: #dedcd7;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #16181b; --fg: #e9e6e0;
|
||||
--muted: #9aa1a9;
|
||||
--card-bg: #1d2024;
|
||||
--border: #2c2f34;
|
||||
--control-border: #3a3e44;
|
||||
--input-bg: #14161a;
|
||||
--code-bg: #14161a;
|
||||
--surface-bg: #23262b; --surface-bg-hover: #2c3036; --surface-open-bg: #343941;
|
||||
--nav-active-bg: #2a1f1b;
|
||||
/* Brick as drawn is about 2:1 against this background — a link nobody can
|
||||
read. The fill lightens just enough to keep white on it above 4.5:1
|
||||
(6.9:1 as set), and the text value lightens further to clear the same bar
|
||||
as text on the page. This is where the two values earn their keep. */
|
||||
--accent-fill: #8e4535; --accent-fill-hover: #a0503e; --accent-text: #ce7b66;
|
||||
--flash-bg: #132318; --flash-border: #22452f; --flash-fg: #7fcb9b;
|
||||
--credential-bg: #26210d; --credential-border: #5e5013;
|
||||
--danger-fill: #9b2c22; --danger-fill-hover: #b0342a;
|
||||
--danger-bg: #2a1412; --danger-border: #5e2721; --danger-fg: #eb9b92; --danger-bg-hover: #381a17;
|
||||
--st-ok-bg: #132318; --st-ok-fg: #7fcb9b; --st-ok-border: #22452f;
|
||||
--st-warn-bg: #2a2109; --st-warn-fg: #e5be72; --st-warn-border: #5e4b12;
|
||||
--st-error-bg: #2a1412; --st-error-fg: #eb9b92; --st-error-border: #5e2721;
|
||||
--st-unknown-bg: #23262b; --st-unknown-fg: #9aa1a9; --st-unknown-border: #2c2f34;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
/* The scrollbar's width is reserved whether or not the page is long enough to
|
||||
need one. Without it a short page (Account) and a long one (Status) are laid
|
||||
out in viewports differing by the scrollbar's width, and everything on the
|
||||
page — the navigation column included — shifts sideways on the way between
|
||||
them. The panel is a set of pages an operator moves through, so that shift
|
||||
read as the layout jumping. */
|
||||
html { scrollbar-gutter: stable; }
|
||||
body {
|
||||
font: 400 15px/1.5 var(--font-sans);
|
||||
margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--fg);
|
||||
}
|
||||
/* The two columns — navigation on the left, page on the right — centred as a
|
||||
pair. The navigation is a fixed width and the page keeps the measure it had:
|
||||
the column is added beside the content, not taken out of it. */
|
||||
.shell { display: flex; justify-content: center; align-items: flex-start; gap: 1.75rem; }
|
||||
/* One column for every page. It used to be the reading measure, 48rem, which
|
||||
the three pages holding data widened to 64rem — and since the shell centres
|
||||
the navigation and the page as a pair, that difference moved the navigation
|
||||
column and the left edge of every card on the way between two pages. The
|
||||
column is now the wider of the two everywhere, so nothing moves; the reading
|
||||
measure is kept inside it by the rule below rather than by the column.
|
||||
No auto margins: inside the shell they would eat the free space and push the
|
||||
navigation column off to the far edge, so the centring is the shell's job
|
||||
(justify-content above). */
|
||||
main { flex: 1 1 auto; min-width: 0; width: 100%; max-width: 64rem; }
|
||||
/* 48rem is a reading measure: right for the forms and prose that make up most
|
||||
of the panel, and too narrow for the pages that are tables of data. Every
|
||||
page therefore keeps its content at that measure, centred in the column, and
|
||||
the pages made of data opt out (main.wide below) and take the column whole.
|
||||
The cap is on <main>'s children rather than on .card so a page's heading, its
|
||||
back link and the version footer travel with the cards: capping the cards
|
||||
alone would leave the heading spanning the column with the card it belongs to
|
||||
floating in the middle — the misalignment that gave login and setup their own
|
||||
narrow column (main.page-login further down). */
|
||||
main > * { max-width: 48rem; margin-left: auto; margin-right: auto; }
|
||||
/* The four monitoring pages are the ones that opt out: six columns of send
|
||||
log, raw mail.log lines that are long by nature — which at the measure left
|
||||
Subject and Status fighting over the last inch and wrapped every second log
|
||||
line — and a single delivery's page, which stands two cards side by side over
|
||||
a block of those same log lines. The class is declared by the page itself
|
||||
(the "wide" block in layout.html) rather than derived from the page name: a
|
||||
page's name is the navigation entry it belongs under, which several pages
|
||||
share, and how wide a page needs to be is a property of that page alone. */
|
||||
main.wide > * { max-width: none; }
|
||||
/* Longhands, not the margin shorthand: the shorthand would re-zero the auto
|
||||
side margins above and pin the heading to the column's edge, a measure's
|
||||
worth to the left of the card under it. */
|
||||
/* Light rather than bold, which is the sheet's own setting for the name and
|
||||
the one thing a variable font makes free. It is the page's title, not an
|
||||
alarm: the size already says which line is the heading, and at 700 it shouted
|
||||
over the card under it. */
|
||||
h1 { font-size: 1.5rem; font-weight: 300; letter-spacing: -0.01em; margin-top: 0; margin-bottom: 1rem; }
|
||||
/* The full mark, on the two pages that have no navigation bar to carry the
|
||||
compact one. It takes the column's width so its edges line up with the card
|
||||
below it, capped at that column's own 24rem; height stays automatic because
|
||||
the stamp's proportions are part of the mark and it is never scaled unevenly.
|
||||
The subtitle stops resolving below 280px, which a 320px viewport still clears
|
||||
once the body's padding is taken off. */
|
||||
.mark { display: block; width: 100%; max-width: 24rem; height: auto; margin-bottom: 1.4rem; }
|
||||
/* The radius comes down from 10px across the panel: the mark is a stamp with a
|
||||
square edge and a hairline, and a card rounded like a phone widget sat in
|
||||
front of it as a different kind of object. 6px on the containers, 5px on the
|
||||
controls inside them. */
|
||||
.card {
|
||||
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 1.5rem; margin: 0 auto;
|
||||
}
|
||||
.card.narrow { max-width: 24rem; }
|
||||
/* A narrow card as a direct child of main means the page is a short form:
|
||||
every sibling in the column (heading, flash, footer) must share that 24rem
|
||||
measure, or they centre on 48rem while the card centres on 24rem and the
|
||||
left edges drift by half the difference (12rem / 192px at a 16px root —
|
||||
measured). The column itself stays 64rem so the navigation does not move;
|
||||
only the stack inside the column narrows. :has tracks the card rather than
|
||||
a per-page class, so a new signed-in page that adds .card.narrow inherits
|
||||
the alignment without a second stylesheet rule.
|
||||
login/setup already shrink main to 24rem (below); this rule is then a no-op
|
||||
relative to the parent. Do not replace that with :has alone: narrowing main
|
||||
is what centres the signed-out block on the page when there is no nav. */
|
||||
main:has(> .card.narrow) > * { max-width: 24rem; }
|
||||
label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; }
|
||||
/* Checkbox rows (domain assignment, encrypt toggle) opt out of the block
|
||||
label and full-width input rules above: the box and its caption sit on one
|
||||
line. Without this the caption drops under a stretched checkbox. */
|
||||
label.check {
|
||||
display: flex; align-items: center; gap: 0.5rem; margin: 0.45rem 0 0; font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
label.check input { width: auto; margin: 0; padding: 0; border: 0; border-radius: 0; background: none; }
|
||||
input {
|
||||
width: 100%; padding: 0.55rem 0.7rem; font-family: inherit; font-size: 1rem;
|
||||
border: 1px solid var(--control-border); border-radius: 5px; background: var(--input-bg); color: inherit;
|
||||
}
|
||||
/* Domain assignment on the user form: a bordered group under Role, not another
|
||||
stacked field label. Legend replaces the usual label; the muted line is the
|
||||
hint that used to sit where a field caption would. */
|
||||
fieldset {
|
||||
margin: 0.9rem 0 0; padding: 0.55rem 0.85rem 0.85rem;
|
||||
border: 1px solid var(--control-border); border-radius: 5px;
|
||||
}
|
||||
fieldset legend { padding: 0 0.25rem; font-weight: 600; }
|
||||
fieldset > .muted { margin: 0.15rem 0 0.35rem; font-size: 0.85rem; font-weight: 400; }
|
||||
fieldset > label.check:first-of-type { margin-top: 0.15rem; }
|
||||
/* One vocabulary for actions. Anything that performs an action looks like a
|
||||
button: a <button>, or an <a> carrying .btn/.danger where the action is a
|
||||
plain navigation (the delete confirmation page, the full queue view).
|
||||
Several of these used to render as bold blue text instead — a POST wrapped
|
||||
in form.inline, a disclosure toggle, the delete links — which read as links
|
||||
and left two appearances for the same kind of control. They all get the
|
||||
button look now: filled for a card's own action, and the compact outlined
|
||||
variant further down where actions cluster (table rows, the nav bar). Bare
|
||||
<a> is left for links that read as part of a sentence or a list. */
|
||||
button, a.btn, a.danger {
|
||||
display: inline-block; margin-top: 1.2rem; padding: 0.6rem 1.1rem;
|
||||
font: inherit; font-size: 1rem; font-weight: 600; text-decoration: none;
|
||||
color: var(--on-accent); background: var(--accent-fill); border: 0; border-radius: 5px; cursor: pointer;
|
||||
}
|
||||
button:hover, a.btn:hover { background: var(--accent-fill-hover); }
|
||||
button:disabled, button.danger:disabled {
|
||||
opacity: 0.45; cursor: not-allowed;
|
||||
}
|
||||
button:disabled:hover { background: var(--accent-fill); }
|
||||
button.danger:disabled:hover { background: var(--danger-fill); }
|
||||
select:disabled {
|
||||
opacity: 0.65; cursor: not-allowed; background: var(--code-bg);
|
||||
}
|
||||
.error { color: var(--danger-fg); margin: 0.6rem 0 0; font-weight: 600; }
|
||||
.muted { color: var(--muted); }
|
||||
/* Links take the accent. The panel never set a colour here and ran on the
|
||||
browser's own blue, which passed unnoticed while the accent was itself blue;
|
||||
against brick it left every domain name and back link belonging to a palette
|
||||
the panel no longer uses. The controls that are links dressed as buttons
|
||||
(a.btn, a.danger, a.st) all carry a class and out-specify this. */
|
||||
a { color: var(--accent-text); }
|
||||
.topbar { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.2rem; }
|
||||
.topbar .actions { display: flex; gap: 0.9rem; align-items: baseline; }
|
||||
/* form.inline only exists so a POST can sit next to other content without a
|
||||
form's block layout; its button is styled like any other. */
|
||||
form.inline { display: inline; margin: 0; }
|
||||
/* The signed-out pages are a single card and nothing else, and .card.narrow
|
||||
centres itself inside whatever holds it — so at the panel's usual width the
|
||||
card floated in the middle while the mark and the heading stayed at the far
|
||||
left, three alignments on a page with four elements. Narrowing the column to
|
||||
the card's own width makes the three line up and puts the block as a whole in
|
||||
the middle of the page.
|
||||
By page name and not by "the page holds a narrow card": signed-in pages that
|
||||
hold a narrow card keep the 64rem column (see main:has(> .card.narrow)
|
||||
above) so the navigation stays put; only these two pages have no nav and
|
||||
need the column itself narrowed. */
|
||||
main.page-login, main.page-setup { max-width: 24rem; }
|
||||
.card + .card { margin-top: 1.2rem; }
|
||||
.flash { background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg); padding: 0.7rem 1rem; border-radius: 6px; margin-bottom: 1.2rem; }
|
||||
/* RateLimitErr renders as .flash.error: a validation failure, not a success
|
||||
notice. Without this rule it inherited the success surface (green) and only
|
||||
.error's text colour, so a rejected rate limit read as good news in red
|
||||
text. Same box, danger palette. */
|
||||
.flash.error { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-fg); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
/* A table column is at least as wide as the longest unbreakable run inside it,
|
||||
and the panel's tables are full of runs with nothing to break on: email
|
||||
addresses, domains, queue ids. One 40-character recipient was enough to widen
|
||||
the send-log past its card and hang Status over the edge. Cells may break mid
|
||||
word, so a column can always be squeezed to the width available. */
|
||||
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
|
||||
/* The exceptions: a timestamp broken across two lines is unreadable, and so is
|
||||
a send-log status — "deferred" was being hyphenless-broken into "deferre" and
|
||||
"d" whenever the row was tight. Both are short enough to never be the reason
|
||||
a row does not fit. */
|
||||
td.time, td.status { white-space: nowrap; }
|
||||
/* Column headings are set in the mono face: they are labels for machine data
|
||||
rather than prose, and the wider tracking a small monospaced capital wants
|
||||
also holds them apart from the first row of values. Same treatment on
|
||||
.fact-label, so a heading of that size reads as one thing wherever it
|
||||
appears. */
|
||||
th {
|
||||
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500;
|
||||
text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted);
|
||||
}
|
||||
/* The controls at the end of a row are short and fixed — Details, Delete — and
|
||||
the cell is the last one, so it is the one the automatic layout squeezes
|
||||
first: in the send log it was breaking "Details" after "Detail". Holding it
|
||||
on one line costs the row nothing and hands the width back to the status
|
||||
column, which was wrapping single words for the same reason. */
|
||||
td.actions { text-align: right; white-space: nowrap; }
|
||||
/* Subject is the one cell whose text we do not control. Breaking mid word (the
|
||||
rule above) keeps it inside the card, but a long subject would do it by
|
||||
growing the row several lines tall, which buries the rows around it. So the
|
||||
subject is clipped to one line instead, with the whole of it in the tooltip.
|
||||
The clamp sits on an inner block box rather than the cell because max-width
|
||||
on a <td> is only advisory in the automatic table layout. */
|
||||
td.subject span {
|
||||
display: block; max-width: 18rem;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.code { display: block; white-space: pre-wrap; word-break: break-all; font-family: var(--font-mono);
|
||||
font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border); border-radius: 5px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
|
||||
/* Semibold, not the browser's bold: a card's title sits a step under the page's
|
||||
own heading, and 700 against a 300 h1 read as the louder of the two. */
|
||||
h2 { font-size: 1.05rem; font-weight: 600; margin: 0 0 0.4rem; }
|
||||
/* The back link is a block so it takes the measure and lines up with the
|
||||
heading above it and the card below: auto side margins centre a block, but
|
||||
they do nothing for an inline-block, which would have stayed at the column's
|
||||
edge while everything around it moved in. Its own text stays at the left of
|
||||
that block — only the click target grows to the line, as it does for the
|
||||
navigation entries. */
|
||||
.back { display: block; margin-bottom: 1rem; }
|
||||
/* Build version, closing every authenticated page. Quiet on purpose: it is
|
||||
reference material, not something to read on the way past. */
|
||||
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: var(--muted); line-height: 1.45; }
|
||||
.version a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
|
||||
.version a:hover { color: var(--fg); }
|
||||
/* The signed-out column is 24rem; a right-aligned multi-link notice wraps
|
||||
into a ragged edge, so centre it there. */
|
||||
main.page-login .version, main.page-setup .version { text-align: center; }
|
||||
select, textarea {
|
||||
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
|
||||
border: 1px solid var(--control-border); border-radius: 5px; background: var(--input-bg); color: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
textarea { resize: vertical; }
|
||||
button.danger, a.danger { background: var(--danger-fill); }
|
||||
button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
|
||||
/* The disclosure toggle is an action too, so it is drawn as a button (see the
|
||||
compact rule below); it carries no marker because the pressed background
|
||||
already shows the open state. */
|
||||
.actions > label.toggle { display: inline-block; cursor: pointer; }
|
||||
/* Applications are a list of blocks, not table rows. As a table it fell apart:
|
||||
four columns of which the last held six controls — two of them <details>
|
||||
panels with textareas — never fit the panel's default width. The controls
|
||||
wrapped into a staircase, .code on the login cell grew into a slab as tall
|
||||
as the row, and the two text cells sat on the baseline halfway down it. One
|
||||
block per application gives the identity a line of its own and the
|
||||
controls a row of their own, at the width they actually need. */
|
||||
.apps { list-style: none; margin: 1.2rem 0 0; padding: 0; }
|
||||
.app { padding: 0.9rem 0; border-top: 1px solid var(--border); }
|
||||
.app:last-child { padding-bottom: 0; }
|
||||
.app-login { margin: 0; font-family: var(--font-mono); font-weight: 600; }
|
||||
.app-addr { margin: 0.15rem 0 0; word-break: break-all; }
|
||||
.app .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem; }
|
||||
/* Mode and rate limit open under the whole row rather than inside it. As
|
||||
<details> the panel sat where its summary was, so opening one cut the row of
|
||||
controls in half and pushed the rest below a block of fields — the buttons
|
||||
moved every time a panel opened or closed. The panel is therefore the last
|
||||
child of the row, and the checkbox that opens it is visually hidden earlier
|
||||
in the row with its label drawn as the button. The checkbox stays in the tab
|
||||
order and keeps its focus ring on the label, so it works from the keyboard,
|
||||
and being pure CSS it also works with JavaScript blocked, as <details> did. */
|
||||
.app .actions > .panel-toggle {
|
||||
position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none;
|
||||
}
|
||||
.app .actions > .panel { display: none; flex: 1 0 100%; }
|
||||
.app .actions > .t-edit:checked ~ .panel-edit { display: block; }
|
||||
.app .actions > .t-edit:checked ~ .for-edit { background: var(--surface-open-bg); }
|
||||
.app .actions > .t-edit:focus-visible ~ .for-edit { outline: 2px solid var(--accent-fill); outline-offset: 2px; }
|
||||
.panel form { margin-top: 0.6rem; }
|
||||
/* A panel's own submit is a form button, not one of the controls in the row
|
||||
above, so it takes back the spacing the compact .actions rule zeroes out:
|
||||
without it Save sits flush against the field it saves and against the
|
||||
Remove button under it, reading as one stack of edges. */
|
||||
.app .actions > .panel button { margin-top: 0.9rem; }
|
||||
/* Where a panel has two of them (save the limit, remove it) they share one row
|
||||
under the fields, which the row itself spaces off instead of each button.
|
||||
The buttons align on their tops rather than stretching: one of the two is
|
||||
wrapped in a form, and that wrapper's margin would otherwise make the row
|
||||
taller than a button and stretch the unwrapped one to match it. */
|
||||
.app .actions > .panel .panel-buttons {
|
||||
display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0.4rem; margin-top: 0.9rem;
|
||||
}
|
||||
.app .actions > .panel .panel-buttons button,
|
||||
.app .actions > .panel .panel-buttons form { margin-top: 0; }
|
||||
.app .actions > .panel .check-cols { margin-top: 0.6rem; }
|
||||
.app .actions > .panel .check-col > form { margin-top: 0; }
|
||||
.app .actions > .panel .check-col > .check-col-title { margin-top: 0; }
|
||||
.app .actions > .panel .check-col > .muted { margin: 0; }
|
||||
/* Address mode select ‖ trusted-IP field: same top offset and control height. */
|
||||
.app .actions > .panel .check-col > .muted + form > select:first-of-type,
|
||||
.app .actions > .panel .check-col > .muted + form > textarea[name="auth_allowed_ips"] {
|
||||
margin-top: 0.45rem;
|
||||
min-height: 2.55rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.app .actions > .panel .check-col > .muted + form > textarea[name="auth_allowed_ips"] {
|
||||
field-sizing: fixed;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.credential { border-color: var(--credential-border); background: var(--credential-bg); }
|
||||
/* Panel navigation: rendered once from the layout, so it is present on every
|
||||
authenticated page without each content template having to include it. */
|
||||
/* A column down the left edge rather than a bar across the top. The six page
|
||||
entries need about 660px and the session block another 260px, against the
|
||||
738px the panel was wide, so as a bar it had to be split over two rows — and
|
||||
even then it cost the top of every page. Standing it up removes that: the
|
||||
entries share one left edge to scan down, the session sits at the foot where
|
||||
it is out of the way. Sticky, so the list stays in view while the page
|
||||
scrolls past it. The layout template lists the blocks in the order they are
|
||||
drawn, so reading and tab order follow the eye without a CSS `order`. */
|
||||
.nav {
|
||||
position: sticky; top: 2rem; align-self: flex-start;
|
||||
flex: none; width: 13.5rem;
|
||||
display: flex; flex-direction: column; align-items: stretch; gap: 0.75rem;
|
||||
/* A viewport shorter than the column would otherwise cut off whatever hangs
|
||||
below the fold — with no page scroll left to reach it, since the column is
|
||||
stuck to the viewport. */
|
||||
max-height: calc(100vh - 4rem); overflow-y: auto;
|
||||
}
|
||||
.nav .links, .nav .session { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||
/* The session is the column's last block and the only one that is not
|
||||
navigation, so it is ruled off from the entries above it. */
|
||||
.nav .session { padding-top: 0.75rem; border-top: 1px solid var(--border); }
|
||||
/* The signed-in administrator's name: a label above the two controls, not one
|
||||
of them. It may be long and there is nothing to break it on, so it is allowed
|
||||
to break mid word rather than widen the column. */
|
||||
.nav .session .muted { padding: 0 0.6rem; font-size: 0.85rem; overflow-wrap: anywhere; }
|
||||
.nav .session-user {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
}
|
||||
/* The mark is a link, but not one of the column's entries: it takes none of the
|
||||
padding and rounding the entry rule below applies, so its own edge lines up
|
||||
with the entries' icons rather than sitting half a step inside them. */
|
||||
.nav .brand { padding: 0; }
|
||||
/* The mark takes the column's full width, so its edges line up with the entries
|
||||
below it rather than ending halfway across — the same way .mark fills the
|
||||
card column on the pages that carry no navigation. width/height are on the
|
||||
element too, so the column reserves the right height before the SVG has
|
||||
loaded; height stays automatic because the stamp's proportions are part of
|
||||
the mark — it is never scaled unevenly. */
|
||||
.nav .brand img { display: block; width: 100%; height: auto; }
|
||||
/* Each entry pairs an icon with its label, so the entry itself is a flex row
|
||||
rather than a run of text — that is also why the entries centre their items
|
||||
instead of aligning them on the text baseline. In the column the entry fills
|
||||
the width, so the whole strip is the click target and the hover and active
|
||||
backgrounds read as one stack of bars. Account is included: it is a page like
|
||||
the others and would otherwise be the one bare word in the column. */
|
||||
/* No underline: in a row the entries were separated by space alone and the
|
||||
underline was what marked them as links, but a column of them reads as a
|
||||
list of links already, and eight underlines down the left edge is a lot of
|
||||
line for no added meaning. The hover background carries the affordance. */
|
||||
/* The entries are page text, not accented links: a column of eight brick rows
|
||||
would leave nothing for the current one to stand out from — that is what the
|
||||
active entry's colour is for. */
|
||||
.nav a, .nav [aria-current] {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.35rem 0.6rem; border-radius: 5px; text-decoration: none;
|
||||
color: var(--fg);
|
||||
}
|
||||
.nav a:hover { background: var(--surface-bg); }
|
||||
/* The marker moved with the bar: a rule under the entry made sense in a row,
|
||||
where the entries sat side by side; standing up, the current entry is marked
|
||||
down its leading edge, which is the edge every entry shares. */
|
||||
.nav [aria-current] {
|
||||
font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg);
|
||||
box-shadow: inset 2px 0 0 var(--accent-fill);
|
||||
}
|
||||
/* Sign out carries an icon too, so it needs the same row layout; its colours
|
||||
come from the compact button rule further down, and its type is put back to
|
||||
the column's beside it (see the rule after that one). */
|
||||
.nav button { display: flex; align-items: center; gap: 0.5rem; }
|
||||
/* The icons draw in the entry's own colour, so the active entry's darker text,
|
||||
a link's blue and Sign out's red all carry through without a rule apiece. */
|
||||
.nav .icon { width: 1rem; height: 1rem; flex: none; }
|
||||
/* Jumping to a card (in-page links) should not leave it touching the top edge. */
|
||||
.card[id] { scroll-margin-top: 1rem; }
|
||||
/* Below the width the two columns need (13.5rem of navigation, 1.75rem of gap
|
||||
and the 48rem measure, plus the body's padding — the measure, not the column
|
||||
it sits in: a page column short of its 64rem is only a page column that has
|
||||
not reached its cap, while a measure that no longer fits is a page that has),
|
||||
the column lies back down
|
||||
into a bar above the page — the same wrapping rows it used to be. A drawer
|
||||
behind a hamburger would save more height, but it would need script to open,
|
||||
and the panel's navigation is six entries: they fit. */
|
||||
@media (max-width: 66rem) {
|
||||
.shell { flex-direction: column; align-items: stretch; gap: 1.2rem; }
|
||||
main { margin: 0 auto; }
|
||||
.nav {
|
||||
position: static; width: auto; max-height: none; overflow-y: visible;
|
||||
flex-direction: row; flex-wrap: wrap; align-items: center; gap: 0.4rem 1.2rem;
|
||||
padding-bottom: 0.6rem; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
/* Lying down there is no column for the mark to span, and at its full width
|
||||
it would take a row to itself above the entries, so it goes back to the
|
||||
compact size it had — enough to read beside them. */
|
||||
.nav .brand img { width: 110px; }
|
||||
/* Each block keeps its own group of entries together and wraps as one; the
|
||||
rules that separated the blocks vertically become the space between them. */
|
||||
.nav .links, .nav .session {
|
||||
flex-direction: row; flex-wrap: wrap; align-items: center;
|
||||
gap: 0.2rem 0.6rem; padding-top: 0; border-top: 0;
|
||||
}
|
||||
}
|
||||
/* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server
|
||||
status page and the per-domain DNS checks, so a colour means the same thing
|
||||
everywhere. The class suffix is the check's own status value. */
|
||||
/* Mono and squared off rather than a bold uppercase pill: the badge carries a
|
||||
value the server produced (ok, warn, delivered, bounced), and the mono face
|
||||
is where the panel puts machine output everywhere else. Uppercasing it was
|
||||
the pill's own emphasis — dropped with the pill, since the colour already
|
||||
says how loud the badge is. */
|
||||
/* line-height 1 and the padding doing the centring, rather than the 1.5 the
|
||||
badge would inherit from the body. IBM Plex Mono sits a shade low in its em
|
||||
square, so equal padding leaves "ok" / "warn" sunk in the box (and visibly
|
||||
below the heading or label it sits next to). The bottom padding is therefore
|
||||
heavier than the top — enough to lift the word to the optical centre. A
|
||||
top-heavy pad was tried earlier and made the sink worse. Words with a
|
||||
descender (queued) hang into that bottom pad rather than growing the badge. */
|
||||
.st {
|
||||
display: inline-block; padding: 0.14rem 0.45rem 0.28rem; border-radius: 4px;
|
||||
font-family: var(--font-mono); font-size: 0.78rem; font-weight: 500; line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
vertical-align: middle; border: 1px solid transparent;
|
||||
}
|
||||
.st-ok { background: var(--st-ok-bg); color: var(--st-ok-fg); border-color: var(--st-ok-border); }
|
||||
.st-warn { background: var(--st-warn-bg); color: var(--st-warn-fg); border-color: var(--st-warn-border); }
|
||||
.st-error { background: var(--st-error-bg); color: var(--st-error-fg); border-color: var(--st-error-border); }
|
||||
.st-unknown { background: var(--st-unknown-bg); color: var(--st-unknown-fg); border-color: var(--st-unknown-border); }
|
||||
/* In the domain list the badge is also the link to that domain's DNS section,
|
||||
so it must not pick up the link colour and underline that would fight with
|
||||
the badge's own palette. */
|
||||
a.st, a.st:hover { color: inherit; text-decoration: none; }
|
||||
a.st:hover { filter: brightness(1.08); }
|
||||
/* Usage bars on the status page's machine card. <meter> rather than a div sized
|
||||
from the reading, because the CSP forbids inline styles (see the note at the
|
||||
top of this file) and a bar's length has to travel on an attribute. The
|
||||
element grades itself from low/high/optimum, so the colour matches the
|
||||
badges' meaning without this file restating the thresholds — and a browser
|
||||
that does not render meters falls back to the percentage beside it, which is
|
||||
printed either way. */
|
||||
meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4rem; }
|
||||
/* The card's own two narrow columns. Cells may break mid word by default (see
|
||||
the th, td rule above), which the detail column needs and these two must not
|
||||
have: the resource names and the readings are short, and the long detail
|
||||
beside them would otherwise win the width and leave "Memory" broken across
|
||||
two lines. */
|
||||
.metric { white-space: nowrap; }
|
||||
/* Copy sits at the top of the value — right for a long DKIM key. The row's
|
||||
.code uses the compact button's vertical padding so a one-line value is the
|
||||
same height as Copy and does not leave the button hanging under an empty
|
||||
band of padding. */
|
||||
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
|
||||
.code-row .code {
|
||||
flex: 1; min-width: 0; margin-top: 0;
|
||||
padding-top: 0.45rem; padding-bottom: 0.45rem;
|
||||
}
|
||||
/* A short field with its submit on the same row (Add domain). The button
|
||||
keeps the card's usual filled look; its top margin is for stacked forms. */
|
||||
.input-row { display: flex; align-items: stretch; gap: 0.5rem; }
|
||||
.input-row input { flex: 1; min-width: 0; }
|
||||
.input-row button { margin-top: 0; flex: none; white-space: nowrap; }
|
||||
|
||||
/* Two cards abreast, on the one page that has a pair of them worth reading
|
||||
together (a delivery's facts beside its history). auto-fit with a minimum
|
||||
rather than a media query: the pair falls into one column exactly when a
|
||||
column would drop below the width its content needs, which is also what
|
||||
happens inside the narrow column the navigation lies down at. */
|
||||
.split {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||
gap: 1.2rem; margin-top: 1.2rem;
|
||||
}
|
||||
/* Cards use margin: 0 auto so a lone card centres in the column. Inside the
|
||||
grid those auto side margins absorb free space and shrink each card to its
|
||||
content instead of filling the 1fr track — so cancel them here, and allow
|
||||
the cell to shrink below the card's intrinsic minimum when the column is
|
||||
tight. */
|
||||
.split > .card { margin-left: 0; margin-right: 0; min-width: 0; }
|
||||
/* The gap already spaces the cards inside the grid; the general .card + .card
|
||||
rule would add a second gap's worth on top of it, and only to the second
|
||||
card, which in one column reads as an uneven stack. The blocks around the
|
||||
grid keep their spacing from it instead. */
|
||||
.split > .card + .card { margin-top: 0; }
|
||||
.split + .card { margin-top: 1.2rem; }
|
||||
.card + .split { margin-top: 1.2rem; }
|
||||
/* Configuration sits outside the polled fragment; without this it would
|
||||
touch the last .split inside #status-body (a nested .split is not a sibling). */
|
||||
#status-body + .card { margin-top: 1.2rem; }
|
||||
/* Peer checks or paired forms inside one card (domain DNS status: two rows of
|
||||
two; domain settings; application edit). Same auto-fit idea as .split, so a
|
||||
wide card gets two columns — not three — and falls to one when narrow. */
|
||||
.check-cols {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||
gap: 1rem 1.2rem; margin-top: 1rem;
|
||||
}
|
||||
.check-col { min-width: 0; }
|
||||
.check-col > label:first-child { margin-top: 0; }
|
||||
.check-col-title {
|
||||
margin: 1rem 0 0.35rem; font-size: 1.05rem; font-weight: 600;
|
||||
}
|
||||
.card > h2 + .check-col-title { margin-top: 0.55rem; }
|
||||
/* Domain settings pairs DMARC ‖ rate limit: share four row tracks so titles,
|
||||
leads, fields and action buttons line up across columns. */
|
||||
@supports (grid-template-rows: subgrid) {
|
||||
.check-cols-rows {
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
}
|
||||
.check-cols-rows > .check-col {
|
||||
display: grid;
|
||||
grid-template-rows: subgrid;
|
||||
grid-row: span 4;
|
||||
}
|
||||
}
|
||||
.check-cols-rows > .check-col > .check-col-title { margin-top: 0; }
|
||||
.check-cols-rows > .check-col > .muted { margin: 0; }
|
||||
.check-col-fields { min-width: 0; }
|
||||
.check-col-fields > form { margin: 0; }
|
||||
.check-col-fields .field-pair { margin-top: 0; }
|
||||
.check-col-fields .field-pair > div > label { margin-top: 0.9rem; }
|
||||
.check-col-actions {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 0.75rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
.check-col-actions > button { margin-top: 1.2rem; }
|
||||
.check-col-actions > form.inline { margin: 0; }
|
||||
.check-col-actions > form.inline > button { margin-top: 1.2rem; }
|
||||
/* Two short fields on one row (rate-limit message count ‖ window). Falls to
|
||||
one column when the parent is too narrow for both. */
|
||||
.field-pair {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||
gap: 0 1rem; margin-top: 0.45rem;
|
||||
}
|
||||
.field-pair > div { min-width: 0; }
|
||||
.field-pair label { margin-top: 0.45rem; }
|
||||
/* Hint under a field-pair sits tight above the form's submit button (the
|
||||
global button margin-top would leave a blank line). */
|
||||
.field-pair + p.muted { margin: 0.45rem 0 0; }
|
||||
.field-pair + p.muted + button { margin-top: 0.45rem; }
|
||||
/* Host / name beside a DNS Type that is always TXT — size the type column to
|
||||
that token rather than giving it half the row. Vertical padding matches
|
||||
.code-row .code so TXT is the same height as Host when Copy sits beside it. */
|
||||
.field-pair.host-type {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
.field-pair.host-type .code {
|
||||
padding-top: 0.45rem; padding-bottom: 0.45rem;
|
||||
}
|
||||
.field-type { width: max-content; }
|
||||
.field-type .code {
|
||||
width: fit-content; min-width: 2.75rem; text-align: center; box-sizing: border-box;
|
||||
}
|
||||
/* The subject heads a delivery's page, and it is the one heading in the panel
|
||||
whose text we do not control: it may be a hundred characters with nothing to
|
||||
break on. It wraps to as many lines as it needs (this is the page's name, not
|
||||
a table cell to clip) but never widens the column. */
|
||||
h1.subject { overflow-wrap: anywhere; }
|
||||
/* The line under it: sender, recipient and outcome. Wrapping rather than
|
||||
clipping, since either address may be long and both are the point. The
|
||||
vertical margins are longhands for the reason given at the h1 rule above —
|
||||
the shorthand would re-zero the auto side margins that keep a page's blocks
|
||||
centred on the measure. It sits close under the heading: the two are one
|
||||
statement of what this page is about. */
|
||||
.route {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
|
||||
margin-top: -0.5rem; margin-bottom: 1rem;
|
||||
}
|
||||
.route .addr { font-family: var(--font-mono); font-size: 0.9rem; overflow-wrap: anywhere; }
|
||||
.route .arrow { color: var(--muted); }
|
||||
/* A card's short facts, as a grid of tiles rather than the label-over-value
|
||||
stack the rest of the panel uses. That stack is right for the pages where a
|
||||
value is a DNS record or a password to copy — one per line, full width — and
|
||||
wrong for six values of a few characters each, which down a narrow column
|
||||
would be a page of mostly empty rows. Same auto-fit as .split: as many
|
||||
columns as fit, and one when nothing else does. */
|
||||
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.5rem; margin-top: 1rem; }
|
||||
.fact { min-width: 0; padding: 0.5rem 0.7rem; border-radius: 6px; background: var(--surface-bg); }
|
||||
.fact-label {
|
||||
display: block; font-family: var(--font-mono); font-size: 0.7rem; font-weight: 500;
|
||||
text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted);
|
||||
}
|
||||
/* Nothing here is guaranteed to have a space in it — domains, logins and queue
|
||||
ids are all one run — so a value may break mid word rather than push its tile
|
||||
wider than the column it shares. */
|
||||
.fact-value { display: block; margin-top: 0.1rem; overflow-wrap: anywhere; }
|
||||
.fact-value.mono { font-family: var(--font-mono); font-size: 0.85rem; }
|
||||
/* Retry-policy tiles carry a short phrase (`doubling, cap about 1 hour 7
|
||||
minutes`) rather than a domain or a queue id, so they need a slightly
|
||||
wider minimum than the delivery-page facts before wrapping to one column. */
|
||||
.facts.retry-facts { grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); }
|
||||
/* A message's history: the steps down a line, each with the time it happened,
|
||||
the status it reached and what that means. The line is the list's own left
|
||||
border and the dots sit on top of it, so nothing has to be positioned against
|
||||
a fixed row height — a step's text is two lines or five depending on what
|
||||
there is to say about it. */
|
||||
.timeline { list-style: none; display: flex; flex-direction: column; gap: 1.1rem;
|
||||
margin: 1rem 0 0; padding: 0.2rem 0 0.2rem 1.4rem; border-left: 2px solid var(--border); }
|
||||
.event { position: relative; min-width: 0; }
|
||||
.event::before {
|
||||
content: ""; position: absolute; left: -1.85rem; top: 0.3rem;
|
||||
width: 0.65rem; height: 0.65rem; border-radius: 50%;
|
||||
background: var(--card-bg); border: 2px solid var(--control-border);
|
||||
}
|
||||
/* The dot carries the step's own verdict, in the same four colours as the badge
|
||||
beside it — so the shape of the history is readable down the line alone. */
|
||||
.event.lvl-ok::before { border-color: var(--st-ok-fg); background: var(--st-ok-bg); }
|
||||
.event.lvl-warn::before { border-color: var(--st-warn-fg); background: var(--st-warn-bg); }
|
||||
.event.lvl-error::before { border-color: var(--st-error-fg); background: var(--st-error-bg); }
|
||||
/* The step that has not happened yet — a queued message's delivery report — is
|
||||
the one hollow dot, and its text is quiet: it is what to expect, not a record
|
||||
of anything. */
|
||||
.event.pending { opacity: 0.7; }
|
||||
.event.pending::before { background: var(--card-bg); border-style: dashed; }
|
||||
.event-time { margin: 0; font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); }
|
||||
.event-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin: 0.15rem 0 0; font-weight: 600; }
|
||||
.event-detail { margin: 0.2rem 0 0; font-size: 0.9rem; }
|
||||
/* A message's own mail.log lines, as a table of when and what. The time column
|
||||
is sized to its content — width:1% is as narrow as the automatic layout will
|
||||
take a column, which with the nowrap on td.time settles on exactly the stamp
|
||||
— so everything left over goes to the text, which is the column that needs
|
||||
it. */
|
||||
table.log { margin-top: 1rem; }
|
||||
table.log th:first-child, table.log td.time { width: 1%; }
|
||||
/* The log's own text: monospace, because it is full of addresses, response
|
||||
codes and identifiers that are compared character by character rather than
|
||||
read as words. pre-wrap keeps the spacing a server's reply was written with
|
||||
while still letting a long line wrap inside the cell instead of widening it
|
||||
(the th/td rule above allows the break). */
|
||||
table.log td.log-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem; white-space: pre-wrap;
|
||||
}
|
||||
/* Compact outlined button: same affordance as the filled one but quiet enough
|
||||
that several can sit together without shouting — the Copy buttons beside a
|
||||
value, the controls of a table row or of an application block. Sign out
|
||||
overrides this with .danger below since signing out is a deliberate,
|
||||
singular action. .actions is the shared hook: a cell that holds controls, or
|
||||
the control row of an application. */
|
||||
button.copy, .actions button, .actions > label.toggle, .actions a.danger, .nav button {
|
||||
margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600;
|
||||
border-radius: 5px; white-space: nowrap;
|
||||
background: var(--surface-bg); color: var(--accent-text); border: 1px solid var(--control-border);
|
||||
}
|
||||
button.copy:hover, .actions button:hover, .actions > label.toggle:hover,
|
||||
.actions a.danger:hover, .nav button:hover { background: var(--surface-bg-hover); }
|
||||
button.copy { flex: none; }
|
||||
.actions button.danger, .actions a.danger, .nav button.danger {
|
||||
color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border);
|
||||
}
|
||||
.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: var(--danger-bg-hover); }
|
||||
/* Sign out takes the compact button's colours but not its type. The rule above
|
||||
is written for controls that cluster — a row of them beside a value or in a
|
||||
table cell — where 0.8rem/600 is what keeps four of them from shouting. In
|
||||
the navigation column it has no cluster to belong to: it stands under
|
||||
Settings, among entries set 0.95rem/400, and being smaller and heavier than
|
||||
every word around it made it read as a different kind of object rather than
|
||||
as the last item of the list. Type and padding go back to the entries';
|
||||
the red, the border and the background stay, and are what say it acts. */
|
||||
.nav button {
|
||||
padding: 0.35rem 0.6rem; font-size: 0.95rem; font-weight: 400;
|
||||
}
|
||||
|
||||
/* The optional "encrypt this download" block on the backup and export forms.
|
||||
The checkbox uses the shared label.check rule above; the fields it reveals
|
||||
are indented under it to read as its consequence rather than as three more
|
||||
fields of the form. panel.js hides the inner block until the box is ticked
|
||||
(and empties it when unticked); without JavaScript everything stays visible,
|
||||
which the server handles identically. The import form reuses the same
|
||||
indented .encrypt-fields look for its password field, but reveals it by
|
||||
file extension instead of a checkbox (see panel.js). */
|
||||
.encrypt { margin-top: 1.2rem; }
|
||||
.encrypt label.check { margin: 0; }
|
||||
.encrypt-fields {
|
||||
margin-top: 1.2rem; margin-left: 1.6rem; padding-left: 0.9rem;
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
.encrypt .encrypt-fields { margin-top: 0; }
|
||||
.encrypt-fields label { margin-top: 0.7rem; }
|
||||
.encrypt-fields .muted { margin: 0.5rem 0 0; font-size: 0.85rem; }
|
||||
|
||||
/* Screen-reader accessible controls that drive UI state (help drawer panes). */
|
||||
.ctrl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||
|
||||
.page-head {
|
||||
display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between;
|
||||
gap: 0.5rem 1rem; margin-bottom: 1rem;
|
||||
}
|
||||
.page-head h1 { margin: 0; }
|
||||
|
||||
.card-head {
|
||||
display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between;
|
||||
gap: 0.5rem 0.75rem; margin-bottom: 0.7rem;
|
||||
}
|
||||
.card-head h2 { margin: 0; }
|
||||
|
||||
.help-link {
|
||||
flex: none; width: 1.35rem; height: 1.35rem; display: inline-flex; align-items: center;
|
||||
justify-content: center; border-radius: 50%; border: 1px solid var(--control-border);
|
||||
color: var(--muted); text-decoration: none; font-family: var(--font-mono);
|
||||
font-size: 0.75rem; font-weight: 600; background: var(--card-bg); cursor: pointer; margin: 0;
|
||||
}
|
||||
.help-link:hover { color: var(--accent-text); border-color: var(--accent-fill); }
|
||||
|
||||
/* Help drawer — CSS checkbox/radio, no script required. */
|
||||
.help-drawer {
|
||||
display: none; position: fixed; top: 0; right: 0; bottom: 0;
|
||||
width: min(26rem, 100vw); z-index: 30;
|
||||
background: var(--card-bg); border-left: 1px solid var(--border);
|
||||
padding: 1.25rem 1.35rem 2rem; overflow-y: auto;
|
||||
}
|
||||
html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-drawer,
|
||||
html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-scrim { display: block; }
|
||||
.help-pane { display: none; }
|
||||
html:has(#help-index:checked) .help-pane-index,
|
||||
html:has(#help-status:checked) .help-pane-status,
|
||||
html:has(#help-password:checked) .help-pane-password,
|
||||
html:has(#help-dns:checked) .help-pane-dns,
|
||||
html:has(#help-records:checked) .help-pane-records,
|
||||
html:has(#help-dmarc:checked) .help-pane-dmarc,
|
||||
html:has(#help-connection:checked) .help-pane-connection,
|
||||
html:has(#help-apps:checked) .help-pane-apps,
|
||||
html:has(#help-domain-settings:checked) .help-pane-domain-settings,
|
||||
html:has(#help-export:checked) .help-pane-export { display: block; }
|
||||
.help-scrim {
|
||||
display: none; position: fixed; inset: 0; z-index: 25;
|
||||
background: rgba(18, 22, 28, 0.28); cursor: pointer;
|
||||
}
|
||||
.help-drawer h2 { margin-top: 1.2rem; }
|
||||
.help-drawer h2:first-of-type,
|
||||
.help-pane h2 { margin-top: 0; }
|
||||
.help-drawer .toc { list-style: none; margin: 0.7rem 0 0; padding: 0; }
|
||||
.help-drawer .toc li { margin: 0.35rem 0 0; }
|
||||
.help-drawer .toc label {
|
||||
margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer;
|
||||
}
|
||||
.help-drawer .more { margin-top: 1.2rem; font-size: 0.85rem; }
|
||||
.help-drawer .more label {
|
||||
display: inline; margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer;
|
||||
}
|
||||
.help-close {
|
||||
position: absolute; top: 0.8rem; right: 0.8rem; margin: 0; padding: 0.35rem 0.6rem;
|
||||
font-size: 0.8rem; font-weight: 600; background: var(--surface-bg); color: var(--fg);
|
||||
border: 1px solid var(--control-border); border-radius: 5px; cursor: pointer;
|
||||
}
|
||||
@media (max-width: 66rem) {
|
||||
.help-drawer { width: 100vw; }
|
||||
}
|
||||
|
||||
/* DMARC pages: paired cards side by side, same layout as .split but for two
|
||||
summary cards at the top of a page. */
|
||||
.pair {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||
gap: 1.2rem; margin-top: 1.2rem;
|
||||
}
|
||||
.pair > .card { margin-left: 0; margin-right: 0; min-width: 0; }
|
||||
.pair > .card + .card { margin-top: 0; }
|
||||
|
||||
/* Attention card: elevated border when a DMARC report has failures. */
|
||||
.card.attn { border-color: var(--st-warn-fg); }
|
||||
|
||||
/* Responsive visibility: hide elements on narrow/wide screens. */
|
||||
.desk-only { display: revert; }
|
||||
.phone-only, .phone-list { display: none; }
|
||||
@media (max-width: 48rem) {
|
||||
.desk-only { display: none !important; }
|
||||
.phone-only, .phone-list { display: revert !important; }
|
||||
}
|
||||
.phone-list { list-style: none; margin: 0; padding: 0; }
|
||||
.phone-list li { border-bottom: 1px solid var(--border); }
|
||||
.phone-list li:last-child { border-bottom: none; }
|
||||
.phone-list a {
|
||||
display: grid; grid-template-columns: auto 1fr; gap: 0.15rem 0.6rem;
|
||||
padding: 0.7rem 0; text-decoration: none; color: inherit;
|
||||
}
|
||||
.phone-list .when { grid-column: 1; font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); }
|
||||
.phone-list .meta { grid-column: 2; font-size: 0.85rem; }
|
||||
.phone-list .subj { grid-column: 1 / -1; font-size: 0.85rem; color: var(--muted); }
|
||||
|
||||
/* Checkbox-label row inside a form (IP restriction toggle). */
|
||||
.check-row {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
margin: 0.5rem 0; cursor: pointer;
|
||||
}
|
||||
+285
-833
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,44 @@
|
||||
});
|
||||
});
|
||||
|
||||
// --- Copy buttons of the component kit ------------------------------
|
||||
// copy_field joins a read-only input or textarea to a Copy button marked
|
||||
// data-copy; the two sit together in one .has-addons field, and the value is
|
||||
// read from the field itself, so it can never drift from what is shown. Where
|
||||
// the clipboard API is absent (plain HTTP outside localhost) the value is
|
||||
// selected instead, ready for the operator's own Ctrl+C. The button's icon
|
||||
// stays; only its word changes for a moment.
|
||||
document.addEventListener("click", function (ev) {
|
||||
var button = ev.target.closest("button[data-copy]");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
var group = button.closest(".has-addons");
|
||||
var field = group && group.querySelector("input, textarea");
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
if (!navigator.clipboard) {
|
||||
field.focus();
|
||||
field.select();
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(field.value).then(function () {
|
||||
var label = button.lastChild;
|
||||
if (!label || label.nodeType !== Node.TEXT_NODE) {
|
||||
return;
|
||||
}
|
||||
var original = label.nodeValue;
|
||||
label.nodeValue = "Copied";
|
||||
setTimeout(function () {
|
||||
label.nodeValue = original;
|
||||
}, 1500);
|
||||
}, function () {
|
||||
field.focus();
|
||||
field.select();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Confirmation on destructive forms --------------------------------
|
||||
// Forms that delete something or invalidate a working credential carry a
|
||||
// data-confirm message. The prompt lives here rather than in an inline
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="4" viewBox="0 0 8 4">
|
||||
<!-- One tooth and one hole of the stamp's edge. Hole radius 2.5 on a pitch of 8:
|
||||
the mark's 2.2 / 7.03. The hairline is the mark's 0.7 stroke at this scale. -->
|
||||
<path d="M0 0H8V3H6.5A2.5 2.5 0 0 0 1.5 3H0Z" fill="#F3EDE1"/>
|
||||
<path d="M0 3H1.5A2.5 2.5 0 0 1 6.5 3H8" fill="none" stroke="#12161C" stroke-width="0.75"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 409 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="52 39.8 117.4 27.6" role="img" aria-label="SelfPost">
|
||||
<!-- The wordmark of selfpost-stamp-compact.svg on its own: the same outlines, no stamp. -->
|
||||
<path d="M61.06 66.41Q58.14 66.41 56.23 65.3Q54.33 64.2 53 62.46L53.96 61.72Q55.21 63.35 56.86 64.32Q58.51 65.29 61.1 65.29Q64.05 65.29 65.67 63.89Q67.28 62.5 67.28 59.88Q67.28 58.08 66.6 57.04Q65.92 56 64.73 55.46Q63.54 54.92 62.08 54.61L59.8 54.17Q57.46 53.73 56.12 52.83Q54.77 51.92 54.18 50.68Q53.58 49.44 53.58 47.98Q53.58 45.91 54.5 44.55Q55.42 43.19 57.08 42.52Q58.75 41.86 60.96 41.86Q63.61 41.86 65.38 42.78Q67.15 43.7 68.17 45.23L67.22 45.94Q66.2 44.48 64.67 43.73Q63.14 42.98 60.89 42.98Q58.07 42.98 56.46 44.21Q54.84 45.43 54.84 47.95Q54.84 49.61 55.54 50.61Q56.23 51.62 57.42 52.16Q58.61 52.71 60.08 53.01L62.35 53.45Q64.77 53.9 66.11 54.83Q67.45 55.77 67.98 57.04Q68.51 58.32 68.51 59.78Q68.51 61.95 67.61 63.42Q66.71 64.88 65.02 65.64Q63.34 66.41 61.06 66.41Z M78.56 66.41Q76.28 66.41 74.64 65.35Q72.99 64.3 72.09 62.29Q71.18 60.29 71.18 57.36Q71.18 54.44 72.07 52.42Q72.95 50.39 74.57 49.36Q76.18 48.32 78.46 48.32Q80.57 48.32 82.17 49.37Q83.76 50.43 84.67 52.35Q85.57 54.27 85.57 56.89V57.43H72.44V58.35Q72.44 61.51 74.06 63.43Q75.67 65.35 78.6 65.35Q80.64 65.35 82.08 64.35Q83.53 63.35 84.27 61.41L85.19 61.92Q84.44 63.96 82.74 65.18Q81.04 66.41 78.56 66.41ZM78.46 49.34Q76.56 49.34 75.21 50.21Q73.87 51.07 73.16 52.66Q72.44 54.24 72.44 56.34V56.48H84.27V56.28Q84.27 54.17 83.54 52.62Q82.81 51.07 81.5 50.21Q80.19 49.34 78.46 49.34Z M93.72 66H91.17Q90.42 66 90.06 65.63Q89.7 65.25 89.7 64.57V40.84H90.89V64.95H93.72Z M97.92 66V49.78H94.83V48.73H97.92V42.57Q97.92 41.76 98.33 41.3Q98.74 40.84 99.59 40.84H102.99V41.89H99.11V48.73H102.99V49.78H99.11V66Z M109.88 66H105.39V42.27H116.07Q118.28 42.27 119.84 43.19Q121.41 44.1 122.26 45.75Q123.11 47.4 123.11 49.61Q123.11 51.82 122.26 53.47Q121.41 55.12 119.84 56.04Q118.28 56.96 116.07 56.96H109.88ZM109.88 46.18V53.08H115.66Q116.55 53.08 117.16 52.76Q117.77 52.43 118.11 51.84Q118.45 51.24 118.45 50.39V48.83Q118.45 47.95 118.11 47.37Q117.77 46.79 117.16 46.48Q116.55 46.18 115.66 46.18Z M132.75 66.41Q130.24 66.41 128.38 65.27Q126.53 64.13 125.53 62.04Q124.53 59.95 124.53 57.09Q124.53 54.24 125.53 52.18Q126.53 50.12 128.38 48.98Q130.24 47.84 132.75 47.84Q135.3 47.84 137.16 48.98Q139.01 50.12 140.01 52.18Q141.02 54.24 141.02 57.09Q141.02 59.95 140.01 62.04Q139.01 64.13 137.16 65.27Q135.3 66.41 132.75 66.41ZM132.75 62.91Q134.49 62.91 135.47 61.83Q136.46 60.76 136.46 58.76V55.46Q136.46 53.45 135.47 52.4Q134.49 51.35 132.75 51.35Q131.09 51.35 130.08 52.4Q129.08 53.45 129.08 55.46V58.76Q129.08 60.76 130.08 61.83Q131.09 62.91 132.75 62.91Z M149.47 66.41Q146.92 66.41 145.19 65.54Q143.45 64.67 142.13 63.14L144.78 60.56Q145.77 61.68 146.94 62.33Q148.11 62.97 149.64 62.97Q151.21 62.97 151.9 62.43Q152.6 61.89 152.6 60.93Q152.6 60.15 152.11 59.69Q151.61 59.23 150.42 59.06L148.66 58.83Q146.75 58.59 145.43 57.94Q144.1 57.3 143.42 56.17Q142.74 55.05 142.74 53.45Q142.74 50.87 144.59 49.36Q146.45 47.84 149.47 47.84Q151.21 47.84 152.45 48.17Q153.69 48.49 154.64 49.12Q155.59 49.75 156.41 50.63L153.86 53.18Q153.08 52.37 151.99 51.82Q150.9 51.28 149.61 51.28Q148.25 51.28 147.58 51.77Q146.92 52.26 146.92 53.11Q146.92 54 147.45 54.46Q147.98 54.92 149.23 55.12L151.04 55.36Q153.96 55.77 155.37 57.08Q156.78 58.38 156.78 60.59Q156.78 62.29 155.88 63.62Q154.98 64.95 153.33 65.68Q151.68 66.41 149.47 66.41Z M168.09 66H164.97Q162.72 66 161.55 64.83Q160.38 63.65 160.38 61.48V51.69H157.76V48.25H159.12Q160.1 48.25 160.46 47.79Q160.82 47.33 160.82 46.42V43.39H164.73V48.25H168.37V51.69H164.73V62.53H168.09Z" fill="#F6EDE2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -24,12 +24,13 @@ func serveStatic(path string, headers map[string]string) *httptest.ResponseRecor
|
||||
// guess, and a rebranded panel keeps serving the old mark from the tab.
|
||||
func TestStaticAssetsCarryETag(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"favicon.png", "favicon.svg", "panel.css", "panel.js", "htmx.min.js",
|
||||
"favicon.png", "favicon.svg", "panel.css", "legacy.css", "panel.js", "htmx.min.js",
|
||||
// The fonts are the assets this matters most for: they are the largest
|
||||
// thing the panel serves and the ones a browser is most willing to keep.
|
||||
"ibm-plex-sans.woff2", "ibm-plex-mono-400.woff2", "ibm-plex-mono-600.woff2",
|
||||
"OFL.txt",
|
||||
"bulma.min.css", "tabler-icons.css", "tabler-icons.woff2",
|
||||
"logo.svg", "wordmark.svg", "perf-edge.svg",
|
||||
} {
|
||||
rec := serveStatic("/static/"+name, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
{{/* The component kit: one {{define}} per partial of docs/plans/panel-redesign.md
|
||||
§ Three layers. This is the only file besides layout.html in which an sp-
|
||||
component class is written; a page calls a partial and passes the struct
|
||||
that components.go declares for it (a page never builds the input with
|
||||
dict), and it never rebuilds the markup. The reference for every partial in
|
||||
every state is the mockup components page,
|
||||
docs/assets/panel-redesign/panel/src/components.html — the markup here is
|
||||
that markup, element for element and class for class, and the kit page
|
||||
(GET /server/components, kit.html) renders it all from fixtures.
|
||||
|
||||
Go templates have no slots, so a component that wraps the page's own
|
||||
content is an open/close pair: box_open … box_close, body_open …
|
||||
body_close, record_open … record_close. Between the two the page writes
|
||||
Bulma markup (a table in its table-container, a form) or calls another
|
||||
partial. */}}
|
||||
|
||||
{{/* ---- inline content: the text a struct field carries */}}
|
||||
|
||||
{{/* inline renders a Text (components.go): plain text, links, code, mono
|
||||
values, strong, emphasis, tags and line breaks, all escaped. It is how a
|
||||
lead, a box's end slot or a fact's value can hold a link or a code span
|
||||
without any field being trusted HTML. */}}
|
||||
{{define "inline"}}{{range .}}{{if .Break}}<br>{{else if .Tag}}{{template "tag" .Tag}}{{else if .Href}}<a href="{{.Href}}">{{template "inline_leaf" .}}</a>{{else}}{{template "inline_leaf" .}}{{end}}{{end}}{{end}}
|
||||
{{define "inline_leaf"}}{{if .Code}}<code>{{.Text}}</code>{{else if .Mono}}<span class="sp-mono">{{.Text}}</span>{{else if .Strong}}<strong>{{.Text}}</strong>{{else if .Em}}<em>{{.Text}}</em>{{else}}{{.Text}}{{end}}{{end}}
|
||||
|
||||
{{/* tag is a status tag (input: Tag). The colour comes from status_tag, the one
|
||||
mapping of status to Bulma classes; the label defaults to the status. */}}
|
||||
{{define "tag"}}<span class="{{status_tag .Status}}">{{if .Label}}{{.Label}}{{else}}{{.Status}}{{end}}</span>{{end}}
|
||||
|
||||
{{/* kicker is the upper-case mono micro-label on its own (input: string).
|
||||
page_head writes its own kicker as the line above the title. */}}
|
||||
{{define "kicker"}}<span class="sp-kicker">{{.}}</span>{{end}}
|
||||
|
||||
{{/* action is a button in a page head or a box foot (input: Action): a link, or
|
||||
a POST form with one submit button when Post is set — a state change is
|
||||
never a link. */}}
|
||||
{{define "action"}}{{if .Post}}<form method="post" action="{{.Post}}"><button type="submit" class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</button></form>{{else}}<a class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}" href="{{.Href}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</a>{{end}}{{end}}
|
||||
|
||||
{{/* ---- page head and postmark */}}
|
||||
|
||||
{{/* page_head opens every page (input: Head): the path or section as the
|
||||
kicker, the h1, a lead or a route line, the postmark when the page has a
|
||||
verdict, a note on the right, and the buttons. */}}
|
||||
{{define "page_head"}}<div class="sp-head">
|
||||
{{if .Postmark}}{{template "postmark" .Postmark}}
|
||||
{{end}}<div class="sp-grow">
|
||||
{{if .Crumbs}}<p class="sp-kicker">{{range $i, $c := .Crumbs}}{{if $i}} {{end}}{{if $c.Href}}<a href="{{$c.Href}}">{{$c.Label}}</a>{{else}}{{$c.Label}}{{end}} /{{end}}</p>
|
||||
{{else if .Kicker}}<p class="sp-kicker">{{.Kicker}}</p>
|
||||
{{end}}<h1 class="title {{if .Compact}}is-4{{else}}is-3{{end}}{{if .Mono}} sp-mono{{end}}">{{.Title}}{{if .Code}} <span class="sp-mono">{{.Code}}</span>{{end}}</h1>
|
||||
{{if .Route}}{{template "route" .Route}}
|
||||
{{else if .Lead}}<p class="sp-lead">{{template "inline" .Lead}}</p>
|
||||
{{end}}</div>
|
||||
{{if .Note}}<div class="sp-muted sp-small">{{range $i, $l := .Note}}{{if $i}}<br>{{end}}{{$l}}{{end}}</div>
|
||||
{{end}}{{if .Actions}}<div class="buttons">{{range .Actions}}{{template "action" .}}{{end}}</div>
|
||||
{{end}}</div>{{end}}
|
||||
|
||||
{{/* postmark is the round verdict stamp (input: Postmark). Inside page_head
|
||||
only, at most one per page. */}}
|
||||
{{define "postmark"}}<div class="sp-postmark{{if eq .Level "warn"}} sp-warn{{else if eq .Level "fail"}} sp-fail{{end}}"><span>{{.Top}}</span><b>{{.Word}}</b><span>{{.Bottom}}</span></div>{{end}}
|
||||
|
||||
{{/* route is the from → to line of a message (input: Route). */}}
|
||||
{{define "route"}}<p class="sp-route">{{range .}}{{if .Arrow}}<i class="ti ti-arrow-right sp-muted"></i>{{else if .Tag}}{{template "tag" .Tag}}{{else}}<span>{{.Text}}</span>{{end}}{{end}}</p>{{end}}
|
||||
|
||||
{{/* flash is the result of the last action, between page_head and the first
|
||||
box (input: Flash). A field's own error stays in help is-danger. */}}
|
||||
{{define "flash"}}<div class="notification {{if .Error}}is-danger{{else}}is-success{{end}} is-light"><i class="ti {{if .Error}}ti-alert-circle{{else}}ti-circle-check{{end}}"></i>{{template "inline" .Text}}</div>{{end}}
|
||||
|
||||
{{/* ---- box */}}
|
||||
|
||||
{{/* box_open starts a box and writes its head (input: Box); box_close ends it.
|
||||
The variants are sp-danger-zone (a destructive confirmation) and
|
||||
sp-credential (a secret shown once). A box foot, a table in its
|
||||
table-container, a run of records, a log or an empty state sit directly
|
||||
inside; anything else goes in body_open … body_close. */}}
|
||||
{{define "box_open"}}<div class="box{{if eq .Variant "danger"}} sp-danger-zone{{else if eq .Variant "credential"}} sp-credential{{end}}"{{if .ID}} id="{{.ID}}"{{end}}>
|
||||
{{template "box_head" .}}
|
||||
{{end}}
|
||||
{{define "box_close"}}</div>{{end}}
|
||||
|
||||
{{/* body_open pads the content of a box (box_body); body_close ends it. */}}
|
||||
{{define "body_open"}}<div class="sp-box-body">{{end}}
|
||||
{{define "body_close"}}</div>{{end}}
|
||||
|
||||
{{/* box_head is the numbered mono head of a box (input: Box): the number — or an
|
||||
icon when the box is not part of a sequence — the title, an end slot (a
|
||||
note, a tag, a link, or a filter form) and an optional link to the
|
||||
section's Help topic. */}}
|
||||
{{define "box_head"}}<div class="sp-box-head"><span class="sp-no">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{else}}{{.No}}{{end}}</span><h2>{{.Title}}</h2>{{if .Filter}}{{template "filter_form" .Filter}}{{else if .End}}<div class="sp-end">{{template "inline" .End}}</div>{{end}}{{if .Help}}<a class="sp-help" href="{{.Help.Href}}" title="{{.Help.Title}}"><i class="ti ti-help-circle"></i></a>{{end}}</div>{{end}}
|
||||
|
||||
{{/* filter_form is the filter of a list in the end slot of its box head
|
||||
(input: Filter): selects, date inputs and one button, sent with GET. */}}
|
||||
{{define "filter_form"}}<form class="sp-end" method="get" action="{{.Action}}">{{range .Selects}}
|
||||
<div class="select"><select name="{{.Name}}" aria-label="{{.Label}}">{{$sel := .Selected}}{{range .Options}}<option value="{{.Value}}"{{if eq .Value $sel}} selected{{end}}>{{.Label}}</option>{{end}}</select></div>{{end}}{{range .Dates}}
|
||||
<input class="input" type="date" name="{{.Name}}" value="{{.Value}}" aria-label="{{.Label}}">{{end}}
|
||||
<button type="submit" class="button"><i class="ti ti-filter"></i>{{if .Button}}{{.Button}}{{else}}Filter{{end}}</button>
|
||||
</form>{{end}}
|
||||
|
||||
{{/* box_foot is the line under a box's content (input: Foot): a legend, paging
|
||||
with an end slot, or the one button that finishes the page. */}}
|
||||
{{define "box_foot"}}<div class="sp-box-foot">{{if .Button}}{{template "action" .Button}}{{else}}{{if .Bare}}{{template "inline" .Text}}{{else}}<span>{{template "inline" .Text}}</span>{{end}}{{if .Link}}<a href="{{.Link.Href}}">{{.Link.Label}}</a>{{end}}{{if .End}}<span class="sp-end">{{template "inline" .End}}</span>{{end}}{{end}}</div>{{end}}
|
||||
|
||||
{{/* ---- health, records, copy */}}
|
||||
|
||||
{{/* health_cards is the Overview grid (input: []HealthCard); each card is
|
||||
health_card (input: HealthCard) and links to its detail. */}}
|
||||
{{define "health_cards"}}<div class="sp-health">{{range .}}
|
||||
{{template "health_card" .}}{{end}}
|
||||
</div>{{end}}
|
||||
{{define "health_card"}}<a href="{{.Href}}"{{if eq .Level "warn"}} class="sp-warn"{{else if eq .Level "fail"}} class="sp-fail"{{end}}><span class="sp-h-icon"><i class="ti {{.Icon}}"></i></span><span><span class="sp-h-name">{{.Name}}</span><span class="sp-h-value">{{.Value}}</span><span class="sp-h-sub">{{.Sub}}</span></span></a>{{end}}
|
||||
|
||||
{{/* dns_record is one DNS record (input: Record): its status, what to publish
|
||||
with a Copy button, and "In DNS now" only when it differs. */}}
|
||||
{{define "dns_record"}}<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>{{.Name}}</h3>{{if .Status.Status}}{{template "tag" .Status}}{{end}}{{if .Note}}<small>{{template "inline" .Note}}</small>{{end}}</div>
|
||||
{{if .Host}}<div class="sp-row">
|
||||
{{template "copy_field" .HostField}}
|
||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="{{.Type}}"></div></div>
|
||||
</div>
|
||||
{{end}}{{if .Value}}{{template "copy_field" .ValueField}}
|
||||
{{end}}{{if .InDNS}}<div class="field"><label class="label">In DNS now</label>
|
||||
<div class="control"><input class="input is-danger" readonly value="{{.InDNS}}"></div>
|
||||
{{if .Problem}}<p class="help is-danger">{{template "inline" .Problem}}</p>
|
||||
{{end}}</div>
|
||||
{{end}}</div>{{end}}
|
||||
|
||||
{{/* record_open starts the padded block a record sits in and record_close ends
|
||||
it, for what is not a DNS record but is laid out like one: the fields of a
|
||||
secret shown once, a form inside a box. */}}
|
||||
{{define "record_open"}}<div class="sp-record">{{end}}
|
||||
{{define "record_close"}}</div>{{end}}
|
||||
|
||||
{{/* copy_field is a read-only value joined to its Copy button (input:
|
||||
CopyField): one line, or a textarea when Rows is set — a DKIM key. The
|
||||
button is wired by panel.js through data-copy. */}}
|
||||
{{define "copy_field"}}<div class="field"><label class="label">{{.Label}}</label>
|
||||
<div class="field has-addons"><div class="control is-expanded">{{if .Rows}}<textarea class="textarea" rows="{{.Rows}}" readonly>{{.Value}}</textarea>{{else}}<input class="input" readonly value="{{.Value}}">{{end}}</div><div class="control"><button type="button" class="button" data-copy><i class="ti ti-copy"></i>Copy</button></div></div>
|
||||
{{if .Help}}<p class="help">{{template "inline" .Help}}</p>
|
||||
{{end}}</div>{{end}}
|
||||
|
||||
{{/* ---- facts, menu, timeline, log, states */}}
|
||||
|
||||
{{/* facts is a label / value grid (input: []Fact); Big is for a headline
|
||||
number, with its supporting line under it. */}}
|
||||
{{define "facts"}}<dl class="sp-facts">{{range .}}
|
||||
<div><dt>{{.Label}}</dt><dd{{if .Mono}} class="sp-mono"{{else if .Big}} class="sp-big"{{end}}>{{template "inline" .Value}}</dd>{{if .Note}}<dd class="sp-muted">{{template "inline" .Note}}</dd>{{end}}</div>{{end}}
|
||||
</dl>{{end}}
|
||||
|
||||
{{/* side_menu is the Bulma menu of a detail page (input: SideMenu): icons,
|
||||
count tags, and the rarely used entries in a group of their own. It
|
||||
stays in view while the page scrolls. */}}
|
||||
{{define "side_menu"}}<aside class="menu sp-side">{{range .Groups}}
|
||||
<p class="menu-label">{{.Label}}</p>
|
||||
<ul class="menu-list">{{range .Items}}
|
||||
<li><a{{if .Active}} class="is-active"{{else if .Danger}} class="has-text-danger"{{end}} href="{{.Href}}"><i class="ti {{.Icon}}{{if .Danger}} has-text-danger{{end}}"></i>{{.Label}}{{if .Tag}} {{template "tag" .Tag}}{{end}}</a></li>{{end}}
|
||||
</ul>{{end}}
|
||||
</aside>{{end}}
|
||||
|
||||
{{/* timeline is what happened to a message, in order (input: []Step). */}}
|
||||
{{define "timeline"}}<ol class="sp-timeline">{{range .}}
|
||||
<li{{if eq .Level "warn"}} class="sp-warn"{{else if eq .Level "fail"}} class="sp-fail"{{else if eq .Level "pending"}} class="sp-pending"{{end}}><time>{{.Time}}</time>{{if .Strong}}<strong>{{.Strong}}</strong>{{end}}{{template "inline" .Text}}</li>{{end}}
|
||||
</ol>{{end}}
|
||||
|
||||
{{/* log_pane is a dark block of log lines (input: []LogLine). The time is
|
||||
dimmed, a warning amber, an error red. The newline between lines is part of
|
||||
the text: the pane is a pre. */}}
|
||||
{{define "log_pane"}}<pre class="sp-log">{{range $i, $l := .}}{{if $i}}
|
||||
{{end}}{{if $l.Time}}<span class="sp-d">{{$l.Time}}</span> {{end}}{{if eq $l.Level "warn"}}<span class="sp-w">{{$l.Text}}</span>{{else if eq $l.Level "error"}}<span class="sp-e">{{$l.Text}}</span>{{else}}{{$l.Text}}{{end}}{{end}}</pre>{{end}}
|
||||
|
||||
{{/* empty_state says what is absent and why that is fine (input: EmptyState). */}}
|
||||
{{define "empty_state"}}<div class="sp-empty"><i class="ti {{.Icon}}"></i>{{template "inline" .Text}}</div>{{end}}
|
||||
|
||||
{{/* confirm_list lists what a destructive action will take with it (input:
|
||||
[]Text), inside an sp-danger-zone box above the danger button. */}}
|
||||
{{define "confirm_list"}}<ul class="sp-list-check">{{range .}}
|
||||
<li><i class="ti ti-x"></i>{{template "inline" .}}</li>{{end}}
|
||||
</ul>{{end}}
|
||||
|
||||
{{/* help_topic is one topic of the Help page (input: HelpTopic): a heading and
|
||||
paragraphs; topics stack inside one box. */}}
|
||||
{{define "help_topic"}}<div class="sp-help-topic"{{if .ID}} id="{{.ID}}"{{end}}><h3>{{.Title}}</h3>{{range .Body}}<p>{{template "inline" .}}</p>{{end}}</div>{{end}}
|
||||
@@ -0,0 +1,153 @@
|
||||
{{/* The kit page (GET /server/components): every partial of components.html in
|
||||
every state, filled from kit.go — the mockup components page rebuilt with
|
||||
the real templates. Like any page it is thin: partial calls with structs,
|
||||
Bulma layout classes, and the page utilities; the markup of a component is
|
||||
written in components.html and nowhere else. */}}
|
||||
{{define "content"}}
|
||||
{{template "page_head" .Head}}
|
||||
|
||||
{{template "flash" .Flash}}
|
||||
{{template "flash" .FlashError}}
|
||||
|
||||
{{template "box_open" .Layouts}}
|
||||
{{template "body_open"}}
|
||||
{{template "facts" .LayoutFacts}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .Postmark}}
|
||||
{{template "body_open"}}
|
||||
<div class="buttons">{{range .Postmarks}}
|
||||
{{template "postmark" .}}{{end}}
|
||||
</div>
|
||||
<p class="sp-muted">Only inside page_head, at most one per page, and only where the page has a verdict to give.</p>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
|
||||
{{template "box_open" .StatusTag}}
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Status</th><th>Tag</th><th>Bulma classes</th></tr></thead>
|
||||
<tbody>{{range .StatusRows}}
|
||||
<tr><td>{{.Words}}</td><td>{{template "tag" .Tag}}</td><td class="sp-mono sp-small">{{status_tag .Tag.Status}}</td></tr>{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .BoxHead}}
|
||||
{{template "body_open"}}
|
||||
<p class="mb-2">The <i class="ti ti-help-circle"></i> after the end slot is <code>sp-help</code>: an optional link to the section's topic on the Help page. It replaces today's help drawer.</p>
|
||||
<p>box_body — padded content. A <code>table</code> or a run of <code>sp-record</code> blocks sits directly in the box, without box_body.</p>
|
||||
{{template "body_close"}}
|
||||
{{template "box_foot" .BoxFoot}}
|
||||
{{template "box_close"}}
|
||||
|
||||
{{template "box_open" .DangerZone}}
|
||||
{{template "body_open"}}
|
||||
<p>For destructive confirmations and the "delete" entry of a settings page. The number may be an icon when the box is not part of a sequence.</p>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
|
||||
{{template "box_open" .Credential}}
|
||||
{{template "body_open"}}
|
||||
<p>Only for a secret the panel will never show again.</p>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "box_open" .HealthCard}}
|
||||
{{template "health_cards" .HealthCards}}
|
||||
{{template "box_close"}}
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-7">
|
||||
{{template "box_open" .DNSRecord}}
|
||||
{{range .Records}}{{template "dns_record" .}}
|
||||
{{end}}{{template "box_close"}}
|
||||
|
||||
{{template "box_open" .Tables}}
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="{{status_tag "sent"}}">sent</span></td><td class="sp-actions"><a href="#">Details</a><a class="has-text-danger" href="#">Delete</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
{{template "box_foot" .TablesFoot}}
|
||||
{{template "box_close"}}
|
||||
|
||||
{{template "box_open" .LogPane}}
|
||||
{{template "log_pane" .LogLines}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
|
||||
<div class="column is-5">
|
||||
{{template "box_open" .Facts}}
|
||||
{{template "body_open"}}
|
||||
{{template "facts" .FactList}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
|
||||
{{template "box_open" .Timeline}}
|
||||
{{template "body_open"}}
|
||||
{{template "timeline" .Steps}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
|
||||
{{template "box_open" .EmptyState}}
|
||||
{{template "empty_state" .Empty}}
|
||||
{{template "box_close"}}
|
||||
|
||||
{{template "box_open" .ConfirmList}}
|
||||
{{template "body_open"}}
|
||||
{{template "confirm_list" .Confirm}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
{{template "side_menu" .SideMenu}}
|
||||
</div>
|
||||
<div class="column is-9">
|
||||
{{template "box_open" .FormRows}}
|
||||
{{template "body_open"}}
|
||||
<div class="sp-form">
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label">sp-row — a wide field</label><div class="control"><input class="input" value="host"></div></div>
|
||||
<div class="field"><label class="label">and a short one</label><div class="control"><input class="input" value="25"></div></div>
|
||||
</div>
|
||||
<div class="sp-pair">
|
||||
<div class="field"><label class="label">sp-pair — two equal fields</label><div class="control"><input class="input" type="password"></div></div>
|
||||
<div class="field"><label class="label">Repeat</label><div class="control"><input class="input" type="password"></div><p class="help">Help text lives in Bulma's <code>help</code>.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "route" .Route}}
|
||||
{{template "body_close"}}
|
||||
{{template "help_topic" .HelpTopic}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "box_open" .Shell}}
|
||||
{{template "body_open"}}
|
||||
{{template "facts" .ShellFacts}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
|
||||
{{template "box_open" .TypeScale}}
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Size</th><th>Looks like</th><th>Used for</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-small">body · 16px</td><td>The quick brown fox, 203.0.113.25</td><td class="sp-muted">Text, table cells, inputs, selects, buttons, menus and their dropdowns</td></tr>
|
||||
<tr><td class="sp-mono sp-small">--sp-fs-s · 14px</td><td class="sp-small">The quick brown fox, <span class="sp-mono">203.0.113.25</span></td><td class="sp-muted">Supporting text: labels, <code>help</code>, notes in a box head or foot, mono values (<code>sp-small</code>), logs, the footer</td></tr>
|
||||
<tr><td class="sp-mono sp-small">--sp-fs-xs · 13px</td><td>{{template "kicker" .KickerSample}} <span class="tag is-light">tag</span></td><td class="sp-muted">Upper-case mono micro-labels (kicker, table head, <code>dt</code>, box title) and tags — nothing else</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
{{template "box_foot" .TypeScaleFoot}}
|
||||
{{template "box_close"}}
|
||||
{{end}}
|
||||
@@ -1,16 +1,32 @@
|
||||
{{define "layout.html"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{{/* The shell of every redesigned page (docs/plans/panel-redesign.md § Three
|
||||
layers): a brick navbar with the wordmark and hover menus, the stamp's
|
||||
perforated edge, the strip of sibling pages, the page, the footer. It is
|
||||
built exactly like the mockups' build.py builds it, and it is the only
|
||||
place besides components.html where sp- classes are written. A page never
|
||||
writes any of it.
|
||||
|
||||
layout.html is executed with a Frame (components.go): .Shell is what the
|
||||
shell shows, .Page is the page's own data, handed to the page's "content"
|
||||
template. Which entries are visible comes from .Shell (Engine.shell derives
|
||||
it from the role, the feature flags and the user's reach); the paths are
|
||||
written out here, so the menu can be read, and checked against the router
|
||||
(guard_routes_test.go), without running anything. */}}
|
||||
|
||||
{{/* layout_head is the head of both documents; the stylesheets, in this order,
|
||||
and the two scripts are all a page ever loads. */}}
|
||||
{{define "layout_head"}}<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}}</title>
|
||||
<title>{{.Title}} · SelfPost</title>
|
||||
{{/* The tab icon is the stamp's small-size variant — the initials rather than
|
||||
the two-line wordmark, which stops resolving below 32px (the thresholds are
|
||||
recorded in docs/assets/selfpost-proof.html). The PNG is declared first and
|
||||
the SVG second: a browser takes the last icon whose type it understands, so
|
||||
vector wins wherever it is supported and the raster catches the rest. */}}
|
||||
the two-line wordmark, which stops resolving below 32px. The PNG is declared
|
||||
first and the SVG second: a browser takes the last icon whose type it
|
||||
understands, so vector wins wherever it is supported and the raster catches
|
||||
the rest. */}}
|
||||
<link rel="icon" href="/static/favicon.png" sizes="32x32">
|
||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||||
<link rel="stylesheet" href="/static/tabler-icons.css">
|
||||
<link rel="stylesheet" href="/static/panel.css">
|
||||
{{/* Unless told otherwise, htmx injects a stylesheet element of its own into
|
||||
the head for the request-indicator classes. The panel uses no
|
||||
@@ -20,126 +36,113 @@
|
||||
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/panel.js" defer></script>
|
||||
</head>
|
||||
</head>{{end}}
|
||||
|
||||
{{/* layout.html is the signed-in document. Engine.Render uses
|
||||
layout_signed_out instead when there is no user. */}}
|
||||
{{define "layout.html"}}<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
{{template "layout_head" .Shell}}
|
||||
<body>
|
||||
{{if .User}}{{template "help_inputs" .}}{{end}}
|
||||
{{/* Two columns: the navigation on the left and the page beside it, centred
|
||||
together (see .shell in panel.css). The signed-out pages have no navigation
|
||||
and the shell holds their card alone. */}}
|
||||
<div class="shell">
|
||||
{{if .User}}{{template "nav" .}}{{end}}
|
||||
{{/* The page name travels onto <main> as a class so the stylesheet can size a
|
||||
page to what it holds — the signed-out pages are narrower than the rest
|
||||
(see main.page-* in panel.css). It is .Active, already carried for the
|
||||
navigation, rather than a second field every handler would have to
|
||||
remember to set. The "wide" block beside it is how a page made of data
|
||||
asks for the whole column instead of the reading measure; it is the page's
|
||||
own declaration rather than a name in the stylesheet, since .Active cannot
|
||||
tell the send log from a single delivery's page. */}}
|
||||
<main class="page-{{.Active}} {{template "wide" .}}">
|
||||
{{template "content" .}}
|
||||
{{/* Appropriate Legal Notices (AGPL-3.0): copyright, how to read the licence,
|
||||
where the Corresponding Source is, and that there is no warranty. Shown
|
||||
on every page, including login/setup — those are interactive UIs too.
|
||||
The running version stays signed-in only: it is what a backup manifest is
|
||||
checked against on restore, and must not be advertised to the internet. */}}
|
||||
<footer class="version">
|
||||
{{if .User}}SelfPost {{.Version}} · {{end}}{{.Copyright}} ·
|
||||
<a href="/license">License (AGPL-3.0)</a> ·
|
||||
<a href="{{.SourceURL}}">Source</a> ·
|
||||
No warranty
|
||||
</footer>
|
||||
{{template "nav" .Shell}}
|
||||
{{template "subnav" .Shell}}
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
{{template "content" .Page}}
|
||||
{{/* Appropriate Legal Notices (AGPL-3.0): copyright, how to read the
|
||||
licence, where the Corresponding Source is, and that there is no
|
||||
warranty. The running version is shown signed in only: it is what a
|
||||
backup manifest is checked against on restore, and must not be
|
||||
advertised to the internet. */}}
|
||||
<footer class="sp-foot">SelfPost {{.Shell.Version}} · {{.Shell.Copyright}} · <a href="/license">License (AGPL-3.0)</a> · <a href="{{.Shell.SourceURL}}">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{{if .User}}{{template "help_drawer" .}}{{end}}
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
{{/* nav is the panel's navigation bar. It is rendered here, from the layout, so
|
||||
every authenticated page has it without the page's own template having to
|
||||
remember to include it; .Active names the current page so it is highlighted
|
||||
instead of linking to itself. Unauthenticated pages (login, setup) carry no
|
||||
.User and get no nav. */}}
|
||||
{{define "nav"}}
|
||||
<nav class="nav">
|
||||
{{/* The order of the blocks is the order they are read in, top to bottom of
|
||||
the column: the mark, the panel's pages, and the session last. The mark
|
||||
goes to /status, the page the panel opens on — named outright rather
|
||||
than as "/", which is only a redirect to it. */}}
|
||||
<a class="brand" href="{{if .IsGlobal}}/status{{else}}/domains{{end}}"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
|
||||
<div class="links">
|
||||
{{if .IsGlobal}}
|
||||
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
|
||||
{{end}}
|
||||
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
|
||||
{{if and .IsGlobal .InboundEnabled}}
|
||||
{{if eq .Active "inbound"}}<span aria-current="page">{{template "icon-inbound"}}Inbound</span>{{else}}<a href="/inbound">{{template "icon-inbound"}}Inbound</a>{{end}}
|
||||
{{end}}
|
||||
{{if .DMARCEnabled}}
|
||||
{{if eq .Active "dmarc"}}<span aria-current="page">{{template "icon-dmarc"}}DMARC</span>{{else}}<a href="/dmarc">{{template "icon-dmarc"}}DMARC</a>{{end}}
|
||||
{{end}}
|
||||
{{if eq .Active "help"}}<span aria-current="page">{{template "icon-help"}}Help</span>{{else}}<a href="/help">{{template "icon-help"}}Help</a>{{end}}
|
||||
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
|
||||
{{if .IsGlobal}}
|
||||
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
|
||||
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/system-log">{{template "icon-system-log"}}System log</a>{{end}}
|
||||
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</a>{{end}}
|
||||
{{if eq .Active "users"}}<span aria-current="page">{{template "icon-users"}}Users</span>{{else}}<a href="/users">{{template "icon-users"}}Users</a>{{end}}
|
||||
{{end}}
|
||||
{{/* layout_signed_out is the split brand / form screen of sign-in and setup:
|
||||
the stamp on the brick, the page's own form beside it, and the legal
|
||||
notice without the version. */}}
|
||||
{{define "layout_signed_out"}}<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
{{template "layout_head" .Shell}}
|
||||
<body>
|
||||
<div class="columns is-gapless sp-signin">
|
||||
<div class="column is-half sp-signin-brand">
|
||||
<div>
|
||||
<img src="/static/logo.svg" alt="SelfPost">
|
||||
<p>YOUR SMTP. YOUR RULES.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session">
|
||||
<span class="session-user muted">{{template "icon-account"}}User: {{.User}}</span>
|
||||
{{if eq .Active "settings"}}<span aria-current="page">{{template "icon-settings"}}Settings</span>{{else}}<a href="/settings">{{template "icon-settings"}}Settings</a>{{end}}
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
|
||||
</form>
|
||||
<div class="column is-half sp-signin-form">
|
||||
{{template "content" .Page}}
|
||||
<p class="sp-foot">{{.Shell.Copyright}} · <a href="/license">License (AGPL-3.0)</a> · <a href="{{.Shell.SourceURL}}">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
{{/* nav is the navbar and the perforated edge under it (input: Shell). A group
|
||||
with nothing in it for this user is absent; the group of the current page
|
||||
carries sp-current and its current entry is-active. */}}
|
||||
{{define "nav"}}<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="{{.Home}}"><img src="/static/wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
{{if .ShowOverview}} <a class="navbar-item{{if eq .Section "overview"}} sp-current{{end}}" href="/overview">Overview</a>
|
||||
{{end}}{{if .ShowOutbound}} <div class="navbar-item has-dropdown is-hoverable{{if eq .Section "outbound"}} sp-current{{end}}">
|
||||
<a class="navbar-link" href="/outbound/domains">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item{{if and (eq .Section "outbound") (eq .Page "domains")}} is-active{{end}}" href="/outbound/domains"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item{{if and (eq .Section "outbound") (eq .Page "log")}} is-active{{end}}" href="/outbound/log"><i class="ti ti-list-details"></i>Log</a>
|
||||
{{if .ShowQueue}} <a class="navbar-item{{if and (eq .Section "outbound") (eq .Page "queue")}} is-active{{end}}" href="/outbound/queue"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
{{end}}{{if .ShowDMARC}} <a class="navbar-item{{if and (eq .Section "outbound") (eq .Page "dmarc")}} is-active{{end}}" href="/outbound/dmarc"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
{{end}} </div>
|
||||
</div>
|
||||
{{end}}{{if .ShowInbound}} <div class="navbar-item has-dropdown is-hoverable{{if eq .Section "inbound"}} sp-current{{end}}">
|
||||
<a class="navbar-link" href="/inbound/domains">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "domains")}} is-active{{end}}" href="/inbound/domains"><i class="ti ti-world-download"></i>Domains</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}{{if .ShowServer}} <div class="navbar-item has-dropdown is-hoverable{{if eq .Section "server"}} sp-current{{end}}">
|
||||
<a class="navbar-link" href="/server/health">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item{{if and (eq .Section "server") (eq .Page "health")}} is-active{{end}}" href="/server/health"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item{{if and (eq .Section "server") (eq .Page "log")}} is-active{{end}}" href="/server/log"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item{{if and (eq .Section "server") (eq .Page "backup")}} is-active{{end}}" href="/server/backup"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item{{if and (eq .Section "server") (eq .Page "users")}} is-active{{end}}" href="/server/users"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item{{if and (eq .Section "server") (eq .Page "settings")}} is-active{{end}}" href="/server/settings"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}} </div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable{{if eq .Section "user"}} sp-current{{end}}">
|
||||
<a class="navbar-link" href="/account"><i class="ti ti-user-circle"></i>{{.User}}</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="/account"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="/help"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
{{/* Signing out is a POST (the handler answers nothing else), so it
|
||||
stays a form that works without scripts. The submit button
|
||||
carries navbar-item: Bulma resets a button to plain text, so
|
||||
it takes the dropdown's entry styling like the links around it
|
||||
(only Bulma's hover tint, which it gives to a.navbar-item
|
||||
alone, is missing). */}}
|
||||
<form method="post" action="/logout"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{{end}}
|
||||
<div class="sp-perf" aria-hidden="true"></div>{{end}}
|
||||
|
||||
{{/* Whether the page takes the column whole. Empty here, so a page is held to
|
||||
the reading measure unless it says otherwise; the pages that are tables of
|
||||
data, raw log lines or side-by-side cards redefine this as the word "wide",
|
||||
which lands in <main>'s class list (see main.wide in panel.css). Each
|
||||
page's template file is parsed after this one, so its definition replaces
|
||||
the empty one. */}}
|
||||
{{define "wide"}}{{end}}
|
||||
|
||||
{{/* back_link — up-navigation on drill-down pages. Invoke with the "back"
|
||||
function, e.g. {{template "back_link" (back "/users" "Back to users")}}.
|
||||
Place it directly under the page <h1> and above the cards (see .back in
|
||||
panel.css). TestDrillDownPagesPlaceBackLinkAboveContent guards this. */}}
|
||||
{{define "back_link"}}
|
||||
<a class="back" href="{{.Href}}">← {{.Label}}</a>
|
||||
{{end}}
|
||||
|
||||
{{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they
|
||||
inherit the link's colour through currentColor, cost no extra request, and
|
||||
need no exemption from the panel's "default-src 'self'" policy. Each is
|
||||
aria-hidden because the entry's own text is already the accessible name;
|
||||
the icon is a landmark for the eye, not a second label. Kept as separate
|
||||
templates so the nav above stays one readable line per page. */}}
|
||||
{{define "icon-status"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></svg>{{end}}
|
||||
{{define "icon-domains"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></svg>{{end}}
|
||||
{{define "icon-inbound"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></svg>{{end}}
|
||||
{{define "icon-dmarc"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4.5h11v7H2.5z"/><path d="M5 7.5h6M5 9.5h4"/></svg>{{end}}
|
||||
{{define "icon-help"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2V11.4"/><path d="M8 5.05v.01"/></svg>{{end}}
|
||||
{{define "icon-deliveries"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></svg>{{end}}
|
||||
{{define "icon-mail-queue"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></svg>{{end}}
|
||||
{{define "icon-system-log"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></svg>{{end}}
|
||||
{{/* A filing cabinet rather than a storage box: the box read as a box, while
|
||||
two drawers with handles is the picture of an archive. */}}
|
||||
{{define "icon-backup"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.75" y="1.75" width="10.5" height="12.5" rx="1.15"/><path d="M2.75 8h10.5"/><path d="M6.4 4.85h3.2M6.4 11.15h3.2"/></svg>{{end}}
|
||||
{{define "icon-account"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="5.4" r="2.75"/><path d="M2.9 14.25a5.1 5.1 0 0 1 10.2 0"/></svg>{{end}}
|
||||
{{/* Two people: rear as a right-side crescent (head + shoulder) so it peeks
|
||||
from behind; front is a full silhouette matching icon-account. Heads share
|
||||
one baseline; shoulder arcs share one floor — otherwise the pair looks
|
||||
lopsided at 16 px. */}}
|
||||
{{define "icon-users"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.9 3.1a2.1 2.1 0 0 1 0 4.2"/><path d="M14.7 14.25a3.8 3.8 0 0 0-3.9-3.65"/><circle cx="5.5" cy="5.2" r="2.5"/><path d="M1.4 14.25a4.8 4.8 0 0 1 8.2 0"/></svg>{{end}}
|
||||
{{/* A gear (Feather "settings"), not a sun-with-rays: at 16 px spokes alone
|
||||
read as brightness, while the toothed rim is the picture of configuration.
|
||||
viewBox 0 0 24 matches the source path; CSS sizes the icon to 1 rem. */}}
|
||||
{{define "icon-settings"}}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>{{end}}
|
||||
{{/* Sign out is the one icon here that marks an action rather than a page: an
|
||||
open door with the arrow leading out of it. */}}
|
||||
{{define "icon-sign-out"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.1 14.25H3.65a1.15 1.15 0 0 1-1.15-1.15V2.9a1.15 1.15 0 0 1 1.15-1.15H6.1"/><path d="M10.6 11.15 13.75 8 10.6 4.85"/><path d="M13.75 8H6.35"/></svg>{{end}}
|
||||
{{/* subnav is the strip of sibling pages under the edge (input: Shell): the
|
||||
pages of the current group, the current one marked. A page outside every
|
||||
group (the user menu, the kit page's neighbours) has none. */}}
|
||||
{{define "subnav"}}{{if and .ShowOutbound (eq .Section "outbound")}}<div class="sp-subnav"><div class="container"><a href="/outbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-upload"></i>Domains</a><a href="/outbound/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowQueue}}<a href="/outbound/queue"{{if eq .Page "queue"}} aria-current="page"{{end}}><i class="ti ti-stack-2"></i>Queue</a>{{end}}{{if .ShowDMARC}}<a href="/outbound/dmarc"{{if eq .Page "dmarc"}} aria-current="page"{{end}}><i class="ti ti-report-analytics"></i>DMARC reports</a>{{end}}</div></div>
|
||||
{{else if and .ShowInbound (eq .Section "inbound")}}<div class="sp-subnav"><div class="container"><a href="/inbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-download"></i>Domains</a></div></div>
|
||||
{{else if and .ShowServer (eq .Section "server")}}<div class="sp-subnav"><div class="container"><a href="/server/health"{{if eq .Page "health"}} aria-current="page"{{end}}><i class="ti ti-heartbeat"></i>Health</a><a href="/server/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-file-text"></i>System log</a><a href="/server/backup"{{if eq .Page "backup"}} aria-current="page"{{end}}><i class="ti ti-archive"></i>Backup</a><a href="/server/users"{{if eq .Page "users"}} aria-current="page"{{end}}><i class="ti ti-users"></i>Users</a><a href="/server/settings"{{if eq .Page "settings"}} aria-current="page"{{end}}><i class="ti ti-settings"></i>Settings</a></div></div>
|
||||
{{end}}{{end}}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
{{/* The layout of the pre-redesign pages: the old stylesheet (legacy.css) and the
|
||||
side navigation. A page that has been restyled is rendered by layout.html
|
||||
instead; Engine decides by the page's name (kitPages in view.go) and the
|
||||
pair goes when the last old page does. */}}
|
||||
{{define "layout_legacy.html"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}}</title>
|
||||
{{/* The tab icon is the stamp's small-size variant — the initials rather than
|
||||
the two-line wordmark, which stops resolving below 32px (the thresholds are
|
||||
recorded in docs/assets/selfpost-proof.html). The PNG is declared first and
|
||||
the SVG second: a browser takes the last icon whose type it understands, so
|
||||
vector wins wherever it is supported and the raster catches the rest. */}}
|
||||
<link rel="icon" href="/static/favicon.png" sizes="32x32">
|
||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/legacy.css">
|
||||
{{/* Unless told otherwise, htmx injects a stylesheet element of its own into
|
||||
the head for the request-indicator classes. The panel uses no
|
||||
hx-indicator, and that injected element would be the one thing the
|
||||
Content-Security-Policy has to make an exception for, so it
|
||||
is switched off here. */}}
|
||||
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/panel.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{{if .User}}{{template "help_inputs" .}}{{end}}
|
||||
{{/* Two columns: the navigation on the left and the page beside it, centred
|
||||
together (see .shell in legacy.css). The signed-out pages have no navigation
|
||||
and the shell holds their card alone. */}}
|
||||
<div class="shell">
|
||||
{{if .User}}{{template "nav" .}}{{end}}
|
||||
{{/* The page name travels onto <main> as a class so the stylesheet can size a
|
||||
page to what it holds — the signed-out pages are narrower than the rest
|
||||
(see main.page-* in legacy.css). It is .Active, already carried for the
|
||||
navigation, rather than a second field every handler would have to
|
||||
remember to set. The "wide" block beside it is how a page made of data
|
||||
asks for the whole column instead of the reading measure; it is the page's
|
||||
own declaration rather than a name in the stylesheet, since .Active cannot
|
||||
tell the send log from a single delivery's page. */}}
|
||||
<main class="page-{{.Active}} {{template "wide" .}}">
|
||||
{{template "content" .}}
|
||||
{{/* Appropriate Legal Notices (AGPL-3.0): copyright, how to read the licence,
|
||||
where the Corresponding Source is, and that there is no warranty. Shown
|
||||
on every page, including login/setup — those are interactive UIs too.
|
||||
The running version stays signed-in only: it is what a backup manifest is
|
||||
checked against on restore, and must not be advertised to the internet. */}}
|
||||
<footer class="version">
|
||||
{{if .User}}SelfPost {{.Version}} · {{end}}{{.Copyright}} ·
|
||||
<a href="/license">License (AGPL-3.0)</a> ·
|
||||
<a href="{{.SourceURL}}">Source</a> ·
|
||||
No warranty
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
{{if .User}}{{template "help_drawer" .}}{{end}}
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
{{/* nav is the panel's navigation bar. It is rendered here, from the layout, so
|
||||
every authenticated page has it without the page's own template having to
|
||||
remember to include it; .Active names the current page so it is highlighted
|
||||
instead of linking to itself. Unauthenticated pages (login, setup) carry no
|
||||
.User and get no nav. */}}
|
||||
{{define "nav"}}
|
||||
<nav class="nav">
|
||||
{{/* The order of the blocks is the order they are read in, top to bottom of
|
||||
the column: the mark, the panel's pages, and the session last. The mark
|
||||
goes to /status, the page the panel opens on — named outright rather
|
||||
than as "/", which is only a redirect to it. */}}
|
||||
<a class="brand" href="{{if .IsGlobal}}/status{{else}}/domains{{end}}"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
|
||||
<div class="links">
|
||||
{{if .IsGlobal}}
|
||||
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
|
||||
{{end}}
|
||||
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
|
||||
{{if and .IsGlobal .InboundEnabled}}
|
||||
{{if eq .Active "inbound"}}<span aria-current="page">{{template "icon-inbound"}}Inbound</span>{{else}}<a href="/inbound">{{template "icon-inbound"}}Inbound</a>{{end}}
|
||||
{{end}}
|
||||
{{if .DMARCEnabled}}
|
||||
{{if eq .Active "dmarc"}}<span aria-current="page">{{template "icon-dmarc"}}DMARC</span>{{else}}<a href="/dmarc">{{template "icon-dmarc"}}DMARC</a>{{end}}
|
||||
{{end}}
|
||||
{{if eq .Active "help"}}<span aria-current="page">{{template "icon-help"}}Help</span>{{else}}<a href="/help">{{template "icon-help"}}Help</a>{{end}}
|
||||
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
|
||||
{{if .IsGlobal}}
|
||||
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
|
||||
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/system-log">{{template "icon-system-log"}}System log</a>{{end}}
|
||||
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</a>{{end}}
|
||||
{{if eq .Active "users"}}<span aria-current="page">{{template "icon-users"}}Users</span>{{else}}<a href="/users">{{template "icon-users"}}Users</a>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="session">
|
||||
<span class="session-user muted">{{template "icon-account"}}User: {{.User}}</span>
|
||||
{{if eq .Active "settings"}}<span aria-current="page">{{template "icon-settings"}}Settings</span>{{else}}<a href="/settings">{{template "icon-settings"}}Settings</a>{{end}}
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
{{/* Whether the page takes the column whole. Empty here, so a page is held to
|
||||
the reading measure unless it says otherwise; the pages that are tables of
|
||||
data, raw log lines or side-by-side cards redefine this as the word "wide",
|
||||
which lands in <main>'s class list (see main.wide in legacy.css). Each
|
||||
page's template file is parsed after this one, so its definition replaces
|
||||
the empty one. */}}
|
||||
{{define "wide"}}{{end}}
|
||||
|
||||
{{/* back_link — up-navigation on drill-down pages. Invoke with the "back"
|
||||
function, e.g. {{template "back_link" (back "/users" "Back to users")}}.
|
||||
Place it directly under the page <h1> and above the cards (see .back in
|
||||
legacy.css). TestDrillDownPagesPlaceBackLinkAboveContent guards this. */}}
|
||||
{{define "back_link"}}
|
||||
<a class="back" href="{{.Href}}">← {{.Label}}</a>
|
||||
{{end}}
|
||||
|
||||
{{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they
|
||||
inherit the link's colour through currentColor, cost no extra request, and
|
||||
need no exemption from the panel's "default-src 'self'" policy. Each is
|
||||
aria-hidden because the entry's own text is already the accessible name;
|
||||
the icon is a landmark for the eye, not a second label. Kept as separate
|
||||
templates so the nav above stays one readable line per page. */}}
|
||||
{{define "icon-status"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></svg>{{end}}
|
||||
{{define "icon-domains"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></svg>{{end}}
|
||||
{{define "icon-inbound"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></svg>{{end}}
|
||||
{{define "icon-dmarc"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4.5h11v7H2.5z"/><path d="M5 7.5h6M5 9.5h4"/></svg>{{end}}
|
||||
{{define "icon-help"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2V11.4"/><path d="M8 5.05v.01"/></svg>{{end}}
|
||||
{{define "icon-deliveries"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></svg>{{end}}
|
||||
{{define "icon-mail-queue"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></svg>{{end}}
|
||||
{{define "icon-system-log"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></svg>{{end}}
|
||||
{{/* A filing cabinet rather than a storage box: the box read as a box, while
|
||||
two drawers with handles is the picture of an archive. */}}
|
||||
{{define "icon-backup"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.75" y="1.75" width="10.5" height="12.5" rx="1.15"/><path d="M2.75 8h10.5"/><path d="M6.4 4.85h3.2M6.4 11.15h3.2"/></svg>{{end}}
|
||||
{{define "icon-account"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="5.4" r="2.75"/><path d="M2.9 14.25a5.1 5.1 0 0 1 10.2 0"/></svg>{{end}}
|
||||
{{/* Two people: rear as a right-side crescent (head + shoulder) so it peeks
|
||||
from behind; front is a full silhouette matching icon-account. Heads share
|
||||
one baseline; shoulder arcs share one floor — otherwise the pair looks
|
||||
lopsided at 16 px. */}}
|
||||
{{define "icon-users"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.9 3.1a2.1 2.1 0 0 1 0 4.2"/><path d="M14.7 14.25a3.8 3.8 0 0 0-3.9-3.65"/><circle cx="5.5" cy="5.2" r="2.5"/><path d="M1.4 14.25a4.8 4.8 0 0 1 8.2 0"/></svg>{{end}}
|
||||
{{/* A gear (Feather "settings"), not a sun-with-rays: at 16 px spokes alone
|
||||
read as brightness, while the toothed rim is the picture of configuration.
|
||||
viewBox 0 0 24 matches the source path; CSS sizes the icon to 1 rem. */}}
|
||||
{{define "icon-settings"}}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>{{end}}
|
||||
{{/* Sign out is the one icon here that marks an action rather than a page: an
|
||||
open door with the arrow leading out of it. */}}
|
||||
{{define "icon-sign-out"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.1 14.25H3.65a1.15 1.15 0 0 1-1.15-1.15V2.9a1.15 1.15 0 0 1 1.15-1.15H6.1"/><path d="M10.6 11.15 13.75 8 10.6 4.85"/><path d="M13.75 8H6.35"/></svg>{{end}}
|
||||
@@ -49,8 +49,11 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
|
||||
}
|
||||
rendered := 0
|
||||
for name := range engine.Pages() {
|
||||
if kitPages[name] {
|
||||
continue // the kit layout has its own tests (components_test.go)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err := engine.Page(name).ExecuteTemplate(&buf, "layout.html", map[string]any{
|
||||
err := engine.Page(name).ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
|
||||
"Title": "t", "User": "admin", "Active": "", "Version": "9.9.9-test",
|
||||
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
|
||||
"SourceURL": "https://github.com/mixeme/selfpost",
|
||||
@@ -79,7 +82,7 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
|
||||
// Signed out (login, setup) the version must not be advertised, but the
|
||||
// Appropriate Legal Notices must still be present.
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("login").ExecuteTemplate(&buf, "layout.html", map[string]any{
|
||||
if err := engine.Page("login").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
|
||||
"Title": "t", "Active": "", "Version": "9.9.9-test",
|
||||
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
|
||||
"SourceURL": "https://github.com/mixeme/selfpost",
|
||||
@@ -210,7 +213,7 @@ func TestNavShowsInboundWhenEnabled(t *testing.T) {
|
||||
}
|
||||
|
||||
// Whether a page takes the whole column or the reading measure is declared by
|
||||
// the page's own "wide" block (see layout.html), which the layout stamps into
|
||||
// the page's own "wide" block (see layout_legacy.html), which the layout stamps into
|
||||
// <main>'s class list. A page that loses the block does not fail to render — it
|
||||
// silently comes back at the measure, with its table squeezed into two thirds
|
||||
// of the column — so the set is asserted here, in both directions.
|
||||
@@ -225,6 +228,9 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
|
||||
"inbound": true, "inbound_domain": true, "dmarc": true,
|
||||
}
|
||||
for name, page := range engine.Pages() {
|
||||
if kitPages[name] {
|
||||
continue // "wide" belongs to the legacy layout; the kit's width comes from its columns
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
|
||||
t.Fatalf("execute the wide block of %s: %v", name, err)
|
||||
@@ -396,17 +402,19 @@ func TestNoTemplateUsesInlineScriptOrStyle(t *testing.T) {
|
||||
// server actually serves, so a typo in a /static path is a blocked request,
|
||||
// not a 404 in the page's own colours.
|
||||
func TestLayoutReferencesOnlyEmbeddedAssets(t *testing.T) {
|
||||
body, err := fs.ReadFile(assetsFS, "templates/layout.html")
|
||||
if err != nil {
|
||||
t.Fatalf("read layout: %v", err)
|
||||
}
|
||||
refs := regexp.MustCompile(`(?:src|href)="/static/([^"]+)"`).FindAllStringSubmatch(string(body), -1)
|
||||
if len(refs) == 0 {
|
||||
t.Fatal("the layout references no static assets at all")
|
||||
}
|
||||
for _, m := range refs {
|
||||
if _, err := fs.Stat(assetsFS, "static/"+m[1]); err != nil {
|
||||
t.Errorf("layout references /static/%s, which is not embedded: %v", m[1], err)
|
||||
for _, file := range []string{"templates/layout_legacy.html", "templates/layout.html"} {
|
||||
body, err := fs.ReadFile(assetsFS, file)
|
||||
if err != nil {
|
||||
t.Fatalf("read layout: %v", err)
|
||||
}
|
||||
refs := regexp.MustCompile(`(?:src|href)="/static/([^"]+)"`).FindAllStringSubmatch(string(body), -1)
|
||||
if len(refs) == 0 {
|
||||
t.Fatalf("%s references no static assets at all", file)
|
||||
}
|
||||
for _, m := range refs {
|
||||
if _, err := fs.Stat(assetsFS, "static/"+m[1]); err != nil {
|
||||
t.Errorf("%s references /static/%s, which is not embedded: %v", file, m[1], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -485,7 +493,7 @@ func renderStatusPage(t *testing.T, data map[string]any) string {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("status").ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
||||
if err := engine.Page("status").ExecuteTemplate(&buf, "layout_legacy.html", data); err != nil {
|
||||
t.Fatalf("execute status page: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
@@ -575,7 +583,7 @@ func TestMailQueuePageRendersRetryPolicy(t *testing.T) {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "layout.html", map[string]any{
|
||||
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
|
||||
"Title": "t", "User": "admin", "Active": "mail_queue", "Version": "test",
|
||||
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
|
||||
"SourceURL": "https://github.com/mixeme/selfpost",
|
||||
@@ -608,7 +616,7 @@ func TestAuthenticatedLayoutIncludesHelpDrawer(t *testing.T) {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("help").ExecuteTemplate(&buf, "layout.html", map[string]any{
|
||||
if err := engine.Page("help").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
|
||||
"Title": "t", "User": "admin", "Active": "help", "IsGlobal": true,
|
||||
}); err != nil {
|
||||
t.Fatalf("execute help layout: %v", err)
|
||||
@@ -630,7 +638,7 @@ func TestLoginPageOmitsHelpDrawer(t *testing.T) {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("login").ExecuteTemplate(&buf, "layout.html", map[string]any{
|
||||
if err := engine.Page("login").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
|
||||
"Title": "t", "Active": "",
|
||||
}); err != nil {
|
||||
t.Fatalf("execute login: %v", err)
|
||||
|
||||
+61
-20
@@ -26,7 +26,9 @@ type Engine struct {
|
||||
}
|
||||
|
||||
// pageFiles maps a logical page name to its template files. Every page
|
||||
// composes with layout.html; pages that embed a polling fragment
|
||||
// composes with a layout — layout_legacy.html for the pages still in the old
|
||||
// design, layout.html and components.html for those that have been rebuilt
|
||||
// from the component kit (kitPages); pages that embed a polling fragment
|
||||
// (architecture.md § Panel HTTP surface) list that fragment's file too, so the
|
||||
// same {{define}} block renders both the initial page and the fragment's own
|
||||
// refresh responses identically. Pages sharing a block of markup (the
|
||||
@@ -55,6 +57,17 @@ var pageFiles = map[string][]string{
|
||||
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
|
||||
"status": {"templates/status.html", "templates/status_body.html"},
|
||||
"help": {"templates/help.html"},
|
||||
"components": {"templates/kit.html"},
|
||||
}
|
||||
|
||||
// kitPages are the pages built from the component kit: they are rendered by
|
||||
// layout.html with the partials of components.html and load panel.css. Every
|
||||
// other page is still the old design — layout_legacy.html and legacy.css — and
|
||||
// is listed in legacy_pages.txt; a page is in exactly one of the two, and
|
||||
// restyling one means moving it from the list to here in the same commit.
|
||||
// pageFiles stays a plain map of file lists because the guard tests read it.
|
||||
var kitPages = map[string]bool{
|
||||
"components": true,
|
||||
}
|
||||
|
||||
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
|
||||
@@ -74,8 +87,14 @@ func New(version string) (*Engine, error) {
|
||||
version: version,
|
||||
}
|
||||
for name, files := range pageFiles {
|
||||
patterns := append([]string{"templates/layout.html", "templates/help_drawer.html"}, files...)
|
||||
tmpl, err := template.New("layout.html").Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
|
||||
// A page parses with the layout of its design. The template is named
|
||||
// after the layout file so that its content is the layout's.
|
||||
layout, shared := "layout_legacy.html", []string{"templates/help_drawer.html"}
|
||||
if kitPages[name] {
|
||||
layout, shared = "layout.html", []string{"templates/components.html"}
|
||||
}
|
||||
patterns := append([]string{"templates/" + layout}, append(shared, files...)...)
|
||||
tmpl, err := template.New(layout).Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse template %s: %w", name, err)
|
||||
}
|
||||
@@ -124,6 +143,11 @@ func templateFuncs() template.FuncMap {
|
||||
"back": func(href, label string) map[string]string {
|
||||
return map[string]string{"Href": href, "Label": label}
|
||||
},
|
||||
// status_tag and wbr_at are the adapters of the component kit
|
||||
// (components.go): the one mapping of a status to Bulma's tag
|
||||
// classes, and the break opportunity after the @ of an address.
|
||||
"status_tag": StatusTag,
|
||||
"wbr_at": wbrAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,31 +162,48 @@ func (e *Engine) Pages() map[string]*template.Template {
|
||||
return e.pages
|
||||
}
|
||||
|
||||
// Render writes a page using the base layout. Rendering to a buffer first means
|
||||
// a template error yields a clean 500 instead of a half-written page.
|
||||
// Render writes a page using its layout. Rendering to a buffer first means a
|
||||
// template error yields a clean 500 instead of a half-written page.
|
||||
func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any) {
|
||||
tmpl, ok := e.pages[page]
|
||||
if !ok {
|
||||
http.Error(w, "template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// The layout's navigation compares .Active against each item, so the key
|
||||
// must exist on every authenticated page. Defaulting it here keeps a page
|
||||
// that forgets it from failing to render — it simply highlights nothing.
|
||||
// Footer fields (.Version, .Copyright, .SourceURL) are the same on every
|
||||
// page, so no handler should have to pass them.
|
||||
if m, ok := data.(map[string]any); ok {
|
||||
if _, has := m["Active"]; !has {
|
||||
m["Active"] = ""
|
||||
var (
|
||||
buf bytes.Buffer
|
||||
layout string
|
||||
frame = data
|
||||
)
|
||||
if kitPages[page] {
|
||||
// A page of the component kit: the shell is typed data derived from the
|
||||
// page's Meta (components.go), and the page's own data travels beside it.
|
||||
// With no user there is no navigation — the signed-out screen.
|
||||
layout = "layout.html"
|
||||
sh := e.shell(metaOf(data), legal.CopyrightLine, legal.SourceURL)
|
||||
if sh.User == "" {
|
||||
layout = "layout_signed_out"
|
||||
}
|
||||
frame = Frame{Shell: sh, Page: data}
|
||||
} else {
|
||||
layout = "layout_legacy.html"
|
||||
// The layout's navigation compares .Active against each item, so the key
|
||||
// must exist on every authenticated page. Defaulting it here keeps a page
|
||||
// that forgets it from failing to render — it simply highlights nothing.
|
||||
// Footer fields (.Version, .Copyright, .SourceURL) are the same on every
|
||||
// page, so no handler should have to pass them.
|
||||
if m, ok := data.(map[string]any); ok {
|
||||
if _, has := m["Active"]; !has {
|
||||
m["Active"] = ""
|
||||
}
|
||||
m["Version"] = e.version
|
||||
m["Copyright"] = legal.CopyrightLine
|
||||
m["SourceURL"] = legal.SourceURL
|
||||
m["InboundEnabled"] = e.inboundEnabled
|
||||
m["DMARCEnabled"] = e.dmarcEnabled
|
||||
}
|
||||
m["Version"] = e.version
|
||||
m["Copyright"] = legal.CopyrightLine
|
||||
m["SourceURL"] = legal.SourceURL
|
||||
m["InboundEnabled"] = e.inboundEnabled
|
||||
m["DMARCEnabled"] = e.dmarcEnabled
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
||||
if err := tmpl.ExecuteTemplate(&buf, layout, frame); err != nil {
|
||||
log.Printf("panel: render %s: %v", page, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user