309 lines
8.7 KiB
Go
309 lines
8.7 KiB
Go
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)})
|
||
}
|