Files
kjol/go/cmd/aria-check/check.go

309 lines
8.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)})
}