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:
Marcos Paulo
2026-10-21 16:11:03 -03:00
parent f3f29a8d2a
commit 79fb875445
29 changed files with 973 additions and 492 deletions
+3 -1
View File
@@ -1,2 +1,4 @@
__pycache__/
/dist/
/playbook
*.pyc
__pycache__/
+3 -3
View File
@@ -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.
+19 -14
View File
@@ -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
+1 -1
View File
@@ -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.
+296
View File
@@ -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)
})
}
+52
View File
@@ -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")
}
}
+8
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
module playbook
go 1.22
+203
View File
@@ -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
}
+72
View File
@@ -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())
}
}
+195
View File
@@ -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
}
+51
View File
@@ -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
}
+2 -3
View File
@@ -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`
+1 -1
View File
@@ -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
View File
@@ -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.
+43
View File
@@ -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
})
}
+1 -1
View File
@@ -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):
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
-143
View File
@@ -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))
-24
View File
@@ -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
-150
View File
@@ -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))
+2 -2
View File
@@ -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
+6 -6
View File
@@ -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.
+6 -7
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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": {
-63
View File
@@ -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()
-67
View File
@@ -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()