Add aria check program to go/cmd

This commit is contained in:
2026-07-20 12:05:55 -04:00
parent 03b5bea72d
commit c0ddef5923
7 changed files with 1871 additions and 0 deletions

308
go/cmd/aria-check/check.go Normal file
View File

@@ -0,0 +1,308 @@
package main
// check.go is the contrast checker proper: it takes the scanned class groups and the
// resolver, works out which foreground/background pairs actually co-occur (per theme
// and per variant state), resolves and composites their colours, and measures each
// pair against the WCAG threshold.
//
// The variant model is deliberately conservative. Only two contexts are evaluated:
// the resting light appearance and the resting dark appearance. `dark:` re-points a
// token (and re-points the whole variable environment), so it is a real second
// appearance worth checking. Interaction and pseudo states (hover:, focus:, group-*,
// data-*, …) are transient and are skipped rather than guessed at — pairing a
// hover-only background with a resting text colour invents an element that never
// renders. Responsive prefixes (sm:, md:, …) are stripped, since they change *when* a
// utility applies, not its colour.
import "strings"
// Options tunes the check.
type Options struct {
Level string // "AA" or "AAA"
MinOverride float64 // if > 0, the required ratio for normal-size text
AssumeSurface bool // check foreground-only groups against the page surface
}
// Finding is one evaluated foreground/background pair.
type Finding struct {
File string
Line int
Snip string
Theme theme
FG, BG string // the tokens ("" BG means the assumed page surface)
FGColor RGBA
BGColor RGBA
Ratio float64
Required float64
Large bool
Pass bool
}
func (f Finding) ThemeName() string {
if f.Theme == dark {
return "dark"
}
return "light"
}
// Check evaluates every group and returns all pairs (passing and failing); callers
// filter by Pass for reporting.
func Check(groups []ClassGroup, r *Resolver, opt Options) []Finding {
var out []Finding
for _, g := range groups {
out = append(out, checkGroup(g, r, opt)...)
}
return out
}
func checkGroup(g ClassGroup, r *Resolver, opt Options) []Finding {
var baseBG, baseFG, darkBG, darkFG []string
for _, tok := range g.Tokens {
cat := variantCategory(tok)
if cat == ctxSkip {
continue
}
switch r.side(tok) {
case "bg":
if cat == ctxDark {
darkBG = append(darkBG, tok)
} else {
baseBG = append(baseBG, tok)
darkBG = append(darkBG, tok) // a base bg also applies in dark unless overridden
}
case "fg":
if cat == ctxDark {
darkFG = append(darkFG, tok)
} else {
baseFG = append(baseFG, tok)
darkFG = append(darkFG, tok)
}
}
}
// A dark: override replaces the base for that property: if the group names any
// dark: background, only those apply in dark (and likewise for text).
if hasDarkOverride(g.Tokens, "bg") {
darkBG = onlyDark(g.Tokens, "bg", r)
}
if hasDarkOverride(g.Tokens, "fg") {
darkFG = onlyDark(g.Tokens, "fg", r)
}
large := isLargeText(g.Tokens)
required := requiredRatio(opt, large)
var out []Finding
seen := map[string]bool{} // dedupe identical (theme-independent) pairs
eval := func(t theme, bgs, fgs []string) {
if len(fgs) == 0 {
return
}
// Resolve the backgrounds to concrete backdrops. A background that resolves
// fully transparent (bg-transparent, or a token that is transparent in this
// theme) carries no contrast information — the real backdrop is an ancestor
// we cannot see — so it drops out. If nothing usable is left, this is the
// foreground-only case, checked against the page surface only under
// -assume-surface.
type backdropColor struct {
tok string
color RGBA
}
var bds []backdropColor
for _, bg := range bgs {
if c, ok := backdrop(r, bg, t); ok {
bds = append(bds, backdropColor{bg, c})
}
}
if len(bds) == 0 {
if !opt.AssumeSurface {
return
}
if s, ok := r.surface(t); ok {
bds = append(bds, backdropColor{"", s})
}
}
for _, bd := range bds {
bg, bgColor := bd.tok, bd.color
for _, fg := range fgs {
fgColor, ok := r.resolveToken(fg, t)
if !ok || fgColor.A == 0 {
continue // transparent / currentcolor / inherit: nothing to measure
}
ratio := contrastRatio(fgColor.over(bgColor), bgColor)
// Suppress a duplicate dark finding when the pair is a pure static
// colour (identical resolution in both themes) — it is already
// reported for light.
key := fg + "|" + bg + "|" + rgbaKey(fgColor) + "|" + rgbaKey(bgColor)
if seen[key] {
continue
}
seen[key] = true
out = append(out, Finding{
File: g.File, Line: g.Line, Snip: g.Snip, Theme: t,
FG: fg, BG: bg, FGColor: fgColor, BGColor: bgColor,
Ratio: ratio, Required: required, Large: large,
Pass: ratio+1e-9 >= required,
})
}
}
}
eval(light, baseBG, baseFG)
eval(dark, darkBG, darkFG)
return out
}
// backdrop resolves the background colour a foreground sits on. A fully transparent
// background is not a usable backdrop (ok=false) — its real colour comes from an
// ancestor we cannot resolve statically. A partially translucent background (e.g.
// bg-black/30) does tint what is behind it, so it is flattened onto the page surface,
// the best available assumption for the ancestor.
func backdrop(r *Resolver, bg string, t theme) (RGBA, bool) {
if bg == "" {
return r.surface(t)
}
c, ok := r.resolveToken(bg, t)
if !ok || c.A == 0 {
return RGBA{}, false
}
if c.Opaque() {
return c, true
}
surf, ok := r.surface(t)
if !ok {
return RGBA{}, false
}
return c.over(surf), true
}
// variant classification --------------------------------------------------
type ctxKind int
const (
ctxBase ctxKind = iota // resting appearance, applies light + dark
ctxDark // dark: only
ctxSkip // an interaction/pseudo state — not a resting appearance
)
// responsiveVariants change *when* a utility applies, not its colour, so they are
// transparent to pairing.
var responsiveVariants = map[string]bool{
"sm": true, "md": true, "lg": true, "xl": true, "2xl": true,
"xs": true, "ultrawide": true, "portrait": true, "landscape": true,
"motion-safe": true, "motion-reduce": true, "print": true, "rtl": true, "ltr": true,
}
// variantCategory decides which resting context (if any) a token belongs to.
func variantCategory(token string) ctxKind {
variants, _ := splitVariants(token)
hasDark := false
for _, v := range variants {
v = strings.TrimPrefix(v, "max-") // max-md: etc. are still responsive
if responsiveVariants[v] {
continue
}
if v == "dark" {
hasDark = true
continue
}
return ctxSkip // hover:, focus:, group-*, data-*, and anything unrecognised
}
if hasDark {
return ctxDark
}
return ctxBase
}
func hasDarkOverride(tokens []string, side string) bool {
for _, tok := range tokens {
if variantCategory(tok) != ctxDark {
continue
}
if sideOf(tok) == side {
return true
}
}
return false
}
func onlyDark(tokens []string, side string, r *Resolver) []string {
var out []string
for _, tok := range tokens {
if variantCategory(tok) == ctxDark && r.side(tok) == side {
out = append(out, tok)
}
}
return out
}
// sideOf classifies a token by its base utility prefix alone (no engine lookup),
// used where we only need bg-vs-fg intent.
func sideOf(token string) string {
_, base := splitVariants(token)
switch {
case strings.HasPrefix(base, "bg-"):
return "bg"
case strings.HasPrefix(base, "text-"):
return "fg"
}
return ""
}
// thresholds --------------------------------------------------------------
func requiredRatio(opt Options, large bool) float64 {
if strings.EqualFold(opt.Level, "AAA") {
if large {
return 4.5
}
return 7.0
}
// AA
if large {
return 3.0
}
if opt.MinOverride > 0 {
return opt.MinOverride
}
return 4.5
}
// isLargeText applies the WCAG large-text rule (≥24px, or ≥18.66px when bold) using
// Tailwind's default font-size scale. Sizes an app has overridden in its theme are
// not reflected here, so this is a best-effort classification.
func isLargeText(tokens []string) bool {
px := 16.0 // default body size if no size utility is present
bold := false
for _, tok := range tokens {
if variantCategory(tok) == ctxSkip {
continue
}
_, base := splitVariants(tok)
if sz, ok := fontSizePx[strings.TrimPrefix(base, "text-")]; ok && strings.HasPrefix(base, "text-") {
if sz > px {
px = sz
}
}
switch base {
case "font-bold", "font-extrabold", "font-black":
bold = true
}
}
return px >= 24 || (px >= 18.66 && bold)
}
// fontSizePx is Tailwind's default type scale (rem × 16), plus kjol's --text-ss.
var fontSizePx = map[string]float64{
"ss": 12.8, "xs": 12, "sm": 14, "base": 16, "lg": 18, "xl": 20,
"2xl": 24, "3xl": 30, "4xl": 36, "5xl": 48, "6xl": 60,
"7xl": 72, "8xl": 96, "9xl": 128,
}
func rgbaKey(c RGBA) string {
q := func(v float64) byte { return byte(clamp01(v) * 255) }
return string([]byte{q(c.R), q(c.G), q(c.B), q(c.A)})
}