panel-redesign: the design contract as guard tests and a design-first CI step

Stage 0, step 2. The guards land before any template is restyled.

Guard tests (guard_*_test.go under internal/web) read their rules from the
accepted mockups instead of copying them - the Bulma subset and WIDTH_OK
from check.py, the shell's classes from build.py, the stylesheet from
theme.css + brand.css, the skeletons from outlines.json:

  - TestPanelClassVocabulary, TestTemplatesUseComponents,
    TestPanelPageStructure, TestPanelOutlinesMatchMockups,
    TestRedesignedPagesLoadOnlyTheKit: hold every page that has left
    legacy_pages.txt to its mockup, in source and as rendered.
  - TestPanelCSSIsTheMockupStylesheet, TestPanelCSSContract: panel.css is
    brand.css + theme.css rule for rule.
  - TestOutlinePortMatchesCheckPy: the Go port of outline() agrees with
    outlines.json on all 33 screens.
  - TestLegacyRatchetMatchesTheEngine, TestVendoredAssetsArePinned,
    TestStaticHoldsOnlyTheKnownAssets.
  - TestRoutesFollowNavigation: the 2.0 route table, no old path answered,
    no template link the router does not serve.

The kit tests are skipped while legacy_pages.txt lists @kit and the route
test while it lists @routes; both were run with the entry removed and fail
where expected (no kit yet, old route table).

.github/scripts/design-first.sh, run per commit in test.yml: a commit that
touches the mockups or a guard touches nothing else under internal/web;
legacy_pages.txt only shrinks; a changed template is not, whitespace aside,
any template from history. Exercised on a scratch clone for each case.

Plan: Enforcement and Against resurrected pages describe the guards as
built (the ratchet is a file beside the guards, not a list inside them).
Roadmap 7/27.

