package check // The WCAG and OKLCH arithmetic of rastrillo's ui/colour.go, from // oklabToLinear through ContrastRatio, every name and signature as // it was: the half the CSS gates and the derived-colour checks read. // The colour engine above it (Pair, Wash, Allocate) stays in // rastrillo; nothing here resolves a palette. import ( "fmt" "math" ) // ─── OKLCH → sRGB ──────────────────────────────────────────────────── // // Björn Ottosson's oklab, the space CSS names in "in oklab" and the one // the themes' own color-mix() calls interpolate in. Written out rather // than pulled in: this is thirty lines of arithmetic and the framework // takes no dependency for it. // oklabToLinear converts oklab to linear-light sRGB, which may fall // outside 0..1 — that is what being out of gamut looks like, and the // caller decides what to do about it. It takes the hue already resolved // into its two rectangular components rather than OKLCh's degrees // because the gamut bisection varies only the chroma: the hue's sine and // cosine are computed once per colour instead of once per iteration. func oklabToLinear(l, a, b float64) [3]float64 { lc := l + 0.3963377774*a + 0.2158037573*b mc := l - 0.1055613458*a - 0.0638541728*b sc := l - 0.0894841775*a - 1.2914855480*b lc, mc, sc = lc*lc*lc, mc*mc*mc, sc*sc*sc return [3]float64{ +4.0767416621*lc - 3.3077115913*mc + 0.2309699292*sc, -1.2684380046*lc + 2.6097574011*mc - 0.3413193965*sc, -0.0041960863*lc - 0.7034186147*mc + 1.7076147010*sc, } } // linearToSRGB is the sRGB gamma transfer function. func linearToSRGB(c float64) float64 { if c <= 0.0031308 { return c * 12.92 } return 1.055*math.Pow(c, 1/2.4) - 0.055 } // inGamut reports whether a linear-light triple lands inside sRGB. The // tolerance absorbs the last bit of floating-point error at the exact // boundary; it is far below one eight-bit step. func inGamut(lin [3]float64) bool { for _, v := range lin { if v < -1e-9 || v > 1+1e-9 { return false } } return true } // oklchHex renders OKLCh as #rrggbb, reducing chroma until it fits sRGB, // and returns the chroma it actually used. Lightness and hue are never // touched: lightness is what the contrast floor is riding on, and hue is // what the caller meant. func oklchHex(l, c, hue float64) (string, float64) { rgb, delivered := oklchRGB(l, c, hue) return hexOf(rgb), delivered } // oklchRGB is oklchHex without the string: the search runs it hundreds of // times per call and throws most of the results away, so the eight-bit // triple is the currency and a hex is minted only for the one that wins. func oklchRGB(l, c, hue float64) ([3]uint8, float64) { rad := hue * math.Pi / 180 ca, sa := math.Cos(rad), math.Sin(rad) if !inGamut(oklabToLinear(l, c*ca, c*sa)) { lo, hi := 0.0, c for i := 0; i < 40; i++ { mid := (lo + hi) / 2 if inGamut(oklabToLinear(l, mid*ca, mid*sa)) { lo = mid } else { hi = mid } } c = lo } lin := oklabToLinear(l, c*ca, c*sa) var v [3]uint8 for i, ch := range lin { // Rounding to eight bits is the last step and the only clamp: // by here the colour is in gamut, so this guards the rounding // rather than mapping anything. n := math.Round(linearToSRGB(ch) * 255) v[i] = uint8(math.Min(255, math.Max(0, n))) } return v, c } const hexDigits = "0123456789abcdef" // hexOf renders an eight-bit triple as lower-case #rrggbb. func hexOf(v [3]uint8) string { b := [7]byte{'#'} for i, c := range v { b[1+i*2] = hexDigits[c>>4] b[2+i*2] = hexDigits[c&15] } return string(b[:]) } // oklabOf converts eight-bit sRGB to OKLab — the way in, where // oklchToLinear is the way out. Two uses: asking which candidate fill is // nearest the background (which wants a perceptual scale rather than a // luminance one), and measuring how far apart two resolved fills are. func oklabOf(r, g, b uint8) [3]float64 { rl := linearFromSRGB(float64(r) / 255) gl := linearFromSRGB(float64(g) / 255) bl := linearFromSRGB(float64(b) / 255) lc := math.Cbrt(0.4122214708*rl + 0.5363325363*gl + 0.0514459929*bl) mc := math.Cbrt(0.2119034982*rl + 0.6806995451*gl + 0.1073969566*bl) sc := math.Cbrt(0.0883024619*rl + 0.2817188376*gl + 0.6299787005*bl) return [3]float64{ 0.2104542553*lc + 0.7936177850*mc - 0.0040720468*sc, 1.9779984951*lc - 2.4285922050*mc + 0.4505937099*sc, 0.0259040371*lc + 0.7827717662*mc - 0.8086757660*sc, } } // MinSeparation is the least perceptual distance two fills may sit at and // still be told apart: 0.03 in OKLab, which is a little over one // just-noticeable difference (about 0.02 for a large flat patch). // // It exists because "twelve hues thirty degrees apart" is a statement // about intents and not about colours. Two intents that are far apart as // angles can resolve within a hair of each other once a background has // forced them both to a low lightness, where there is no room left to be // saturated in — and at that point Allocate's separation guarantee is // true about slots and false about the screen. The shipped set's worst // pair is 0.045, on day's dark page, so the floor is not fitted to pass. // // Exported for the same reason the contrast floors are: an app checking // its own intents against its own canvas should hold the same bar. const MinSeparation = 0.03 // DeltaEOK is the perceptual distance between two colours: plain // euclidean distance in OKLab, which is what OKLab is for. Roughly, 0.02 // is the point two large flat patches stop being reliably tellable // apart, and 0.1 is comfortably different. // // Exported because a caller mapping an arbitrary colour onto the offered // set — an imported XLSX fill, a pasted highlight — needs a perceptual // nearest-match, and re-deriving one from a hue angle gets it wrong at // exactly the low lightnesses where it matters. func DeltaEOK(a, b string) (float64, error) { ar, ag, ab, err := parseHex(a) if err != nil { return 0, fmt.Errorf("first colour: %w", err) } br, bg, bb, err := parseHex(b) if err != nil { return 0, fmt.Errorf("second colour: %w", err) } return labDistance(oklabOf(ar, ag, ab), oklabOf(br, bg, bb)), nil } // labDistance is DeltaEOK's kernel, for callers inside this package that // already hold the OKLab triples. One notion of perceptual distance, one // place it is written down: the separation check over an offered set, a // swatch's Separation, and the wash floor are all this function. func labDistance(a, b [3]float64) float64 { dl, da, db := a[0]-b[0], a[1]-b[1], a[2]-b[2] return math.Sqrt(dl*dl + da*da + db*db) } // linearFromSRGB is the inverse gamma transfer, at the sRGB spec's own // 0.04045 breakpoint. It is not srgbToLinear below, which uses WCAG 2.x's // 0.03928 — the two differ in the fourth decimal place of one threshold // and are kept apart deliberately, so the WCAG numbers this file publishes // are the numbers WCAG's formula gives. func linearFromSRGB(c float64) float64 { if c <= 0.04045 { return c / 12.92 } return math.Pow((c+0.055)/1.055, 2.4) } // ─── WCAG arithmetic ───────────────────────────────────────────────── // // This used to live in contrast_test.go and be reachable only from a // test. It is shipped code now because the colour engine is built on it // and because a caller writing its own gate should hold its own colours // to this arithmetic rather than to a second copy of it. The theme // contrast gate calls these, so there is exactly one implementation and // TestContrastMathMatchesDocumentedDangerFillRatios calibrates the // shipped one. // parseHex reads a #rgb or #rrggbb literal into eight-bit channels. // Anything else — var(), color-mix(), rgba(), a bare colour name, a // value with whitespace around it — is an error rather than a guess, // because a colour this package cannot read is a colour it must not // silently substitute something for. func parseHex(s string) (r, g, b uint8, err error) { // Hand-decoded rather than matched against a regexp: ContrastRatio // is exported API that two apps will call in loops, and a regexp // plus three Sscanf calls cost thirty times the arithmetic they // feed. The accepted grammar is unchanged — #rgb or #rrggbb, no // whitespace, nothing else. var v [3]uint8 switch len(s) { case 4: if s[0] != '#' { return 0, 0, 0, notHex(s) } for i := 0; i < 3; i++ { n, ok := nibble(s[1+i]) if !ok { return 0, 0, 0, notHex(s) } v[i] = n<<4 | n } case 7: if s[0] != '#' { return 0, 0, 0, notHex(s) } for i := 0; i < 3; i++ { hi, okHi := nibble(s[1+i*2]) lo, okLo := nibble(s[2+i*2]) if !okHi || !okLo { return 0, 0, 0, notHex(s) } v[i] = hi<<4 | lo } default: return 0, 0, 0, notHex(s) } return v[0], v[1], v[2], nil } func notHex(s string) error { return fmt.Errorf("not a #rgb/#rrggbb literal: %q", s) } func nibble(c byte) (uint8, bool) { switch { case c >= '0' && c <= '9': return c - '0', true case c >= 'a' && c <= 'f': return c - 'a' + 10, true case c >= 'A' && c <= 'F': return c - 'A' + 10, true } return 0, false } // srgbToLinear converts one sRGB channel (0..1) to its linearized form — // the standard WCAG 2.x formula (also used by the WCAG 2.2 spec this // project targets). func srgbToLinear(c float64) float64 { if c <= 0.03928 { return c / 12.92 } return math.Pow((c+0.055)/1.055, 2.4) } // srgbLinearTable is srgbToLinear for all 256 eight-bit values. Every // colour this package measures has already been rounded to eight bits, so // there are only 256 answers and a table is the whole domain rather than // a cache of part of it. Built from srgbToLinear itself, so the two // cannot say different things. var srgbLinearTable = func() (t [256]float64) { for i := range t { t[i] = srgbToLinear(float64(i) / 255) } return t }() // relLuminance is the WCAG relative luminance of an sRGB colour. func relLuminance(r, g, b uint8) float64 { return 0.2126*srgbLinearTable[r] + 0.7152*srgbLinearTable[g] + 0.0722*srgbLinearTable[b] } // ratioOf is the WCAG ratio between two relative luminances. Split out so // the search can compare luminances it already has instead of re-parsing // two hex strings, and so there is still exactly one place the formula is // written down. func ratioOf(a, b float64) float64 { if b > a { a, b = b, a } return (a + 0.05) / (b + 0.05) } // ContrastRatio is the WCAG contrast ratio between two hex colours: // (lighter + 0.05) / (darker + 0.05) over relative luminance. It is // symmetric — there is no foreground and background here, only two // colours — and a colour against itself is exactly 1. // // Exported for the same reason the floors are: an app gating its own // colours should measure them with the framework's arithmetic, not a // re-typed one that rounds differently at the boundary. func ContrastRatio(a, b string) (float64, error) { ar, ag, ab, err := parseHex(a) if err != nil { return 0, fmt.Errorf("first colour: %w", err) } br, bg, bb, err := parseHex(b) if err != nil { return 0, fmt.Errorf("second colour: %w", err) } return ratioOf(relLuminance(ar, ag, ab), relLuminance(br, bg, bb)), nil }