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