go test ./internal/web/...
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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-09 17:19:42 +03:00
co-authored by Claude Opus 5.5
parent 2fa0bef6c2
commit 92678c4157
8 changed files with 1551 additions and 19 deletions
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# GUARD FILE — docs/plans/panel-redesign.md § Enforcement, "design-first".
#
# Checks every commit of a push or pull request, one by one:
#
# 1. Design first. A commit that touches the accepted design — the mockups
# under docs/assets/panel-redesign/panel/, the guard tests, this script —
# touches nothing else under internal/web/. The guards are not adjusted
# in the commit they are supposed to judge.
# 2. The ratchet. internal/web/view/legacy_pages.txt only shrinks: a commit's
# list is a subset of its parent's. A page that was restyled stays held
# to its mockup; its name cannot be put back.
# 3. No blob from the past. A template a commit adds or changes is not,
# whitespace aside, a template that already existed anywhere in the
# history before it. Taking an old page out of git is not a redesign.
#
# Usage: design-first.sh <base> <head> (commits base..head are checked)
# Needs the full history: actions/checkout with fetch-depth: 0.
set -euo pipefail
base="${1:?usage: design-first.sh <base> <head>}"
head="${2:?usage: design-first.sh <base> <head>}"
RATCHET=internal/web/view/legacy_pages.txt
TEMPLATES=internal/web/view/templates/
ZERO=0000000000000000000000000000000000000000
# A new branch, or a force push whose old tip is gone, has no usable base:
# check the tip commit alone rather than nothing.
if [ "$base" = "$ZERO" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
base="$(git rev-parse "$head^" 2>/dev/null || true)"
fi
if [ -n "$base" ]; then
commits="$(git rev-list --reverse --no-merges "$base..$head")"
else
commits="$(git rev-list --reverse --no-merges "$head")"
fi
is_guard() {
case "$1" in
docs/assets/panel-redesign/panel/*) return 0 ;;
.github/scripts/design-first.sh) return 0 ;;
internal/web/guard_*_test.go | internal/web/*/guard_*_test.go) return 0 ;;
esac
return 1
}
entries() { # the ratchet's entries at a commit, sorted; nothing if the file is absent
git show "$1:$RATCHET" 2>/dev/null | tr -d '\r' | sed -e 's/#.*//' -e 's/[[:space:]]//g' -e '/^$/d' | sort -u
}
normalised() { # a blob's hash with all whitespace removed
git cat-file blob "$1" | tr -d ' \t\r\n' | git hash-object --stdin
}
cache="$(mktemp -d)"
trap 'rm -rf "$cache"' EXIT
past_hash() { # normalised hash of a historical blob, computed once
if [ ! -f "$cache/$1" ]; then
normalised "$1" > "$cache/$1"
fi
cat "$cache/$1"
}
fail=0
say() {
echo "::error::design-first: $*"
fail=1
}
for c in $commits; do
short="$(git rev-parse --short "$c")"
parent="$(git rev-parse --verify --quiet "$c^" || true)"
if [ -n "$parent" ]; then
changed="$(git diff --name-only --no-renames "$parent" "$c")"
else
changed="$(git ls-tree -r --name-only "$c")"
fi
# 1. design first
guards="" impl=""
while IFS= read -r f; do
[ -n "$f" ] || continue
if is_guard "$f"; then
guards="$guards $f"
elif [ "${f#internal/web/}" != "$f" ]; then
impl="$impl $f"
fi
done <<< "$changed"
if [ -n "$guards" ] && [ -n "$impl" ]; then
say "$short changes the design or its guards ($(echo $guards | cut -c1-200)) together with the implementation ($(echo $impl | cut -c1-200)). A design change is its own 'design:' commit, made and accepted first."
fi
# 2. the ratchet
if [ -n "$parent" ] && grep -qx "$RATCHET" <<< "$changed"; then
# Once the file has existed, a parent without it means an empty list.
if git cat-file -e "$parent:$RATCHET" 2>/dev/null || [ -n "$(git log -1 --format=%h "$parent" -- "$RATCHET")" ]; then
added="$(comm -13 <(entries "$parent") <(entries "$c"))"
if [ -n "$added" ]; then
say "$short adds to $RATCHET: $(echo $added). The list only shrinks — what was held to the design stays held."
fi
fi
fi
# 3. no blob from the past
if [ -n "$parent" ]; then
touched="$(git diff --name-only --no-renames --diff-filter=AM "$parent" "$c" -- "$TEMPLATES")"
if [ -n "$touched" ]; then
past="$(git log --format= --raw --no-abbrev --no-renames "$parent" -- "$TEMPLATES" | awk '$4 != "'$ZERO'" {print $4}' | sort -u)"
while IFS= read -r f; do
[ -n "$f" ] || continue
now="$(normalised "$(git rev-parse "$c:$f")")"
for blob in $past; do
if [ "$(past_hash "$blob")" = "$now" ]; then
say "$short: $f is, whitespace aside, a template that already existed in history (blob ${blob:0:12}). An old template is not a redesigned page."
break
fi
done
done <<< "$touched"
fi
fi
done
if [ "$fail" -ne 0 ]; then
exit 1
fi
echo "design-first: $(echo "$commits" | grep -c . || true) commit(s) checked, nothing to report"
+9
View File
@@ -10,6 +10,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# design-first.sh reads each commit's parent and the template history.
fetch-depth: 0
- name: Design first
env:
BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
run: bash .github/scripts/design-first.sh "$BASE" "$HEAD"
- uses: actions/setup-go@v5
with:
+8
View File
@@ -10,6 +10,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
- panel-redesign (stage 0): Bulma 1.0.4 and a 57-icon subset of Tabler Icons
3.49.0 are vendored under `/static` (MIT, listed in NOTICE). Nothing links
them yet; the old panel is unchanged.
- panel-redesign (stage 0): the design contract as guard tests
(`guard_*_test.go` under `internal/web`) and a `design-first` CI step. The
tests read their rules from the accepted mockups — class vocabulary,
stylesheet, page structure, component outlines, the 2.0 route table — and
pin the vendored assets. `internal/web/view/legacy_pages.txt` lists what is
still pre-2.0 and may only shrink; CI also rejects a commit that changes a
guard together with the implementation, or brings back a template from
history. The kit and route tests stay skipped until their stages land.
### Changed
+38 -18
View File
@@ -394,16 +394,30 @@ reviewed by models other than the one that implements the templates.
| Test | Fails when |
|---|---|
| `TestPanelClassVocabulary` | a template uses a class outside the Bulma subset + the `sp-` classes of `panel.css`; an `sp-` class is defined but unused, or used but absent from `components.html` |
| `TestPanelCSSContract` | `panel.css` has `!important`, an id or page-name selector, or a width outside `WIDTH_OK` |
| `TestTemplatesUseComponents` | a page template contains `sp-box-head`, `sp-postmark`, `sp-record`, `sp-facts`, … literally instead of `{{template "…"}}` |
| `TestPanelOutlinesMatchMockups` | a page rendered with fixtures has a different component skeleton than `outlines.json` — a different design cannot pass as this one |
| `TestVendoredBulmaChecksum` | `bulma.min.css` differs from the pinned SHA-256 |
| `TestPanelClassVocabulary` | a template, or the page it renders, uses a class outside the Bulma subset + the `sp-` classes of `panel.css`, or an icon outside the vendored subset; an `sp-` class is defined but unused, or used but not shown on the components page |
| `TestPanelCSSIsTheMockupStylesheet` | `panel.css` is not `brand.css` + `theme.css` rule for rule — a rule the mockups do not have is a design change |
| `TestPanelCSSContract` | `panel.css` has `!important`, an id or page selector, a size under the type floor, or a width outside `WIDTH_OK` |
| `TestTemplatesUseComponents` | a page template writes a component's `sp-` class itself instead of `{{template "…"}}`, or builds a partial's input with `dict`; a partial or its typed input is missing |
| `TestPanelOutlinesMatchMockups` | a page rendered with its fixture has a different component skeleton than `outlines.json` — a different design cannot pass as this one |
| `TestPanelPageStructure` | a rendered page breaks a structure rule of `check.py`: one `<h1>`, opens with `page_head`, a box starts with `box_head`, a table sits in a `table-container`, the shell is the layout's |
| `TestRedesignedPagesLoadOnlyTheKit` | a redesigned page loads the old stylesheet, or any stylesheet or script outside the kit |
| `TestOutlinePortMatchesCheckPy` | the Go port of `outline()` reads a mockup differently from `check.py` |
| `TestLegacyRatchetMatchesTheEngine` | the ratchet names something that is not a page, or a page off the ratchet is not a drawn screen |
| `TestVendoredAssetsArePinned` | `bulma.min.css` or the icon subset differs from its pinned SHA-256 |
| `TestStaticHoldsOnlyTheKnownAssets` | `/static` holds a file outside the allow-list — a second framework, icon set or script |
| `TestRoutesFollowNavigation` | § Routes: a path is missing, an old path still answers, a template links to a path the router does not serve |
| `TestNoTemplateUsesInlineScriptOrStyle` | (exists) inline `style` / script |
CI adds one step, `design-first`: a commit that touches
`internal/web/view/**` must not touch `docs/assets/panel-redesign/panel/**`
or the guard tests.
The guards are the files `guard_*_test.go` under `internal/web`. They read
their lists from the mockup directory itself — the Bulma subset and
`WIDTH_OK` from `check.py`, the shell's classes from `build.py`, the
stylesheet, the outlines — so there is no second copy to drift.
CI adds one step, `design-first`
([.github/scripts/design-first.sh](../../.github/scripts/design-first.sh),
run on every commit of a push or pull request): a commit that touches
`docs/assets/panel-redesign/panel/**`, a guard test or the script itself must
not touch anything else under `internal/web/`.
### Against resurrected pages
@@ -411,24 +425,30 @@ It has happened once: an implementer took the old page templates out of git
history and reported them as the redesigned pages. Four guards make that
impossible to pass, and none of them depends on anyone looking:
1. **The ratchet.** The guard tests carry one list, `legacyPages`, of page
names that are still old and therefore exempt from
1. **The ratchet.** One list,
[internal/web/view/legacy_pages.txt](../../internal/web/view/legacy_pages.txt),
names the pages that are still old and therefore exempt from
`TestPanelOutlinesMatchMockups`, `TestPanelClassVocabulary` and
`TestTemplatesUseComponents`. It starts as every page and **can only
shrink**: a CI step fails a commit whose `legacyPages` is not a subset of
its parent's. Restyling a page means removing it from the list in the same
`TestTemplatesUseComponents`. It is a file of its own, beside the guards
and not inside them, because the step that restyles a page must edit it
and must not edit a guard. It starts as every page and **can only
shrink**: `design-first` fails a commit whose list is not a subset of its
parent's. Restyling a page means removing it from the list in the same
commit; from then on that page is held to its outline forever. Putting the
old template back fails three tests at once, and putting the name back on
the list fails CI.
the list fails CI. Two entries are not pages: `@kit` keeps the kit's own
tests quiet until stage 0 is accepted, `@routes` does the same for
`TestRoutesFollowNavigation` until stage 1 renames the paths — "red where
expected" without a red main branch, and equally unable to come back.
2. **The outline is the design.** An old page has a different component
skeleton from `outlines.json` — different boxes, no `page_head`, no
`side_menu` — so it cannot match, however its classes are renamed.
3. **Old paths are forbidden.** `TestRoutesFollowNavigation` fails on any
template that contains a pre-2.0 path; every old page does.
4. **No blob from the past.** The `design-first` CI step also compares every
file under `internal/web/view/templates/` with the same path's blobs in
history up to the last 1.x tag, whitespace-normalised, and fails on a
match. This catches a byte-for-byte revert that happens to be on the
template a commit adds or changes under `internal/web/view/templates/`
with every template blob in the history before it — under any path, so a
rename does not hide it — whitespace-normalised, and fails on a match. This catches a byte-for-byte revert that happens to be on the
ratchet list still; the three tests above catch everything else.
The evidence rules below close the remaining gap, a screenshot that is not
@@ -478,7 +498,7 @@ only when both are done. Every stage-2 step ends with the evidence of
**Stage 0 — foundation and component kit** (§ Component kit)
- [x] Vendor Bulma 1.0.4 and the icon subset (MIT) under `/static`, NOTICE and development.md rows, pinned checksum — **Haiku**
- [ ] Guard tests from § Enforcement and `TestRoutesFollowNavigation`, red against today's panel where expected, with the `legacyPages` ratchet listing every page; `design-first` CI step including the ratchet and the history-blob check (§ Against resurrected pages) — **Opus**
- [x] Guard tests from § Enforcement and `TestRoutesFollowNavigation`, red against today's panel where expected, with the `legacyPages` ratchet listing every page; `design-first` CI step including the ratchet and the history-blob check (§ Against resurrected pages) — **Opus**
- [ ] `panel.css` from `panel/theme.css` + `shared/brand.css`; `components.html` partials with typed inputs in `components.go`; `status_tag`, `copy_field`, `wbr_at` — **Sonnet**
- [ ] `layout.html`: navbar, perforated edge, sibling strip, user menu, footer, visibility flags; the signed-out shell — **Sonnet**
- [ ] `GET /server/components` behind the global role rendering every partial from fixtures; kit acceptance (§ Component kit) with evidence — **Sonnet**, reviewed by **Opus**
+1 -1
View File
@@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| panel-notifications | E-mail notifications about important events | candidate | — | — |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — |
| password-reset | Password reset by e-mail | candidate | — | — |
| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 6/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 7/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — |
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
+215
View File
@@ -0,0 +1,215 @@
package web
// GUARD FILE — docs/plans/panel-redesign.md § The contract, rule 7. Not edited
// together with internal/web, and never to make an implementation pass.
//
// The route table of panel-redesign.md § Routes as a program: a URL reads like
// the menu, the pre-2.0 paths are gone without redirects, and no template
// points anywhere the router does not answer.
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// The 2.0 routes behind a session, pattern for pattern as registered.
var routes = struct{ always, inbound, dmarc []string }{
always: []string{
"GET /{$}",
"GET /overview", "GET /overview/fragment",
"GET /server/health", "POST /server/health/recheck", "POST /server/health/reload",
"GET /outbound/domains", "POST /outbound/domains",
"GET /outbound/domains/{id}", "POST /outbound/domains/{id}/dns-recheck",
"GET /outbound/domains/{id}/settings",
"POST /outbound/domains/{id}/settings/reports",
"POST /outbound/domains/{id}/settings/ratelimit",
"POST /outbound/domains/{id}/settings/ratelimit/recalc",
"POST /outbound/domains/{id}/settings/export",
"GET /outbound/domains/{id}/delete", "POST /outbound/domains/{id}/delete",
"GET /outbound/domains/{id}/applications/new", "POST /outbound/domains/{id}/applications/new",
"GET /outbound/domains/{id}/applications/{aid}", "POST /outbound/domains/{id}/applications/{aid}",
"POST /outbound/domains/{id}/applications/{aid}/password",
"POST /outbound/domains/{id}/applications/{aid}/ratelimit/recalc",
"POST /outbound/domains/{id}/applications/{aid}/delete",
"GET /outbound/log", "GET /outbound/log/fragment", "GET /outbound/log/{id}",
"GET /outbound/queue", "GET /outbound/queue/fragment",
"GET /server/log", "GET /server/log/fragment",
"GET /server/backup", "POST /server/backup", "POST /server/backup/import",
"GET /server/users",
"GET /server/users/new", "POST /server/users/new",
"GET /server/users/{uid}", "POST /server/users/{uid}",
"GET /server/users/{uid}/delete", "POST /server/users/{uid}/delete",
"GET /server/settings", "POST /server/settings",
"GET /server/components",
"GET /account", "POST /account/profile", "POST /account/password", "POST /account/dmarc",
"GET /help",
},
inbound: []string{
"GET /inbound/domains", "POST /inbound/domains",
"GET /inbound/domains/{id}",
"POST /inbound/domains/{id}/dns-recheck",
"POST /inbound/domains/{id}/upstream",
"POST /inbound/domains/{id}/recipients",
"GET /inbound/domains/{id}/delete", "POST /inbound/domains/{id}/delete",
},
dmarc: []string{
"GET /outbound/dmarc", "GET /outbound/dmarc/domains/{id}", "GET /outbound/dmarc/reports/{id}",
},
}
// Requests the pre-2.0 panel answered. None of them is answered now — not by a
// page and not by a redirect (§ No compatibility).
var oldRequests = []string{
"GET /status", "GET /status/fragment", "POST /status/recheck", "POST /reload",
"GET /domains", "POST /domains", "POST /domains/import",
"GET /domains/7", "POST /domains/7/dns-recheck", "GET /domains/7/delete", "POST /domains/7/delete",
"POST /domains/7/applications", "POST /domains/7/ratelimit", "POST /domains/7/ratelimit/recalc",
"POST /domains/7/dmarc", "POST /domains/7/export",
"POST /applications/7/mode", "POST /applications/7/authips", "POST /applications/7/password",
"POST /applications/7/ratelimit", "POST /applications/7/ratelimit/recalc", "POST /applications/7/delete",
"GET /inbound", "POST /inbound", "GET /inbound/7", "POST /inbound/7/dns-recheck",
"POST /inbound/7/upstream", "POST /inbound/7/recipients", "GET /inbound/7/delete", "POST /inbound/7/delete",
"GET /dmarc", "GET /dmarc/reports/7", "GET /dmarc/domains/7",
"GET /settings", "POST /settings", "POST /account",
"GET /users", "GET /users/new", "POST /users/new", "GET /users/7", "POST /users/7",
"GET /users/7/delete", "POST /users/7/delete",
"GET /backup", "POST /backup",
"GET /deliveries", "GET /deliveries/rows", "GET /deliveries/7",
"GET /mail-queue", "GET /mail-queue/body", "GET /system-log", "GET /system-log/body",
}
// routesStillOld reports whether view/legacy_pages.txt lists @routes: the
// pre-2.0 table is in place and stage 1 has not renamed it yet. The entry can
// only be removed (.github/scripts/design-first.sh).
func routesStillOld(t *testing.T) bool {
t.Helper()
b, err := os.ReadFile(filepath.Join("view", "legacy_pages.txt"))
if err != nil {
return false
}
for _, line := range strings.Split(string(b), "\n") {
if strings.TrimSpace(line) == "@routes" {
return true
}
}
return false
}
// answers reports the pattern that serves a request, "" when nothing does.
func answers(mux *http.ServeMux, method, target string) string {
_, pattern := mux.Handler(httptest.NewRequest(method, target, nil))
return pattern
}
// concrete turns a pattern into a request for it: {id} becomes a number.
func concrete(pattern string) (method, target string) {
method, target, _ = strings.Cut(pattern, " ")
target = strings.ReplaceAll(target, "{$}", "")
return method, regexp.MustCompile(`\{[a-z]+\}`).ReplaceAllString(target, "7")
}
func TestRoutesFollowNavigation(t *testing.T) {
if routesStillOld(t) {
t.Skip("view/legacy_pages.txt still lists @routes: the route rename of stage 1 has not landed")
}
on := &Server{cfg: Config{InboundEnabled: true, DMARCEnabled: true}}
public, authed := on.muxes()
// Every path of the table is registered with the listed method.
for _, group := range [][]string{routes.always, routes.inbound, routes.dmarc} {
for _, want := range group {
method, target := concrete(want)
if got := answers(authed, method, target); got != want {
t.Errorf("%s %s is answered by %q, want the route %q", method, target, got, want)
}
}
}
// The feature flags still take their routes away entirely.
_, bare := (&Server{}).muxes()
for _, group := range [][]string{routes.inbound, routes.dmarc} {
for _, p := range group {
method, target := concrete(p)
if got := answers(bare, method, target); got != "" {
t.Errorf("%s %s is answered (%q) with its feature switched off", method, target, got)
}
}
}
for _, p := range routes.always {
method, target := concrete(p)
if got := answers(bare, method, target); got != p {
t.Errorf("%s %s is answered by %q with inbound and DMARC off, want %q", method, target, got, p)
}
}
// The old paths are gone, GET and POST alike.
for _, req := range oldRequests {
method, target, _ := strings.Cut(req, " ")
if got := answers(authed, method, target); got != "" {
t.Errorf("the pre-2.0 request %s is still answered, by %q — old paths are removed, not redirected", req, got)
}
if got := answers(public, method, target); got != "" && got != "/" {
t.Errorf("the pre-2.0 request %s is answered by the public route %q", req, got)
}
}
// What stays as it was.
for _, p := range []string{"/healthz", "/license", "/static/panel.css", "/setup/token", "/login", "/logout"} {
if got := answers(public, http.MethodGet, p); got == "" || got == "/" {
t.Errorf("GET %s is no longer a public route", p)
}
}
// No template points at a path the router does not answer: every menu
// entry, link, form and polled fragment resolves to a registered route.
// An old path is such a dead end, so this is also what keeps a pre-2.0
// template from coming back.
files, err := filepath.Glob(filepath.Join("view", "templates", "*.html"))
if err != nil || len(files) == 0 {
t.Fatalf("no templates found: %v", err)
}
var (
link = regexp.MustCompile(`\b(href|action|hx-get|hx-post)\s*=\s*"([^"]*)"`)
action = regexp.MustCompile(`(?s)\{\{.*?\}\}`)
menu = 0
)
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
name := filepath.Base(f)
for _, m := range link.FindAllStringSubmatch(action.ReplaceAllString(string(b), "7"), -1) {
attr, target := m[1], m[2]
if !strings.HasPrefix(target, "/") || strings.HasPrefix(target, "//") {
continue // another site, a fragment, or a value the handler supplies whole
}
target, _, _ = strings.Cut(target, "#")
target, _, _ = strings.Cut(target, "?")
if attr == "hx-get" && !strings.HasSuffix(target, "/fragment") {
t.Errorf("%s polls %s; a polled page answers <page>/fragment", name, target)
}
if name == "layout.html" && attr == "href" {
menu++
}
found := false
for _, method := range []string{http.MethodGet, http.MethodPost} {
if p := answers(public, method, target); p != "" && p != "/" {
found = true
}
found = found || answers(authed, method, target) != ""
}
if !found {
t.Errorf("%s: %s=%q is not a route of the panel", name, attr, m[2])
}
}
}
if menu < 10 {
t.Errorf("layout.html links to %d paths; the menu was not found", menu)
}
}
+422
View File
@@ -0,0 +1,422 @@
package view
// GUARD FILE — docs/plans/panel-redesign.md § The contract, rule 7. Not edited
// together with internal/web, and never to make an implementation pass.
//
// The HTML reading the guard tests share: a small tolerant parser and the port
// of outline() from docs/assets/panel-redesign/panel/check.py. The port is
// itself tested against the mockups (TestOutlinePortMatchesCheckPy), so the Go
// tests and the Python lint cannot drift apart unnoticed.
import (
"encoding/json"
"html"
"regexp"
"strings"
)
// gnode is one element of a parsed page.
type gnode struct {
tag string
attrs map[string]string
parent *gnode
kids []*gnode
text string // the text directly inside this element
}
func (n *gnode) classes() []string { return strings.Fields(n.attrs["class"]) }
func (n *gnode) has(class string) bool {
for _, c := range n.classes() {
if c == class {
return true
}
}
return false
}
// walk visits n and every element under it, parents first.
func (n *gnode) walk(fn func(*gnode)) {
fn(n)
for _, k := range n.kids {
k.walk(fn)
}
}
func (n *gnode) find(class string) []*gnode {
var out []*gnode
n.walk(func(m *gnode) {
if m.has(class) {
out = append(out, m)
}
})
return out
}
func (n *gnode) byTag(tag string) []*gnode {
var out []*gnode
n.walk(func(m *gnode) {
if m.tag == tag {
out = append(out, m)
}
})
return out
}
// textOf is the element's text with its descendants', whitespace collapsed.
func textOf(n *gnode) string {
var b strings.Builder
n.walk(func(m *gnode) { b.WriteString(m.text) })
return strings.Join(strings.Fields(b.String()), " ")
}
var (
voidTags = map[string]bool{"input": true, "img": true, "br": true, "hr": true, "meta": true, "link": true, "wbr": true}
rawTags = map[string]bool{"script": true, "style": true, "textarea": true, "title": true}
tagName = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9-]*`)
)
// parseHTML builds the element tree of a document or a fragment. Like the
// parser check.py uses, it never fails: an end tag closes the nearest open
// element of that name and is ignored when there is none.
func parseHTML(src string) *gnode {
root := &gnode{tag: "root", attrs: map[string]string{}}
cur := root
for i := 0; i < len(src); {
lt := strings.IndexByte(src[i:], '<')
if lt < 0 {
cur.text += html.UnescapeString(src[i:])
break
}
cur.text += html.UnescapeString(src[i : i+lt])
i += lt
rest := src[i:]
switch {
case strings.HasPrefix(rest, "<!--"):
end := strings.Index(rest, "-->")
if end < 0 {
return root
}
i += end + 3
case strings.HasPrefix(rest, "<!"), strings.HasPrefix(rest, "<?"):
end := strings.IndexByte(rest, '>')
if end < 0 {
return root
}
i += end + 1
case strings.HasPrefix(rest, "</"):
end := strings.IndexByte(rest, '>')
if end < 0 {
return root
}
name := strings.ToLower(strings.TrimSpace(rest[2:end]))
for n := cur; n != root; n = n.parent {
if n.tag == name {
cur = n.parent
break
}
}
i += end + 1
default:
name := tagName.FindString(rest[1:])
if name == "" {
cur.text += "<"
i++
continue
}
name = strings.ToLower(name)
attrs, n := parseAttrs(rest[1+len(name):])
i += 1 + len(name) + n
node := &gnode{tag: name, attrs: attrs, parent: cur}
cur.kids = append(cur.kids, node)
switch {
case voidTags[name]:
case rawTags[name]:
end := strings.Index(strings.ToLower(src[i:]), "</"+name)
if end < 0 {
return root
}
node.text = html.UnescapeString(src[i : i+end])
i += end
if gt := strings.IndexByte(src[i:], '>'); gt >= 0 {
i += gt + 1
}
default:
cur = node
}
}
}
return root
}
// parseAttrs reads the attributes of a start tag and reports how many bytes it
// consumed, the closing '>' included.
func parseAttrs(s string) (map[string]string, int) {
attrs := map[string]string{}
i := 0
for i < len(s) {
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r' || s[i] == '/') {
i++
}
if i >= len(s) {
break
}
if s[i] == '>' {
return attrs, i + 1
}
start := i
for i < len(s) && !strings.ContainsRune(" \t\r\n=>/", rune(s[i])) {
i++
}
name := strings.ToLower(s[start:i])
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') {
i++
}
value := ""
if i < len(s) && s[i] == '=' {
i++
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') {
i++
}
if i < len(s) && (s[i] == '"' || s[i] == '\'') {
q := s[i]
end := strings.IndexByte(s[i+1:], q)
if end < 0 {
return attrs, len(s)
}
value = s[i+1 : i+1+end]
i += end + 2
} else {
start = i
for i < len(s) && !strings.ContainsRune(" \t\r\n>", rune(s[i])) {
i++
}
value = s[start:i]
}
}
if _, dup := attrs[name]; !dup && name != "" {
attrs[name] = html.UnescapeString(value)
}
}
return attrs, i
}
var sizeClass = regexp.MustCompile(`^is-\d+$`)
// boxKind names what a box holds under its head: check.py box_kind().
func boxKind(box *gnode) string {
var kinds []string
for _, k := range box.kids {
if k.has("sp-box-head") || k.has("sp-box-foot") {
continue
}
kind := k.tag
if k.tag == "table" || k.has("table-container") {
kind = "table"
}
for _, m := range [][2]string{{"sp-record", "record"}, {"sp-health", "health"}, {"sp-empty", "empty"},
{"sp-log", "log"}, {"sp-help-topic", "help"}, {"sp-box-body", "body"}} {
if k.has(m[0]) {
kind = m[1]
break
}
}
kinds = append(kinds, kind)
}
var out []string
for i := 0; i < len(kinds); {
j := i
for j < len(kinds) && kinds[j] == kinds[i] {
j++
}
if j-i == 1 {
out = append(out, kinds[i])
} else {
out = append(out, kinds[i]+"×"+itoa(j-i))
}
i = j
}
return strings.Join(out, "+")
}
func itoa(n int) string {
b, _ := json.Marshal(n)
return string(b)
}
// outline is the component skeleton of a page — what a reviewer sees with the
// text blurred: check.py outline(). The result marshals to the JSON shape of
// outlines.json.
func outline(node *gnode) []any {
out := []any{}
for _, k := range node.kids {
switch {
case k.has("sp-head"):
parts := []string{"head"}
if len(k.find("sp-postmark")) > 0 {
parts = append(parts, "postmark")
}
if len(k.find("buttons")) > 0 {
parts = append(parts, "actions")
} else {
for _, c := range k.kids {
if c.tag == "a" || c.tag == "form" {
parts = append(parts, "actions")
break
}
}
}
out = append(out, strings.Join(parts, "+"))
case k.has("box"):
variant := ""
for _, v := range []string{"sp-danger-zone", "sp-credential"} {
if k.has(v) {
variant = "." + v
break
}
}
title := "?"
if heads := k.find("sp-box-head"); len(heads) > 0 {
title = "?!" // a head without the number + title pair is not box_head
if nos := heads[0].find("sp-no"); len(nos) > 0 && len(nos[0].parent.kids) > 1 {
title = textOf(nos[0].parent.kids[1])
}
}
out = append(out, "box"+variant+"["+title+"]:"+boxKind(k))
case k.has("columns"):
cols := []any{}
for _, c := range k.kids {
size := "auto"
for _, cls := range c.classes() {
if sizeClass.MatchString(cls) {
size = cls
break
}
}
cols = append(cols, map[string]any{size: outline(c)})
}
out = append(out, map[string]any{"columns": cols})
case k.tag == "form":
out = append(out, map[string]any{"form": outline(k)})
case k.has("menu") || k.tag == "aside":
out = append(out, "side_menu")
case k.has("buttons"):
out = append(out, "submit_row")
}
}
return out
}
// outlineJSON renders an outline the way two of them are compared and shown.
func outlineJSON(v any) string {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return "unmarshalable outline: " + err.Error()
}
return string(b)
}
// classAttr finds every class="…" in template or HTML source.
var classAttr = regexp.MustCompile(`\bclass\s*=\s*"([^"]*)"`)
// templateAction matches one {{…}} of a Go template.
var templateAction = regexp.MustCompile(`(?s)\{\{.*?\}\}`)
// staticClasses lists the class tokens a template writes literally. A token an
// action contributes to is dropped here — the rendered page is checked too, and
// that is where a computed class is seen whole.
func staticClasses(src string) []string {
const dyn = "\x00"
var out []string
for _, m := range classAttr.FindAllStringSubmatch(templateAction.ReplaceAllString(src, dyn), -1) {
for _, c := range strings.Fields(m[1]) {
if !strings.Contains(c, dyn) {
out = append(out, c)
}
}
}
return out
}
// cssRule is one rule of a stylesheet, normalised for comparison.
type cssRule struct {
media string // the enclosing @media prelude, "" at the top level
selector string
body string
}
func (r cssRule) String() string {
s := r.selector + " { " + r.body + " }"
if r.media != "" {
s = r.media + " { " + s + " }"
}
return s
}
var (
cssComment = regexp.MustCompile(`(?s)/\*.*?\*/`)
cssURL = regexp.MustCompile(`url\(\s*["']?([^"')]*)["']?\s*\)`)
cssSpace = regexp.MustCompile(`\s+`)
)
// parseCSS splits a stylesheet into its rules, in order. Selectors and
// declarations are whitespace-normalised and a url() keeps only its file name,
// so the same rule reads the same wherever the file it came from lives.
func parseCSS(css string) []cssRule {
return parseCSSBlock(cssComment.ReplaceAllString(css, ""), "")
}
func parseCSSBlock(css, media string) []cssRule {
var rules []cssRule
for {
open := strings.IndexByte(css, '{')
if open < 0 {
return rules
}
prelude := cssSpace.ReplaceAllString(strings.TrimSpace(css[:open]), " ")
depth, end := 1, -1
for i := open + 1; i < len(css); i++ {
if css[i] == '{' {
depth++
} else if css[i] == '}' {
depth--
if depth == 0 {
end = i
break
}
}
}
if end < 0 {
return rules
}
inner := css[open+1 : end]
css = css[end+1:]
if strings.HasPrefix(prelude, "@media") || strings.HasPrefix(prelude, "@supports") {
rules = append(rules, parseCSSBlock(inner, prelude)...)
continue
}
var decls []string
for _, d := range strings.Split(inner, ";") {
d = cssSpace.ReplaceAllString(strings.TrimSpace(d), " ")
if d == "" {
continue
}
if c := strings.IndexByte(d, ':'); c >= 0 {
d = strings.TrimSpace(d[:c]) + ": " + strings.TrimSpace(d[c+1:])
}
d = cssURL.ReplaceAllStringFunc(d, func(u string) string {
name := cssURL.FindStringSubmatch(u)[1]
if s := strings.LastIndexByte(name, '/'); s >= 0 {
name = name[s+1:]
}
return "url(" + name + ")"
})
decls = append(decls, d)
}
var sels []string
for _, s := range strings.Split(prelude, ",") {
sels = append(sels, strings.TrimSpace(s))
}
rules = append(rules, cssRule{media: media, selector: strings.Join(sels, ", "), body: strings.Join(decls, "; ")})
}
}
+729
View File
@@ -0,0 +1,729 @@
package view
// GUARD FILE — docs/plans/panel-redesign.md § The contract, rule 7. Not edited
// together with internal/web, and never to make an implementation pass: a
// failing guard means the implementation is wrong.
//
// These tests are the design contract as a program. They hold everything that
// has left legacy_pages.txt to the accepted mockups under
// docs/assets/panel-redesign/panel/, and they read their rules from that
// directory — the Bulma subset and the width allow-list from check.py, the
// shell's classes from build.py, the stylesheet from theme.css and brand.css,
// the skeletons from outlines.json — so the mockups stay the one source and a
// design change has one route: mockup first.
//
// What the guards expect of the implementation (it may not be renamed away):
// - pageFiles (view.go) maps every page name to its template files; a
// redesigned page carries the name of its mockup (out-domains, in-domain…);
// - templates/components.html holds every component partial, layout.html the
// shell; a page template calls them and writes no component markup itself;
// - static/panel.css is theme.css + brand.css, rule for rule;
// - Engine.Render renders a page with the fixture in fixtures_test.go.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"testing"
)
const mockupDir = "../../../docs/assets/panel-redesign/panel"
// Screens that are drawn but not part of 2.0: each belongs to a roadmap item
// with its own plan (panel-redesign.md § Room for roadmap items). They are the
// only keys of outlines.json the finished panel may lack.
var deferredScreens = map[string]string{
"in-log": "inbound-antispam-panel, 2.1.0",
"in-filter-lists": "inbound-antispam-panel, 2.1.0",
"in-queue": "queue as a table / inbound queue, undecided",
"in-quarantine": "inbound-quarantine, candidate",
"in-quarantine-message": "inbound-quarantine, candidate",
"preflight": "preflight, candidate",
}
// Pages whose 2.0 content is deliberately not the mockup's (decided
// 2026-10-09: both queues ship the old content inside the new shell until the
// queue is a table). They obey every rule except the outline comparison.
var outlineExempt = map[string]string{
"out-queue": "old postqueue output in the new shell until queue-as-a-table is decided",
}
// Pages that are redesigned but have no outline: the kit page is the
// vocabulary itself, and the signed-out screens have no shell to outline.
var noOutline = map[string]bool{"components": true, "login": true, "setup": true}
// The classes a page template may write itself: text and table-cell utilities
// and the form grid. Every other sp- class is a component's and is written in
// components.html (or layout.html) only.
var pageUtilities = set("sp-muted", "sp-small", "sp-mono", "sp-nowrap", "sp-clip", "sp-actions", "sp-form", "sp-row", "sp-pair")
// The partials of panel-redesign.md § Three layers. Each is a {{define}} in
// components.html.
var requiredPartials = []string{
"page_head", "postmark", "box_head", "box_foot", "flash", "health_card", "dns_record", "facts",
"side_menu", "timeline", "log_pane", "empty_state", "confirm_list", "help_topic",
}
// The typed inputs of § Component kit. Each is declared in components.go with
// a doc comment that says where it is used.
var requiredInputs = []string{"Head", "Box", "Record", "Fact", "SideMenu", "Tag"}
// Vendored files, pinned. A change here is a deliberate upgrade (or, for the
// icon subset, a design change that added an icon) and nothing else.
var vendoredSHA256 = map[string]string{
"bulma.min.css": "67fa26df1ca9e95d8f2adc7c04fa1b15fa3d24257470ebc10cc68b9aab914bee",
"tabler-icons.css": "956cfe253a5588684af3e970bd98b666e97c7044d6c2eca3a1c34ebb10f4298e",
"tabler-icons.woff2": "4a0e1ab8991d10497efa72e1c37c307db7b9509e96450198fc421d8d36b78187",
}
// Everything /static may hold. A second framework, icon set or script does not
// get in by being dropped into the directory.
var staticFiles = set(
"OFL.txt", "favicon.png", "favicon.svg", "htmx.min.js", "panel.js", "panel.css",
"ibm-plex-sans.woff2", "ibm-plex-mono-400.woff2", "ibm-plex-mono-600.woff2",
"bulma.min.css", "tabler-icons.css", "tabler-icons.woff2",
"logo.svg", "wordmark.svg", "perf-edge.svg",
)
// What only the old pages need; gone with the last of them.
var legacyStaticFiles = set("legacy.css", "logo-compact.svg")
func set(items ...string) map[string]bool {
m := make(map[string]bool, len(items))
for _, i := range items {
m[i] = true
}
return m
}
func sorted(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// ratchet is legacy_pages.txt: what is still exempt.
type ratchet struct {
pages map[string]bool
kit bool // the kit is not complete
routes bool // the old route table is still in place
}
func readRatchet(t *testing.T) ratchet {
t.Helper()
b, err := os.ReadFile("legacy_pages.txt")
if err != nil {
if os.IsNotExist(err) {
return ratchet{pages: map[string]bool{}}
}
t.Fatalf("read legacy_pages.txt: %v", err)
}
r := ratchet{pages: map[string]bool{}}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
switch {
case line == "" || strings.HasPrefix(line, "#"):
case line == "@kit":
r.kit = true
case line == "@routes":
r.routes = true
case strings.HasPrefix(line, "@"):
t.Fatalf("legacy_pages.txt: unknown entry %q", line)
default:
r.pages[line] = true
}
}
return r
}
// skipUntilKit keeps the kit's own tests quiet while stage 0 is under way. The
// entry can only be removed, so the quiet ends for good with the kit.
func skipUntilKit(t *testing.T) ratchet {
t.Helper()
r := readRatchet(t)
if r.kit {
t.Skip("legacy_pages.txt still lists @kit: the component kit is not complete, so its contract is not enforced yet")
}
return r
}
func mockup(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(mockupDir, name))
if err != nil {
t.Fatalf("the guard tests read the accepted mockups and cannot run without them: %v", err)
}
return string(b)
}
func embedded(t *testing.T, name string) string {
t.Helper()
b, err := fs.ReadFile(assetsFS, name)
if err != nil {
t.Fatalf("%s is not embedded: %v", name, err)
}
return string(b)
}
func quoted(src string) []string {
var out []string
for _, m := range regexp.MustCompile(`"([^"]*)"`).FindAllStringSubmatch(src, -1) {
out = append(out, m[1])
}
return out
}
// contract is the vocabulary check.py and build.py define.
type contract struct {
bulma map[string]bool // the Bulma subset a screen may use
shellBulma map[string]bool // Bulma classes of the shell (navbar…), layout.html only
shell map[string]bool // sp- classes of the shell, layout.html only
signedOut map[string]bool // sp- classes of the signed-out screens
widthOK []string
}
func readContract(t *testing.T) contract {
t.Helper()
py := mockup(t, "check.py")
grab := func(re string) string {
m := regexp.MustCompile(re).FindStringSubmatch(py)
if m == nil {
t.Fatalf("check.py no longer matches %s — the guard reads its lists from there", re)
}
return m[1]
}
c := contract{
bulma: set(strings.Fields(grab(`(?s)BULMA = set\("""(.*?)"""`))...),
shell: set(quoted(grab(`SHELL = \{([^}]*)\}`))...),
signedOut: set(quoted(grab(`SIGNED_OUT = \{([^}]*)\}`))...),
widthOK: quoted(grab(`(?s)WIDTH_OK = \((.*?)\)\n`)),
shellBulma: map[string]bool{},
}
for _, cls := range staticClasses(mockup(t, "build.py")) {
if !strings.HasPrefix(cls, "sp-") && !strings.HasPrefix(cls, "ti") && !strings.ContainsAny(cls, "{}%") {
c.shellBulma[cls] = true
}
}
if len(c.bulma) < 40 || len(c.shell) < 4 || len(c.signedOut) < 3 || len(c.widthOK) < 5 || !c.shellBulma["navbar"] {
t.Fatalf("check.py / build.py were read but the lists look wrong: %d Bulma, %d shell, %d signed-out, %d widths, %d shell Bulma",
len(c.bulma), len(c.shell), len(c.signedOut), len(c.widthOK), len(c.shellBulma))
}
return c
}
var spClass = regexp.MustCompile(`\.(sp-[a-z0-9-]+)`)
func definedSP(css string) map[string]bool {
out := map[string]bool{}
for _, m := range spClass.FindAllStringSubmatch(cssComment.ReplaceAllString(css, ""), -1) {
out[m[1]] = true
}
return out
}
func definedIcons(t *testing.T) map[string]bool {
out := map[string]bool{"ti": true}
for _, m := range regexp.MustCompile(`\.(ti-[a-z0-9-]+):`).FindAllStringSubmatch(embedded(t, "static/tabler-icons.css"), -1) {
out[m[1]] = true
}
return out
}
func newGuardEngine(t *testing.T) *Engine {
t.Helper()
engine, err := New("9.9.9-test")
if err != nil {
t.Fatalf("New: %v", err)
}
engine.SetInboundEnabled(true)
engine.SetDMARCEnabled(true)
return engine
}
// renderFixture renders a redesigned page the way a handler would.
func renderFixture(t *testing.T, engine *Engine, page string) *gnode {
t.Helper()
fixture, ok := pageFixtures[page]
if !ok {
t.Fatalf("page %q has left legacy_pages.txt but has no fixture in fixtures_test.go, so nothing can check it", page)
}
rec := httptest.NewRecorder()
engine.Render(rec, http.StatusOK, page, fixture())
if rec.Code != http.StatusOK {
t.Fatalf("page %q does not render with its fixture: status %d, %s", page, rec.Code, strings.TrimSpace(rec.Body.String()))
}
return parseHTML(rec.Body.String())
}
// redesigned lists the engine's pages that are held to the contract.
func redesigned(engine *Engine, r ratchet) []string {
var out []string
for name := range engine.Pages() {
if !r.pages[name] {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
// pageTemplates lists the template files of the redesigned pages — everything
// but the kit's own two files.
func pageTemplates(engine *Engine, r ratchet) []string {
seen := map[string]bool{}
for _, name := range redesigned(engine, r) {
for _, f := range pageFiles[name] {
if base := path.Base(f); base != "components.html" && base != "layout.html" {
seen[f] = true
}
}
}
return sorted(seen)
}
// The ratchet and the engine must describe the same panel: a name that is not
// a page exempts nothing and only hides a typo, and a page that is off the
// list must be a screen that was actually drawn.
func TestLegacyRatchetMatchesTheEngine(t *testing.T) {
r := readRatchet(t)
engine := newGuardEngine(t)
var golden map[string]any
if err := json.Unmarshal([]byte(mockup(t, "outlines.json")), &golden); err != nil {
t.Fatalf("outlines.json: %v", err)
}
for name := range r.pages {
if engine.Page(name) == nil {
t.Errorf("legacy_pages.txt lists %q, which is not a page of the engine — delete the line", name)
}
}
for _, name := range redesigned(engine, r) {
if _, drawn := golden[name]; !drawn && !noOutline[name] {
t.Errorf("page %q is not on legacy_pages.txt and is not a drawn screen: a redesigned page carries the name of its mockup", name)
}
if _, deferred := deferredScreens[name]; deferred {
t.Errorf("page %q belongs to a roadmap item (%s) and is not built with the redesign", name, deferredScreens[name])
}
}
if len(r.pages) == 0 && !r.kit {
for name := range golden {
if _, deferred := deferredScreens[name]; !deferred && engine.Page(name) == nil {
t.Errorf("screen %q is drawn and in scope, but the engine has no such page", name)
}
}
}
for _, name := range redesigned(engine, r) {
if r.kit && name != "components" {
t.Errorf("page %q has left legacy_pages.txt while @kit is still listed: no page is restyled before the kit is accepted", name)
}
}
}
// The Go port of outline() must read the mockups exactly as check.py does, or
// the comparison below would be between two different measures.
func TestOutlinePortMatchesCheckPy(t *testing.T) {
var golden map[string]any
if err := json.Unmarshal([]byte(mockup(t, "outlines.json")), &golden); err != nil {
t.Fatalf("outlines.json: %v", err)
}
if len(golden) < 30 {
t.Fatalf("outlines.json has %d screens; it should hold every drawn screen", len(golden))
}
for name, want := range golden {
root := parseHTML(mockup(t, name+".html"))
pages := root.find("sp-page")
if len(pages) != 1 {
t.Errorf("%s.html: %d sp-page containers", name, len(pages))
continue
}
if got := outlineJSON(outline(pages[0])); got != outlineJSON(want) {
t.Errorf("%s: the Go outline differs from outlines.json\n got: %s\nwant: %s", name, got, outlineJSON(want))
}
}
}
// A different design cannot pass as this one: rendered with its fixture, a
// page has the component skeleton recorded for its mockup.
func TestPanelOutlinesMatchMockups(t *testing.T) {
r := skipUntilKit(t)
engine := newGuardEngine(t)
var golden map[string]any
if err := json.Unmarshal([]byte(mockup(t, "outlines.json")), &golden); err != nil {
t.Fatalf("outlines.json: %v", err)
}
for _, name := range redesigned(engine, r) {
if noOutline[name] || outlineExempt[name] != "" {
continue
}
t.Run(name, func(t *testing.T) {
want, ok := golden[name]
if !ok {
t.Fatalf("no outline is recorded for %q", name)
}
pages := renderFixture(t, engine, name).find("sp-page")
if len(pages) != 1 {
t.Fatalf("the rendered page has %d sp-page containers; the layout provides exactly one", len(pages))
}
if got := outlineJSON(outline(pages[0])); got != outlineJSON(want) {
t.Errorf("the page's component skeleton is not its mockup's\n got: %s\nwant: %s", got, outlineJSON(want))
}
})
}
}
// The structure rules of check.py, on the rendered page.
func TestPanelPageStructure(t *testing.T) {
r := skipUntilKit(t)
engine := newGuardEngine(t)
for _, name := range redesigned(engine, r) {
t.Run(name, func(t *testing.T) {
root := renderFixture(t, engine, name)
if n := len(root.byTag("h1")); n != 1 {
t.Errorf("%d <h1>; a page has exactly one", n)
}
for _, tbl := range root.byTag("table") {
if !tbl.parent.has("table-container") {
t.Error("a <table> must sit in a table-container so a wide one can scroll")
}
}
for _, box := range root.find("box") {
if len(box.kids) == 0 || !box.kids[0].has("sp-box-head") {
t.Errorf("a box must start with box_head (box: %q)", textOf(box))
}
}
pages := root.find("sp-page")
if name == "login" || name == "setup" {
if len(pages) != 0 || len(root.find("sp-signin")) != 1 || len(root.find("navbar")) != 0 {
t.Error("a signed-out page is the split sp-signin screen, without the navbar and the page container")
}
return
}
if len(pages) != 1 || len(root.find("navbar")) != 1 || len(root.find("sp-perf")) != 1 || len(root.find("sp-foot")) != 1 {
t.Fatal("a signed-in page is rendered by the shell: one navbar, one perforated edge, one sp-page container, one footer")
}
var top []*gnode
for _, k := range pages[0].kids {
if !k.has("sp-foot") {
top = append(top, k)
}
}
if len(top) == 0 || !top[0].has("sp-head") {
t.Fatal("the page must open with page_head")
}
marks := root.find("sp-postmark")
if name != "components" {
if len(marks) > 1 {
t.Errorf("%d postmarks; at most one", len(marks))
}
for _, m := range marks {
if !m.parent.has("sp-head") {
t.Error("the postmark belongs inside page_head")
}
}
}
for _, k := range top[1:] {
// A flash is conditional: allowed at the top level, never part of an outline.
if !(k.has("box") || k.has("columns") || k.has("notification") || (k.tag == "form" && k.has("sp-form"))) {
t.Errorf("top-level <%s class=%q> — a page is boxes, columns or one form", k.tag, k.attrs["class"])
}
}
})
}
}
// panel.css is the mockups' stylesheet and nothing else: brand.css followed by
// theme.css, rule for rule. A rule the mockups do not have is a design change,
// and a design change is made in the mockup first.
func TestPanelCSSIsTheMockupStylesheet(t *testing.T) {
skipUntilKit(t)
got := parseCSS(embedded(t, "static/panel.css"))
want := append(parseCSS(mockup(t, "../shared/brand.css")), parseCSS(mockup(t, "theme.css"))...)
for i := 0; i < len(got) || i < len(want); i++ {
switch {
case i >= len(got):
t.Fatalf("panel.css ends after %d rules; the mockup stylesheet goes on with\n %s", len(got), want[i])
case i >= len(want):
t.Fatalf("panel.css has a rule the mockups do not have:\n %s", got[i])
case got[i] != want[i]:
t.Fatalf("panel.css differs from brand.css + theme.css at rule %d\n got: %s\nwant: %s", i+1, got[i], want[i])
}
}
for _, m := range cssURL.FindAllStringSubmatch(embedded(t, "static/panel.css"), -1) {
if strings.Contains(m[1], "/") || strings.Contains(m[1], ":") {
t.Errorf("panel.css loads %q; assets sit beside it in /static and are named without a path", m[1])
} else if _, err := fs.Stat(assetsFS, "static/"+m[1]); err != nil {
t.Errorf("panel.css loads %q, which is not embedded", m[1])
}
}
}
// The CSS rules of check.py: no !important, no id or page selector, no size
// under the type floor, no width outside WIDTH_OK.
func TestPanelCSSContract(t *testing.T) {
skipUntilKit(t)
c := readContract(t)
var (
idSel = regexp.MustCompile(`(^|[\s,>+~])#[a-zA-Z]`)
pageSel = regexp.MustCompile(`\b(html|body|main)[.\[#]|\[data-page`)
fontSize = regexp.MustCompile(`font-size\s*:\s*([\d.]+)(rem|em|px)`)
noShrink = regexp.MustCompile(`min-width\s*:\s*0\s*(;|$)`)
width = regexp.MustCompile(`(^|[^-\w])(max-width|min-width|width)\s*:`)
floor = map[string]float64{"rem": 0.8125, "em": 0.85, "px": 13}
)
for _, rule := range parseCSS(embedded(t, "static/panel.css")) {
sel, body := rule.selector, rule.body
if strings.Contains(body, "!important") {
t.Errorf("`%s`: !important", sel)
}
if idSel.MatchString(sel) || pageSel.MatchString(sel) {
t.Errorf("`%s`: an id or page selector — style components, not one element on one page", sel)
}
// Icons (.ti) and the postmark imprint are not running text.
for _, m := range fontSize.FindAllStringSubmatch(body, -1) {
v, _ := strconv.ParseFloat(m[1], 64)
if v < floor[m[2]] && !strings.Contains(sel, ".sp-postmark") && !strings.Contains(sel, ".ti") {
t.Errorf("`%s`: font-size %s%s is below the 13px floor — use --sp-fs-xs or --sp-fs-s", sel, m[1], m[2])
}
}
// `min-width: 0` sizes nothing: it only lets a flex or grid child shrink.
if width.MatchString(noShrink.ReplaceAllString(body, "")) {
ok := false
for _, w := range c.widthOK {
ok = ok || strings.Contains(sel, w)
}
if !ok {
t.Errorf("`%s` sets a width; widths come from the page layout (WIDTH_OK in check.py)", sel)
}
}
}
}
// One vocabulary: the Bulma subset, the sp- components of panel.css, the
// vendored icons — in the template sources and in what they render.
func TestPanelClassVocabulary(t *testing.T) {
r := skipUntilKit(t)
c := readContract(t)
engine := newGuardEngine(t)
defined := definedSP(embedded(t, "static/panel.css"))
icons := definedIcons(t)
check := func(t *testing.T, where string, classes []string, inShell, inKit bool) {
for _, cls := range classes {
switch {
case strings.HasPrefix(cls, "sp-"):
switch {
case !defined[cls]:
t.Errorf("%s: `%s` is not defined in panel.css", where, cls)
case c.shell[cls] && !inShell:
t.Errorf("%s: `%s` belongs to the shell (layout.html)", where, cls)
case c.signedOut[cls] && !inShell && !inKit:
t.Errorf("%s: `%s` belongs to the signed-out shell", where, cls)
}
case cls == "ti" || strings.HasPrefix(cls, "ti-"):
if !icons[cls] {
t.Errorf("%s: icon `%s` is not in the vendored subset (tabler-icons.css) — it would render as nothing", where, cls)
}
case c.bulma[cls], inShell && c.shellBulma[cls]:
default:
t.Errorf("%s: class `%s` is neither in the Bulma subset nor an sp- component", where, cls)
}
}
}
smallOnlyOnProgress := func(t *testing.T, where string, root *gnode) {
root.walk(func(n *gnode) {
if n.has("is-small") && !n.has("progress") {
t.Errorf("%s: `is-small` on <%s> — controls keep the normal size; only a progress bar is small", where, n.tag)
}
})
}
used := map[string]bool{}
for _, f := range []string{"templates/components.html", "templates/layout.html"} {
classes := staticClasses(embedded(t, f))
check(t, f, classes, f == "templates/layout.html", true)
for _, cls := range classes {
used[cls] = true
}
}
for _, f := range pageTemplates(engine, r) {
src := embedded(t, f)
check(t, f, staticClasses(src), false, false)
smallOnlyOnProgress(t, f, parseHTML(templateAction.ReplaceAllString(src, "")))
}
kit := renderFixture(t, engine, "components")
shown := map[string]bool{}
kit.walk(func(n *gnode) {
for _, cls := range n.classes() {
shown[cls], used[cls] = true, true
}
})
for _, name := range redesigned(engine, r) {
root := renderFixture(t, engine, name)
var all []string
root.walk(func(n *gnode) { all = append(all, n.classes()...) })
check(t, "rendered "+name, all, true, true)
smallOnlyOnProgress(t, "rendered "+name, root)
for _, cls := range all {
used[cls] = true
if strings.HasPrefix(cls, "sp-") && !shown[cls] && !c.shell[cls] && !c.signedOut[cls] {
t.Errorf("page %s uses `%s`, which the components page does not show — document it there or stop using it", name, cls)
}
}
}
for cls := range defined {
if !used[cls] {
t.Errorf("panel.css defines `%s` but no template uses it", cls)
}
}
for _, cls := range sorted(definedSP(mockup(t, "theme.css"))) {
if !defined[cls] {
t.Errorf("the mockups define `%s`; panel.css does not", cls)
}
}
}
// Component markup exists in one place. A page calls the partial and passes
// it a struct; it does not rebuild the component, and it does not assemble
// the partial's input on the spot.
func TestTemplatesUseComponents(t *testing.T) {
r := skipUntilKit(t)
engine := newGuardEngine(t)
components := embedded(t, "templates/components.html")
for _, name := range requiredPartials {
if !regexp.MustCompile(`\{\{-?\s*define\s+"` + name + `"`).MatchString(components) {
t.Errorf("components.html does not define the partial %q", name)
}
}
funcs := templateFuncs()
for _, name := range []string{"status_tag", "wbr_at"} {
if _, ok := funcs[name]; !ok {
t.Errorf("the template function %q is missing", name)
}
}
if _, ok := funcs["copy_field"]; !ok && !strings.Contains(components, `define "copy_field"`) {
t.Error("copy_field is neither a template function nor a partial")
}
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "components.go", nil, parser.ParseComments)
if err != nil {
t.Fatalf("components.go holds the typed input of every partial: %v", err)
}
documented := map[string]bool{}
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
for _, spec := range gen.Specs {
ts := spec.(*ast.TypeSpec)
doc := ts.Doc
if doc == nil && len(gen.Specs) == 1 {
doc = gen.Doc
}
documented[ts.Name.Name] = doc != nil && strings.TrimSpace(doc.Text()) != ""
}
}
for _, name := range requiredInputs {
if doc, ok := documented[name]; !ok {
t.Errorf("components.go does not declare the partial input %s", name)
} else if !doc {
t.Errorf("components.go: %s has no doc comment saying where it is used", name)
}
}
defined := definedSP(embedded(t, "static/panel.css"))
dict := regexp.MustCompile(`\{\{[^}]*\bdict\b`)
for _, f := range pageTemplates(engine, r) {
src := embedded(t, f)
for _, cls := range staticClasses(src) {
if strings.HasPrefix(cls, "sp-") && defined[cls] && !pageUtilities[cls] {
t.Errorf("%s writes `%s` itself; that markup belongs to a partial of components.html — call it", f, cls)
}
}
if dict.MatchString(src) {
t.Errorf("%s builds a partial's input with dict; a page passes the struct from components.go", f)
}
}
}
// A redesigned page leaves nothing of the old panel on screen: it does not
// load the old stylesheet and does not render through the old layout.
func TestRedesignedPagesLoadOnlyTheKit(t *testing.T) {
r := skipUntilKit(t)
engine := newGuardEngine(t)
for _, name := range redesigned(engine, r) {
root := renderFixture(t, engine, name)
var sheets []string
for _, l := range root.byTag("link") {
if l.attrs["rel"] == "stylesheet" {
sheets = append(sheets, l.attrs["href"])
}
}
want := []string{"/static/bulma.min.css", "/static/tabler-icons.css", "/static/panel.css"}
if strings.Join(sheets, " ") != strings.Join(want, " ") {
t.Errorf("page %s loads the stylesheets %v, want exactly %v in that order", name, sheets, want)
}
for _, s := range root.byTag("script") {
if src := s.attrs["src"]; src != "/static/htmx.min.js" && src != "/static/panel.js" {
t.Errorf("page %s loads the script %q", name, src)
}
}
for _, img := range root.byTag("img") {
if !strings.HasPrefix(img.attrs["src"], "/static/") {
t.Errorf("page %s shows an image from %q", name, img.attrs["src"])
}
}
}
}
func TestVendoredAssetsArePinned(t *testing.T) {
for name, want := range vendoredSHA256 {
b, err := fs.ReadFile(assetsFS, "static/"+name)
if err != nil {
t.Errorf("%s is not vendored: %v", name, err)
continue
}
sum := sha256.Sum256(b)
if got := hex.EncodeToString(sum[:]); got != want {
t.Errorf("%s has SHA-256 %s, pinned %s — a vendored file is replaced by a deliberate upgrade, never edited", name, got, want)
}
}
}
func TestStaticHoldsOnlyTheKnownAssets(t *testing.T) {
r := readRatchet(t)
entries, err := fs.ReadDir(assetsFS, "static")
if err != nil {
t.Fatalf("read static: %v", err)
}
for _, e := range entries {
switch name := e.Name(); {
case e.IsDir():
t.Errorf("static/%s is a directory; assets are flat files", name)
case staticFiles[name]:
case legacyStaticFiles[name] && len(r.pages) > 0:
case legacyStaticFiles[name]:
t.Errorf("static/%s served the old pages only; the last of them is gone, delete it", name)
default:
t.Errorf("static/%s is not an asset the panel is allowed to ship: no second framework, icon set or script", name)
}
}
}