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)}) }