437 lines
12 KiB
Go
437 lines
12 KiB
Go
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},
|
|
}
|