diff --git a/.github/scripts/design-first.sh b/.github/scripts/design-first.sh new file mode 100755 index 0000000..ad204d4 --- /dev/null +++ b/.github/scripts/design-first.sh @@ -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 (commits base..head are checked) +# Needs the full history: actions/checkout with fetch-depth: 0. +set -euo pipefail + +base="${1:?usage: design-first.sh }" +head="${2:?usage: design-first.sh }" + +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" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 619b007..b598be7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index b872d7d..175a8a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/plans/panel-redesign.md b/docs/plans/panel-redesign.md index 1fef930..4675cc3 100644 --- a/docs/plans/panel-redesign.md +++ b/docs/plans/panel-redesign.md @@ -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 `

`, 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** diff --git a/docs/roadmap.md b/docs/roadmap.md index e6c0f49..e92eed6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/internal/web/guard_routes_test.go b/internal/web/guard_routes_test.go new file mode 100644 index 0000000..fc1ee26 --- /dev/null +++ b/internal/web/guard_routes_test.go @@ -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 /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) + } +} diff --git a/internal/web/view/guard_html_test.go b/internal/web/view/guard_html_test.go new file mode 100644 index 0000000..ec1bb0b --- /dev/null +++ b/internal/web/view/guard_html_test.go @@ -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, "") + if end < 0 { + return root + } + i += end + 3 + case strings.HasPrefix(rest, "') + if end < 0 { + return root + } + i += end + 1 + case strings.HasPrefix(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:]), "'); 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, "; ")}) + } +} diff --git a/internal/web/view/guard_panel_test.go b/internal/web/view/guard_panel_test.go new file mode 100644 index 0000000..976eb38 --- /dev/null +++ b/internal/web/view/guard_panel_test.go @@ -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

; a page has exactly one", n) + } + for _, tbl := range root.byTag("table") { + if !tbl.parent.has("table-container") { + t.Error("a 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) + } + } +}