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 }