initial commit
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
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
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
426
internal/tokenengine/color.go
Normal file
426
internal/tokenengine/color.go
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue