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:
@@ -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, "; ")})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user