Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
426 lines
12 KiB
Go
426 lines
12 KiB
Go
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
|
|
}
|