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

436
go/cmd/aria-check/color.go Normal file
View File

@@ -0,0 +1,436 @@
package main
// color.go is the colour engine: it parses every colour syntax Tailwind can emit
// into one target space — gamma-encoded sRGB with an alpha channel — and from there
// computes the WCAG 2.x relative luminance and contrast ratio exactly as WebAIM's
// checker does (https://webaim.org/resources/contrastchecker/).
//
// sRGB is the target space on purpose. WCAG defines luminance in terms of sRGB, so
// converting there once means the contrast maths is a single well-specified formula
// and never depends on which syntax a colour was written in. The palette is authored
// in OKLCH, the semantic tokens in hex, and an app can drop an oklab()/rgb()/hsl()
// literal into an arbitrary value — all of them land here as an RGBA before any
// contrast is computed.
import (
"math"
"strconv"
"strings"
)
// RGBA is a colour in gamma-encoded sRGB. Channels and alpha are all in [0,1].
// This is aria-check's single internal colour representation — the "target
// colorspace" every parser converts into.
type RGBA struct {
R, G, B, A float64
}
// Opaque reports whether the colour needs no compositing.
func (c RGBA) Opaque() bool { return c.A >= 1 }
// over composites c (the source) onto an opaque backdrop using the standard
// source-over rule, in gamma space. WCAG contrast is only defined for opaque
// colours, so a translucent foreground or a translucent surface must be flattened
// against what sits behind it before its luminance means anything. Compositing in
// gamma-encoded sRGB (rather than linear) is the approximation browsers and the
// WebAIM checker effectively use.
func (c RGBA) over(bg RGBA) RGBA {
if c.Opaque() {
return c
}
a := c.A
return RGBA{
R: c.R*a + bg.R*(1-a),
G: c.G*a + bg.G*(1-a),
B: c.B*a + bg.B*(1-a),
A: 1,
}
}
// luminance is the WCAG relative luminance of an (assumed opaque) colour: linearise
// each sRGB channel, then weight. This is byte-for-byte the WebAIM formula, including
// its 0.03928 threshold.
func (c RGBA) luminance() float64 {
lin := func(ch float64) float64 {
if ch <= 0.03928 {
return ch / 12.92
}
return math.Pow((ch+0.055)/1.055, 2.4)
}
return 0.2126*lin(c.R) + 0.7152*lin(c.G) + 0.0722*lin(c.B)
}
// contrastRatio returns the WCAG contrast ratio between two opaque colours, in
// [1, 21]. Order does not matter. Callers must composite any translucency away first
// (see over) — this treats both colours as fully opaque.
func contrastRatio(a, b RGBA) float64 {
la, lb := a.luminance(), b.luminance()
if la < lb {
la, lb = lb, la
}
return (la + 0.05) / (lb + 0.05)
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
// parseLiteralColor parses a self-contained colour literal — one that names no CSS
// variable and is not a color-mix() (those are resolved in theme.go, which has the
// variable environment). It returns ok=false for anything it cannot turn into a
// concrete colour, including the deliberately-unresolvable keywords `currentcolor`,
// `inherit`, `transparent` (transparent is a real colour but alpha 0, handled here).
func parseLiteralColor(s string) (RGBA, bool) {
s = strings.TrimSpace(s)
if s == "" {
return RGBA{}, false
}
lower := strings.ToLower(s)
switch {
case strings.HasPrefix(s, "#"):
return parseHex(s)
case strings.HasPrefix(lower, "rgb"):
return parseRGBFunc(s)
case strings.HasPrefix(lower, "hsl"):
return parseHSLFunc(s)
case strings.HasPrefix(lower, "oklch("):
return parseOKLCH(s)
case strings.HasPrefix(lower, "oklab("):
return parseOKLab(s)
}
if c, ok := namedColors[lower]; ok {
return c, true
}
return RGBA{}, false
}
func parseHex(s string) (RGBA, bool) {
h := strings.TrimPrefix(s, "#")
// Expand shorthand #rgb / #rgba to full byte pairs.
switch len(h) {
case 3, 4:
var sb strings.Builder
for _, r := range h {
sb.WriteRune(r)
sb.WriteRune(r)
}
h = sb.String()
case 6, 8:
default:
return RGBA{}, false
}
val, err := strconv.ParseUint(h, 16, 64)
if err != nil {
return RGBA{}, false
}
c := RGBA{A: 1}
if len(h) == 8 {
c.R = float64((val>>24)&0xff) / 255
c.G = float64((val>>16)&0xff) / 255
c.B = float64((val>>8)&0xff) / 255
c.A = float64(val&0xff) / 255
} else {
c.R = float64((val>>16)&0xff) / 255
c.G = float64((val>>8)&0xff) / 255
c.B = float64(val&0xff) / 255
}
return c, true
}
// funcArgs splits the inside of a colour function into its space/comma-separated
// components and an optional trailing alpha introduced by `/`. Both the legacy
// comma syntax and the modern space syntax are accepted.
func funcArgs(s string) (parts []string, alpha string) {
open := strings.IndexByte(s, '(')
close := strings.LastIndexByte(s, ')')
if open < 0 || close < 0 || close < open {
return nil, ""
}
body := s[open+1 : close]
body = strings.ReplaceAll(body, ",", " ")
if i := strings.IndexByte(body, '/'); i >= 0 {
alpha = strings.TrimSpace(body[i+1:])
body = body[:i]
}
return strings.Fields(body), alpha
}
// numOrPct parses a number that may be a percentage. A percentage is scaled by
// pctBase (255 for rgb channels, 1 for alpha, 0.4 for oklab/oklch a/b/chroma).
func numOrPct(s string, pctBase float64) (float64, bool) {
s = strings.TrimSpace(s)
if s == "" || s == "none" {
return 0, true
}
if pct, ok := strings.CutSuffix(s, "%"); ok {
v, err := strconv.ParseFloat(pct, 64)
if err != nil {
return 0, false
}
return v / 100 * pctBase, true
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
return v, true
}
func parseAlpha(s string) float64 {
if s == "" {
return 1
}
if v, ok := numOrPct(s, 1); ok {
return clamp01(v)
}
return 1
}
func parseRGBFunc(s string) (RGBA, bool) {
parts, alpha := funcArgs(s)
if len(parts) < 3 {
return RGBA{}, false
}
r, ok1 := numOrPct(parts[0], 255)
g, ok2 := numOrPct(parts[1], 255)
b, ok3 := numOrPct(parts[2], 255)
if !ok1 || !ok2 || !ok3 {
return RGBA{}, false
}
a := 1.0
if len(parts) >= 4 {
a = parseAlpha(parts[3])
} else if alpha != "" {
a = parseAlpha(alpha)
}
return RGBA{clamp01(r / 255), clamp01(g / 255), clamp01(b / 255), a}, true
}
func parseHSLFunc(s string) (RGBA, bool) {
parts, alpha := funcArgs(s)
if len(parts) < 3 {
return RGBA{}, false
}
h, ok1 := parseAngle(parts[0])
sat, ok2 := numOrPct(parts[1], 1) // percentage → [0,1]
l, ok3 := numOrPct(parts[2], 1)
if !ok1 || !ok2 || !ok3 {
return RGBA{}, false
}
a := 1.0
if len(parts) >= 4 {
a = parseAlpha(parts[3])
} else if alpha != "" {
a = parseAlpha(alpha)
}
r, g, b := hslToRGB(h, clamp01(sat), clamp01(l))
return RGBA{r, g, b, a}, true
}
func parseAngle(s string) (float64, bool) {
s = strings.TrimSpace(strings.ToLower(s))
s = strings.TrimSuffix(s, "deg")
if s == "none" {
return 0, true
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
return v, true
}
func hslToRGB(h, s, l float64) (float64, float64, float64) {
h = math.Mod(math.Mod(h, 360)+360, 360) / 360
if s == 0 {
return l, l, l
}
var q float64
if l < 0.5 {
q = l * (1 + s)
} else {
q = l + s - l*s
}
p := 2*l - q
hue := func(t float64) float64 {
if t < 0 {
t++
}
if t > 1 {
t--
}
switch {
case t < 1.0/6:
return p + (q-p)*6*t
case t < 1.0/2:
return q
case t < 2.0/3:
return p + (q-p)*(2.0/3-t)*6
default:
return p
}
}
return hue(h + 1.0/3), hue(h), hue(h - 1.0/3)
}
func parseOKLCH(s string) (RGBA, bool) {
parts, alpha := funcArgs(s)
if len(parts) < 3 {
return RGBA{}, false
}
l, ok1 := numOrPct(parts[0], 1) // L: % → [0,1], or already 0..1
c, ok2 := numOrPct(parts[1], 0.4)
h, ok3 := parseAngle(parts[2])
if !ok1 || !ok2 || !ok3 {
return RGBA{}, false
}
a := 1.0
if len(parts) >= 4 {
a = parseAlpha(parts[3])
} else if alpha != "" {
a = parseAlpha(alpha)
}
rad := h * math.Pi / 180
return oklabToRGBA(l, c*math.Cos(rad), c*math.Sin(rad), a), true
}
func parseOKLab(s string) (RGBA, bool) {
parts, alpha := funcArgs(s)
if len(parts) < 3 {
return RGBA{}, false
}
l, ok1 := numOrPct(parts[0], 1)
aa, ok2 := numOrPct(parts[1], 0.4)
bb, ok3 := numOrPct(parts[2], 0.4)
if !ok1 || !ok2 || !ok3 {
return RGBA{}, false
}
alp := 1.0
if len(parts) >= 4 {
alp = parseAlpha(parts[3])
} else if alpha != "" {
alp = parseAlpha(alpha)
}
return oklabToRGBA(l, aa, bb, alp), true
}
// oklabToRGBA is Björn Ottosson's OKLab → linear sRGB transform, followed by the
// sRGB transfer function and a gamut clamp. Out-of-gamut OKLCH colours (the palette
// has a few) clamp per channel, which is what a browser paints too.
func oklabToRGBA(L, a, b, alpha float64) RGBA {
l_ := L + 0.3963377774*a + 0.2158037573*b
m_ := L - 0.1055613458*a - 0.0638541728*b
s_ := L - 0.0894841775*a - 1.2914855480*b
l := l_ * l_ * l_
m := m_ * m_ * m_
s := s_ * s_ * s_
lr := +4.0767416621*l - 3.3077115913*m + 0.2309699292*s
lg := -1.2684380046*l + 2.6097574011*m - 0.3413193965*s
lb := -0.0041960863*l - 0.7034186147*m + 1.7076147010*s
return RGBA{
R: clamp01(linearToSRGB(lr)),
G: clamp01(linearToSRGB(lg)),
B: clamp01(linearToSRGB(lb)),
A: alpha,
}
}
func linearToSRGB(c float64) float64 {
if c <= 0.0031308 {
return 12.92 * c
}
return 1.055*math.Pow(c, 1/2.4) - 0.055
}
// mixOKLab evaluates the two-colour case of CSS color-mix() in the oklab space,
// which is the form Tailwind emits for an opacity modifier
// (`color-mix(in oklab, <color> P%, transparent)`). Weights are normalised and the
// interpolation is alpha-premultiplied, matching the CSS spec closely enough for a
// contrast estimate.
func mixOKLab(c1 RGBA, w1 float64, c2 RGBA, w2 float64) RGBA {
if w1+w2 == 0 {
return c1
}
total := w1 + w2
w1 /= total
w2 /= total
l1, a1, b1 := rgbaToOKLab(c1)
l2, a2, b2 := rgbaToOKLab(c2)
// Premultiply the lab coordinates by alpha, interpolate, then un-premultiply.
pa := c1.A*w1 + c2.A*w2
L := (l1*c1.A*w1 + l2*c2.A*w2)
A := (a1*c1.A*w1 + a2*c2.A*w2)
B := (b1*c1.A*w1 + b2*c2.A*w2)
if pa > 0 {
L /= pa
A /= pa
B /= pa
}
return oklabToRGBA(L, A, B, pa)
}
// rgbaToOKLab inverts oklabToRGBA (sRGB → linear → OKLab), needed by mixOKLab.
func rgbaToOKLab(c RGBA) (L, a, b float64) {
lr := srgbToLinear(c.R)
lg := srgbToLinear(c.G)
lb := srgbToLinear(c.B)
l := 0.4122214708*lr + 0.5363325363*lg + 0.0514459929*lb
m := 0.2119034982*lr + 0.6806995451*lg + 0.1073969566*lb
s := 0.0883024619*lr + 0.2817188376*lg + 0.6299787005*lb
l_ := math.Cbrt(l)
m_ := math.Cbrt(m)
s_ := math.Cbrt(s)
return 0.2104542553*l_ + 0.7936177850*m_ - 0.0040720468*s_,
1.9779984951*l_ - 2.4285922050*m_ + 0.4505937099*s_,
0.0259040371*l_ + 0.7827717662*m_ - 0.8086757660*s_
}
func srgbToLinear(c float64) float64 {
if c <= 0.04045 {
return c / 12.92
}
return math.Pow((c+0.055)/1.055, 2.4)
}
// namedColors covers the CSS keywords likely to appear in an arbitrary value or a
// hand-written token. It is deliberately not the full 148-name list; extend as real
// usage demands. `transparent` is a real value (alpha 0); `currentcolor` and
// `inherit` are intentionally absent — they cannot be resolved statically.
var namedColors = map[string]RGBA{
"transparent": {0, 0, 0, 0},
"white": {1, 1, 1, 1},
"black": {0, 0, 0, 1},
"red": {1, 0, 0, 1},
"green": {0, 128.0 / 255, 0, 1},
"blue": {0, 0, 1, 1},
"yellow": {1, 1, 0, 1},
"cyan": {0, 1, 1, 1},
"aqua": {0, 1, 1, 1},
"magenta": {1, 0, 1, 1},
"fuchsia": {1, 0, 1, 1},
"gray": {128.0 / 255, 128.0 / 255, 128.0 / 255, 1},
"grey": {128.0 / 255, 128.0 / 255, 128.0 / 255, 1},
"silver": {192.0 / 255, 192.0 / 255, 192.0 / 255, 1},
"maroon": {128.0 / 255, 0, 0, 1},
"olive": {128.0 / 255, 128.0 / 255, 0, 1},
"lime": {0, 1, 0, 1},
"teal": {0, 128.0 / 255, 128.0 / 255, 1},
"navy": {0, 0, 128.0 / 255, 1},
"purple": {128.0 / 255, 0, 128.0 / 255, 1},
"orange": {1, 165.0 / 255, 0, 1},
}