go-bananas/internal/tokenengine/color.go

427 lines
12 KiB
Go
Raw Permalink Normal View History

2026-10-07 15:11:35 +02:00
package tokenengine
import (
"fmt"
"math"
"strconv"
"strings"
)
// RGB is an sRGB colour with an alpha channel in the range 0..1.
type RGB struct {
R, G, B float64
A float64
}
// Color is a parsed CSS colour. Only the subset that can be reasoned about
// numerically is modelled; any other valid CSS colour syntax is kept verbatim
// in Raw so that it can still be emitted, but comparisons and contrast
// computations fall back to Neutral when it is not parseable.
type Color struct {
RGB
// Raw is the original author-written token, preserved so that rendering
// never rewrites what a human typed.
Raw string
// Numeric reports whether RGB is meaningful.
Numeric bool
}
// Neutral is the fallback colour used when a token cannot be parsed. It is a
// mid grey: it is intentionally low-contrast against both light and dark
// backgrounds so that a broken token is visible rather than silently plausible.
var Neutral = Color{RGB: RGB{R: 0.5, G: 0.5, B: 0.5, A: 1}, Raw: "#80808080", Numeric: false}
// NeutralHex is the plain-text form of Neutral.
const NeutralHex = "#808080"
const (
// alphaEpsilon is the rounding tolerance used when snapping alpha to 0 or 1
// so that #ffffff00 and #ffffff render identically.
alphaEpsilon = 1e-6
// channelEpsilon guards against float drift when comparing components.
channelEpsilon = 1e-9
)
// ParseColor parses the CSS colour syntaxes a theme author is realistically
// going to use: #hex in 3/4/6/8 digit forms, rgb()/rgba(), hsl()/hsla() and the
// functional-notation whitespace/comma variants. Anything else is returned as a
// non-numeric colour that still round-trips.
func ParseColor(input string) (Color, error) {
raw := strings.TrimSpace(input)
if raw == "" {
return Color{}, fmt.Errorf("empty colour")
}
lower := strings.ToLower(raw)
// Modern CSS colour spaces are valid CSS but cannot be interpreted with
// sRGB math. Pass them through untouched.
for _, fn := range []string{"oklch(", "oklab(", "lab(", "lch(", "color(", "color-mix(", "light-dark("} {
if strings.HasPrefix(lower, fn) {
return Color{Raw: raw}, nil
}
}
// CSS-wide keywords resolve to unpredictable values depending on the host
// page; treating them as opaque keeps rendering faithful.
if lower == "inherit" || lower == "initial" || lower == "unset" || lower == "currentcolor" || lower == "transparent" {
return Color{Raw: raw}, nil
}
if strings.HasPrefix(lower, "#") {
return parseHex(raw, lower[1:])
}
open := strings.IndexByte(lower, '(')
if open < 0 || !strings.HasSuffix(lower, ")") {
return Color{Raw: raw}, nil
}
name := strings.TrimSpace(lower[:open])
body := lower[open+1 : len(lower)-1]
args := splitArgs(body)
switch name {
case "rgb", "rgba":
return parseRGBFunc(raw, args)
case "hsl", "hsla":
return parseHSLFunc(raw, args)
default:
return Color{Raw: raw}, nil
}
}
// parseHex decodes #rgb, #rgba, #rrggbb and #rrggbbaa.
func parseHex(raw, digits string) (Color, error) {
expand := func(s string) (float64, error) {
n, err := strconv.ParseUint(s, 16, 8)
if err != nil {
return 0, err
}
return float64(n) / 255, nil
}
switch len(digits) {
case 3, 4:
var comps [4]float64
for i := 0; i < len(digits); i++ {
v, err := expand(string(digits[i]) + string(digits[i]))
if err != nil {
return Color{}, fmt.Errorf("invalid hex colour %q", raw)
}
comps[i] = v
}
if len(digits) == 3 {
comps[3] = 1
}
return numericColor(comps[0], comps[1], comps[2], comps[3], raw), nil
case 6, 8:
var comps [4]float64
for i := 0; i < len(digits)/2; i++ {
v, err := expand(digits[i*2 : i*2+2])
if err != nil {
return Color{}, fmt.Errorf("invalid hex colour %q", raw)
}
comps[i] = v
}
if len(digits) == 6 {
comps[3] = 1
}
return numericColor(comps[0], comps[1], comps[2], comps[3], raw), nil
default:
return Color{}, fmt.Errorf("invalid hex colour %q: expected 3, 4, 6 or 8 digits, got %d", raw, len(digits))
}
}
// splitArgs splits a CSS function argument list on commas or whitespace,
// ignoring separators nested inside parentheses.
func splitArgs(body string) []string {
var (
out []string
depth int
cur strings.Builder
)
for _, r := range body {
switch {
case r == '(':
depth++
cur.WriteRune(r)
case r == ')':
depth--
cur.WriteRune(r)
case (r == ',' || r == '/' || r == ' ' || r == '\t') && depth == 0:
if s := strings.TrimSpace(cur.String()); s != "" {
out = append(out, s)
}
cur.Reset()
default:
cur.WriteRune(r)
}
}
if s := strings.TrimSpace(cur.String()); s != "" {
out = append(out, s)
}
return out
}
// parseRGBFunc handles rgb(255 0 0 / 50%) and rgba(255, 0, 0, 0.5).
func parseRGBFunc(raw string, args []string) (Color, error) {
if len(args) < 3 || len(args) > 4 {
return Color{}, fmt.Errorf("invalid rgb() colour %q: expected 3 or 4 components", raw)
}
var comps [4]float64
for i := 0; i < 3; i++ {
v, err := parseChannel(args[i])
if err != nil {
return Color{}, fmt.Errorf("invalid rgb() colour %q: %w", raw, err)
}
comps[i] = v
}
comps[3] = 1
if len(args) == 4 {
v, err := parseAlpha(args[3])
if err != nil {
return Color{}, fmt.Errorf("invalid rgb() colour %q: %w", raw, err)
}
comps[3] = v
}
return numericColor(comps[0], comps[1], comps[2], comps[3], raw), nil
}
// parseHSLFunc handles both comma and space separated hsl notation.
func parseHSLFunc(raw string, args []string) (Color, error) {
if len(args) < 3 || len(args) > 4 {
return Color{}, fmt.Errorf("invalid hsl() colour %q: expected 3 or 4 components", raw)
}
h, err := parseChannel(args[0])
if err != nil {
return Color{}, fmt.Errorf("invalid hsl() colour %q: %w", raw, err)
}
s, err := parseChannel(args[1])
if err != nil {
return Color{}, fmt.Errorf("invalid hsl() colour %q: %w", raw, err)
}
l, err := parseChannel(args[2])
if err != nil {
return Color{}, fmt.Errorf("invalid hsl() colour %q: %w", raw, err)
}
r, g, b := hslToRGB(h, s, l)
a := 1.0
if len(args) == 4 {
v, err := parseAlpha(args[3])
if err != nil {
return Color{}, fmt.Errorf("invalid hsl() colour %q: %w", raw, err)
}
a = v
}
return numericColor(r, g, b, a, raw), nil
}
// parseChannel accepts a bare number or a percentage and normalises to 0..1.
func parseChannel(s string) (float64, error) {
s = strings.TrimSpace(s)
if strings.HasSuffix(s, "%") {
v, err := strconv.ParseFloat(strings.TrimSuffix(s, "%"), 64)
if err != nil {
return 0, err
}
return clamp01(v / 100), nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, err
}
return clamp01(v / 255), nil
}
// parseAlpha accepts a bare 0..1 number or a percentage.
func parseAlpha(s string) (float64, error) {
s = strings.TrimSpace(s)
if strings.HasSuffix(s, "%") {
v, err := strconv.ParseFloat(strings.TrimSuffix(s, "%"), 64)
if err != nil {
return 0, err
}
return clamp01(v / 100), nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, err
}
return clamp01(v), nil
}
// numericColor clamps and normalises a parsed colour, snapping alpha that is
// effectively 0 or 1 so that round-tripping is stable.
func numericColor(r, g, b, a float64, raw string) Color {
a = clamp01(a)
if a < alphaEpsilon {
a = 0
}
if a > 1-alphaEpsilon {
a = 1
}
return Color{RGB: RGB{R: clamp01(r), G: clamp01(g), B: clamp01(b), A: a}, Raw: raw, Numeric: true}
}
func clamp01(v float64) float64 { return math.Max(0, math.Min(1, v)) }
// hslToRGB converts normalised hue (0..1), saturation and lightness to sRGB.
func hslToRGB(h, s, l float64) (float64, float64, float64) {
if s <= channelEpsilon {
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
return hueToChannel(p, q, h+1.0/3.0), hueToChannel(p, q, h), hueToChannel(p, q, h-1.0/3.0)
}
// hueToChannel is the standard hue-to-channel helper from the CSS Color spec.
func hueToChannel(p, q, t float64) float64 {
if t < 0 {
t++
}
if t > 1 {
t--
}
switch {
case t < 1.0/6.0:
return p + (q-p)*6*t
case t < 1.0/2.0:
return q
case t < 2.0/3.0:
return p + (q-p)*(2.0/3.0-t)*6
default:
return p
}
}
// Hex renders the colour as #rrggbb, or #rrggbbaa when translucent.
func (c Color) Hex() string {
if !c.Numeric {
return c.Raw
}
r := int(math.Round(c.R * 255))
g := int(math.Round(c.G * 255))
b := int(math.Round(c.B * 255))
if c.A >= 1-alphaEpsilon {
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
}
return fmt.Sprintf("#%02x%02x%02x%02x", r, g, b, int(math.Round(c.A*255)))
}
// WithAlpha returns the colour with a replaced alpha channel.
func (c Color) WithAlpha(a float64) Color {
if !c.Numeric {
return Color{Raw: c.Raw}
}
return numericColor(c.R, c.G, c.B, a, c.Raw)
}
// Mix blends this colour toward other by t in 0..1, carrying alpha through the
// CSS compositing rules so that translucent tokens do not darken incorrectly.
func (c Color) Mix(other Color, t float64) Color {
if !c.Numeric || !other.Numeric {
return Neutral
}
t = clamp01(t)
// Straight (non-premultiplied) alpha compositing.
alpha := c.A*(1-t) + other.A*t
if alpha < alphaEpsilon {
return numericColor(0, 0, 0, 0, "")
}
r := (c.R*c.A*(1-t) + other.R*other.A*t) / alpha
g := (c.G*c.A*(1-t) + other.G*other.A*t) / alpha
b := (c.B*c.A*(1-t) + other.B*other.A*t) / alpha
return numericColor(r, g, b, alpha, "")
}
// Lighten mixes toward white by t.
func (c Color) Lighten(t float64) Color {
return c.Mix(Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true}, t)
}
// Darken mixes toward black by t.
func (c Color) Darken(t float64) Color { return c.Mix(Color{RGB: RGB{A: 1}, Numeric: true}, t) }
// LightenDark adapts a lightening or darkening step for a dark colour scheme,
// where "more prominent" means lighter rather than darker.
func (c Color) LightenDark(t float64, dark bool) Color {
if dark {
return c.Lighten(t)
}
return c.Darken(t)
}
// RelativeLuminance implements WCAG 2.1 relative luminance.
func (c Color) RelativeLuminance() float64 {
if !c.Numeric {
return 0.5
}
return 0.2126*linearize(c.R) + 0.7152*linearize(c.G) + 0.0722*linearize(c.B)
}
func linearize(v float64) float64 {
if v <= 0.04045 {
return v / 12.92
}
return math.Pow((v+0.055)/1.055, 2.4)
}
// ContrastRatio returns the WCAG 2.1 contrast ratio between the two colours,
// always >= 1.
func ContrastRatio(a, b Color) float64 {
la, lb := a.RelativeLuminance(), b.RelativeLuminance()
if la < lb {
la, lb = lb, la
}
return (la + 0.05) / (lb + 0.05)
}
// WCAG contrast thresholds for normal-weight text.
const (
// ContrastAA is the WCAG 2.1 AA minimum for body text (4.5:1).
ContrastAA = 4.5
// ContrastAAA is the WCAG 2.1 AAA minimum for body text (7:1).
ContrastAAA = 7.0
// ContrastAALarge is the AA minimum for large text and UI components (3:1).
ContrastAALarge = 3.0
)
// BestForeground picks whichever of the light or dark anchor contrasts more
// strongly with bg. This is how the engine derives readable "on-colour"
// tokens without asking the author to maintain a second palette.
func BestForeground(bg Color) Color {
light := Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true}
dark := Color{RGB: RGB{A: 1}, Numeric: true}
if ContrastRatio(bg, light) >= ContrastRatio(bg, dark) {
return light
}
return dark
}
// IsDark reports whether a colour is perceived as dark, using the WCAG
// luminance threshold that matches ContrastRatio's midpoint.
func IsDark(c Color) bool { return c.RelativeLuminance() < 0.4 }
// FormatNumber renders a float for CSS output: no trailing zeros, no
// exponent for the small magnitudes we emit, and a hard cap on precision so
// that generated CSS is byte-stable across platforms.
func FormatNumber(v float64) string {
if math.IsNaN(v) || math.IsInf(v, 0) {
return "0"
}
rounded := math.Round(v*10000) / 10000
if rounded == 0 {
return "0"
}
s := strconv.FormatFloat(rounded, 'f', -1, 64)
if strings.Contains(s, ".") {
s = strings.TrimRight(s, "0")
s = strings.TrimSuffix(s, ".")
}
return s
}