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