// Command aria-check is a static accessibility linter for kjol front-ends. It reads // source (Solid .tsx and gowasm .go alike — anything that authors Tailwind classes as // string literals) and reports problems that can be caught without a browser. // // The first and only check today is COLOUR CONTRAST, implemented against the same // algorithm as WebAIM's contrast checker (https://webaim.org/resources/contrastchecker/): // WCAG 2.x relative luminance in sRGB, ratio (L1+0.05)/(L2+0.05). // // How it works: // // 1. Scan sources for string literals that hold class lists, keeping the utilities // that co-occur in one literal together (contrast is about a foreground and a // background on the SAME element — see scan.go). // 2. Compile every bg-/text- token through kjol's own Tailwind engine (package tw) // and read back the colours it resolves — palette OKLCH, hex tokens, semantic // tokens, opacity modifiers and arbitrary values all included (theme.go). // 3. For each co-occurring foreground/background pair, in both the light and dark // appearances, composite away any translucency and measure the ratio against the // WCAG threshold (check.go, color.go). // // Because the tokens are resolved by the real engine, aria-check stays correct as the // design system changes: it never hard-codes a colour. // // Usage (globs/roots are relative to -base; omit them to scan the whole base): // // aria-check -base . -entry app/style.css frontend // aria-check -base kjol/go/webui -level AAA // aria-check -assume-surface -json . // // Exit status is non-zero when any contrast failure is found (unless -warn), so it // drops straight into CI or a pre-commit hook. // // Extending it: contrast is one Checker; the scan + resolve scaffolding is meant to // carry more. Natural next checks that are equally static-friendly — missing alt text // on images, controls with no accessible label, heading-order jumps, redundant/absent // ARIA roles — would each add a pass over the same file walk. See the closing notes in // the repository discussion for the full list. package main import ( "encoding/json" "flag" "fmt" "os" "path/filepath" "sort" "strings" ) func main() { var ( base = flag.String("base", ".", "root directory to scan and resolve -entry against") entry = flag.String("entry", "", "app brand Tailwind entry stylesheet (optional; kjol's theme layer is always included)") level = flag.String("level", "AA", "WCAG conformance level: AA or AAA") min = flag.Float64("min", 0, "override the required ratio for normal-size text (AA only)") exts = flag.String("ext", strings.Join(DefaultExts, ","), "comma-separated source file extensions to scan") assumeSurface = flag.Bool("assume-surface", false, "also check text-only elements against the page surface colour") asJSON = flag.Bool("json", false, "emit findings as JSON") verbose = flag.Bool("v", false, "also report passing pairs") warn = flag.Bool("warn", false, "always exit 0, even when failures are found") ) flag.Parse() opt := Options{Level: *level, MinOverride: *min, AssumeSurface: *assumeSurface} groups, err := ScanTree(*base, flag.Args(), splitExts(*exts)) if err != nil { fatal(err) } var entryCSS string if *entry != "" { b, err := os.ReadFile(*entry) if err != nil { fatal(err) } entryCSS = string(b) } resolver, err := NewResolver(entryCSS, *base, ColorCandidates(groups)) if err != nil { fatal(fmt.Errorf("compiling theme: %w", err)) } findings := Check(groups, resolver, opt) sort.Slice(findings, func(i, j int) bool { if findings[i].File != findings[j].File { return findings[i].File < findings[j].File } if findings[i].Line != findings[j].Line { return findings[i].Line < findings[j].Line } return findings[i].Ratio < findings[j].Ratio }) failures := 0 for _, f := range findings { if !f.Pass { failures++ } } if *asJSON { reportJSON(findings, *verbose) } else { reportText(findings, failures, len(groups), *verbose) } if failures > 0 && !*warn { os.Exit(1) } } func reportText(findings []Finding, failures, groups int, verbose bool) { filesWithFail := map[string]bool{} for _, f := range findings { if f.Pass && !verbose { continue } if !f.Pass { filesWithFail[f.File] = true } fmt.Println(formatFinding(f)) } fmt.Println() if failures == 0 { fmt.Printf("aria-check: no contrast failures (%d class groups scanned)\n", groups) return } fmt.Printf("aria-check: %d contrast failure(s) across %d file(s) — %d class groups scanned\n", failures, len(filesWithFail), groups) } func formatFinding(f Finding) string { verdict := "FAIL" if f.Pass { verdict = "ok " } size := "normal" if f.Large { size = "large" } bg := f.BG if bg == "" { bg = "surface" } rel, err := filepath.Rel(".", f.File) if err != nil { rel = f.File } return fmt.Sprintf("%s:%d: %s %s %.2f:1 (need %.1f:1) %s on %s [%s %s]\n %s → %s %q", rel, f.Line, verdict, f.ThemeName(), f.Ratio, f.Required, f.FG, bg, f.ThemeName(), size, hexOf(f.FGColor), hexOf(f.BGColor), truncate(f.Snip, 90)) } type jsonFinding struct { File string `json:"file"` Line int `json:"line"` Theme string `json:"theme"` FG string `json:"fg"` BG string `json:"bg"` FGColor string `json:"fgColor"` BGColor string `json:"bgColor"` Ratio float64 `json:"ratio"` Required float64 `json:"required"` Large bool `json:"large"` Pass bool `json:"pass"` } func reportJSON(findings []Finding, verbose bool) { out := make([]jsonFinding, 0, len(findings)) for _, f := range findings { if f.Pass && !verbose { continue } bg := f.BG if bg == "" { bg = "surface" } out = append(out, jsonFinding{ File: f.File, Line: f.Line, Theme: f.ThemeName(), FG: f.FG, BG: bg, FGColor: hexOf(f.FGColor), BGColor: hexOf(f.BGColor), Ratio: round2(f.Ratio), Required: f.Required, Large: f.Large, Pass: f.Pass, }) } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") _ = enc.Encode(out) } func hexOf(c RGBA) string { to := func(v float64) int { return int(clamp01(v)*255 + 0.5) } if c.A < 1 { return fmt.Sprintf("#%02x%02x%02x%02x", to(c.R), to(c.G), to(c.B), to(c.A)) } return fmt.Sprintf("#%02x%02x%02x", to(c.R), to(c.G), to(c.B)) } func round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n-1] + "…" } func splitExts(s string) []string { var out []string for _, e := range strings.Split(s, ",") { e = strings.TrimSpace(e) if e == "" { continue } if !strings.HasPrefix(e, ".") { e = "." + e } out = append(out, strings.ToLower(e)) } return out } func fatal(err error) { fmt.Fprintln(os.Stderr, "aria-check:", err) os.Exit(2) }