Add aria check program to go/cmd
This commit is contained in:
308
go/cmd/aria-check/check.go
Normal file
308
go/cmd/aria-check/check.go
Normal 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)})
|
||||
}
|
||||
Reference in New Issue
Block a user