Files
selfpost/internal/web/view/templates/components.html
T
mixandClaude Opus 5.5 9ca50d33fd
test / test (push) Canceled after 0s
web: Referrer-Policy same-origin; a password shown once emptied on pagehide
D-B3. Fetch Standard § 3.2 "append a request `Origin` header": under
Referrer-Policy no-referrer a request that is not GET/HEAD and not CORS -
a form POST, a navigation - gets Origin "null". originAllowed() refuses
"null" whenever Sec-Fetch-Site is absent, and Fetch Metadata § 3 sends
Sec-Fetch-Site only to a potentially trustworthy URL: every form,
sign-in included, failed in Safari before 16.4 and in any browser on a
plain-HTTP panel at a name. same-origin nulls Origin only for a request
not same origin with its URL and still sends no referrer to another
site. The comments of the origin check say so.

D-B13. noStore keeps the page out of HTTP caches (RFC 9111 § 5.2.2.5);
a history mechanism "might honor" it (§ 6), and Pragma in a response
"was never specified" (§ 5.4) - it goes. CopyField.Secret marks the
password of out-app-created data-secret, and panel.js empties such
fields on pagehide (HTML § 7.5.9, "unload a Document", step 10.2: fired
whether or not the document is kept for history traversal).

Measured 2026-10-15 in Chrome 152 (the desktop app's browser), a local
probe page with Cache-Control: no-store and a data-secret field: Back
reloads it (pageshow persisted=false, a new load), so this browser keeps
no-store pages out of its back/forward cache; the handler, fired on the
same page, empties the field. Firefox and Safari not measured.

Tests: the header value; the created page has the password data-secret
and the login not, no Pragma. Stand (2026-10-15, make e2e, exit 0): new
form_post_with_the_browsers_origin - Referrer-Policy: same-origin on a
page; a POST carrying the page's own Origin and no Sec-Fetch-Site, what
a browser sends now, passes; one carrying Origin: null, what it sent
before, is 403. (A first run failed where the e2e client and a view test
scraped the old input markup; both read data-secret now.) go vet and go
test ./... pass on the server (24 packages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:04:45 +03:00

203 lines
15 KiB
HTML

{{/* 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}}
{{/* 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}}
{{/* 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}}"{{if .Confirm}} data-confirm="{{.Confirm}}"{{end}}><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}}
{{/* row_action: a POST in the last cell of a table row (td.sp-actions), a form
with one button that is drawn like the links beside it. It sits against the
previous link with no whitespace between, as the cell's spacing is margins. */}}
{{define "row_action"}}<form method="post" action="{{.Post}}"{{if .Confirm}} data-confirm="{{.Confirm}}"{{end}}><button type="submit"{{if .Danger}} class="has-text-danger"{{end}}>{{.Label}}</button></form>{{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 .ID}} id="{{.ID}}"{{end}}{{if .OOB}} hx-swap-oob="true"{{end}}>
{{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}}{{if .OOB}} hx-swap-oob="true"{{end}}{{if .Poll}} data-poll aria-live="polite" hx-get="{{.Poll}}" hx-trigger="load" hx-swap="outerHTML"{{end}}{{if .DomainPick}} data-domain-pick{{end}}>
{{template "box_head" .}}
{{end}}
{{define "box_close"}}</div>{{end}}
{{/* cards_open starts the `cards` layout and cards_close ends it: two
`column is-6` stacks of numbered boxes that are read in rows, the odd ones
on the left and the even ones on the right, each stack closed up so that a
short box leaves no gap beside a tall one. On a phone the stacks dissolve
and the boxes come in the order of their numbers (panel.css) - which
knows four boxes to a stack and takes every child of a stack for a box:
a fifth, or a flash put between them, needs the rules extended first. */}}
{{define "cards_open"}}<div class="columns sp-cards">{{end}}
{{define "cards_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 .ID}} id="{{.ID}}"{{end}}{{if .OOB}} hx-swap-oob="true"{{end}}>{{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{{if .Secret}} data-secret{{end}}>{{.Value}}</textarea>{{else}}<input class="input" readonly value="{{.Value}}"{{if .Secret}} data-secret{{end}}>{{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 and .Mono .Small}} class="sp-mono sp-small"{{else 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}}