inbound-antispam-panel: Inbound log, Why as a sheet; no Deny on a Postfix reject

Follows the two design commits before it (owner's decisions quoted
there).

Kit: a new component, Sheet / SheetRow (components.go), partials
sheet_tag and sheet (components.html), its rules in panel.css as in
theme.css, and its sample on the kit page next to status_tag. It is
the browser's popover: no script.

Inbound log: the Why column is gone; the decision tag opens a sheet
with who decided, then either the filter's verdict and its symbols one
a line (a detail the journal milter wrote) or the reply (a detail the
tailer read from mail.log - it starts with its code), and the subject
whole. The sheet's id is the journal row's. A row Postfix itself
refused no longer links to the filter lists.

Tests: the page fixture and view tests for the sheet's markup and
content (TestWhyRows), the link rule, three Deny and two Allow on the
mockup's six rows; e2e reads "Decided by" and "Reply" from the sheet
and wants no sender link on the refused row.

Seen: the fixture page with a sheet open, headless Edge at 1360 px -
the same as the accepted drawing.

Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-12 00:23:51 +03:00
co-authored by Claude Opus 5.5
parent 970c8ca8da
commit 9d330bed77
12 changed files with 177 additions and 48 deletions
@@ -90,7 +90,7 @@ func (h *Handlers) HandleInboundLog(w http.ResponseWriter, r *http.Request) {
table := make([]view.InLogRow, len(rows))
for i, row := range rows {
table[i] = out.NewRow(view.InLogRowInput{
At: row.CreatedAt, From: row.From, Client: row.ClientIP, To: row.To, Subject: row.Subject,
ID: row.ID, At: row.CreatedAt, From: row.From, Client: row.ClientIP, To: row.To, Subject: row.Subject,
Decision: row.Decision, Engine: row.Engine, Detail: row.Detail,
})
}
@@ -94,9 +94,9 @@ func TestInboundLogForTheGlobalRole(t *testing.T) {
`Rows older than 30 days are deleted — change that in <a href="/server/settings">Settings</a>.`,
`alice@<wbr>a.example.com`, `bob@<wbr>b.example.com`, `Hello A`, `Hello B`,
`<option value="a.example.com">a.example.com</option>`, `<option value="b.example.com">b.example.com</option>`,
`<span class="tag is-success is-light">accept</span>`, `<span class="tag is-primary is-light">quarantine</span>`,
`class="tag is-success is-light" popovertarget="why-`, `class="tag is-primary is-light" popovertarget="why-`,
`<span class="sp-mono sp-muted sp-small">203.0.113.9</span>`,
`rspamd · detail of Hello A`,
`<tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>detail of Hello A</td></tr>`,
`<a href="/inbound/filter-lists?list=deny&amp;kind=address&amp;value=sender%40example.net">Deny sender</a>`,
`<a href="/inbound/filter-lists?list=allow&amp;kind=address&amp;value=sender%40example.net">Allow sender</a>`,
`Page 1 of 1`, `Allow / Deny sender opens <a href="/inbound/filter-lists">Filter lists</a>`,
+21
View File
@@ -105,6 +105,27 @@ type Tag struct {
Label string
}
// Sheet is what does not fit a table row, opened over the page from the row's
// tag (partials "sheet_tag" and "sheet"; used by the Inbound log, where it says
// why a message was decided the way it was). It is the browser's own popover:
// the tag is a button that names the sheet by ID, so ID is unique on the page.
// Its content is a table of name and value.
type Sheet struct {
ID string
Tag Tag
Title string
Rows []SheetRow
}
// SheetRow is one name and its value in a sheet. A value of several lines is
// one line each; Mono is for what is read as written - a reply, a list of
// symbols.
type SheetRow struct {
Label string
Lines []string
Mono bool
}
// 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.
+3 -1
View File
@@ -420,12 +420,14 @@ func userDeleteFixture() *UserDelete {
func inLogFixture() *InLog {
p := NewInLog(admin(), 90, []string{"lists.example.org", "acme.io"},
InLogFilter{From: "2026-09-14", To: "2026-09-21"})
var id int64
row := func(clock, from, client, to, subject, decision, engine, detail string) InLogRow {
at, err := time.Parse("2006-01-02 15:04:05", "2026-09-21 "+clock)
if err != nil {
panic(err)
}
return p.NewRow(InLogRowInput{At: at, From: from, Client: client, To: to, Subject: subject,
id++
return p.NewRow(InLogRowInput{ID: id, At: at, From: from, Client: client, To: to, Subject: subject,
Decision: decision, Engine: engine, Detail: detail})
}
return p.WithRows([]InLogRow{
+48 -22
View File
@@ -12,6 +12,7 @@ package view
import (
"net/url"
"strconv"
"strings"
"time"
)
@@ -67,23 +68,24 @@ type InLog struct {
// InLogRow is one recipient of one message: when it was decided, who the mail
// was from (and the address of the server that offered it) and for, its subject,
// the decision as a tag, who decided and why, and — for the global role, who
// alone edits the filter lists — the way to correct the decision.
// the decision as a tag that opens the sheet saying who decided and why, and —
// for the global role, who alone edits the filter lists — the way to correct
// the decision.
type InLogRow struct {
Time string
From string
Client string
To string
Subject string
Decision Tag
Why string
Action *Anchor
Time string
From string
Client string
To string
Subject string
Why Sheet
Action *Anchor
}
// InLogRowInput is what the journal holds about one row. Subject, Detail and
// From may be empty: refused mail is read from mail.log, which has no subject,
// and a filter that said nothing has no detail.
type InLogRowInput struct {
ID int64
At time.Time
From string
Client string
@@ -140,13 +142,12 @@ func NewInLog(m Meta, retentionDays int, domains []string, filter InLogFilter) *
// NewRow words one row of the journal for the viewer of the page.
func (p *InLog) NewRow(in InLogRowInput) InLogRow {
why := in.Engine
if in.Detail != "" {
why += " · " + in.Detail
}
r := InLogRow{
Time: FormatLogTime(in.At), From: orDash(in.From), Client: in.Client, To: in.To, Subject: in.Subject,
Decision: Tag{Status: in.Decision}, Why: why,
Why: Sheet{
ID: "why-" + strconv.FormatInt(in.ID, 10), Tag: Tag{Status: in.Decision}, Title: "Why",
Rows: whyRows(in.Engine, in.Detail, in.Subject),
},
}
if p.IsGlobal {
r.Action = senderAction(in.Decision, in.Engine, in.From)
@@ -154,11 +155,36 @@ func (p *InLog) NewRow(in InLogRowInput) InLogRow {
return r
}
// whyRows is the content of a row's sheet: who decided, what they said, and
// the subject whole, which the table clips. What was said has one of two
// shapes, told apart by how it starts. The inbound journal milter writes the
// filter's verdict on mail that passed - an action and a score, then the
// symbols after a semicolon. The log tailer writes the reply a refusal was
// given, which starts with its code.
func whyRows(engine, detail, subject string) []SheetRow {
rows := []SheetRow{{Label: "Decided by", Lines: []string{engine}}}
switch {
case detail == "":
case detail[0] >= '0' && detail[0] <= '9':
rows = append(rows, SheetRow{Label: "Reply", Lines: []string{detail}, Mono: true})
default:
verdict, symbols, _ := strings.Cut(detail, "; ")
rows = append(rows, SheetRow{Label: "Verdict", Lines: []string{verdict}})
if symbols != "" {
rows = append(rows, SheetRow{Label: "Symbols", Lines: strings.Fields(symbols), Mono: true})
}
}
if subject != "" {
rows = append(rows, SheetRow{Label: "Subject", Lines: []string{subject}})
}
return rows
}
// senderAction is the way to correct a decision from its row, if there is one:
// a message that got through is the one to deny its sender for, a message the
// filter turned away or put off is the one to allow its sender for, and a
// refusal by Postfix's own policy (an unknown recipient, say) can only be
// answered by denying — allowing the sender would not change what Postfix
// a message that got through is the one to deny its sender for, and a message
// the filter turned away, put off or held is the one to allow its sender for.
// A refusal by Postfix's own policy (an unknown recipient, say) offers nothing:
// the message is refused already and the lists would not change what Postfix
// thinks of the recipient. A row with no sender has nobody to list.
func senderAction(decision, engine, from string) *Anchor {
if from == "" {
@@ -170,8 +196,6 @@ func senderAction(decision, engine, from string) *Anchor {
list = "deny"
case engine == "rspamd":
list = "allow"
case decision == "reject" && engine == "postfix":
list = "deny"
default:
return nil
}
@@ -199,7 +223,9 @@ func (p *InLog) WithRows(rows []InLogRow, page, last int) *InLog {
p.Foot.Link = &Anchor{Href: InLogHref(p.filter, page+1), Label: "Older →"}
}
if p.IsGlobal {
p.Foot.End = Rich("Allow / Deny sender opens ", Link(inboundFilterLists, "Filter lists"), " with the entry filled in")
p.Foot.End = Rich("A decision opens its reason · Allow / Deny sender opens ", Link(inboundFilterLists, "Filter lists"), " with the entry filled in")
} else {
p.Foot.End = Plain("A decision opens its reason")
}
return p
}
+48 -12
View File
@@ -22,19 +22,29 @@ func TestInLogShowsTheRowsAndTheirFilters(t *testing.T) {
`<input class="input" type="date" name="from" value="2026-09-14" aria-label="From date">`,
`<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">`,
`href="/help#inbound-log" title="What the decisions mean"`,
`<th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Why</th>`,
`<th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th></th></tr>`,
`09-21 14:00:18`,
`t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span>`,
`dev@<wbr>lists.example.org`, `title="Re: [dev] patch series v3"`,
`<td class="sp-clip sp-muted">—</td>`,
`<span class="tag is-success is-light">accept</span>`, `<span class="tag is-danger is-light">reject</span>`,
`<span class="tag is-warning is-light">tempfail</span>`,
`rspamd · no action, score 0.40 / 15.00`,
`postfix · 550 5.1.1 &lt;sales@lists.example.org&gt;: Recipient address rejected`,
// the decision is a tag that opens the sheet with who decided and why
`<button type="button" class="tag is-success is-light" popovertarget="why-1">accept</button><div class="sp-sheet" id="why-1" popover>`,
`<div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-1" popovertargetaction="hide" aria-label="Close">`,
`<button type="button" class="tag is-danger is-light" popovertarget="why-3">reject</button>`,
`<button type="button" class="tag is-warning is-light" popovertarget="why-5">tempfail</button>`,
`<tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score 0.40 / 15.00</td></tr><tr><th>Subject</th><td>Re: [dev] patch series v3</td></tr>`,
`<tr><th>Verdict</th><td>add header, score 9.80 / 15.00</td></tr><tr><th>Symbols</th><td class="sp-mono sp-small">PHISHING(5.00)<br>FORGED_SENDER(3.00)<br>R_SPF_FAIL(1.00)</td></tr>`,
`<tr><th>Decided by</th><td>postfix</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">550 5.1.1 &lt;sales@lists.example.org&gt;: Recipient address rejected: User unknown in relay recipient table</td></tr></tbody>`,
`Page 1 of 38`, `<a href="/inbound/log?from=2026-09-14&amp;p=2&amp;to=2026-09-21">Older →</a>`,
`Allow / Deny sender opens <a href="/inbound/filter-lists">Filter lists</a> with the entry filled in`,
`A decision opens its reason · Allow / Deny sender opens <a href="/inbound/filter-lists">Filter lists</a> with the entry filled in`,
)
pageLacks(t, "Inbound log", out, `← Newer`, `data-poll`, `hx-get`)
pageLacks(t, "Inbound log", out, `← Newer`, `data-poll`, `hx-get`, `<th>Why</th>`)
// A message Postfix itself refused offers no way into the lists: one Deny
// for each of the three accepted messages, one Allow for each the filter
// refused or put off.
if deny, allow := strings.Count(out, `>Deny sender</a>`), strings.Count(out, `>Allow sender</a>`); deny != 3 || allow != 2 {
t.Errorf("the fixture's six rows offer Deny %d times and Allow %d times, want 3 and 2", deny, allow)
}
// Without the global role there is nowhere to change the retention, and
// nowhere to send a sender.
@@ -96,11 +106,11 @@ func TestInLogRowWording(t *testing.T) {
p := NewInLog(admin(), 90, nil, InLogFilter{})
at := time.Date(2026, 9, 21, 14, 0, 18, 0, time.UTC)
r := p.NewRow(InLogRowInput{At: at, From: "a@example.net", To: "b@acme.io", Decision: "accept", Engine: "selfpost"})
if r.Why != "selfpost" || r.Time != "09-21 14:00:18" || r.Client != "" {
if len(r.Why.Rows) != 1 || r.Why.Rows[0].Lines[0] != "selfpost" || r.Time != "09-21 14:00:18" || r.Client != "" {
t.Errorf("row = %+v", r)
}
r = p.NewRow(InLogRowInput{At: at, To: "b@acme.io", Decision: "reject", Engine: "postfix", Detail: "550 5.1.1 no"})
if r.Why != "postfix · 550 5.1.1 no" || r.From != "—" || r.Action != nil {
r = p.NewRow(InLogRowInput{ID: 7, At: at, To: "b@acme.io", Decision: "reject", Engine: "postfix", Detail: "550 5.1.1 no"})
if r.Why.ID != "why-7" || r.Why.Tag.Status != "reject" || len(r.Why.Rows) != 2 || r.Why.Rows[1].Label != "Reply" || r.From != "—" || r.Action != nil {
t.Errorf("row = %+v", r)
}
out := renderSignedIn(t, "in-log", p.WithRows([]InLogRow{r}, 1, 1))
@@ -110,7 +120,7 @@ func TestInLogRowWording(t *testing.T) {
// Which decisions lead to which list, for the global role: the sender of mail
// that got through can be denied, the sender of mail the filter turned away or
// put off can be allowed, and a refusal that is Postfix's own can only be denied.
// put off can be allowed, and a refusal that is Postfix's own offers neither.
func TestInLogSenderAction(t *testing.T) {
for _, c := range []struct {
decision, engine, from, label, href string
@@ -120,7 +130,7 @@ func TestInLogSenderAction(t *testing.T) {
{"reject", "rspamd", "a+b@example.net", "Allow sender", "/inbound/filter-lists?list=allow&kind=address&value=a%2Bb%40example.net"},
{"tempfail", "rspamd", "a@example.net", "Allow sender", "/inbound/filter-lists?list=allow&kind=address&value=a%40example.net"},
{"quarantine", "rspamd", "a@example.net", "Allow sender", "/inbound/filter-lists?list=allow&kind=address&value=a%40example.net"},
{"reject", "postfix", "a@example.net", "Deny sender", "/inbound/filter-lists?list=deny&kind=address&value=a%40example.net"},
{"reject", "postfix", "a@example.net", "", ""},
{"tempfail", "postfix", "a@example.net", "", ""},
{"reject", "selfpost", "a@example.net", "", ""},
{"accept", "rspamd", "", "", ""},
@@ -254,3 +264,29 @@ func TestInboundMenuHasLogAndFilterLists(t *testing.T) {
pageHas(t, "Inbound log", out, `<a class="navbar-item is-active" href="/inbound/log">`, `<a href="/inbound/log" aria-current="page">`)
pageLacks(t, "Inbound log", out, `/inbound/filter-lists`)
}
// What a sheet says, from the two shapes a row's detail has: the filter's
// verdict on mail that passed, and the reply a refusal was given.
func TestWhyRows(t *testing.T) {
flat := func(rows []SheetRow) string {
var out []string
for _, r := range rows {
out = append(out, r.Label+"="+strings.Join(r.Lines, "|"))
}
return strings.Join(out, "; ")
}
for _, c := range []struct{ engine, detail, subject, want string }{
{"selfpost", "", "", "Decided by=selfpost"},
{"selfpost", "", "Hello", "Decided by=selfpost; Subject=Hello"},
{"rspamd", "no action, score 0.40 / 15.00", "Hi", "Decided by=rspamd; Verdict=no action, score 0.40 / 15.00; Subject=Hi"},
{"rspamd", "add header, score 9.80 / 15.00; A(5.00) B(-0.10)", "", "Decided by=rspamd; Verdict=add header, score 9.80 / 15.00; Symbols=A(5.00)|B(-0.10)"},
// a verdict with symbols and no action or score
{"rspamd", "A(5.00)", "", "Decided by=rspamd; Verdict=A(5.00)"},
{"rspamd", "5.7.1 Spam message rejected", "", "Decided by=rspamd; Reply=5.7.1 Spam message rejected"},
{"postfix", "450 4.1.8 <a@nx.example>: Sender address rejected; see x", "", "Decided by=postfix; Reply=450 4.1.8 <a@nx.example>: Sender address rejected; see x"},
} {
if got := flat(whyRows(c.engine, c.detail, c.subject)); got != c.want {
t.Errorf("whyRows(%q, %q, %q)\n got: %s\nwant: %s", c.engine, c.detail, c.subject, got, c.want)
}
}
}
+5
View File
@@ -21,6 +21,7 @@ type Kit struct {
Postmarks []Postmark
StatusTag Box
StatusRows []KitStatusRow
Sheet Sheet
BoxHead Box
BoxFoot Foot
DangerZone Box
@@ -91,6 +92,10 @@ func KitPage() *Kit {
},
StatusTag: Box{No: "03", Title: "status_tag", End: Plain("One mapping, one function")},
Sheet: Sheet{ID: "kit-sheet", Tag: Tag{Status: "reject"}, Title: "sheet", Rows: []SheetRow{
{Label: "What it holds", Lines: []string{"What does not fit the row, as name and value"}},
{Label: "How it closes", Lines: []string{"Esc", "a click outside", "the cross"}, Mono: true},
}},
StatusRows: []KitStatusRow{
{"ok, published, running, sent, accept", Tag{Status: "ok"}},
{"warn, deferred, quiet, tempfail", Tag{Status: "warn"}},
+24
View File
@@ -327,3 +327,27 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
.sp-record-head small { margin-left: 0; text-align: left; flex-basis: 100%; }
.sp-postmark { display: none; }
}
/* sheet: what does not fit a table row, opened over the page from the row's
tag. It is the browser's own popover (the tag is a button with
popovertarget), so it needs no script: Esc, a click outside and the close
button all shut it, and focus returns to the tag. It rises from the bottom
edge across the whole window - the one component that is not laid out by the
page, hence the widths - with its content, a table of name and value, in a
column of reading width in the middle. */
.sp-sheet {
inset: auto 0 0 0; width: auto; margin: 0; padding: 0; border: 0; border-top: 3px solid var(--sp-brick);
max-height: 75vh; overflow: auto; background: #fff; color: inherit; box-shadow: 0 -12px 40px rgba(40, 20, 15, .22);
}
.sp-sheet-in { max-width: 44rem; margin: 0 auto; padding: 0 1.25rem; }
.sp-sheet::backdrop { background: rgba(40, 20, 15, .38); }
.sp-sheet-head { display: flex; align-items: center; gap: .7rem; padding: 1rem 0 .8rem; border-bottom: 1px solid var(--sp-rule); }
.sp-sheet-head h2 { font-size: 1.15rem; font-weight: 600; margin-right: auto; }
.sp-sheet-head button { border: 0; background: none; cursor: pointer; font-size: 1.3rem; color: var(--sp-muted); }
.sp-sheet-body { padding: .4rem 0 1.4rem; }
/* The sheet's content is a table of name and value: the name is a label, set like the dt of facts. */
.sp-sheet .table th { font-family: var(--sp-mono); font-size: var(--sp-fs-xs); font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); white-space: nowrap; vertical-align: top; width: 1%; padding-right: 2rem; }
.sp-sheet .table td { overflow-wrap: anywhere; }
/* A tag that opens a sheet is a button: it keeps the tag's look and says it can
be pressed with the dotted underline links to more detail carry. */
button.tag { border: 0; cursor: pointer; text-decoration: underline dotted; text-underline-offset: 3px; }
@@ -27,6 +27,13 @@
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}}
{{/* sheet_tag is a status tag that opens a sheet (input: Sheet): the tag as a
button, and the sheet it names right after it. sheet is what opens: the tag
again, a title, the cross that closes it, and a table of name and value.
The browser's popover does the rest - no script. */}}
{{define "sheet_tag"}}<button type="button" class="{{status_tag .Tag.Status}}" popovertarget="{{.ID}}">{{if .Tag.Label}}{{.Tag.Label}}{{else}}{{.Tag.Status}}{{end}}</button>{{template "sheet" .}}{{end}}
{{define "sheet"}}<div class="sp-sheet" id="{{.ID}}" popover><div class="sp-sheet-in"><div class="sp-sheet-head">{{template "tag" .Tag}}<h2>{{.Title}}</h2><button type="button" popovertarget="{{.ID}}" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody>{{range .Rows}}<tr><th>{{.Label}}</th><td{{if .Mono}} class="sp-mono sp-small"{{end}}>{{range $i, $line := .Lines}}{{if $i}}<br>{{end}}{{$line}}{{end}}</td></tr>{{end}}</tbody></table></div></div></div></div>{{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}}
+6 -5
View File
@@ -1,17 +1,18 @@
{{/* Inbound log (mockup in-log.html): what port 25 decided about each message
offered for an inbound domain, one row per recipient, newest first,
filtered by domain, decision and day. It does not poll. The last cell is
the way to correct a decision in Filter lists, and only the global role,
who alone edits the lists, is given it. */}}
filtered by domain, decision and day. It does not poll. The decision is a
tag that opens a sheet with who decided and why. The last cell is the way
to correct a decision in Filter lists, and only the global role, who alone
edits the lists, is given it. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{template "box_open" .Messages}}
{{if .Rows}}
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Why</th><th></th></tr></thead>
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th></th></tr></thead>
<tbody>{{range .Rows}}
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Time}}</td><td>{{wbr_at .From}}{{if .Client}}<br><span class="sp-mono sp-muted sp-small">{{.Client}}</span>{{end}}</td><td>{{wbr_at .To}}</td>{{if .Subject}}<td class="sp-clip" title="{{.Subject}}">{{.Subject}}</td>{{else}}<td class="sp-clip sp-muted">—</td>{{end}}<td>{{template "tag" .Decision}}</td><td class="sp-muted sp-small">{{.Why}}</td><td class="sp-actions">{{if .Action}}<a href="{{.Action.Href}}">{{.Action.Label}}</a>{{end}}</td></tr>{{end}}
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Time}}</td><td>{{wbr_at .From}}{{if .Client}}<br><span class="sp-mono sp-muted sp-small">{{.Client}}</span>{{end}}</td><td>{{wbr_at .To}}</td>{{if .Subject}}<td class="sp-clip" title="{{.Subject}}">{{.Subject}}</td>{{else}}<td class="sp-clip sp-muted">—</td>{{end}}<td>{{template "sheet_tag" .Why}}</td><td class="sp-actions">{{if .Action}}<a href="{{.Action.Href}}">{{.Action.Label}}</a>{{end}}</td></tr>{{end}}
</tbody>
</table></div>
{{template "box_foot" .Foot}}
+1
View File
@@ -31,6 +31,7 @@
<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}}
<tr><td>a tag that opens a <code>sheet</code></td><td>{{template "sheet_tag" .Sheet}}</td><td class="sp-mono sp-small">button.tag + sp-sheet (popover)</td></tr>
</tbody>
</table></div>
{{template "box_close"}}