docs(standard): screenshot-free UI documentation standard + UI-label gate
The planned Playwright screenshot docs (SCREENSHOT_AUTOMATION.md) are replaced by a text standard: screenshots rot silently and cannot be gate-checked, while this UI's labels are server-rendered templ literals that can. Layers: pattern-first prose (concepts survive redesigns) > capability inventory > label->outcome flows > quoted labels > generated references where a registry exists (filters/fields.md precedent). The filter docs stay hands-off exemplars; user-doc tone is plain language by contract, dev/admin tone explicitly deferred. writing-ui-docs.md is the standard (page template, findability notes, tone-by-audience, process rule: a UI change is not done until the docs audit runs). ui-controls.md is the canonical control-label glossary the docs quote from; TestUIControlLabels greps every entry against templates/*.templ (source: ts override; also:-fragments for Alpine-composed labels like the Match/Group header) so renames fail make test in the same commit as the rename. Gate proven both ways: a planted bogus label failed; restored set passes.
This commit is contained in:
1 parent
fbdd4ab9f9
commit
e07301f74a
3 files changed
+498
No files matched your search
@@ -0,0 +1,215 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestUIControlLabels holds the UI documentation to the source of the UI.
|
||||
// The glossary (docs/developer/ui-controls.md) is the canonical spelling of
|
||||
// every control the docs cite; this test greps that spelling against the
|
||||
// server-rendered templates, so a renamed button fails make test in the
|
||||
// same commit as the rename — the class of rot screenshots could never be
|
||||
// gated. Dynamic (Alpine-composed) labels are OR-checked with their `also`
|
||||
// fragments instead of requiring the composed string to exist.
|
||||
//
|
||||
// Scope is substring-level (not word-boundary): the point is catching
|
||||
// renames and deletions, not stopping coincidental substrings. New controls
|
||||
// cited in prose get a glossary entry in the same commit (writing-ui-docs.md
|
||||
// is the standard; the filter docs are the hands-off exemplar).
|
||||
|
||||
const (
|
||||
labelGlossaryRel = "../../docs/developer/ui-controls.md"
|
||||
labelTemplRootRel = "../../templates"
|
||||
labelTSRootRel = "../../web/src"
|
||||
)
|
||||
|
||||
type docGlossaryEntry struct {
|
||||
label string
|
||||
where string
|
||||
does string
|
||||
source string
|
||||
reason string
|
||||
also []string
|
||||
skip bool
|
||||
}
|
||||
|
||||
// docCorpus concatenates every file under root ending in suffix.
|
||||
func docCorpus(t *testing.T, root string, suffix string) string {
|
||||
t.Helper()
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
t.Fatalf("abs %s: %v", root, err)
|
||||
}
|
||||
var b strings.Builder
|
||||
err = filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(path, suffix) {
|
||||
return nil
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Write(raw)
|
||||
b.WriteString("\n")
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", root, err)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func corpusName(source string) string {
|
||||
if source == "ts" {
|
||||
return "web/src"
|
||||
}
|
||||
return "templ"
|
||||
}
|
||||
|
||||
func parseUIControlsBlock(t *testing.T, raw string) []docGlossaryEntry {
|
||||
t.Helper()
|
||||
|
||||
var block strings.Builder
|
||||
inBlock, foundAny := false, false
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "```controls"):
|
||||
inBlock, foundAny = true, true
|
||||
continue
|
||||
case trimmed == "```" && inBlock:
|
||||
inBlock = false
|
||||
continue
|
||||
}
|
||||
if inBlock {
|
||||
block.WriteString(line)
|
||||
block.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if !foundAny {
|
||||
t.Fatalf("no ```controls block found in %s", labelGlossaryRel)
|
||||
}
|
||||
|
||||
var entries []docGlossaryEntry
|
||||
haveCur := false
|
||||
cur := docGlossaryEntry{}
|
||||
flush := func() {
|
||||
if haveCur {
|
||||
entries = append(entries, cur)
|
||||
haveCur = false
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(block.String(), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
t.Fatalf("malformed controls line %q (want `key: value`)", line)
|
||||
}
|
||||
key, val = strings.TrimSpace(key), strings.TrimSpace(val)
|
||||
switch key {
|
||||
case "label":
|
||||
flush()
|
||||
haveCur = true
|
||||
cur = docGlossaryEntry{label: val}
|
||||
case "where":
|
||||
cur.where = val
|
||||
case "does":
|
||||
cur.does = val
|
||||
case "source":
|
||||
cur.source = val
|
||||
case "also":
|
||||
for _, frag := range strings.Split(val, "|") {
|
||||
if frag = strings.TrimSpace(frag); frag != "" {
|
||||
cur.also = append(cur.also, frag)
|
||||
}
|
||||
}
|
||||
case "skip":
|
||||
cur.skip = val == "true"
|
||||
case "reason":
|
||||
cur.reason = val
|
||||
case "note":
|
||||
// free text for humans
|
||||
default:
|
||||
t.Fatalf("unknown field in controls block: %q (allowed: label|where|does|source|also|skip|reason|note)", key)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return entries
|
||||
}
|
||||
|
||||
func TestUIControlLabels(t *testing.T) {
|
||||
raw, err := os.ReadFile(labelGlossaryRel)
|
||||
if err != nil {
|
||||
t.Fatalf("read glossary: %v", err)
|
||||
}
|
||||
entries := parseUIControlsBlock(t, string(raw))
|
||||
if len(entries) == 0 {
|
||||
t.Fatalf("glossary has no entries — add at least one before citing controls")
|
||||
}
|
||||
|
||||
tmplRequired, tsRequired := false, false
|
||||
for _, e := range entries {
|
||||
if e.source == "ts" {
|
||||
tsRequired = true
|
||||
} else {
|
||||
tmplRequired = true
|
||||
}
|
||||
}
|
||||
corpora := map[string]string{}
|
||||
if tmplRequired {
|
||||
corpora[corpusName("")] = docCorpus(t, labelTemplRootRel, ".templ")
|
||||
}
|
||||
if tsRequired {
|
||||
corpora[corpusName("ts")] = docCorpus(t, labelTSRootRel, ".ts")
|
||||
}
|
||||
|
||||
seen := map[string]int{}
|
||||
for _, e := range entries {
|
||||
if e.label == "" {
|
||||
t.Errorf("entry with empty label (where: %q, does: %q)", e.where, e.does)
|
||||
continue
|
||||
}
|
||||
seen[e.label]++
|
||||
if e.where == "" || e.does == "" {
|
||||
t.Errorf("control %q: `where` and `does` are required", e.label)
|
||||
}
|
||||
if e.skip && e.reason == "" {
|
||||
t.Errorf("control %q: skip=true requires a reason", e.label)
|
||||
}
|
||||
switch e.source {
|
||||
case "", "templ", "ts":
|
||||
default:
|
||||
t.Errorf("control %q: unknown source %q (templ|ts)", e.label, e.source)
|
||||
}
|
||||
if e.skip {
|
||||
continue
|
||||
}
|
||||
|
||||
fragments := append([]string{e.label}, e.also...)
|
||||
found := false
|
||||
for _, frag := range fragments {
|
||||
if strings.Contains(corpora[corpusName(e.source)], frag) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("control %q (%s): none of the fragments [%s] found in %s sources — the label was renamed or the glossary is stale. Update ui-controls.md in the same commit as the rename.",
|
||||
e.label, e.where, strings.Join(fragments, " | "), corpusName(e.source))
|
||||
}
|
||||
}
|
||||
for label, n := range seen {
|
||||
if n > 1 {
|
||||
t.Errorf("control %q listed %d times — one entry per control", label, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user