replace Python scripts with a Go playbook CLI
One static binary with the rules embedded: search, rule, list, doc, contrast, tokens, skills install. Output matches the Python scripts on the checked queries. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -1,2 +1,4 @@
|
||||
__pycache__/
|
||||
/dist/
|
||||
/playbook
|
||||
*.pyc
|
||||
__pycache__/
|
||||
|
||||
@@ -10,10 +10,10 @@ any user interface, and cite rule ids.
|
||||
| Plan a screen, flow or component | Skill `playbook-design`, or read `templates/screen-brief.md` |
|
||||
| Build or change UI code | Skill `playbook-implement`; read `platforms/web.md` or `platforms/native-egui.md` |
|
||||
| Review or audit UI | Skill `playbook-audit`; `checklists/design-review.md`, `checklists/a11y-audit.md` |
|
||||
| Answer a specific question | `python3 scripts/search.py "<question>"` or `--id <ID>` |
|
||||
| Answer a specific question | `playbook search "<question>"` or `playbook rule <ID>` |
|
||||
| CSS/animation/shadow detail | `principles/09-css-craft.md` |
|
||||
| Build tooltip, toast, toggle | `principles/10-component-patterns.md` |
|
||||
| Check colors | `python3 scripts/contrast.py FG BG` |
|
||||
| Check colors | `playbook contrast FG BG` |
|
||||
| Ship gate | `checklists/pre-ship.md` |
|
||||
|
||||
## Rules for using it
|
||||
@@ -30,7 +30,7 @@ any user interface, and cite rule ids.
|
||||
## Editing this repo
|
||||
|
||||
- Rule headings `### ID Title`; ids unique and stable; every cited id must exist
|
||||
(`python3 -I -m unittest discover -s tests` enforces this).
|
||||
(`go test ./...` enforces this).
|
||||
- Keep videos as notes in our words; do not commit transcripts.
|
||||
- Keep files plain Markdown and JSON; scripts use only the Python standard library.
|
||||
|
||||
|
||||
@@ -15,23 +15,28 @@ improving a UI library, on the web (HTML, CSS, React) or native (Rust, egui).
|
||||
| `templates/` | Screen brief, component spec, DTCG-shaped token files (light, dark, base) with enforced contrast pairs |
|
||||
| `sources/` | Source registry with trust levels, per-video notes, a reading list of blogs/magazines/design systems with read status, a list of unverified claims, and a research protocol |
|
||||
| `skills/` | `playbook-design`, `playbook-implement`, `playbook-audit` |
|
||||
| `scripts/` | `search.py` (find rules), `contrast.py` (WCAG ratios and token-pair CI), `install-skills.sh` |
|
||||
| `cmd/playbook`, `internal/` | The `playbook` CLI (Go, standard library only): rule search, WCAG contrast and token-pair checks, skill installer. The corpus is embedded in the binary |
|
||||
| `llms.txt` | Index for agents that fetch over HTTP |
|
||||
|
||||
## Using it
|
||||
|
||||
```bash
|
||||
python3 scripts/search.py "confirm vs undo delete" # ranked sections
|
||||
python3 scripts/search.py --id A11Y-09 # one rule in full
|
||||
python3 scripts/search.py --list # every rule id
|
||||
python3 scripts/contrast.py '#767676' '#ffffff' # 4.54:1
|
||||
python3 scripts/contrast.py --tokens templates/tokens.light.json templates/tokens.dark.json
|
||||
python3 -I -m unittest discover -s tests # keep it honest
|
||||
export PLAYBOOK_DIR="$PWD" # the skills read this variable
|
||||
scripts/install-skills.sh # link skills into ~/.claude and ~/.agents
|
||||
go build -o ~/.local/bin/playbook ./cmd/playbook # one static binary, corpus embedded
|
||||
|
||||
playbook search "confirm vs undo delete" # ranked sections
|
||||
playbook rule A11Y-09 # one rule in full (a prefix like MOT works)
|
||||
playbook list # every rule id
|
||||
playbook doc platforms/native-egui.md # print a whole document (no PATH lists them)
|
||||
playbook contrast '#767676' '#ffffff' # 4.54:1, exit 1 below 4.5
|
||||
playbook tokens # check the shipped light and dark token files
|
||||
playbook tokens my.tokens.json # check your own contrastPairs
|
||||
playbook skills install # copy the skills into ~/.claude and ~/.agents
|
||||
go test ./... # keep it honest
|
||||
```
|
||||
|
||||
No dependencies beyond Python 3.
|
||||
No dependencies beyond the Go toolchain to build. The binary reads its embedded
|
||||
copy of the rules; set `PLAYBOOK_DIR` to a checkout to read live files instead
|
||||
(useful while editing rules).
|
||||
|
||||
## How agents should reach it
|
||||
|
||||
@@ -40,8 +45,8 @@ Ordered by reliability. Details in `AGENTS.md`.
|
||||
1. **Skills** (primary). `playbook-design`, `playbook-implement`, `playbook-audit`
|
||||
trigger on UI work and tell the agent to look rules up here. Procedural,
|
||||
exact thresholds, no retrieval noise.
|
||||
2. **Local files and `search.py`**. Any agent with a shell can search the corpus
|
||||
by keyword or rule id with no index to maintain.
|
||||
2. **The `playbook` CLI**. Any agent with a shell can search the corpus by
|
||||
keyword or rule id with no index to maintain and no checkout to locate.
|
||||
3. **`llms.txt` over HTTP** from the raw file URL of wherever this repo is hosted,
|
||||
for agents without filesystem access.
|
||||
4. **RAG pointer only**, if you run a RAG. A short note tells agents this repo
|
||||
@@ -53,7 +58,7 @@ Ordered by reliability. Details in `AGENTS.md`.
|
||||
## Rule conventions
|
||||
|
||||
- Rule heading: `### A11Y-10 Title`. Ids never change meaning; deprecated rules
|
||||
stay with a note. Cross-references are tested (`tests/test_search.py`).
|
||||
stay with a note. Cross-references are tested (`playbook_test.go`).
|
||||
- A rule cites sources (`S..` standards and articles, `V..` videos). A rule with no
|
||||
citation is general knowledge (`GK`): a default to test, not a requirement.
|
||||
See `sources/UNVERIFIED.md` for claims that could not be confirmed.
|
||||
@@ -73,7 +78,7 @@ your own library with `playbook-audit`.
|
||||
|
||||
1. Fetch a primary source. 2. Add the rule to the right `principles/` file with
|
||||
the next free id. 3. Add the source row. 4. If numeric and testable, put it in
|
||||
tokens or a script. 5. `python3 -I -m unittest discover -s tests`.
|
||||
tokens or a script. 5. `go test ./...`.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ passes matter most. Record evidence (screenshot, command output) per failure.
|
||||
|
||||
## A. Automated floor (5 minutes)
|
||||
- [ ] axe-core or Lighthouse on each key view in light and dark
|
||||
- [ ] `python3 scripts/contrast.py --pairs <file>` on token pairs
|
||||
- [ ] `playbook tokens <file>` on token pairs
|
||||
- [ ] HTML validation for duplicate ids and invalid ARIA
|
||||
Automated pass is not a pass.
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
// Command playbook searches and checks the design playbook.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"playbook"
|
||||
"playbook/internal/contrast"
|
||||
"playbook/internal/search"
|
||||
)
|
||||
|
||||
const usage = `playbook: design rules for accessible, non-generic UI
|
||||
|
||||
playbook search [-n N] QUERY... ranked sections matching the query
|
||||
playbook rule ID print a rule by id, or all rules with a prefix (A11Y)
|
||||
playbook list list every rule id and title
|
||||
playbook doc [PATH] print a document; no PATH lists them
|
||||
playbook contrast FG BG WCAG ratio for two hex colors (exit 1 below 4.5)
|
||||
playbook tokens [FILE...] check contrastPairs in DTCG token files
|
||||
(no FILE: the shipped light and dark templates)
|
||||
playbook skills install [DIR...] copy the playbook skills into agent skill dirs
|
||||
(default: ~/.claude/skills and ~/.agents/skills)
|
||||
|
||||
Set PLAYBOOK_DIR to read a live checkout instead of the embedded copy.
|
||||
`
|
||||
|
||||
func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) }
|
||||
|
||||
func corpus() fs.FS {
|
||||
if dir := os.Getenv("PLAYBOOK_DIR"); dir != "" {
|
||||
return os.DirFS(dir)
|
||||
}
|
||||
return playbook.Corpus
|
||||
}
|
||||
|
||||
func run(args []string, out, errw io.Writer) int {
|
||||
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
|
||||
fmt.Fprint(out, usage)
|
||||
if len(args) == 0 {
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
rest := args[1:]
|
||||
switch args[0] {
|
||||
case "search":
|
||||
return cmdSearch(rest, out, errw)
|
||||
case "rule":
|
||||
return cmdRule(rest, out, errw)
|
||||
case "list":
|
||||
return cmdList(out, errw)
|
||||
case "doc":
|
||||
return cmdDoc(rest, out, errw)
|
||||
case "contrast":
|
||||
return cmdContrast(rest, out, errw)
|
||||
case "tokens":
|
||||
return cmdTokens(rest, out, errw)
|
||||
case "skills":
|
||||
return cmdSkills(rest, out, errw)
|
||||
}
|
||||
fmt.Fprintf(errw, "unknown command %q\n\n%s", args[0], usage)
|
||||
return 2
|
||||
}
|
||||
|
||||
func sections(errw io.Writer) ([]search.Section, bool) {
|
||||
secs, err := search.Sections(corpus())
|
||||
if err != nil {
|
||||
fmt.Fprintln(errw, err)
|
||||
return nil, false
|
||||
}
|
||||
return secs, true
|
||||
}
|
||||
|
||||
func show(out io.Writer, s search.Section, full bool) {
|
||||
fmt.Fprintf(out, "%s:%d %s\n", s.Path, s.Line, s.Title)
|
||||
text := []rune(s.Text)
|
||||
t := s.Text
|
||||
if !full && len(text) > 420 {
|
||||
t = string(text[:420])
|
||||
if i := strings.LastIndex(t, " "); i >= 0 {
|
||||
t = t[:i]
|
||||
}
|
||||
t += " ..."
|
||||
}
|
||||
for _, line := range strings.Split(t, "\n") {
|
||||
fmt.Fprintln(out, " "+line)
|
||||
}
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
|
||||
func cmdSearch(args []string, out, errw io.Writer) int {
|
||||
fl := flag.NewFlagSet("search", flag.ContinueOnError)
|
||||
fl.SetOutput(errw)
|
||||
n := fl.Int("n", 5, "number of results")
|
||||
if fl.Parse(args) != nil {
|
||||
return 2
|
||||
}
|
||||
if fl.NArg() == 0 {
|
||||
fmt.Fprint(errw, "usage: playbook search [-n N] QUERY...\n")
|
||||
return 2
|
||||
}
|
||||
secs, ok := sections(errw)
|
||||
if !ok {
|
||||
return 1
|
||||
}
|
||||
hits := search.Rank(strings.Join(fl.Args(), " "), secs, *n)
|
||||
if len(hits) == 0 {
|
||||
fmt.Fprintln(errw, "no match; widen the query, or follow sources/SOURCES.md to research")
|
||||
return 1
|
||||
}
|
||||
for _, h := range hits {
|
||||
show(out, h.Section, false)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdRule(args []string, out, errw io.Writer) int {
|
||||
if len(args) != 1 {
|
||||
fmt.Fprint(errw, "usage: playbook rule ID\n")
|
||||
return 2
|
||||
}
|
||||
secs, ok := sections(errw)
|
||||
if !ok {
|
||||
return 1
|
||||
}
|
||||
hits := search.ByID(secs, args[0])
|
||||
if len(hits) == 0 {
|
||||
fmt.Fprintf(errw, "no rule %s\n", strings.ToUpper(args[0]))
|
||||
return 1
|
||||
}
|
||||
for _, s := range hits {
|
||||
show(out, s, true)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdList(out, errw io.Writer) int {
|
||||
secs, ok := sections(errw)
|
||||
if !ok {
|
||||
return 1
|
||||
}
|
||||
for _, s := range secs {
|
||||
if s.ID != "" {
|
||||
fmt.Fprintf(out, "%-10s %s (%s:%d)\n", s.ID, strings.TrimSpace(s.Title[len(s.ID):]), s.Path, s.Line)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdDoc(args []string, out, errw io.Writer) int {
|
||||
fsys := corpus()
|
||||
if len(args) == 0 {
|
||||
for _, d := range search.Docs(fsys) {
|
||||
fmt.Fprintln(out, d)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
p := path.Clean(args[0])
|
||||
if !fs.ValidPath(p) {
|
||||
fmt.Fprintf(errw, "invalid path %q\n", args[0])
|
||||
return 2
|
||||
}
|
||||
data, err := fs.ReadFile(fsys, p)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errw, "no document %s (run `playbook doc` to list)\n", p)
|
||||
return 1
|
||||
}
|
||||
out.Write(data)
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdContrast(args []string, out, errw io.Writer) int {
|
||||
if len(args) != 2 {
|
||||
fmt.Fprint(errw, "usage: playbook contrast FG BG\n")
|
||||
return 2
|
||||
}
|
||||
r, err := contrast.Ratio(args[0], args[1])
|
||||
if err != nil {
|
||||
fmt.Fprintln(errw, err)
|
||||
return 2
|
||||
}
|
||||
fmt.Fprintf(out, "%.2f:1\n", r)
|
||||
for _, v := range contrast.Verdicts(r) {
|
||||
mark := "pass"
|
||||
if !v.Pass {
|
||||
mark = "FAIL"
|
||||
}
|
||||
fmt.Fprintf(out, " %s %s\n", mark, v.Name)
|
||||
}
|
||||
if r < 4.5 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdTokens(args []string, out, errw io.Writer) int {
|
||||
type src struct {
|
||||
fsys fs.FS
|
||||
path string
|
||||
}
|
||||
var srcs []src
|
||||
if len(args) == 0 {
|
||||
for _, p := range []string{"templates/tokens.light.json", "templates/tokens.dark.json"} {
|
||||
srcs = append(srcs, src{corpus(), p})
|
||||
}
|
||||
}
|
||||
for _, a := range args {
|
||||
srcs = append(srcs, src{os.DirFS(filepath.Dir(a)), filepath.Base(a)})
|
||||
}
|
||||
total, bad := 0, 0
|
||||
for _, s := range srcs {
|
||||
n, f, err := contrast.CheckTokens(s.fsys, s.path, out)
|
||||
if err != nil {
|
||||
fmt.Fprintln(errw, err)
|
||||
return 2
|
||||
}
|
||||
total += n
|
||||
bad += f
|
||||
}
|
||||
fmt.Fprintf(out, "%d/%d pairs pass\n", total-bad, total)
|
||||
if bad > 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdSkills(args []string, out, errw io.Writer) int {
|
||||
if len(args) == 0 || args[0] != "install" {
|
||||
fmt.Fprint(errw, "usage: playbook skills install [DIR...]\n")
|
||||
return 2
|
||||
}
|
||||
targets := args[1:]
|
||||
if len(targets) == 0 {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
fmt.Fprintln(errw, err)
|
||||
return 1
|
||||
}
|
||||
for _, t := range []string{".claude/skills", ".agents/skills"} {
|
||||
d := filepath.Join(home, t)
|
||||
if st, err := os.Stat(d); err == nil && st.IsDir() {
|
||||
targets = append(targets, d)
|
||||
} else {
|
||||
fmt.Fprintf(out, "skip %s (missing)\n", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
fsys := corpus()
|
||||
entries, err := fs.ReadDir(fsys, "skills")
|
||||
if err != nil {
|
||||
fmt.Fprintln(errw, err)
|
||||
return 1
|
||||
}
|
||||
for _, t := range targets {
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
dest := filepath.Join(t, e.Name())
|
||||
// Migrate the old symlink install: replace the link, never its target.
|
||||
if st, err := os.Lstat(dest); err == nil && st.Mode()&os.ModeSymlink != 0 {
|
||||
os.Remove(dest)
|
||||
}
|
||||
if err := copyTree(fsys, path.Join("skills", e.Name()), dest); err != nil {
|
||||
fmt.Fprintln(errw, err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(out, "installed %s\n", dest)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func copyTree(fsys fs.FS, src, dest string) error {
|
||||
return fs.WalkDir(fsys, src, func(p string, e fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dest, filepath.FromSlash(strings.TrimPrefix(p, src)))
|
||||
if e.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
data, err := fs.ReadFile(fsys, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, data, 0o644)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func do(args ...string) (int, string, string) {
|
||||
var o, e bytes.Buffer
|
||||
c := run(args, &o, &e)
|
||||
return c, o.String(), e.String()
|
||||
}
|
||||
|
||||
func TestRuleLookup(t *testing.T) {
|
||||
if c, out, _ := do("rule", "A11Y-10"); c != 0 || !strings.Contains(out, "Visible, unobscured focus") {
|
||||
t.Fatalf("rule A11Y-10: %d %q", c, out)
|
||||
}
|
||||
if _, out, _ := do("rule", "MOT"); !strings.Contains(out, "MOT-07") {
|
||||
t.Fatal("prefix lookup failed")
|
||||
}
|
||||
if c, _, _ := do("rule", "NOPE-99"); c != 1 {
|
||||
t.Fatal("unknown id must exit 1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchFindsUndoRule(t *testing.T) {
|
||||
if _, out, _ := do("search", "-n", "3", "confirmation dialog for delete undo"); !strings.Contains(out, "INT-06") {
|
||||
t.Fatalf("INT-06 not in top 3:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShippedThemesPass(t *testing.T) {
|
||||
if c, out, _ := do("tokens"); c != 0 {
|
||||
t.Fatalf("shipped tokens failed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContrastExitCode(t *testing.T) {
|
||||
if c, _, _ := do("contrast", "#767676", "#fff"); c != 0 {
|
||||
t.Fatal("4.54 should pass")
|
||||
}
|
||||
if c, _, _ := do("contrast", "#777", "#fff"); c != 1 {
|
||||
t.Fatal("4.48 should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocRejectsTraversal(t *testing.T) {
|
||||
if c, _, _ := do("doc", "../etc/passwd"); c == 0 {
|
||||
t.Fatal("traversal must fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Package playbook embeds the design playbook corpus so the CLI works without a
|
||||
// checkout. Set PLAYBOOK_DIR to read a live checkout instead.
|
||||
package playbook
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed README.md AGENTS.md llms.txt principles platforms checklists templates sources skills
|
||||
var Corpus embed.FS
|
||||
@@ -0,0 +1,203 @@
|
||||
// Package contrast implements WCAG 2.x contrast ratios and DTCG token-pair checks.
|
||||
//
|
||||
// WCAG 2 ratios are compared unrounded: 4.499 fails 4.5. APCA is not implemented.
|
||||
package contrast
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var hexRE = regexp.MustCompile(`^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`)
|
||||
|
||||
// RGBA holds 0-255 channels and a 0-1 alpha.
|
||||
type RGBA struct{ R, G, B, A float64 }
|
||||
|
||||
func ParseHex(value string) (RGBA, error) {
|
||||
m := hexRE.FindStringSubmatch(strings.TrimSpace(value))
|
||||
if m == nil {
|
||||
return RGBA{}, fmt.Errorf("not a hex color: %q", value)
|
||||
}
|
||||
h := m[1]
|
||||
if len(h) == 3 {
|
||||
var b strings.Builder
|
||||
for _, c := range h {
|
||||
b.WriteRune(c)
|
||||
b.WriteRune(c)
|
||||
}
|
||||
h = b.String()
|
||||
}
|
||||
var v [4]int
|
||||
for i := 0; i < len(h)/2; i++ {
|
||||
fmt.Sscanf(h[i*2:i*2+2], "%x", &v[i])
|
||||
}
|
||||
a := 1.0
|
||||
if len(h) == 8 {
|
||||
a = float64(v[3]) / 255
|
||||
}
|
||||
return RGBA{float64(v[0]), float64(v[1]), float64(v[2]), a}, nil
|
||||
}
|
||||
|
||||
func lin(c float64) float64 {
|
||||
c /= 255
|
||||
if c <= 0.03928 {
|
||||
return c / 12.92
|
||||
}
|
||||
return math.Pow((c+0.055)/1.055, 2.4)
|
||||
}
|
||||
|
||||
func luminance(c RGBA) float64 {
|
||||
return 0.2126*lin(c.R) + 0.7152*lin(c.G) + 0.0722*lin(c.B)
|
||||
}
|
||||
|
||||
// composite flattens a translucent foreground over an opaque background.
|
||||
func composite(fg, bg RGBA) RGBA {
|
||||
mix := func(f, b float64) float64 { return math.RoundToEven(f*fg.A + b*(1-fg.A)) }
|
||||
return RGBA{mix(fg.R, bg.R), mix(fg.G, bg.G), mix(fg.B, bg.B), 1}
|
||||
}
|
||||
|
||||
// Ratio returns the WCAG contrast ratio of fg over bg (hex strings).
|
||||
func Ratio(fgHex, bgHex string) (float64, error) {
|
||||
bg, err := ParseHex(bgHex)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if bg.A < 1 {
|
||||
return 0, fmt.Errorf("background must be opaque")
|
||||
}
|
||||
fg, err := ParseHex(fgHex)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
l1, l2 := luminance(composite(fg, bg)), luminance(bg)
|
||||
return (math.Max(l1, l2) + 0.05) / (math.Min(l1, l2) + 0.05), nil
|
||||
}
|
||||
|
||||
// Verdict is one WCAG threshold and whether a ratio meets it.
|
||||
type Verdict struct {
|
||||
Name string
|
||||
Pass bool
|
||||
}
|
||||
|
||||
func Verdicts(r float64) []Verdict {
|
||||
return []Verdict{
|
||||
{"AA normal (4.5)", r >= 4.5},
|
||||
{"AA large / UI (3)", r >= 3},
|
||||
{"AAA normal (7)", r >= 7},
|
||||
{"AAA large (4.5)", r >= 4.5},
|
||||
}
|
||||
}
|
||||
|
||||
func flatten(node any, path []string, out map[string]map[string]any) {
|
||||
obj, ok := node.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, has := obj["$value"]; has {
|
||||
out[strings.Join(path, ".")] = obj
|
||||
return
|
||||
}
|
||||
for k, v := range obj {
|
||||
if !strings.HasPrefix(k, "$") {
|
||||
flatten(v, append(append([]string{}, path...), k), out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveHex(tokens map[string]map[string]any, name string, seen map[string]bool) (string, error) {
|
||||
if seen[name] {
|
||||
return "", fmt.Errorf("alias cycle at %s", name)
|
||||
}
|
||||
tok, ok := tokens[name]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unknown token %s", name)
|
||||
}
|
||||
seen[name] = true
|
||||
switch v := tok["$value"].(type) {
|
||||
case map[string]any:
|
||||
hex, _ := v["hex"].(string)
|
||||
if hex == "" {
|
||||
return "", fmt.Errorf("%s: color object has no hex", name)
|
||||
}
|
||||
return hex, nil
|
||||
case string:
|
||||
if strings.HasPrefix(v, "{") && strings.HasSuffix(v, "}") {
|
||||
return resolveHex(tokens, v[1:len(v)-1], seen)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
return "", fmt.Errorf("%s: unsupported $value", name)
|
||||
}
|
||||
|
||||
// CheckTokens verifies every contrastPairs entry declared in a DTCG token file
|
||||
// and prints one line per pair. It returns the pair count and failure count.
|
||||
func CheckTokens(fsys fs.FS, path string, w io.Writer) (total, failed int, err error) {
|
||||
data, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return 0, 0, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
tokens := map[string]map[string]any{}
|
||||
flatten(doc, nil, tokens)
|
||||
ext, _ := doc["$extensions"].(map[string]any)
|
||||
pb, _ := ext["dev.playbook"].(map[string]any)
|
||||
pairs, _ := pb["contrastPairs"].([]any)
|
||||
if len(pairs) == 0 {
|
||||
fmt.Fprintf(w, "%s: no contrastPairs declared\n", path)
|
||||
return 0, 0, nil
|
||||
}
|
||||
for _, p := range pairs {
|
||||
pair, _ := p.([]any)
|
||||
if len(pair) < 3 {
|
||||
fmt.Fprintf(w, "ERROR %s: malformed pair %v\n", path, p)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
fg, _ := pair[0].(string)
|
||||
bg, _ := pair[1].(string)
|
||||
need, _ := pair[2].(float64)
|
||||
note := ""
|
||||
if len(pair) > 3 {
|
||||
note, _ = pair[3].(string)
|
||||
}
|
||||
fgHex, e1 := resolveHex(tokens, fg, map[string]bool{})
|
||||
bgHex, e2 := resolveHex(tokens, bg, map[string]bool{})
|
||||
var r float64
|
||||
e := firstErr(e1, e2)
|
||||
if e == nil {
|
||||
r, e = Ratio(fgHex, bgHex)
|
||||
}
|
||||
if e != nil {
|
||||
fmt.Fprintf(w, "ERROR %s: %s on %s: %v\n", path, fg, bg, e)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
ok := r >= need
|
||||
if !ok {
|
||||
failed++
|
||||
}
|
||||
status := "PASS"
|
||||
if !ok {
|
||||
status = "FAIL"
|
||||
}
|
||||
fmt.Fprintf(w, "%s %6.2f (need %g) %s on %s %s\n", status, r, need, fg, bg, note)
|
||||
}
|
||||
return len(pairs), failed, nil
|
||||
}
|
||||
|
||||
func firstErr(errs ...error) error {
|
||||
for _, e := range errs {
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package contrast
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func ratio(t *testing.T, a, b string) float64 {
|
||||
t.Helper()
|
||||
r, err := Ratio(a, b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestBlackWhite(t *testing.T) {
|
||||
if math.Abs(ratio(t, "#000", "#fff")-21) > 0.01 {
|
||||
t.Fatal("black on white should be 21:1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnownGrayBoundary(t *testing.T) {
|
||||
if ratio(t, "#767676", "#ffffff") < 4.5 {
|
||||
t.Fatal("#767676 on white must pass AA")
|
||||
}
|
||||
if ratio(t, "#777777", "#ffffff") >= 4.5 {
|
||||
t.Fatal("#777777 on white must fail AA")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSymmetry(t *testing.T) {
|
||||
if math.Abs(ratio(t, "#123456", "#fedcba")-ratio(t, "#fedcba", "#123456")) > 1e-9 {
|
||||
t.Fatal("ratio must be symmetric")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslucentForegroundIsFlattened(t *testing.T) {
|
||||
if r := ratio(t, "#00000080", "#ffffff"); r >= 8 || r <= 3 {
|
||||
t.Fatalf("50%% black on white = %.2f", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsBadInput(t *testing.T) {
|
||||
if _, err := Ratio("blue", "#fff"); err == nil {
|
||||
t.Fatal("named color must be rejected")
|
||||
}
|
||||
if _, err := Ratio("#000", "#ffffff80"); err == nil {
|
||||
t.Fatal("translucent background must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func tokenFile(fg string) fstest.MapFS {
|
||||
return fstest.MapFS{"t.json": {Data: []byte(`{
|
||||
"color": {"$type": "color", "a": {"$value": "` + fg + `"}, "b": {"$value": "#ffffff"},
|
||||
"c": {"$value": "{color.a}"}},
|
||||
"$extensions": {"dev.playbook": {"contrastPairs": [["color.c", "color.b", 4.5, "alias"]]}}}`)}}
|
||||
}
|
||||
|
||||
func TestTokenPairs(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
if _, failed, err := CheckTokens(tokenFile("#767676"), "t.json", &out); err != nil || failed != 0 {
|
||||
t.Fatalf("expected pass, failed=%d err=%v\n%s", failed, err, out.String())
|
||||
}
|
||||
out.Reset()
|
||||
if _, failed, _ := CheckTokens(tokenFile("#777777"), "t.json", &out); failed != 1 || !strings.Contains(out.String(), "FAIL") {
|
||||
t.Fatalf("expected one failure\n%s", out.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Package search ranks playbook sections. A section is a Markdown heading plus
|
||||
// its body; rule ids come from headings like "### A11Y-10 Title". Ranking is BM25
|
||||
// over section text with a boost for title/id hits, enough for a corpus this size.
|
||||
package search
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io/fs"
|
||||
"math"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
dirs = []string{"principles", "platforms", "checklists", "templates", "sources", "skills"}
|
||||
top = []string{"README.md", "AGENTS.md"}
|
||||
headingRE = regexp.MustCompile(`^(#{1,6})\s+(.*\S)\s*$`)
|
||||
ruleIDRE = regexp.MustCompile(`^([A-Z0-9]+-\d+)\b`)
|
||||
tokenRE = regexp.MustCompile(`[a-z0-9][a-z0-9\-_.]*[a-z0-9]|[a-z0-9]`)
|
||||
)
|
||||
|
||||
type Section struct {
|
||||
Path string
|
||||
Line int
|
||||
Title string
|
||||
ID string
|
||||
Text string
|
||||
}
|
||||
|
||||
// Stem folds plurals and gerunds so "transitions" matches "transition".
|
||||
func Stem(t string) string {
|
||||
if len(t) > 5 && strings.HasSuffix(t, "ing") {
|
||||
return t[:len(t)-3]
|
||||
}
|
||||
if len(t) > 3 && strings.HasSuffix(t, "s") && !strings.HasSuffix(t, "ss") {
|
||||
return t[:len(t)-1]
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func Tokenize(text string) []string {
|
||||
raw := tokenRE.FindAllString(strings.ToLower(text), -1)
|
||||
for i, t := range raw {
|
||||
raw[i] = Stem(t)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func markdownFiles(fsys fs.FS) []string {
|
||||
var out []string
|
||||
for _, name := range top {
|
||||
if _, err := fs.Stat(fsys, name); err == nil {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
for _, d := range dirs {
|
||||
fs.WalkDir(fsys, d, func(p string, e fs.DirEntry, err error) error {
|
||||
if err == nil && !e.IsDir() && strings.HasSuffix(p, ".md") {
|
||||
out = append(out, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Docs lists every Markdown document the search covers.
|
||||
func Docs(fsys fs.FS) []string { return markdownFiles(fsys) }
|
||||
|
||||
// Sections splits every document into heading-delimited sections.
|
||||
func Sections(fsys fs.FS) ([]Section, error) {
|
||||
var out []Section
|
||||
for _, p := range markdownFiles(fsys) {
|
||||
f, err := fsys.Open(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 1<<20), 1<<20)
|
||||
inFence := false
|
||||
var cur *Section
|
||||
var body []string
|
||||
flush := func() {
|
||||
if cur != nil {
|
||||
cur.Text = strings.TrimSpace(strings.Join(body, "\n"))
|
||||
out = append(out, *cur)
|
||||
}
|
||||
}
|
||||
for line := 1; sc.Scan(); line++ {
|
||||
text := sc.Text()
|
||||
if strings.HasPrefix(strings.TrimLeft(text, " \t"), "```") {
|
||||
inFence = !inFence
|
||||
}
|
||||
var m []string
|
||||
if !inFence {
|
||||
m = headingRE.FindStringSubmatch(text)
|
||||
}
|
||||
if m != nil {
|
||||
flush()
|
||||
title := m[2]
|
||||
id := ""
|
||||
if im := ruleIDRE.FindStringSubmatch(title); im != nil {
|
||||
id = im[1]
|
||||
}
|
||||
cur = &Section{Path: path.Clean(p), Line: line, Title: title, ID: id}
|
||||
body = body[:0]
|
||||
} else if cur != nil {
|
||||
body = append(body, text)
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
flush()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type Hit struct {
|
||||
Score float64
|
||||
Section
|
||||
}
|
||||
|
||||
// Rank returns the k best sections for a query.
|
||||
func Rank(query string, secs []Section, k int) []Hit {
|
||||
q := Tokenize(query)
|
||||
if len(q) == 0 {
|
||||
return nil
|
||||
}
|
||||
qset := map[string]bool{}
|
||||
for _, t := range q {
|
||||
qset[t] = true
|
||||
}
|
||||
docs := make([][]string, len(secs))
|
||||
df := map[string]int{}
|
||||
total := 0
|
||||
for i, s := range secs {
|
||||
docs[i] = Tokenize(s.Title + " " + s.ID + " " + s.Text)
|
||||
total += len(docs[i])
|
||||
seen := map[string]bool{}
|
||||
for _, t := range docs[i] {
|
||||
if !seen[t] {
|
||||
seen[t] = true
|
||||
df[t]++
|
||||
}
|
||||
}
|
||||
}
|
||||
n := float64(len(docs))
|
||||
avg := float64(total) / math.Max(n, 1)
|
||||
var hits []Hit
|
||||
for i, s := range secs {
|
||||
tf := map[string]int{}
|
||||
for _, t := range docs[i] {
|
||||
tf[t]++
|
||||
}
|
||||
score := 0.0
|
||||
for t := range qset {
|
||||
f, ok := tf[t]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
d := float64(df[t])
|
||||
idf := math.Log(1 + (n-d+0.5)/(d+0.5))
|
||||
ff := float64(f)
|
||||
score += idf * ff * 2.2 / (ff + 1.2*(0.25+0.75*float64(len(docs[i]))/avg))
|
||||
}
|
||||
head := map[string]bool{}
|
||||
for _, t := range Tokenize(s.Title + " " + s.ID) {
|
||||
if qset[t] {
|
||||
head[t] = true
|
||||
}
|
||||
}
|
||||
score += 1.5 * float64(len(head))
|
||||
if score > 0 {
|
||||
hits = append(hits, Hit{score, s})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(hits, func(a, b int) bool { return hits[a].Score > hits[b].Score })
|
||||
if len(hits) > k {
|
||||
hits = hits[:k]
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// ByID returns rules whose id equals want or starts with want+"-" (a prefix).
|
||||
func ByID(secs []Section, want string) []Section {
|
||||
want = strings.ToUpper(want)
|
||||
var out []Section
|
||||
for _, s := range secs {
|
||||
if s.ID != "" && (s.ID == want || strings.HasPrefix(s.ID, want+"-")) {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
var fsys = fstest.MapFS{
|
||||
"README.md": {Data: []byte("# Title\nintro\n")},
|
||||
"principles/a.md": {Data: []byte("### INT-06 Undo over confirm\nPrefer undo to a confirmation dialog for delete.\n" +
|
||||
"```\n# not a heading\n```\n### MOT-07 Reduced motion\nUse 1ms, not none, for view transitions.\n")},
|
||||
}
|
||||
|
||||
func TestSectionsSkipFences(t *testing.T) {
|
||||
secs, err := Sections(fsys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range secs {
|
||||
if s.Title == "not a heading" {
|
||||
t.Fatal("fenced line parsed as heading")
|
||||
}
|
||||
}
|
||||
if len(ByID(secs, "int-06")) != 1 || len(ByID(secs, "MOT")) != 1 || len(ByID(secs, "NOPE-99")) != 0 {
|
||||
t.Fatal("id and prefix lookup wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankFindsRuleWithStemming(t *testing.T) {
|
||||
secs, _ := Sections(fsys)
|
||||
hits := Rank("reduced motion view transition frozen", secs, 1)
|
||||
if len(hits) != 1 || hits[0].ID != "MOT-07" {
|
||||
t.Fatalf("got %+v", hits)
|
||||
}
|
||||
if h := Rank("confirmations of deleting", secs, 1); len(h) != 1 || h[0].ID != "INT-06" {
|
||||
t.Fatalf("stemming failed: %+v", h)
|
||||
}
|
||||
if len(Rank(" ", secs, 3)) != 0 {
|
||||
t.Fatal("empty query must return nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStem(t *testing.T) {
|
||||
for in, want := range map[string]string{"transitions": "transition", "scrolling": "scroll", "class": "class", "css": "css"} {
|
||||
if got := Stem(in); got != want {
|
||||
t.Errorf("Stem(%q)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
_ = strings.ToLower
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# design-playbook
|
||||
|
||||
> Design reference for building accessible, non-generic user interfaces. About 150 rules with stable ids (INT, PSY, VIS, MOT, A11Y, FBK, SYS, BAD), each traced to a source; WCAG 2.2 AA oriented. Web and native egui. Search with scripts/search.py or follow the links below. Rules without a cited source are general knowledge defaults.
|
||||
> Design reference for building accessible, non-generic user interfaces. About 150 rules with stable ids (INT, PSY, VIS, MOT, A11Y, FBK, SYS, BAD), each traced to a source; WCAG 2.2 AA oriented. Web and native egui. Search with the `playbook` CLI (`playbook search QUERY`) or follow the links below. Rules without a cited source are general knowledge defaults.
|
||||
|
||||
Rule id prefixes: INT interaction intent, PSY perception and timing, VIS visual design, MOT motion, A11Y accessibility, FBK feedback states forms, SYS design systems tokens, BAD anti-patterns, CSS CSS craft, PAT component patterns.
|
||||
|
||||
@@ -38,5 +38,4 @@ Rule id prefixes: INT interaction intent, PSY perception and timing, VIS visual
|
||||
## Optional
|
||||
- [Unverified claims](sources/UNVERIFIED.md)
|
||||
- [Agent guide](AGENTS.md)
|
||||
- [Search tool](scripts/search.py)
|
||||
- [Contrast tool](scripts/contrast.py)
|
||||
- [CLI source](cmd/playbook/main.go): `playbook search|rule|list|doc|contrast|tokens|skills`
|
||||
|
||||
@@ -22,7 +22,7 @@ research; "to check" means consult egui/AccessKit docs or the code first.
|
||||
| A11Y-14 accessible name/role/value | Give every widget an AccessKit node: set the label and role (`Response::widget_info`, `ui.ctx().accesskit_node_builder`). Icon-only buttons need a label. To check against the egui version in use. |
|
||||
| A11Y-07/08 keyboard | egui focus moves with Tab; make custom widgets `Sense::focusable_noninteractive()` or interactive senses so they enter the focus order; draw a visible focus ring from `response.has_focus()`. Provide arrow-key navigation inside lists, tabs and menus. |
|
||||
| A11Y-09 target size | Minimum interactive size 24 x 24 logical px; 44 for touch. In egui set `style.spacing.interact_size` and `button_padding`; for icon buttons allocate an expanded rect larger than the glyph. |
|
||||
| A11Y-01/02 contrast | Run the token pairs through `scripts/contrast.py` for every theme. Egui `Visuals` colors derive from tokens, never raw literals. Disabled state still meets 3:1 for non-text where it conveys information. |
|
||||
| A11Y-01/02 contrast | Run the token pairs through `playbook contrast` for every theme. Egui `Visuals` colors derive from tokens, never raw literals. Disabled state still meets 3:1 for non-text where it conveys information. |
|
||||
| A11Y-10/11 focus ring | Draw a 2 px ring offset by 2 px, color from `focus` token with 3:1 against both the widget and the surface; do not rely on the default faint egui hover/focus stroke. |
|
||||
| MOT-02/04 durations, interruptibility | `ctx.animate_value_with_time` and springs retarget naturally: always animate toward a target value rather than replaying a timeline. Request repaints only while animating. |
|
||||
| MOT-07 reduced motion | Read the OS preference where available (not provided by egui by default: to check) and expose an app setting that sets a single motion scale to 0; all easing/spring helpers multiply by it. |
|
||||
|
||||
+1
-1
@@ -117,6 +117,6 @@ Generate real values with Utopia (utopia.fyi); keep `rem` terms so zoom works.
|
||||
|
||||
## Testing on web
|
||||
1. Tab through the whole page, no mouse. 2. 200% zoom and 320 px width.
|
||||
3. OS reduce-motion on. 4. Contrast via `scripts/contrast.py` or devtools.
|
||||
3. OS reduce-motion on. 4. Contrast via `playbook contrast` or devtools.
|
||||
5. axe/Lighthouse as a floor, not proof. 6. Screen reader pass on the main flow.
|
||||
7. Throttled CPU/network. 8. Filmstrip (CDP screencast) for transitions.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package playbook
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"playbook/internal/search"
|
||||
)
|
||||
|
||||
// Every rule id cited anywhere must exist, so cross-references cannot rot.
|
||||
func TestCrossReferencesResolve(t *testing.T) {
|
||||
secs, err := search.Sections(Corpus)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
known := map[string]bool{}
|
||||
for _, s := range secs {
|
||||
if s.ID != "" {
|
||||
if known[s.ID] {
|
||||
t.Errorf("duplicate rule id %s", s.ID)
|
||||
}
|
||||
known[s.ID] = true
|
||||
}
|
||||
}
|
||||
if len(known) < 80 {
|
||||
t.Fatalf("only %d rules found", len(known))
|
||||
}
|
||||
ref := regexp.MustCompile(`\b(INT|PSY|VIS|MOT|A11Y|FBK|SYS|BAD|CSS|PAT)-(\d+)\b`)
|
||||
fs.WalkDir(Corpus, ".", func(p string, e fs.DirEntry, err error) error {
|
||||
if err != nil || e.IsDir() || !strings.HasSuffix(p, ".md") && !strings.HasSuffix(p, ".json") && !strings.HasSuffix(p, ".txt") {
|
||||
return nil
|
||||
}
|
||||
data, _ := fs.ReadFile(Corpus, p)
|
||||
for _, id := range ref.FindAllString(string(data), -1) {
|
||||
if !known[id] {
|
||||
t.Errorf("%s cites unknown rule %s", p, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -79,7 +79,7 @@ mint, lime, yellow, amber (dark text).
|
||||
White text on orange often fails contrast; darkening it turns brown. Use a
|
||||
lighter, more saturated fill with dark text, and tint the dark text toward the
|
||||
fill's hue instead of neutral gray (V02). Always verify numerically
|
||||
(`scripts/contrast.py`).
|
||||
(`playbook contrast`).
|
||||
|
||||
### VIS-10 Never rely on color alone
|
||||
Pair color with text, icon, shape or border thickness. Karen Hawkins (V06):
|
||||
|
||||
@@ -16,7 +16,7 @@ reader passes (checklists/a11y-audit.md).
|
||||
### A11Y-01 Text contrast >= 4.5:1; large text >= 3:1 (SC 1.4.3, AA)
|
||||
Large = 18 pt (24 px) or 14 pt bold (about 18.5 px). No rounding: 4.499 fails.
|
||||
Hover and focus text states count. Brand guidelines are not an exemption;
|
||||
logotypes and inactive controls are. Compute with `scripts/contrast.py`.
|
||||
logotypes and inactive controls are. Compute with `playbook contrast`.
|
||||
|
||||
### A11Y-02 Non-text contrast >= 3:1 (SC 1.4.11, AA)
|
||||
Input borders, icon-only controls, focus indicators, chart marks against their
|
||||
|
||||
@@ -41,7 +41,7 @@ model modes as separate token files or a documented `$extensions` key.
|
||||
Encode checkable rules: every `text-*`/`bg-*` pair listed in
|
||||
`contrast.pairs` meets 4.5:1 (3:1 large), border/focus tokens meet 3:1,
|
||||
`target-min` is 44, `focus-ring-width` >= 2, `motion.reduce` exists. CI fails
|
||||
when a theme breaks an invariant (`scripts/contrast.py --tokens`). Child
|
||||
when a theme breaks an invariant (`playbook tokens`). Child
|
||||
themes may change hue, never the invariants (V06).
|
||||
|
||||
### SYS-06 Atomic structure is a thinking tool, not a folder rule
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""WCAG 2.x contrast checker. Standard library only.
|
||||
|
||||
contrast.py FG BG ratio and AA/AAA verdicts for two hex colors
|
||||
contrast.py --tokens FILE [FILE...] check every contrastPairs entry in DTCG token files
|
||||
|
||||
A DTCG token file declares the pairs it promises to keep, at the root:
|
||||
|
||||
"$extensions": {"dev.playbook": {"contrastPairs": [
|
||||
["color.text.default", "color.surface.default", 4.5, "body text"]
|
||||
]}}
|
||||
|
||||
Colors are tokens whose $value is a hex string or an object with a "hex" key.
|
||||
Aliases ("{color.x}") are resolved. Exit code 1 if any pair fails.
|
||||
|
||||
WCAG 2 ratios are compared unrounded: 4.499 fails 4.5. APCA is not implemented
|
||||
here; use the apca-w3 package when a product opts into it.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
HEX = re.compile(r"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")
|
||||
|
||||
|
||||
def parse_hex(value):
|
||||
m = HEX.match(value.strip())
|
||||
if not m:
|
||||
raise ValueError(f"not a hex color: {value!r}")
|
||||
h = m.group(1)
|
||||
if len(h) == 3:
|
||||
h = "".join(c * 2 for c in h)
|
||||
r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4))
|
||||
a = int(h[6:8], 16) / 255 if len(h) == 8 else 1.0
|
||||
return r, g, b, a
|
||||
|
||||
|
||||
def _lin(c):
|
||||
c /= 255
|
||||
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
|
||||
|
||||
|
||||
def luminance(rgb):
|
||||
r, g, b = rgb[:3]
|
||||
return 0.2126 * _lin(r) + 0.7152 * _lin(g) + 0.0722 * _lin(b)
|
||||
|
||||
|
||||
def composite(fg, bg):
|
||||
"""Flatten a translucent foreground over an opaque background."""
|
||||
a = fg[3]
|
||||
return tuple(round(fg[i] * a + bg[i] * (1 - a)) for i in range(3)) + (1.0,)
|
||||
|
||||
|
||||
def ratio(fg_hex, bg_hex):
|
||||
bg = parse_hex(bg_hex)
|
||||
if bg[3] < 1:
|
||||
raise ValueError("background must be opaque")
|
||||
fg = composite(parse_hex(fg_hex), bg)
|
||||
l1, l2 = luminance(fg), luminance(bg)
|
||||
hi, lo = max(l1, l2), min(l1, l2)
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
|
||||
|
||||
def verdicts(r):
|
||||
return {
|
||||
"AA normal (4.5)": r >= 4.5,
|
||||
"AA large / UI (3)": r >= 3.0,
|
||||
"AAA normal (7)": r >= 7.0,
|
||||
"AAA large (4.5)": r >= 4.5,
|
||||
}
|
||||
|
||||
|
||||
def flatten(node, path=()):
|
||||
"""Yield (dotted_path, token) for every object that has $value."""
|
||||
if isinstance(node, dict):
|
||||
if "$value" in node:
|
||||
yield ".".join(path), node
|
||||
return
|
||||
for k, v in node.items():
|
||||
if not k.startswith("$"):
|
||||
yield from flatten(v, path + (k,))
|
||||
|
||||
|
||||
def resolve_hex(tokens, name, seen=()):
|
||||
if name in seen:
|
||||
raise ValueError(f"alias cycle at {name}")
|
||||
if name not in tokens:
|
||||
raise KeyError(f"unknown token {name}")
|
||||
value = tokens[name]["$value"]
|
||||
if isinstance(value, dict):
|
||||
value = value.get("hex")
|
||||
if not value:
|
||||
raise ValueError(f"{name}: color object has no hex")
|
||||
if isinstance(value, str) and value.startswith("{") and value.endswith("}"):
|
||||
return resolve_hex(tokens, value[1:-1], seen + (name,))
|
||||
return value
|
||||
|
||||
|
||||
def check_tokens(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
doc = json.load(f)
|
||||
tokens = dict(flatten(doc))
|
||||
pairs = (doc.get("$extensions", {}).get("dev.playbook", {}).get("contrastPairs", []))
|
||||
if not pairs:
|
||||
print(f"{path}: no contrastPairs declared")
|
||||
return 0, 0
|
||||
failed = 0
|
||||
for pair in pairs:
|
||||
fg, bg, need = pair[0], pair[1], float(pair[2])
|
||||
note = pair[3] if len(pair) > 3 else ""
|
||||
try:
|
||||
r = ratio(resolve_hex(tokens, fg), resolve_hex(tokens, bg))
|
||||
except (KeyError, ValueError) as e:
|
||||
print(f"ERROR {path}: {fg} on {bg}: {e}")
|
||||
failed += 1
|
||||
continue
|
||||
ok = r >= need
|
||||
failed += not ok
|
||||
print(f"{'PASS' if ok else 'FAIL'} {r:6.2f} (need {need:g}) {fg} on {bg} {note}")
|
||||
return len(pairs), failed
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) >= 3 and argv[1] == "--tokens":
|
||||
total = bad = 0
|
||||
for p in argv[2:]:
|
||||
n, f = check_tokens(p)
|
||||
total += n
|
||||
bad += f
|
||||
print(f"{total - bad}/{total} pairs pass")
|
||||
return 1 if bad else 0
|
||||
if len(argv) == 3:
|
||||
r = ratio(argv[1], argv[2])
|
||||
print(f"{r:.2f}:1")
|
||||
for k, v in verdicts(r).items():
|
||||
print(f" {'pass' if v else 'FAIL'} {k}")
|
||||
return 0 if r >= 4.5 else 1
|
||||
print(__doc__)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Symlink the playbook skills into the agent skill directories so edits in this
|
||||
# repo take effect immediately. Idempotent; refuses to overwrite a real directory.
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
targets=("$HOME/.claude/skills" "$HOME/.agents/skills")
|
||||
|
||||
for dest in "${targets[@]}"; do
|
||||
[ -d "$dest" ] || { echo "skip $dest (missing)"; continue; }
|
||||
for skill in "$root"/skills/*/; do
|
||||
name="$(basename "$skill")"
|
||||
link="$dest/$name"
|
||||
if [ -L "$link" ]; then
|
||||
ln -sfn "${skill%/}" "$link"
|
||||
echo "updated $link"
|
||||
elif [ -e "$link" ]; then
|
||||
echo "REFUSE $link exists and is not a symlink" >&2
|
||||
else
|
||||
ln -s "${skill%/}" "$link"
|
||||
echo "linked $link"
|
||||
fi
|
||||
done
|
||||
done
|
||||
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Search the playbook. Standard library only, no index to maintain.
|
||||
|
||||
search.py QUERY... ranked sections matching the query
|
||||
search.py --id A11Y-10 print one rule by id (also accepts a prefix: A11Y)
|
||||
search.py --list list all rule ids and titles
|
||||
search.py -n 8 QUERY... number of results (default 5)
|
||||
|
||||
A "section" is a Markdown heading plus its body. Rule ids come from headings of
|
||||
the form "### A11Y-10 Title". Ranking is BM25 over section text with a boost for
|
||||
title/id hits, which is enough for a corpus this size.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DIRS = ["principles", "platforms", "checklists", "templates", "sources", "skills"]
|
||||
TOP = ["README.md", "AGENTS.md"]
|
||||
HEADING = re.compile(r"^(#{1,6})\s+(.*\S)\s*$")
|
||||
RULE_ID = re.compile(r"^([A-Z0-9]+-\d+)\b")
|
||||
TOKEN = re.compile(r"[a-z0-9][a-z0-9\-_.]*[a-z0-9]|[a-z0-9]")
|
||||
|
||||
|
||||
def stem(t):
|
||||
"""Crude plural/gerund folding so 'transitions' matches 'transition'."""
|
||||
if len(t) > 5 and t.endswith("ing"):
|
||||
return t[:-3]
|
||||
if len(t) > 3 and t.endswith("s") and not t.endswith("ss"):
|
||||
return t[:-1]
|
||||
return t
|
||||
|
||||
|
||||
def tokenize(text):
|
||||
return [stem(t) for t in TOKEN.findall(text.lower())]
|
||||
|
||||
|
||||
def files():
|
||||
for name in TOP:
|
||||
p = os.path.join(ROOT, name)
|
||||
if os.path.exists(p):
|
||||
yield p
|
||||
for d in DIRS:
|
||||
for base, _, names in os.walk(os.path.join(ROOT, d)):
|
||||
for n in sorted(names):
|
||||
if n.endswith(".md"):
|
||||
yield os.path.join(base, n)
|
||||
|
||||
|
||||
def sections():
|
||||
out = []
|
||||
for path in files():
|
||||
rel = os.path.relpath(path, ROOT)
|
||||
in_fence = False
|
||||
cur = None
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for i, line in enumerate(f, 1):
|
||||
if line.lstrip().startswith("```"):
|
||||
in_fence = not in_fence
|
||||
m = None if in_fence else HEADING.match(line)
|
||||
if m:
|
||||
if cur:
|
||||
out.append(cur)
|
||||
title = m.group(2)
|
||||
idm = RULE_ID.match(title)
|
||||
cur = {"path": rel, "line": i, "title": title,
|
||||
"id": idm.group(1) if idm else None, "body": []}
|
||||
elif cur is not None:
|
||||
cur["body"].append(line.rstrip("\n"))
|
||||
if cur:
|
||||
out.append(cur)
|
||||
cur = None
|
||||
for s in out:
|
||||
s["text"] = "\n".join(s["body"]).strip()
|
||||
return out
|
||||
|
||||
|
||||
def rank(query, secs, k):
|
||||
q = tokenize(query)
|
||||
if not q:
|
||||
return []
|
||||
docs = [tokenize(s["title"] + " " + (s["id"] or "") + " " + s["text"]) for s in secs]
|
||||
n = len(docs)
|
||||
avg = sum(len(d) for d in docs) / max(n, 1)
|
||||
df = Counter(t for d in docs for t in set(d))
|
||||
scored = []
|
||||
for s, d in zip(secs, docs):
|
||||
tf = Counter(d)
|
||||
score = 0.0
|
||||
for t in set(q):
|
||||
if t not in tf:
|
||||
continue
|
||||
idf = math.log(1 + (n - df[t] + 0.5) / (df[t] + 0.5))
|
||||
score += idf * tf[t] * 2.2 / (tf[t] + 1.2 * (0.25 + 0.75 * len(d) / avg))
|
||||
head = set(tokenize(s["title"] + " " + (s["id"] or "")))
|
||||
score += 1.5 * len(head & set(q))
|
||||
if score > 0:
|
||||
scored.append((score, s))
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
return scored[:k]
|
||||
|
||||
|
||||
def show(s, full):
|
||||
print(f"{s['path']}:{s['line']} {s['title']}")
|
||||
text = s["text"]
|
||||
if not full and len(text) > 420:
|
||||
text = text[:420].rsplit(" ", 1)[0] + " ..."
|
||||
for line in text.splitlines():
|
||||
print(" " + line)
|
||||
print()
|
||||
|
||||
|
||||
def main(argv):
|
||||
args = argv[1:]
|
||||
k = 5
|
||||
if "-n" in args:
|
||||
i = args.index("-n")
|
||||
k = int(args[i + 1])
|
||||
del args[i:i + 2]
|
||||
secs = sections()
|
||||
if args[:1] == ["--list"]:
|
||||
for s in secs:
|
||||
if s["id"]:
|
||||
print(f"{s['id']:10} {s['title'][len(s['id']):].strip()} ({s['path']}:{s['line']})")
|
||||
return 0
|
||||
if args[:1] == ["--id"] and len(args) == 2:
|
||||
want = args[1].upper()
|
||||
hits = [s for s in secs if s["id"] and (s["id"] == want or s["id"].startswith(want + "-"))]
|
||||
if not hits:
|
||||
print(f"no rule {want}", file=sys.stderr)
|
||||
return 1
|
||||
for s in hits:
|
||||
show(s, True)
|
||||
return 0
|
||||
if not args:
|
||||
print(__doc__)
|
||||
return 2
|
||||
results = rank(" ".join(args), secs, k)
|
||||
if not results:
|
||||
print("no match; widen the query, or follow sources/SOURCES.md to research", file=sys.stderr)
|
||||
return 1
|
||||
for _, s in results:
|
||||
show(s, False)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -5,7 +5,7 @@ description: Audit an existing UI, component library or screenshot against the p
|
||||
|
||||
# Audit with the playbook
|
||||
|
||||
Playbook path: `$PLAYBOOK_DIR` (the root of your design-playbook clone). This skill is for review;
|
||||
Use the `playbook` CLI (rules are embedded in the binary). This skill is for review;
|
||||
use `playbook-design` to plan and `playbook-implement` to fix.
|
||||
|
||||
## Inputs to establish first
|
||||
@@ -22,7 +22,7 @@ use `playbook-design` to plan and `playbook-implement` to fix.
|
||||
component contract in `principles/07-design-systems-tokens.md` (SYS-07).
|
||||
2. Gather evidence, in this order of reliability: run it and use the keyboard;
|
||||
inspect the accessibility tree; measure contrast
|
||||
(`python3 scripts/contrast.py FG BG`, or `--tokens` on token files); read the
|
||||
(`playbook contrast FG BG`, or `--tokens` on token files); read the
|
||||
code for semantics (div buttons, outline none, positive tabindex, transition
|
||||
all, hover-only); look at screenshots.
|
||||
3. For web, an automated pass (axe, Lighthouse) is a floor; never report
|
||||
|
||||
@@ -5,16 +5,16 @@ description: Design a UI screen, flow, component or interaction using the person
|
||||
|
||||
# Design with the playbook
|
||||
|
||||
The playbook lives at `$PLAYBOOK_DIR` (the root of your design-playbook clone). Rules have stable ids (`A11Y-10`, `INT-06`);
|
||||
The playbook is the `playbook` CLI (build it from the repo with
|
||||
`go build -o ~/.local/bin/playbook ./cmd/playbook`; the rules are embedded). Rules have stable ids (`A11Y-10`, `INT-06`);
|
||||
cite ids in your output so choices are auditable.
|
||||
|
||||
## Look things up, do not recall them
|
||||
|
||||
```bash
|
||||
cd "$PLAYBOOK_DIR"
|
||||
python3 scripts/search.py "undo vs confirm delete" # ranked sections
|
||||
python3 scripts/search.py --id A11Y-09 # one rule, full text
|
||||
python3 scripts/search.py --list # every rule id
|
||||
playbook search "undo vs confirm delete" # ranked sections
|
||||
playbook rule A11Y-09 # one rule, full text
|
||||
playbook list # every rule id
|
||||
```
|
||||
Thresholds (contrast 4.5:1, targets 24/44 px, durations) must come from the
|
||||
playbook or a fetched primary source, never from memory. A rule tagged GK or
|
||||
@@ -43,7 +43,7 @@ listed in `sources/UNVERIFIED.md` is a default to test, not a requirement.
|
||||
6. **Tokens first.** Reuse the product's existing tokens (web: the project's
|
||||
token files; native: its palette module). Propose new
|
||||
tokens by role name only when needed (SYS-02, SYS-03). Check any new color
|
||||
pair with `python3 scripts/contrast.py FG BG`.
|
||||
pair with `playbook contrast FG BG`.
|
||||
7. **Accessibility plan** (brief section 8): keyboard map, names/roles, live
|
||||
announcements, target sizes, contrast pairs, reduced-motion variant,
|
||||
cognitive load. Use `checklists/a11y-audit.md` as the source of items.
|
||||
|
||||
@@ -5,13 +5,12 @@ description: Implement or modify UI code (web HTML/CSS/React/Tailwind or native
|
||||
|
||||
# Implement with the playbook
|
||||
|
||||
Playbook path: `$PLAYBOOK_DIR` (the root of your design-playbook clone). Look rules up instead of
|
||||
Use the `playbook` CLI (rules are embedded in the binary). Look rules up instead of
|
||||
recalling them:
|
||||
|
||||
```bash
|
||||
cd "$PLAYBOOK_DIR"
|
||||
python3 scripts/search.py "focus trap dialog"
|
||||
python3 scripts/search.py --id MOT-07
|
||||
playbook search "focus trap dialog"
|
||||
playbook rule MOT-07
|
||||
```
|
||||
|
||||
## Before writing code
|
||||
@@ -52,8 +51,8 @@ python3 scripts/search.py --id MOT-07
|
||||
## Verify before saying done
|
||||
|
||||
- Run the checks that exist: project tests, type check, lint.
|
||||
- New or changed color pairs: `python3 scripts/contrast.py FG BG`, or add them to
|
||||
the product's `contrastPairs` and run `python3 scripts/contrast.py --tokens <files>`.
|
||||
- New or changed color pairs: `playbook contrast FG BG`, or add them to
|
||||
the product's `contrastPairs` and run `playbook tokens <files>`.
|
||||
- Keyboard-only run of the changed flow. 200% zoom and 320 px width for web.
|
||||
- Turn on the OS reduced-motion setting and re-run the flow.
|
||||
- Actually look at the rendering (screenshot; filmstrip via CDP
|
||||
@@ -71,4 +70,4 @@ python3 scripts/search.py --id MOT-07
|
||||
|
||||
If the task reveals a missing rule or a wrong one, fix the playbook in the same
|
||||
sitting (`principles/`, a source row in `sources/SOURCES.md`, then
|
||||
`python3 -I -m unittest discover -s tests`).
|
||||
`go test ./...`).
|
||||
|
||||
+2
-2
@@ -81,14 +81,14 @@ Kole Jain "Every UI/UX Concept Explained" (EcbgbKtOELY), Hatch Conference
|
||||
"Design Systems in the Age of Agents" (JeJhPtsbIGA).
|
||||
|
||||
## Research protocol when the playbook has no answer
|
||||
1. `python3 scripts/search.py "<question>"`; follow rule ids to sources.
|
||||
1. `playbook search "<question>"`; follow rule ids to sources.
|
||||
2. Prefer P sources: W3C WAI, MDN, platform HIG/Material, NN/g. Fetch the page,
|
||||
do not rely on memory for thresholds.
|
||||
3. For a pattern, read the WAI-ARIA APG pattern and Inclusive Components.
|
||||
4. For taste and craft, study real products (Mobbin, Linear, Raycast, Vercel,
|
||||
Stripe) and note the mechanism, not the look.
|
||||
5. Add the finding to the right principle with a new rule id, add the source row
|
||||
here, and run `python3 -I -m unittest discover -s tests`.
|
||||
here, and run `go test ./...`.
|
||||
6. If a claim cannot be fetched, put it in `UNVERIFIED.md`, not in a rule.
|
||||
|
||||
## Other places to look (not yet read)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$description": "Semantic color tokens, light mode. Components use these names only (SYS-02). Pairs below are enforced by scripts/contrast.py.",
|
||||
"$description": "Semantic color tokens, light mode. Components use these names only (SYS-02). Pairs below are enforced by playbook tokens.",
|
||||
"color": {
|
||||
"$type": "color",
|
||||
"surface": {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "scripts"))
|
||||
import contrast # noqa: E402
|
||||
|
||||
|
||||
class Ratio(unittest.TestCase):
|
||||
def test_black_white(self):
|
||||
self.assertAlmostEqual(contrast.ratio("#000", "#fff"), 21.0, places=2)
|
||||
|
||||
def test_known_gray_boundary(self):
|
||||
# #767676 on white is the classic minimum AA gray (4.54); #777 just fails.
|
||||
self.assertGreaterEqual(contrast.ratio("#767676", "#ffffff"), 4.5)
|
||||
self.assertLess(contrast.ratio("#777777", "#ffffff"), 4.5)
|
||||
|
||||
def test_symmetry(self):
|
||||
self.assertAlmostEqual(contrast.ratio("#123456", "#fedcba"), contrast.ratio("#fedcba", "#123456"))
|
||||
|
||||
def test_translucent_foreground_is_flattened(self):
|
||||
# 50% black on white flattens to mid gray, far below black's 21:1.
|
||||
r = contrast.ratio("#00000080", "#ffffff")
|
||||
self.assertLess(r, 8)
|
||||
self.assertGreater(r, 3)
|
||||
|
||||
def test_rejects_bad_input(self):
|
||||
with self.assertRaises(ValueError):
|
||||
contrast.ratio("blue", "#fff")
|
||||
with self.assertRaises(ValueError):
|
||||
contrast.ratio("#000", "#ffffff80")
|
||||
|
||||
|
||||
class Tokens(unittest.TestCase):
|
||||
def run_cli(self, *args):
|
||||
return subprocess.run([sys.executable, "-I", os.path.join(ROOT, "scripts", "contrast.py"), *args],
|
||||
capture_output=True, text=True)
|
||||
|
||||
def test_shipped_themes_pass(self):
|
||||
r = self.run_cli("--tokens", os.path.join(ROOT, "templates", "tokens.light.json"),
|
||||
os.path.join(ROOT, "templates", "tokens.dark.json"))
|
||||
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||
|
||||
def test_failing_pair_is_reported(self):
|
||||
import tempfile
|
||||
doc = {"color": {"$type": "color",
|
||||
"a": {"$value": "#777777"}, "b": {"$value": "#ffffff"}},
|
||||
"$extensions": {"dev.playbook": {"contrastPairs": [["color.a", "color.b", 4.5]]}}}
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
|
||||
json.dump(doc, f)
|
||||
try:
|
||||
r = self.run_cli("--tokens", f.name)
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
self.assertEqual(r.returncode, 1)
|
||||
self.assertIn("FAIL", r.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,67 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SEARCH = os.path.join(ROOT, "scripts", "search.py")
|
||||
|
||||
|
||||
def run(*args):
|
||||
return subprocess.run([sys.executable, "-I", SEARCH, *args], capture_output=True, text=True)
|
||||
|
||||
|
||||
def all_rule_ids():
|
||||
out = run("--list").stdout
|
||||
return [line.split()[0] for line in out.splitlines()]
|
||||
|
||||
|
||||
class Search(unittest.TestCase):
|
||||
def test_id_lookup(self):
|
||||
r = run("--id", "A11Y-10")
|
||||
self.assertEqual(r.returncode, 0)
|
||||
self.assertIn("Visible, unobscured focus", r.stdout)
|
||||
|
||||
def test_prefix_lookup(self):
|
||||
r = run("--id", "MOT")
|
||||
self.assertIn("MOT-07", r.stdout)
|
||||
|
||||
def test_semantic_query_finds_rule(self):
|
||||
r = run("-n", "3", "confirmation dialog for delete undo")
|
||||
self.assertIn("INT-06", r.stdout)
|
||||
|
||||
def test_unknown_id(self):
|
||||
self.assertEqual(run("--id", "NOPE-99").returncode, 1)
|
||||
|
||||
def test_rule_ids_unique(self):
|
||||
ids = all_rule_ids()
|
||||
self.assertEqual(len(ids), len(set(ids)))
|
||||
self.assertGreater(len(ids), 80)
|
||||
|
||||
|
||||
class Integrity(unittest.TestCase):
|
||||
"""Every rule id cited anywhere must exist, so cross-references cannot rot."""
|
||||
|
||||
def test_cross_references_resolve(self):
|
||||
known = set(all_rule_ids())
|
||||
pat = re.compile(r"\b(INT|PSY|VIS|MOT|A11Y|FBK|SYS|BAD|CSS|PAT)-(\d+)\b")
|
||||
missing = []
|
||||
for base, _, names in os.walk(ROOT):
|
||||
if ".git" in base.split(os.sep):
|
||||
continue
|
||||
for n in names:
|
||||
if not n.endswith((".md", ".json", ".txt")):
|
||||
continue
|
||||
p = os.path.join(base, n)
|
||||
with open(p, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
for m in pat.finditer(text):
|
||||
rid = m.group(0)
|
||||
if rid not in known:
|
||||
missing.append((os.path.relpath(p, ROOT), rid))
|
||||
self.assertEqual(missing, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user