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
|
||||
}
|
||||
216
internal/tokenengine/color_test.go
Normal file
216
internal/tokenengine/color_test.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseColorFormats(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want Color
|
||||
}{
|
||||
{"hex6", "#1a2b3c", Color{RGB: RGB{R: 0x1a / 255.0, G: 0x2b / 255.0, B: 0x3c / 255.0, A: 1}, Raw: "#1a2b3c", Numeric: true}},
|
||||
{"hex3", "#abc", Color{RGB: RGB{R: 0xaa / 255.0, G: 0xbb / 255.0, B: 0xcc / 255.0, A: 1}, Raw: "#abc", Numeric: true}},
|
||||
{"hex8 alpha", "#00000080", Color{RGB: RGB{R: 0, G: 0, B: 0, A: 128 / 255.0}, Raw: "#00000080", Numeric: true}},
|
||||
{"rgb legacy comma", "rgb(255, 0, 0)", Color{RGB: RGB{R: 1, G: 0, B: 0, A: 1}, Raw: "rgb(255, 0, 0)", Numeric: true}},
|
||||
{"rgb modern space", "rgb(255 0 0)", Color{RGB: RGB{R: 1, G: 0, B: 0, A: 1}, Raw: "rgb(255 0 0)", Numeric: true}},
|
||||
{"rgba alpha", "rgba(0, 0, 0, 0.5)", Color{RGB: RGB{A: 0.5}, Raw: "rgba(0, 0, 0, 0.5)", Numeric: true}},
|
||||
{"hsl", "hsl(0, 100%, 50%)", Color{RGB: RGB{R: 1, G: 0, B: 0, A: 1}, Raw: "hsl(0, 100%, 50%)", Numeric: true}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := ParseColor(tc.input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseColor(%q) error: %v", tc.input, err)
|
||||
}
|
||||
if !got.Numeric {
|
||||
t.Fatalf("ParseColor(%q) returned a non-numeric colour", tc.input)
|
||||
}
|
||||
assertClose(t, got.R, tc.want.R, 0.002)
|
||||
assertClose(t, got.G, tc.want.G, 0.002)
|
||||
assertClose(t, got.B, tc.want.B, 0.002)
|
||||
assertClose(t, got.A, tc.want.A, 0.002)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseColorRejectsGarbage(t *testing.T) {
|
||||
for _, bad := range []string{"", "#", "#12", "#1234567", "rgb(1,2)", "#gggggg"} {
|
||||
if _, err := ParseColor(bad); err == nil {
|
||||
t.Errorf("ParseColor(%q) should have failed", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseColorPassesThroughModernSyntax(t *testing.T) {
|
||||
// These are valid CSS the engine cannot reason about numerically, but they
|
||||
// must round-trip rather than be rejected.
|
||||
for _, ok := range []string{"oklch(0.7 0.1 200)", "color(display-p3 1 0 0)", "light-dark(#fff, #000)", "currentColor", "transparent"} {
|
||||
got, err := ParseColor(ok)
|
||||
if err != nil {
|
||||
t.Errorf("ParseColor(%q) error: %v", ok, err)
|
||||
}
|
||||
if got.Numeric {
|
||||
t.Errorf("ParseColor(%q) should not be numeric", ok)
|
||||
}
|
||||
if got.Raw != ok {
|
||||
t.Errorf("ParseColor(%q).Raw = %q, want the original", ok, got.Raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContrastRatioMatchesWCAGReferenceValues(t *testing.T) {
|
||||
// Reference values from the WCAG 2.1 relative-luminance definition.
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want float64
|
||||
}{
|
||||
{"#ffffff", "#000000", 21},
|
||||
{"#ffffff", "#ffffff", 1},
|
||||
{"#000000", "#000000", 1},
|
||||
{"#777777", "#ffffff", 4.48},
|
||||
{"#767676", "#ffffff", 4.54}, // the canonical AA boundary grey
|
||||
}
|
||||
for _, tc := range cases {
|
||||
a, err := ParseColor(tc.a)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
b, err := ParseColor(tc.b)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
got := ContrastRatio(a, b)
|
||||
if math.Abs(got-tc.want) > 0.05 {
|
||||
t.Errorf("ContrastRatio(%s, %s) = %.3f, want %.3f", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestForegroundPicksHigherContrast(t *testing.T) {
|
||||
light := Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true}
|
||||
dark := Color{RGB: RGB{A: 1}, Numeric: true}
|
||||
|
||||
if got := BestForeground(Color{RGB: RGB{A: 1}, Numeric: true}); got.R != light.R {
|
||||
t.Errorf("foreground for black should be white, got %s", got.Hex())
|
||||
}
|
||||
if got := BestForeground(Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true}); got.R != dark.R {
|
||||
t.Errorf("foreground for white should be black, got %s", got.Hex())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexRoundTrip(t *testing.T) {
|
||||
for _, hex := range []string{"#000000", "#ffffff", "#4f46e5", "#4f46e580"} {
|
||||
c, err := ParseColor(hex)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if got := c.Hex(); got != hex {
|
||||
t.Errorf("round trip of %q produced %q", hex, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMixProducesIntermediateColour(t *testing.T) {
|
||||
black, _ := ParseColor("#000000")
|
||||
white, _ := ParseColor("#ffffff")
|
||||
|
||||
mid := black.Mix(white, 0.5)
|
||||
if got := mid.Hex(); got != "#808080" && got != "#7f7f7f" {
|
||||
t.Errorf("50%% black/white mix = %s, want roughly #808080", got)
|
||||
}
|
||||
if got := black.Mix(white, 0).Hex(); got != "#000000" {
|
||||
t.Errorf("0%% mix = %s, want #000000", got)
|
||||
}
|
||||
if got := black.Mix(white, 1).Hex(); got != "#ffffff" {
|
||||
t.Errorf("100%% mix = %s, want #ffffff", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightenDarkInvertsDirectionForDarkSchemes(t *testing.T) {
|
||||
base, _ := ParseColor("#4f46e5")
|
||||
|
||||
light := base.LightenDark(0.1, false)
|
||||
dark := base.LightenDark(0.1, true)
|
||||
|
||||
if light.RelativeLuminance() >= base.RelativeLuminance() {
|
||||
t.Error("light scheme emphasis should reduce luminance")
|
||||
}
|
||||
if dark.RelativeLuminance() <= base.RelativeLuminance() {
|
||||
t.Error("dark scheme emphasis should increase luminance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRampIsOrderedAndComplete(t *testing.T) {
|
||||
ramp := GenerateRamp(DefaultRampOptions())
|
||||
|
||||
shades := StandardPaletteShades()
|
||||
if len(ramp) != len(shades) {
|
||||
t.Fatalf("ramp has %d steps, want %d", len(ramp), len(shades))
|
||||
}
|
||||
|
||||
previous := math.Inf(1)
|
||||
for _, shade := range shades {
|
||||
leaf, ok := ramp[shade].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("shade %s is not a token leaf", shade)
|
||||
}
|
||||
value, ok := leaf[ValueKey].(string)
|
||||
if !ok {
|
||||
t.Fatalf("shade %s has no string value", shade)
|
||||
}
|
||||
col, err := ParseColor(value)
|
||||
if err != nil {
|
||||
t.Fatalf("shade %s value %q is not a colour: %v", shade, value, err)
|
||||
}
|
||||
luminance := col.RelativeLuminance()
|
||||
if luminance > previous {
|
||||
t.Errorf("shade %s is lighter than the preceding shade", shade)
|
||||
}
|
||||
previous = luminance
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRampNormalisesOutOfRangeInput(t *testing.T) {
|
||||
opts := RampOptions{Hue: 400, Saturation: -20, Lightness: 999, HueSpread: 1000}.Normalize()
|
||||
|
||||
if opts.Hue < 0 || opts.Hue >= 360 {
|
||||
t.Errorf("hue = %f, want it wrapped into 0..360", opts.Hue)
|
||||
}
|
||||
if opts.Saturation != 0 {
|
||||
t.Errorf("saturation = %f, want it clamped to 0", opts.Saturation)
|
||||
}
|
||||
if opts.Lightness != 80 {
|
||||
t.Errorf("lightness = %f, want it clamped to 80", opts.Lightness)
|
||||
}
|
||||
if opts.HueSpread != 40 {
|
||||
t.Errorf("hueSpread = %f, want it clamped to 40", opts.HueSpread)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatNumberIsStable(t *testing.T) {
|
||||
cases := map[float64]string{
|
||||
0: "0",
|
||||
1: "1",
|
||||
1.5: "1.5",
|
||||
0.1250001: "0.125",
|
||||
-0.5: "-0.5",
|
||||
100: "100",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := FormatNumber(in); got != want {
|
||||
t.Errorf("FormatNumber(%v) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertClose fails the test when want and got differ by more than tolerance.
|
||||
func assertClose(t *testing.T, got, want, tolerance float64) {
|
||||
t.Helper()
|
||||
if math.Abs(got-want) > tolerance {
|
||||
t.Errorf("got %v, want %v (tolerance %v)", got, want, tolerance)
|
||||
}
|
||||
}
|
||||
311
internal/tokenengine/contract.go
Normal file
311
internal/tokenengine/contract.go
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// The two-attribute contract.
|
||||
//
|
||||
// Every application this service feeds has its own way of naming "which palette" and
|
||||
// "which appearance", and only two of them have both. Rather than ask each of them to
|
||||
// rename its call sites, the engine emits one contract and an alias layer under it:
|
||||
//
|
||||
// :root[data-theme='tokyo-night'][data-mode='dark'] {
|
||||
// --bn-bg: #1a1b26;
|
||||
// --bn-surface: #20222f;
|
||||
// /* ... the short names the applications already use ... */
|
||||
// }
|
||||
//
|
||||
// /* Compatibility: applications that already use these names keep working. */
|
||||
// :root[data-theme='tokyo-night'][data-mode='dark'] {
|
||||
// --bg: var(--bn-bg);
|
||||
// --surface: var(--bn-surface);
|
||||
// /* ... */
|
||||
// }
|
||||
//
|
||||
// Two attribute names are accepted, because two generations of applications exist. The
|
||||
// short pair (`data-theme`, `data-mode`) is what go-loose and go-mad already put on
|
||||
// <html>; the prefixed pair is what this engine has always emitted. A rule matching
|
||||
// either is the whole point: an application adopts the contract by adding one link.
|
||||
const (
|
||||
// ContractThemeAttribute is the short theme attribute.
|
||||
ContractThemeAttribute = "data-theme"
|
||||
// ContractModeAttribute is the short appearance attribute.
|
||||
ContractModeAttribute = "data-mode"
|
||||
)
|
||||
|
||||
// ContractMode is a storage key and DOM value for the appearance axis. It matches the
|
||||
// attribute so there is one word for the concept in the CSS, the DOM and localStorage.
|
||||
type ContractMode = domain.Appearance
|
||||
|
||||
// contractRole is one short name standing for a full token path.
|
||||
type contractRole struct {
|
||||
// Name is the short custom property, without the prefix.
|
||||
Name string
|
||||
// Path is the dotted token path whose value it carries.
|
||||
Path string
|
||||
// Fallback is emitted when the theme does not define Path, so the contract is
|
||||
// never silently incomplete. A missing alias makes an application render with an
|
||||
// inherited value from the host page, which is worse than an explicit default.
|
||||
Fallback string
|
||||
}
|
||||
|
||||
// contractRoles is the shared vocabulary. The names are chosen to match the tokens the
|
||||
// applications already use, so the alias layer below is a rename rather than a
|
||||
// translation.
|
||||
//
|
||||
// The colour roles are spelled out rather than derived, because a derived name
|
||||
// (`--bn-color-semantic-background` shortened by rule) would be guessable but not
|
||||
// predictable, and a consumer cannot be asked to remember which abbreviation applies.
|
||||
var contractRoles = []contractRole{
|
||||
// Surfaces, from the page background up to the most raised.
|
||||
{"bg", "color.semantic.background", "#ffffff"},
|
||||
{"surface", "color.semantic.surface", "#ffffff"},
|
||||
{"surface-raised", "color.semantic.surface-raised", "#f8fafc"},
|
||||
{"surface-sunken", "color.semantic.surface-sunken", "#eef2f6"},
|
||||
{"overlay", "color.semantic.overlay", "rgba(15, 23, 42, 0.5)"},
|
||||
|
||||
// Text, in descending emphasis.
|
||||
{"text", "color.semantic.text", "#0f172a"},
|
||||
{"muted", "color.semantic.text-muted", "#475569"},
|
||||
{"subtle", "color.semantic.text-subtle", "#64748b"},
|
||||
{"inverted", "color.semantic.text-inverted", "#ffffff"},
|
||||
|
||||
// Borders.
|
||||
{"border", "color.semantic.border", "#e2e8f0"},
|
||||
{"border-strong", "color.semantic.border-strong", "#cbd5e1"},
|
||||
|
||||
// The accent. Two names for one role, because "primary" is what a token author
|
||||
// calls it and "accent" is what a component author looks for.
|
||||
{"accent", "color.semantic.primary", "#2563eb"},
|
||||
{"accent-hover", "color.semantic.primary-hover", "#1d4ed8"},
|
||||
{"accent-active", "color.semantic.primary-active", "#1e40af"},
|
||||
{"on-accent", "color.semantic.on-primary", "#ffffff"},
|
||||
{"secondary", "color.semantic.secondary", "#e2e8f0"},
|
||||
{"on-secondary", "color.semantic.on-secondary", "#0f172a"},
|
||||
|
||||
// Status.
|
||||
{"good", "color.semantic.success", "#16a34a"},
|
||||
{"warning", "color.semantic.warning", "#d97706"},
|
||||
{"danger", "color.semantic.danger", "#dc2626"},
|
||||
{"info", "color.semantic.info", "#0284c7"},
|
||||
{"focus", "color.semantic.focus-ring", "#2563eb"},
|
||||
}
|
||||
|
||||
// contractFamilies are the token groups that are already short enough to alias
|
||||
// wholesale. Geometry, spacing and typography have no sensible per-role renaming, so
|
||||
// the whole group is re-exported under its own prefix rather than picked apart.
|
||||
var contractFamilies = []string{"radius", "space", "font", "shadow", "border.width", "duration", "ease", "zIndex", "opacity", "breakpoint"}
|
||||
|
||||
// legacyAliases are the unprefixed names the applications already use, mapped to the
|
||||
// contract name that replaces them.
|
||||
//
|
||||
// This is the compatibility half of the contract. Emitting it means an application
|
||||
// adopts the shared styling by adding one <link> and deleting its own :root block, with
|
||||
// no call site edited. Dropping it means the unprefixed custom properties stop existing
|
||||
// at all, which is a coordinated change across every application.
|
||||
var legacyAliases = map[string]string{
|
||||
"bg": "bg",
|
||||
"surface": "surface",
|
||||
"surface2": "surface-raised",
|
||||
"text": "text",
|
||||
"muted": "muted",
|
||||
"line": "border",
|
||||
"accent": "accent",
|
||||
"accent2": "secondary",
|
||||
"good": "good",
|
||||
"danger": "danger",
|
||||
}
|
||||
|
||||
// contractSelector returns the selector matching a theme and mode under either
|
||||
// attribute convention.
|
||||
//
|
||||
// The two groups are comma-separated rather than a compound selector, so an
|
||||
// application that uses one convention still gets the values.
|
||||
//
|
||||
// Each group appears twice: once rooted at :root, and once bare. The bare form is
|
||||
// what makes a palette adoptable by any element rather than only by the document
|
||||
// root, which is what a theme preview is. A preview has to sit next to the palette
|
||||
// it is previewing, inside a control that renders into a shadow root, where :root
|
||||
// matches nothing -- a stylesheet in the document does not select into a shadow tree
|
||||
// at all. Emitting the bare form lets the preview carry data-theme and pick the
|
||||
// palette up, so it shows the theme rather than a swatch of it.
|
||||
func contractSelector(slug string, mode domain.Appearance) string {
|
||||
return fmt.Sprintf(":root[%s=%s][%s=%s], :root[%s=%s][%s=%s], [%s=%s][%s=%s], [%s=%s][%s=%s]",
|
||||
ContractThemeAttribute, selectorValue(slug),
|
||||
ContractModeAttribute, selectorValue(string(mode)),
|
||||
ThemeAttribute, selectorValue(slug),
|
||||
AppearanceAttribute, selectorValue(string(mode)),
|
||||
ContractThemeAttribute, selectorValue(slug),
|
||||
ContractModeAttribute, selectorValue(string(mode)),
|
||||
ThemeAttribute, selectorValue(slug),
|
||||
AppearanceAttribute, selectorValue(string(mode)))
|
||||
}
|
||||
|
||||
// contractDefaultSelector matches a palette with no explicit appearance.
|
||||
//
|
||||
// This exists because requiring both attributes is a trap. An application that
|
||||
// declares only `data-theme` -- which is what go-tell did, and what any application
|
||||
// written before the appearance axis existed still does -- would match no rule at all
|
||||
// and render entirely on its fallbacks, with no error anywhere. Matching the palette
|
||||
// alone and treating the absence of an appearance as "the designer's choice" is what
|
||||
// the axis means anyway, and it is what the theme's own default appearance says.
|
||||
//
|
||||
// As with contractSelector, the bare form follows the :root one so a preview can
|
||||
// carry the palette without an appearance, and inherit the designer's choice with it.
|
||||
func contractDefaultSelector(slug string) string {
|
||||
return fmt.Sprintf(":root[%s=%s], :root[%s=%s], [%s=%s], [%s=%s]",
|
||||
ContractThemeAttribute, selectorValue(slug),
|
||||
ThemeAttribute, selectorValue(slug),
|
||||
ContractThemeAttribute, selectorValue(slug),
|
||||
ThemeAttribute, selectorValue(slug))
|
||||
}
|
||||
|
||||
// selectorValue quotes a value for use inside an attribute selector.
|
||||
func selectorValue(v string) string {
|
||||
return fmt.Sprintf("%q", v)
|
||||
}
|
||||
|
||||
// Contract renders the contract layer for one theme and appearance.
|
||||
//
|
||||
// It emits the short --bn-* names and the unprefixed aliases. It does not emit the
|
||||
// theme's own tokens: those are already in the stylesheet, and duplicating them would
|
||||
// double the payload for no gain. This is the layer that makes a long token path
|
||||
// reachable under a name a component author would guess.
|
||||
func Contract(theme *domain.Theme, mode domain.Appearance) string {
|
||||
doc := Resolve(theme, mode)
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "@layer %s.base;\n", LayerName)
|
||||
writeContractRule(&b, contractSelector(theme.Slug, mode), doc)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ContractAll renders the contract layer for every theme at once, for both modes.
|
||||
//
|
||||
// This is what makes a theme selector instant. Selecting a palette becomes a change to
|
||||
// one attribute rather than a fetch, because every palette is already in the document.
|
||||
// The cost is that the file grows with the number of published themes, which is why it
|
||||
// is served separately from theme.css: a single-theme consumer should not download the
|
||||
// others.
|
||||
func ContractAll(themes []*domain.Theme) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "/*!\n * Go Bananas!! shared token contract\n")
|
||||
fmt.Fprintf(&b, " * %d theme(s), both appearances each.\n", len(themes))
|
||||
fmt.Fprintf(&b, " * Set %s and %s on <html>; nothing is fetched when you switch.\n",
|
||||
ContractThemeAttribute, ContractModeAttribute)
|
||||
fmt.Fprintf(&b, " */\n")
|
||||
fmt.Fprintf(&b, "@layer %s.base;\n", LayerName)
|
||||
|
||||
for _, theme := range themes {
|
||||
// The default first, then the explicit overrides. Order matters only as a
|
||||
// readability property: the mode selectors carry two attributes against the
|
||||
// default's one, so they win on specificity regardless of source order.
|
||||
def := theme.DefaultAppearance
|
||||
if def == "" {
|
||||
def = domain.AppearanceDark
|
||||
}
|
||||
writeContractRule(&b, contractDefaultSelector(theme.Slug), Resolve(theme, def))
|
||||
|
||||
for _, mode := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} {
|
||||
writeContractRule(&b, contractSelector(theme.Slug, mode), Resolve(theme, mode))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// writeContractRule writes one selector's declarations and its alias block.
|
||||
func writeContractRule(b *strings.Builder, selector string, doc *Doc) {
|
||||
decls := make([]string, 0, len(contractRoles)*2)
|
||||
|
||||
for _, role := range contractRoles {
|
||||
value := role.Fallback
|
||||
if token, ok := doc.Lookup(role.Path); ok && token.CSS != "" {
|
||||
value = token.CSS
|
||||
}
|
||||
decls = append(decls, VariablePrefix+"-"+role.Name+": "+value+";")
|
||||
}
|
||||
|
||||
// The families whose names are already short are re-exported rather than
|
||||
// re-derived, so `--bn-radius-md` is the same variable the theme defines and an
|
||||
// override in the theme's own stylesheet still wins.
|
||||
for _, family := range contractFamilies {
|
||||
prefix := family + "."
|
||||
for _, token := range doc.Tokens {
|
||||
if !strings.HasPrefix(token.Path, prefix) {
|
||||
continue
|
||||
}
|
||||
name := VariablePrefix + "-" + strings.ReplaceAll(token.Path, ".", "-")
|
||||
if token.CSS != "" {
|
||||
decls = append(decls, name+": "+token.CSS+";")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(decls) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
b.WriteString(selector + " {\n")
|
||||
for _, d := range decls {
|
||||
b.WriteString(" " + d + "\n")
|
||||
}
|
||||
|
||||
// The alias block, in the same rule so it is impossible to emit one without the
|
||||
// other. Aliases reference the contract names rather than repeating the values, so
|
||||
// there is exactly one place a value is written.
|
||||
aliases := make([]string, 0, len(legacyAliases))
|
||||
for legacy, target := range legacyAliases {
|
||||
aliases = append(aliases, fmt.Sprintf("--%s: var(%s-%s);", legacy, VariablePrefix, target))
|
||||
}
|
||||
// Sorted, because a map iteration order would make the output differ between
|
||||
// builds and defeat any caching or diffing of this file.
|
||||
sortStrings(aliases)
|
||||
for _, a := range aliases {
|
||||
b.WriteString(" " + a + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("}\n\n")
|
||||
}
|
||||
|
||||
// sortStrings sorts in place. It exists so this file does not need to import sort for
|
||||
// one call.
|
||||
func sortStrings(s []string) {
|
||||
for i := 1; i < len(s); i++ {
|
||||
for j := i; j > 0 && s[j] < s[j-1]; j-- {
|
||||
s[j], s[j-1] = s[j-1], s[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ContractManifestEntry describes one theme in the contract, for a selector to render
|
||||
// without having to parse CSS.
|
||||
type ContractManifestEntry struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Version int64 `json:"version"`
|
||||
// DefaultMode is the appearance the theme was designed for. It is what a
|
||||
// consumer shows before the user expresses a preference.
|
||||
DefaultMode string `json:"defaultMode"`
|
||||
}
|
||||
|
||||
// ContractManifest returns the entries a theme selector needs.
|
||||
func ContractManifest(themes []*domain.Theme) []ContractManifestEntry {
|
||||
out := make([]ContractManifestEntry, 0, len(themes))
|
||||
for _, t := range themes {
|
||||
mode := t.DefaultAppearance
|
||||
if mode == "" {
|
||||
mode = domain.AppearanceDark
|
||||
}
|
||||
out = append(out, ContractManifestEntry{
|
||||
Slug: t.Slug,
|
||||
Name: t.Name,
|
||||
Version: t.Version,
|
||||
DefaultMode: string(mode),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
82
internal/tokenengine/contract_test.go
Normal file
82
internal/tokenengine/contract_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// The aggregate contract is consumed two ways: as a stylesheet the application
|
||||
// links, which can only be styled from :root, and as a stylesheet a control adopts
|
||||
// into its own shadow root, where :root matches nothing because a stylesheet in the
|
||||
// document does not select into a shadow tree. The bare selector forms are what make
|
||||
// the second use possible, and a palette preview depends on them, so their absence is
|
||||
// a silent failure: the selector still lists every palette and every preview renders
|
||||
// in whatever the host's fallbacks happen to be.
|
||||
|
||||
// assertSelectorPair asserts that a selector list contains both halves of a pair as
|
||||
// distinct comma-separated entries.
|
||||
//
|
||||
// The assertion is on the joined pair rather than on each half alone, because one half
|
||||
// cannot tell the two forms apart: the string `[data-theme="acme"][data-mode="light"]`
|
||||
// occurs inside `:root[data-theme="acme"][data-mode="light"]`, so a bare Contains
|
||||
// reports a bare selector as present when only the rooted form was emitted. Joining
|
||||
// them is what discriminates, because in the rooted-only list the two halves are
|
||||
// separated by `:root`.
|
||||
func assertSelectorPair(t *testing.T, css, want string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(css, want) {
|
||||
t.Errorf("contract does not emit %q as a pair of entries, so a palette preview cannot adopt it", want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractEmitsBareSelectorsForPreviewScope(t *testing.T) {
|
||||
theme := testTheme()
|
||||
theme.Slug = "acme"
|
||||
theme.DefaultAppearance = domain.AppearanceDark
|
||||
css := ContractAll([]*domain.Theme{theme})
|
||||
|
||||
// The palette on its own, so a preview that declares no appearance still adopts
|
||||
// the designer's choice.
|
||||
assertSelectorPair(t, css, `[data-theme="acme"], [data-bananas-theme="acme"]`)
|
||||
|
||||
// Each appearance, in both attribute conventions.
|
||||
assertSelectorPair(t, css, `[data-theme="acme"][data-mode="light"], [data-bananas-theme="acme"][data-bananas-appearance="light"]`)
|
||||
assertSelectorPair(t, css, `[data-theme="acme"][data-mode="dark"], [data-bananas-theme="acme"][data-bananas-appearance="dark"]`)
|
||||
|
||||
// The rooted forms have to survive: every application that links the contract
|
||||
// rather than adopting it styles itself from :root.
|
||||
assertSelectorPair(t, css, `:root[data-theme="acme"], :root[data-bananas-theme="acme"]`)
|
||||
assertSelectorPair(t, css, `:root[data-theme="acme"][data-mode="light"], :root[data-bananas-theme="acme"][data-bananas-appearance="light"]`)
|
||||
}
|
||||
|
||||
// TestContractBareSelectorsCarryTheSameValues guards against the two forms drifting
|
||||
// apart, which would mean a preview shows a different palette from the one applying
|
||||
// to the page. Both forms are emitted from the same declarations, so this is a
|
||||
// statement that the pairing is not an accident worth breaking.
|
||||
func TestContractBareSelectorsCarryTheSameValues(t *testing.T) {
|
||||
css := ContractAll([]*domain.Theme{testTheme()})
|
||||
|
||||
for _, want := range []string{
|
||||
`--bn-bg: #ffffff;`,
|
||||
`--bn-text: #0f172a;`,
|
||||
`--bn-accent: #4f46e5;`,
|
||||
} {
|
||||
if !strings.Contains(css, want) {
|
||||
t.Errorf("contract no longer emits %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestContractLegacyAliasesStillExist protects the compatibility promise: an
|
||||
// application that already uses the unprefixed names must keep working.
|
||||
func TestContractLegacyAliasesStillExist(t *testing.T) {
|
||||
css := ContractAll([]*domain.Theme{testTheme()})
|
||||
|
||||
for legacy := range legacyAliases {
|
||||
if !strings.Contains(css, "--"+legacy+":") {
|
||||
t.Errorf("legacy alias --%s is no longer emitted", legacy)
|
||||
}
|
||||
}
|
||||
}
|
||||
617
internal/tokenengine/css.go
Normal file
617
internal/tokenengine/css.go
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// LayerName is the cascade layer the generated component styles are placed in.
|
||||
// Everything the engine emits is layered so that a consuming application's own
|
||||
// unlayered CSS always wins the cascade without needing !important.
|
||||
const LayerName = "bananas"
|
||||
|
||||
// RenderOptions controls stylesheet generation.
|
||||
type RenderOptions struct {
|
||||
// Slug scopes the theme to an attribute selector so that several themes can
|
||||
// coexist on one page. When empty the tokens are emitted on :root instead.
|
||||
Slug string
|
||||
// Appearance is the scheme to render as the default for the selector.
|
||||
Appearance domain.Appearance
|
||||
// ApplyToRoot also emits the tokens on :root, making the theme the page
|
||||
// default. Ignored when Slug is set unless ForceRoot is true.
|
||||
ApplyToRoot bool
|
||||
// ForceRoot emits :root rules even when Slug is set.
|
||||
ForceRoot bool
|
||||
// AutoDark adds a prefers-color-scheme: dark block that swaps in the dark
|
||||
// tokens when the slug selector is not explicitly pinned to light.
|
||||
AutoDark bool
|
||||
// Components appends the ready-made component layer.
|
||||
Components bool
|
||||
// Derive computes engine-supplied tokens (foreground pairs, interaction
|
||||
// states, tinted surfaces) for values the author did not define.
|
||||
Derive DeriveOptions
|
||||
// Minify strips comments and collapses whitespace.
|
||||
Minify bool
|
||||
// SourceURL appends a source comment pointing at the runtime URL the
|
||||
// stylesheet was generated from, which makes browser devtools navigable.
|
||||
SourceURL string
|
||||
}
|
||||
|
||||
// DeriveOptions selects which engine-computed token families to emit.
|
||||
type DeriveOptions struct {
|
||||
// Foreground adds `--bn-color-on-*` roles derived by WCAG contrast.
|
||||
Foreground bool
|
||||
// Interaction adds `-hover` and `-active` variants for interactive roles.
|
||||
Interaction bool
|
||||
// Tint adds subtle tinted surfaces derived from a role's own hue.
|
||||
Tint bool
|
||||
}
|
||||
|
||||
// DefaultDerive returns the derivation set used when a caller does not care.
|
||||
func DefaultDerive() DeriveOptions {
|
||||
return DeriveOptions{Foreground: true, Interaction: true, Tint: true}
|
||||
}
|
||||
|
||||
// RenderOptionsFrom builds options from a theme with sensible defaults for the
|
||||
// given delivery channel.
|
||||
func RenderOptionsFrom(theme *domain.Theme, appearance domain.Appearance) RenderOptions {
|
||||
return RenderOptions{
|
||||
Slug: theme.Slug,
|
||||
Appearance: appearance,
|
||||
Derive: DefaultDerive(),
|
||||
Components: true,
|
||||
}
|
||||
}
|
||||
|
||||
// pair is one CSS custom property declaration.
|
||||
type pair struct {
|
||||
name string
|
||||
value string
|
||||
}
|
||||
|
||||
// Render produces the stylesheet for a theme.
|
||||
func Render(theme *domain.Theme, opts RenderOptions) string {
|
||||
appearance := opts.Appearance
|
||||
if appearance == "" {
|
||||
appearance = theme.DefaultAppearance
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
if !opts.Minify {
|
||||
writeHeader(&b, theme, opts, appearance)
|
||||
}
|
||||
|
||||
light := Resolve(theme, domain.AppearanceLight)
|
||||
dark := Resolve(theme, domain.AppearanceDark)
|
||||
|
||||
// The scoped rule carries the theme's *default* appearance, so that setting a
|
||||
// single attribute is enough to get the theme the author designed. The
|
||||
// alternate scheme is emitted under its own appearance attribute.
|
||||
base := light
|
||||
alternate := dark
|
||||
if appearance == domain.AppearanceDark {
|
||||
base, alternate = dark, light
|
||||
}
|
||||
baseDecls := renderDeclarations(base, opts, appearance)
|
||||
alternateDecls := renderDeclarations(alternate, opts, opposite(appearance))
|
||||
darkDecls := renderDeclarations(dark, opts, domain.AppearanceDark)
|
||||
|
||||
rootSelector := opts.ApplyToRoot || opts.ForceRoot || opts.Slug == ""
|
||||
scope := themeSelector(opts.Slug)
|
||||
|
||||
if !opts.Minify {
|
||||
fmt.Fprintf(&b, "@layer %s.base;\n", LayerName)
|
||||
}
|
||||
|
||||
// The default scheme.
|
||||
switch {
|
||||
case rootSelector && opts.Slug != "":
|
||||
// Emit on :root so the theme works with zero configuration, and again on
|
||||
// the scoped selector so a page can host several themes side by side.
|
||||
writeRule(&b, ":root", baseDecls, opts.Minify)
|
||||
writeRule(&b, scope, baseDecls, opts.Minify)
|
||||
case rootSelector:
|
||||
writeRule(&b, ":root", baseDecls, opts.Minify)
|
||||
default:
|
||||
writeRule(&b, scope, baseDecls, opts.Minify)
|
||||
}
|
||||
|
||||
// The alternate scheme, selected explicitly. This is what a consumer's theme
|
||||
// switcher sets, and it is the only selector needed to override the default.
|
||||
if opts.Slug != "" {
|
||||
writeRule(&b, darkScopeSelector(opts.Slug, opposite(appearance)), alternateDecls, opts.Minify)
|
||||
} else {
|
||||
writeRule(&b, ":root["+AppearanceAttribute+"="+string(opposite(appearance))+"], :root."+darkClass,
|
||||
alternateDecls, opts.Minify)
|
||||
}
|
||||
|
||||
// A `.dark` ancestor class covers the common Tailwind and shadcn convention
|
||||
// where dark mode is toggled by a class on <html> rather than an attribute.
|
||||
// It always means dark, regardless of the theme's default.
|
||||
if opts.Slug != "" && appearance != domain.AppearanceDark {
|
||||
fmt.Fprintf(&b, ".dark %s, %s.%s {\n", scope, scope, darkClass)
|
||||
writeDeclarations(&b, darkDecls, opts.Minify, "")
|
||||
b.WriteString("}\n\n")
|
||||
}
|
||||
|
||||
if opts.AutoDark {
|
||||
writeAutoDark(&b, scope, darkDecls, opts.Minify)
|
||||
}
|
||||
|
||||
if opts.Components {
|
||||
writeComponents(&b, opts)
|
||||
}
|
||||
|
||||
if !opts.Minify && opts.SourceURL != "" {
|
||||
fmt.Fprintf(&b, "/*# sourceURL=%s */\n", opts.SourceURL)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n") + "\n"
|
||||
}
|
||||
|
||||
// opposite returns the other colour scheme.
|
||||
func opposite(a domain.Appearance) domain.Appearance {
|
||||
if a == domain.AppearanceDark {
|
||||
return domain.AppearanceLight
|
||||
}
|
||||
return domain.AppearanceDark
|
||||
}
|
||||
|
||||
// writeHeader emits a provenance comment. It is what makes a generated
|
||||
// stylesheet debuggable: it names the theme, version and generation time.
|
||||
func writeHeader(b *strings.Builder, theme *domain.Theme, opts RenderOptions, appearance domain.Appearance) {
|
||||
fmt.Fprintf(b, "/*!\n")
|
||||
fmt.Fprintf(b, " * Go Bananas!! theme: %s\n", theme.Name)
|
||||
fmt.Fprintf(b, " * slug: %s | version: %d | status: %s | appearance: %s\n",
|
||||
theme.Slug, theme.Version, theme.Status, appearance)
|
||||
fmt.Fprintf(b, " * variables are prefixed %q; override them in an unlayered rule to win the cascade\n", VariablePrefix)
|
||||
fmt.Fprintf(b, " * generated: %s\n", theme.UpdatedAt.UTC().Format(domain.TimeFormat))
|
||||
fmt.Fprintf(b, " */\n")
|
||||
}
|
||||
|
||||
// themeSelector returns the attribute selector that scopes a theme.
|
||||
func themeSelector(slug string) string {
|
||||
return fmt.Sprintf("[%s=%q]", ThemeAttribute, selectorSafe(slug))
|
||||
}
|
||||
|
||||
// darkScopeSelector returns the selector for the dark scheme of a scoped theme.
|
||||
func darkScopeSelector(slug string, appearance domain.Appearance) string {
|
||||
base := themeSelector(slug)
|
||||
return fmt.Sprintf("%s[%s=%s]", base, AppearanceAttribute, appearance)
|
||||
}
|
||||
|
||||
// writeAutoDark emits the prefers-color-scheme block. The `:not()` guard makes
|
||||
// the media query yield to an explicit appearance attribute so that a manual
|
||||
// override always beats the OS preference.
|
||||
func writeAutoDark(b *strings.Builder, scope string, decls []pair, minify bool) {
|
||||
b.WriteString("@media (prefers-color-scheme: dark) {\n")
|
||||
var selector string
|
||||
if scope == ":root" {
|
||||
selector = fmt.Sprintf(":root:not([%s=%s])", AppearanceAttribute, domain.AppearanceLight)
|
||||
} else {
|
||||
selector = fmt.Sprintf("%s:not([%s=%s]):not(.dark)", scope, AppearanceAttribute, domain.AppearanceLight)
|
||||
}
|
||||
inner := " "
|
||||
writeRuleIndent(b, selector, decls, minify, inner)
|
||||
b.WriteString("}\n\n")
|
||||
}
|
||||
|
||||
// renderDeclarations builds the ordered declaration list for one appearance.
|
||||
func renderDeclarations(doc *Doc, opts RenderOptions, appearance domain.Appearance) []pair {
|
||||
decls := make([]pair, 0, len(doc.Tokens)+32)
|
||||
seen := make(map[string]struct{}, len(doc.Tokens))
|
||||
|
||||
add := func(name, value string) {
|
||||
if _, dup := seen[name]; dup {
|
||||
return
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
decls = append(decls, pair{name: name, value: value})
|
||||
}
|
||||
|
||||
for _, t := range doc.Tokens {
|
||||
add(VariableName(t.Path), t.CSS)
|
||||
}
|
||||
|
||||
if opts.Derive.Foreground {
|
||||
for _, d := range deriveForeground(doc, appearance) {
|
||||
add(d.name, d.value)
|
||||
}
|
||||
}
|
||||
if opts.Derive.Interaction {
|
||||
for _, d := range deriveInteraction(doc, appearance) {
|
||||
add(d.name, d.value)
|
||||
}
|
||||
}
|
||||
if opts.Derive.Tint {
|
||||
for _, d := range deriveTints(doc, appearance) {
|
||||
add(d.name, d.value)
|
||||
}
|
||||
}
|
||||
return decls
|
||||
}
|
||||
|
||||
// deriveForeground produces the `--bn-color-on-*` family. For every palette
|
||||
// step and every semantic role that is a solid colour, it picks black or white
|
||||
// whichever has the higher WCAG contrast ratio. Authors who want an explicit
|
||||
// value simply define it: derived names are only emitted when absent.
|
||||
func deriveForeground(doc *Doc, appearance domain.Appearance) []pair {
|
||||
out := make([]pair, 0, 48)
|
||||
for _, t := range doc.Tokens {
|
||||
if t.Kind != KindColor || !strings.HasPrefix(t.Path, "color.") {
|
||||
continue
|
||||
}
|
||||
// Skip roles that are not solid backgrounds and skip anything already
|
||||
// spelled `on-*`.
|
||||
rest := strings.TrimPrefix(t.Path, "color.")
|
||||
if strings.HasPrefix(rest, "on-") {
|
||||
continue
|
||||
}
|
||||
col, err := ParseColor(t.CSS)
|
||||
if err != nil || !col.Numeric || col.A < 0.5 {
|
||||
continue
|
||||
}
|
||||
if _, exists := doc.Lookup("color.on-" + rest); exists {
|
||||
continue
|
||||
}
|
||||
out = append(out, pair{
|
||||
name: VariableName("color.on-" + rest),
|
||||
value: BestForeground(col).Hex(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// interactionRatios are the mix steps used to derive hover and active states.
|
||||
// Hover moves 8% toward the foreground, active 16%: subtle enough to read as
|
||||
// the same colour, strong enough to be perceptible.
|
||||
var (
|
||||
hoverRatio = 0.08
|
||||
activeRatio = 0.16
|
||||
)
|
||||
|
||||
// interactiveRoles are the semantic roles that get state variants. They are the
|
||||
// ones a component stylesheet can reasonably apply :hover and :active to.
|
||||
var interactiveRoles = []string{
|
||||
"semantic.primary",
|
||||
"semantic.secondary",
|
||||
"semantic.accent",
|
||||
"semantic.success",
|
||||
"semantic.warning",
|
||||
"semantic.danger",
|
||||
"semantic.info",
|
||||
"semantic.surface",
|
||||
"semantic.surface-raised",
|
||||
}
|
||||
|
||||
// deriveInteraction produces `-hover`, `-active` and `-subtle` companions for
|
||||
// the interactive semantic roles that exist in the document.
|
||||
func deriveInteraction(doc *Doc, appearance domain.Appearance) []pair {
|
||||
out := make([]pair, 0, 48)
|
||||
for _, role := range interactiveRoles {
|
||||
t, ok := doc.Lookup("color." + role)
|
||||
if !ok || t.Kind != KindColor {
|
||||
continue
|
||||
}
|
||||
col, err := ParseColor(t.CSS)
|
||||
if err != nil || !col.Numeric {
|
||||
continue
|
||||
}
|
||||
// In a dark scheme the base colour is already light, so "emphasis" means
|
||||
// getting lighter, not darker.
|
||||
for _, suffix := range []struct {
|
||||
name string
|
||||
amount float64
|
||||
}{{"hover", hoverRatio}, {"active", activeRatio}} {
|
||||
path := "color." + role + "-" + suffix.name
|
||||
if _, exists := doc.Lookup(path); exists {
|
||||
continue
|
||||
}
|
||||
out = append(out, pair{
|
||||
name: VariableName(path),
|
||||
value: col.LightenDark(suffix.amount, appearance == domain.AppearanceDark).Hex(),
|
||||
})
|
||||
}
|
||||
if _, exists := doc.Lookup("color." + role + "-subtle"); !exists {
|
||||
out = append(out, pair{
|
||||
name: VariableName("color." + role + "-subtle"),
|
||||
value: col.WithAlpha(0.12).Hex(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tintRoles are the solid roles that get very light/dark companions for badge,
|
||||
// callout and hover-row backgrounds.
|
||||
var tintRoles = []string{
|
||||
"semantic.primary",
|
||||
"semantic.success",
|
||||
"semantic.warning",
|
||||
"semantic.danger",
|
||||
"semantic.info",
|
||||
}
|
||||
|
||||
// deriveTints mixes a role toward its scheme's background, producing a surface
|
||||
// that reads as "this role, but quiet".
|
||||
func deriveTints(doc *Doc, appearance domain.Appearance) []pair {
|
||||
background, ok := doc.Lookup("color.semantic.background")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
bg, err := ParseColor(background.CSS)
|
||||
if err != nil || !bg.Numeric {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]pair, 0, 16)
|
||||
for _, role := range tintRoles {
|
||||
t, exists := doc.Lookup("color." + role)
|
||||
if !exists || t.Kind != KindColor {
|
||||
continue
|
||||
}
|
||||
col, err := ParseColor(t.CSS)
|
||||
if err != nil || !col.Numeric {
|
||||
continue
|
||||
}
|
||||
for _, suffix := range []struct {
|
||||
name string
|
||||
amount float64
|
||||
}{{"tint", 0.14}, {"tint-strong", 0.26}} {
|
||||
path := "color." + role + "-" + suffix.name
|
||||
if _, exists := doc.Lookup(path); exists {
|
||||
continue
|
||||
}
|
||||
out = append(out, pair{
|
||||
name: VariableName(path),
|
||||
value: bg.Mix(col, suffix.amount).Hex(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeRule emits a complete CSS rule.
|
||||
func writeRule(b *strings.Builder, selector string, decls []pair, minify bool) {
|
||||
writeRuleIndent(b, selector, decls, minify, "")
|
||||
}
|
||||
|
||||
// writeRuleIndent emits a CSS rule with every line prefixed by indent.
|
||||
func writeRuleIndent(b *strings.Builder, selector string, decls []pair, minify bool, indent string) {
|
||||
if len(decls) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString(indent)
|
||||
b.WriteString(selector)
|
||||
b.WriteString(" {\n")
|
||||
writeDeclarations(b, decls, minify, indent+" ")
|
||||
b.WriteString(indent)
|
||||
b.WriteString("}\n")
|
||||
if !minify {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// writeDeclarations emits the body of a rule. In minified mode the whole body is
|
||||
// written on one line with no indentation, which is what a minifier would
|
||||
// produce and keeps the output byte-identical to what a CDN would serve.
|
||||
func writeDeclarations(b *strings.Builder, decls []pair, minify bool, indent string) {
|
||||
if minify {
|
||||
indent = ""
|
||||
}
|
||||
for _, d := range decls {
|
||||
b.WriteString(indent)
|
||||
b.WriteString(d.name)
|
||||
b.WriteString(":")
|
||||
if !minify {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
b.WriteString(d.value)
|
||||
b.WriteString(";")
|
||||
if !minify {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeComponents appends the ready-made component layer. It is intentionally
|
||||
// small: enough for a host application to look correct immediately, expressed
|
||||
// entirely in terms of the generated variables so that re-theming is free.
|
||||
func writeComponents(b *strings.Builder, opts RenderOptions) {
|
||||
fmt.Fprintf(b, "@layer %s.components {\n", LayerName)
|
||||
componentCSS(b, opts.Minify)
|
||||
b.WriteString("}\n")
|
||||
}
|
||||
|
||||
// componentRules is the body of the component layer. It uses only generated
|
||||
// variables plus layout primitives that carry no colour of their own.
|
||||
const componentRules = `
|
||||
.bn-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--bn-space-2, 0.5rem);
|
||||
padding: var(--bn-space-2, 0.5rem) var(--bn-space-4, 1rem);
|
||||
border: var(--bn-border-width-default, 1px) solid transparent;
|
||||
border-radius: var(--bn-radius-md, 0.375rem);
|
||||
background: var(--bn-color-semantic-primary, currentColor);
|
||||
color: var(--bn-color-on-primary, #fff);
|
||||
font-family: var(--bn-font-family-sans, system-ui, sans-serif);
|
||||
font-size: var(--bn-font-size-sm, 0.875rem);
|
||||
font-weight: var(--bn-font-weight-medium, 500);
|
||||
line-height: var(--bn-line-height-normal, 1.5);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background-color var(--bn-duration-fast, 150ms) var(--bn-ease-standard, ease),
|
||||
color var(--bn-duration-fast, 150ms) var(--bn-ease-standard, ease),
|
||||
border-color var(--bn-duration-fast, 150ms) var(--bn-ease-standard, ease);
|
||||
}
|
||||
.bn-btn:hover { background: var(--bn-color-semantic-primary-hover, var(--bn-color-semantic-primary)); }
|
||||
.bn-btn:active { background: var(--bn-color-semantic-primary-active, var(--bn-color-semantic-primary)); }
|
||||
.bn-btn:focus-visible {
|
||||
outline: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-focus-ring, currentColor);
|
||||
outline-offset: var(--bn-space-1, 0.25rem);
|
||||
}
|
||||
.bn-btn[disabled] { opacity: var(--bn-opacity-disabled, 0.5); cursor: not-allowed; }
|
||||
.bn-btn--secondary {
|
||||
background: var(--bn-color-semantic-secondary, transparent);
|
||||
color: var(--bn-color-semantic-text, inherit);
|
||||
border-color: var(--bn-color-semantic-border, currentColor);
|
||||
}
|
||||
.bn-btn--secondary:hover { background: var(--bn-color-semantic-secondary-hover, transparent); }
|
||||
.bn-btn--ghost { background: transparent; color: var(--bn-color-semantic-primary, inherit); }
|
||||
.bn-btn--ghost:hover { background: var(--bn-color-semantic-primary-subtle, transparent); }
|
||||
.bn-btn--danger { background: var(--bn-color-semantic-danger, currentColor); color: var(--bn-color-on-danger, #fff); }
|
||||
.bn-btn--sm { padding: var(--bn-space-1, 0.25rem) var(--bn-space-3, 0.75rem); font-size: var(--bn-font-size-xs, 0.75rem); }
|
||||
.bn-btn--lg { padding: var(--bn-space-3, 0.75rem) var(--bn-space-6, 1.5rem); font-size: var(--bn-font-size-md, 1rem); }
|
||||
|
||||
.bn-card {
|
||||
background: var(--bn-color-semantic-surface, Canvas);
|
||||
color: var(--bn-color-semantic-text, CanvasText);
|
||||
border: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-border, transparent);
|
||||
border-radius: var(--bn-radius-lg, 0.5rem);
|
||||
box-shadow: var(--bn-shadow-sm, none);
|
||||
padding: var(--bn-space-6, 1.5rem);
|
||||
font-family: var(--bn-font-family-sans, system-ui, sans-serif);
|
||||
}
|
||||
.bn-card--raised { background: var(--bn-color-semantic-surface-raised, Canvas); box-shadow: var(--bn-shadow-md, none); }
|
||||
.bn-card__header { margin-bottom: var(--bn-space-4, 1rem); font-size: var(--bn-font-size-lg, 1.125rem); font-weight: var(--bn-font-weight-semibold, 600); }
|
||||
.bn-card__body { font-size: var(--bn-font-size-sm, 0.875rem); color: var(--bn-color-semantic-text-muted, inherit); }
|
||||
|
||||
.bn-input,
|
||||
.bn-select,
|
||||
.bn-textarea {
|
||||
width: 100%;
|
||||
padding: var(--bn-space-2, 0.5rem) var(--bn-space-3, 0.75rem);
|
||||
border: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-border, currentColor);
|
||||
border-radius: var(--bn-radius-md, 0.375rem);
|
||||
background: var(--bn-color-semantic-background, Canvas);
|
||||
color: var(--bn-color-semantic-text, CanvasText);
|
||||
font-family: inherit;
|
||||
font-size: var(--bn-font-size-sm, 0.875rem);
|
||||
line-height: var(--bn-line-height-normal, 1.5);
|
||||
}
|
||||
.bn-input:focus-visible,
|
||||
.bn-select:focus-visible,
|
||||
.bn-textarea:focus-visible {
|
||||
outline: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-focus-ring, currentColor);
|
||||
outline-offset: var(--bn-space-1, 0.25rem);
|
||||
border-color: var(--bn-color-semantic-primary, currentColor);
|
||||
}
|
||||
.bn-input::placeholder { color: var(--bn-color-semantic-text-subtle, inherit); opacity: 1; }
|
||||
.bn-input:disabled { opacity: var(--bn-opacity-disabled, 0.5); cursor: not-allowed; }
|
||||
.bn-label { display: block; margin-bottom: var(--bn-space-1, 0.25rem); font-size: var(--bn-font-size-xs, 0.75rem); font-weight: var(--bn-font-weight-medium, 500); color: var(--bn-color-semantic-text-muted, inherit); }
|
||||
.bn-field { margin-bottom: var(--bn-space-4, 1rem); }
|
||||
.bn-field__error { margin-top: var(--bn-space-1, 0.25rem); font-size: var(--bn-font-size-xs, 0.75rem); color: var(--bn-color-semantic-danger, red); }
|
||||
|
||||
.bn-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--bn-space-1, 0.25rem);
|
||||
padding: var(--bn-space-1, 0.25rem) var(--bn-space-2, 0.5rem);
|
||||
border-radius: var(--bn-radius-full, 9999px);
|
||||
font-size: var(--bn-font-size-xs, 0.75rem);
|
||||
font-weight: var(--bn-font-weight-medium, 500);
|
||||
background: var(--bn-color-semantic-primary-subtle, transparent);
|
||||
color: var(--bn-color-semantic-primary, inherit);
|
||||
}
|
||||
.bn-badge--success { background: var(--bn-color-semantic-success-tint, transparent); color: var(--bn-color-semantic-success, inherit); }
|
||||
.bn-badge--warning { background: var(--bn-color-semantic-warning-tint, transparent); color: var(--bn-color-semantic-warning, inherit); }
|
||||
.bn-badge--danger { background: var(--bn-color-semantic-danger-tint, transparent); color: var(--bn-color-semantic-danger, inherit); }
|
||||
.bn-badge--neutral { background: var(--bn-color-semantic-surface-raised, transparent); color: var(--bn-color-semantic-text-muted, inherit); }
|
||||
|
||||
.bn-alert {
|
||||
display: flex;
|
||||
gap: var(--bn-space-3, 0.75rem);
|
||||
padding: var(--bn-space-4, 1rem);
|
||||
border-radius: var(--bn-radius-md, 0.375rem);
|
||||
border-left: var(--bn-border-width-thick, 3px) solid var(--bn-color-semantic-info, currentColor);
|
||||
background: var(--bn-color-semantic-info-tint, transparent);
|
||||
color: var(--bn-color-semantic-text, inherit);
|
||||
font-size: var(--bn-font-size-sm, 0.875rem);
|
||||
}
|
||||
.bn-alert--success { border-color: var(--bn-color-semantic-success, currentColor); background: var(--bn-color-semantic-success-tint, transparent); }
|
||||
.bn-alert--warning { border-color: var(--bn-color-semantic-warning, currentColor); background: var(--bn-color-semantic-warning-tint, transparent); }
|
||||
.bn-alert--danger { border-color: var(--bn-color-semantic-danger, currentColor); background: var(--bn-color-semantic-danger-tint, transparent); }
|
||||
|
||||
.bn-link { color: var(--bn-color-semantic-primary, currentColor); text-decoration: underline; text-underline-offset: var(--bn-space-1, 0.25rem); }
|
||||
.bn-link:hover { color: var(--bn-color-semantic-primary-hover, inherit); }
|
||||
|
||||
.bn-table { width: 100%; border-collapse: collapse; font-size: var(--bn-font-size-sm, 0.875rem); }
|
||||
.bn-table th { text-align: left; font-weight: var(--bn-font-weight-semibold, 600); color: var(--bn-color-semantic-text-muted, inherit); padding: var(--bn-space-2, 0.5rem) var(--bn-space-3, 0.75rem); border-bottom: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-border, transparent); }
|
||||
.bn-table td { padding: var(--bn-space-3, 0.75rem); border-bottom: var(--bn-border-width-thin, 1px) solid var(--bn-color-semantic-border, transparent); }
|
||||
.bn-table tbody tr:hover { background: var(--bn-color-semantic-surface-raised, transparent); }
|
||||
|
||||
.bn-stack { display: flex; flex-direction: column; gap: var(--bn-space-4, 1rem); }
|
||||
.bn-cluster { display: flex; flex-wrap: wrap; align-items: center; gap: var(--bn-space-2, 0.5rem); }
|
||||
.bn-divider { height: var(--bn-border-width-thin, 1px); background: var(--bn-color-semantic-border, transparent); border: 0; }
|
||||
`
|
||||
|
||||
// componentCSS writes the component layer, optionally stripped of comments and
|
||||
// blank lines.
|
||||
func componentCSS(b *strings.Builder, minify bool) {
|
||||
css := componentRules
|
||||
if minify {
|
||||
var out strings.Builder
|
||||
for _, line := range strings.Split(css, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
out.WriteString(trimmed)
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
css = out.String()
|
||||
}
|
||||
b.WriteString(css)
|
||||
}
|
||||
|
||||
// Render variant identifiers. A variant is a named, cacheable option set: the
|
||||
// stylesheet a browser fetches and the compact stylesheet a server-side
|
||||
// renderer inlines are different artefacts and must not share a cache entry.
|
||||
const (
|
||||
// VariantFull is the complete stylesheet: tokens, derived tokens and the
|
||||
// component layer.
|
||||
VariantFull = "full"
|
||||
// VariantTokens is tokens and derived tokens only, for applications that
|
||||
// ship their own components.
|
||||
VariantTokens = "tokens"
|
||||
// VariantMinified is the compact, comment-free stylesheet for production.
|
||||
VariantMinified = "minified"
|
||||
)
|
||||
|
||||
// VariantNames lists the render variants in a stable order.
|
||||
func VariantNames() []string { return []string{VariantFull, VariantTokens, VariantMinified} }
|
||||
|
||||
// Variant identifies the option set that produced a stylesheet. It is part of
|
||||
// the cache key so two variants can never be confused.
|
||||
func (o RenderOptions) Variant() string {
|
||||
switch {
|
||||
case o.Minify:
|
||||
return VariantMinified
|
||||
case o.Components:
|
||||
return VariantFull
|
||||
default:
|
||||
return VariantTokens
|
||||
}
|
||||
}
|
||||
|
||||
// OptionsForVariant returns the render options for a named variant.
|
||||
func OptionsForVariant(variant string, slug string, appearance domain.Appearance) RenderOptions {
|
||||
opts := RenderOptions{
|
||||
Slug: slug,
|
||||
Appearance: appearance,
|
||||
Derive: DefaultDerive(),
|
||||
}
|
||||
switch variant {
|
||||
case VariantMinified:
|
||||
opts.Minify = true
|
||||
opts.Components = true
|
||||
case VariantFull:
|
||||
opts.Components = true
|
||||
default:
|
||||
opts.Components = false
|
||||
}
|
||||
return opts
|
||||
}
|
||||
165
internal/tokenengine/json.go
Normal file
165
internal/tokenengine/json.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// ResolvedTheme is the JSON payload served to runtime consumers. It carries
|
||||
// both a flat variable map (what a browser or a design tool wants) and the
|
||||
// nested resolved document (what an application that maps tokens onto component
|
||||
// props wants), so consumers never have to choose.
|
||||
type ResolvedTheme struct {
|
||||
Theme ThemeRef `json:"theme"`
|
||||
Tokens domain.TokenDoc `json:"tokens"`
|
||||
Variable map[string]string `json:"variables"`
|
||||
Meta domain.ThemeMeta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// ThemeRef is the identity block repeated in every runtime payload.
|
||||
type ThemeRef struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version int64 `json:"version"`
|
||||
Appearance domain.Appearance `json:"appearance"`
|
||||
DefaultAppearance domain.Appearance `json:"defaultAppearance"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// JSONPayload builds the runtime JSON payload for one appearance.
|
||||
func JSONPayload(theme *domain.Theme, appearance domain.Appearance) ResolvedTheme {
|
||||
if appearance == "" {
|
||||
appearance = theme.DefaultAppearance
|
||||
}
|
||||
doc := Resolve(theme, appearance)
|
||||
return ResolvedTheme{
|
||||
Theme: ThemeRef{
|
||||
Slug: theme.Slug,
|
||||
Name: theme.Name,
|
||||
Description: theme.Description,
|
||||
Version: theme.Version,
|
||||
Appearance: appearance,
|
||||
DefaultAppearance: theme.DefaultAppearance,
|
||||
UpdatedAt: theme.UpdatedAt.UTC().Format(domain.TimeFormat),
|
||||
},
|
||||
Tokens: resolvedDocument(doc),
|
||||
Variable: doc.Variables(),
|
||||
Meta: theme.Meta,
|
||||
}
|
||||
}
|
||||
|
||||
// resolvedDocument renders the resolved tokens back into nested form, keeping
|
||||
// the DTCG `$type`/`$value` shape so that the output can be fed straight into
|
||||
// any DTCG-aware tool.
|
||||
func resolvedDocument(doc *Doc) domain.TokenDoc {
|
||||
out := make(domain.TokenDoc, len(Groups)+1)
|
||||
for _, t := range doc.Tokens {
|
||||
insertResolved(out, strings.Split(t.Path, "."), t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// insertResolved places a token into the nested output document, creating the
|
||||
// intermediate group objects on the way down.
|
||||
func insertResolved(root domain.TokenDoc, segments []string, t FlatToken) {
|
||||
current := root
|
||||
for _, seg := range segments[:len(segments)-1] {
|
||||
child, ok := current[seg].(map[string]any)
|
||||
if !ok {
|
||||
child = map[string]any{}
|
||||
current[seg] = child
|
||||
}
|
||||
// Re-widen so that the next iteration can index it as a TokenDoc.
|
||||
current = child
|
||||
}
|
||||
leaf := map[string]any{ValueKey: t.Value, TypeKey: string(t.Kind)}
|
||||
if t.Description != "" {
|
||||
leaf[DescKey] = t.Description
|
||||
}
|
||||
current[segments[len(segments)-1]] = leaf
|
||||
}
|
||||
|
||||
// ExportDocument produces a portable, self-describing token document for a
|
||||
// theme. Unlike the runtime payload it keeps the appearance overrides intact,
|
||||
// because the point of an export is that it can be re-imported losslessly.
|
||||
func ExportDocument(theme *domain.Theme) domain.TokenDoc {
|
||||
out := domain.TokenDoc{SchemaKey: SchemaID}
|
||||
merged := make(domain.TokenDoc, len(theme.Tokens)+1)
|
||||
for k, v := range theme.Tokens {
|
||||
merged[k] = v
|
||||
}
|
||||
if _, ok := merged[SchemaKey]; !ok {
|
||||
out[SchemaKey] = SchemaID
|
||||
}
|
||||
delete(merged, SchemaKey)
|
||||
for k, v := range merged {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ManifestEntry describes one theme in the runtime manifest: the small payload
|
||||
// a loader needs in order to build a theme picker without fetching every theme.
|
||||
type ManifestEntry struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DefaultAppearance domain.Appearance `json:"defaultAppearance"`
|
||||
Version int64 `json:"version"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CSS string `json:"css"`
|
||||
Tokens string `json:"tokens"`
|
||||
}
|
||||
|
||||
// Manifest is the discovery document served from the runtime API.
|
||||
type Manifest struct {
|
||||
// APIVersion is the runtime contract version. Consumers should refuse to
|
||||
// load a manifest whose major version they do not understand.
|
||||
APIVersion string `json:"apiVersion"`
|
||||
// Default is the slug applied when a consumer does not choose.
|
||||
Default string `json:"default,omitempty"`
|
||||
// BaseURL is the absolute runtime base the entries are relative to.
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Themes []ManifestEntry `json:"themes"`
|
||||
Extra map[string]string `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// RuntimeAPIVersion is the current runtime contract version.
|
||||
const RuntimeAPIVersion = "1"
|
||||
|
||||
// Tags extracts theme tags from the opaque meta block. Tags are the one piece
|
||||
// of meta the engine understands, because the manifest needs them.
|
||||
func Tags(meta domain.ThemeMeta) []string {
|
||||
raw, ok := meta["tags"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch t := raw.(type) {
|
||||
case []string:
|
||||
return t
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, v := range t {
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||
out = append(out, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
case string:
|
||||
parts := strings.Split(t, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if s := strings.TrimSpace(p); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
147
internal/tokenengine/palette.go
Normal file
147
internal/tokenengine/palette.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// RampOptions describes a generated colour ramp. It is the programmatic form of
|
||||
// "here is a brand colour, give me the other ten shades", which the editor UI
|
||||
// exposes as a one-click palette builder and which the built-in themes use to
|
||||
// stay tonally consistent.
|
||||
type RampOptions struct {
|
||||
// Hue in degrees, 0..360. Defaults to 250 (indigo) when zero-value.
|
||||
Hue float64
|
||||
// Saturation at the mid steps, in percent, 0..100.
|
||||
Saturation float64
|
||||
// Lightness at step 500, in percent, 0..100.
|
||||
Lightness float64
|
||||
// HueSpread drifts the hue from the lightest to the darkest step. Modern
|
||||
// perceptual ramps lean warm at the light end and cool at the dark end;
|
||||
// a value of 12 reproduces that effect.
|
||||
HueSpread float64
|
||||
}
|
||||
|
||||
// DefaultRampOptions returns the ramp the palette builder starts from.
|
||||
func DefaultRampOptions() RampOptions {
|
||||
return RampOptions{Hue: 250, Saturation: 78, Lightness: 55, HueSpread: 12}
|
||||
}
|
||||
|
||||
// Normalize clamps the options into usable ranges and fills in defaults.
|
||||
func (o RampOptions) Normalize() RampOptions {
|
||||
out := o
|
||||
out.Hue = math.Mod(math.Mod(out.Hue, 360)+360, 360)
|
||||
out.Saturation = clampRange(out.Saturation, 0, 100)
|
||||
out.Lightness = clampRange(out.Lightness, 20, 80)
|
||||
out.HueSpread = clampRange(out.HueSpread, 0, 40)
|
||||
return out
|
||||
}
|
||||
|
||||
func clampRange(v, lo, hi float64) float64 {
|
||||
if math.IsNaN(v) {
|
||||
return lo
|
||||
}
|
||||
return math.Max(lo, math.Min(hi, v))
|
||||
}
|
||||
|
||||
// RampEndpoints are the lightness percentages used for shade 50 and shade 950.
|
||||
// They sit just short of pure white and pure black so that the lightest step is
|
||||
// still usable as a surface and the darkest still holds text.
|
||||
const (
|
||||
RampLightest = 97.0
|
||||
RampDarkest = 12.0
|
||||
)
|
||||
|
||||
// GenerateRamp builds a canonical 11-step colour ramp as token leaves.
|
||||
//
|
||||
// Lightness is interpolated along a curve rather than linearly: the middle steps
|
||||
// carry the overwhelming majority of UI usage (a button's resting colour is
|
||||
// step 500 or 600), so they get the most separation. Saturation tapers at both
|
||||
// ends because a fully saturated near-white or near-black reads as a fluorescent
|
||||
// smear rather than a tint.
|
||||
func GenerateRamp(opts RampOptions) map[string]any {
|
||||
opts = opts.Normalize()
|
||||
shades := StandardPaletteShades()
|
||||
out := make(map[string]any, len(shades))
|
||||
|
||||
for i, shade := range shades {
|
||||
pos := float64(i) / float64(len(shades)-1)
|
||||
|
||||
light := rampLightness(pos, opts.Lightness)
|
||||
sat := opts.Saturation * saturationEnvelope(pos)
|
||||
hue := opts.Hue + opts.HueSpread*(0.5-pos)
|
||||
|
||||
out[shade] = map[string]any{
|
||||
TypeKey: string(KindColor),
|
||||
ValueKey: HSLHex(hue, sat, light),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rampLightness returns the lightness percentage for a ramp position.
|
||||
//
|
||||
// The curve blends a full linear sweep across the ramp endpoints with a sweep
|
||||
// anchored on the caller's requested step-500 lightness. The anchored sweep
|
||||
// guarantees step 500 lands exactly where the caller asked, while the linear
|
||||
// term keeps the extremes reaching near-white and near-black instead of
|
||||
// bunching up in the middle.
|
||||
func rampLightness(pos, midLight float64) float64 {
|
||||
linear := RampLightest + (RampDarkest-RampLightest)*pos
|
||||
|
||||
// Anchored sweep: walk from midLight out to the nearest endpoint.
|
||||
var anchored float64
|
||||
if pos <= 0.5 {
|
||||
anchored = midLight + (RampLightest-midLight)*2*(0.5-pos)
|
||||
} else {
|
||||
anchored = midLight - (midLight-RampDarkest)*2*(pos-0.5)
|
||||
}
|
||||
return linear*0.35 + anchored*0.65
|
||||
}
|
||||
|
||||
// saturationEnvelope tapers saturation towards both ends of the ramp.
|
||||
func saturationEnvelope(pos float64) float64 {
|
||||
// 1 at the midpoint, ~0.5 at the extremes.
|
||||
return 0.5 + 0.5*math.Sin(math.Pi*pos)
|
||||
}
|
||||
|
||||
// HSLHex converts hue/saturation/lightness (hue in degrees, the others in
|
||||
// percent) to a hex colour.
|
||||
func HSLHex(hue, saturation, light float64) string {
|
||||
h := math.Mod(math.Mod(hue, 360)+360, 360) / 360
|
||||
s := clampRange(saturation, 0, 100) / 100
|
||||
l := clampRange(light, 0, 100) / 100
|
||||
r, g, b := hslToRGB(h, s, l)
|
||||
return fmt.Sprintf("#%02x%02x%02x", int(r*255+0.5), int(g*255+0.5), int(b*255+0.5))
|
||||
}
|
||||
|
||||
// RampStep returns the hex colour at one step of a generated ramp. It exists so
|
||||
// that callers such as the semantic-group builder can reference a concrete
|
||||
// colour without materialising the whole ramp.
|
||||
func RampStep(opts RampOptions, shade string) string {
|
||||
opts = opts.Normalize()
|
||||
shades := StandardPaletteShades()
|
||||
for i, s := range shades {
|
||||
if s == shade {
|
||||
pos := float64(i) / float64(len(shades)-1)
|
||||
light := rampLightness(pos, opts.Lightness)
|
||||
sat := opts.Saturation * saturationEnvelope(pos)
|
||||
hue := opts.Hue + opts.HueSpread*(0.5-pos)
|
||||
return HSLHex(hue, sat, light)
|
||||
}
|
||||
}
|
||||
// Unknown shade: fall back to the mid lightness rather than failing, so a
|
||||
// typo in a configuration file cannot produce an invalid colour.
|
||||
return HSLHex(opts.Hue, opts.Saturation, opts.Lightness)
|
||||
}
|
||||
|
||||
// RampShades lists the canonical shade steps.
|
||||
func RampShades() []string { return StandardPaletteShades() }
|
||||
|
||||
// ParseShade extracts the numeric step from a shade name, reporting whether it
|
||||
// is a plain number.
|
||||
func ParseShade(shade string) (int, bool) {
|
||||
n, err := strconv.Atoi(shade)
|
||||
return n, err == nil
|
||||
}
|
||||
508
internal/tokenengine/schema.go
Normal file
508
internal/tokenengine/schema.go
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
// Package tokenengine turns a Go Bananas!! design-token document into the
|
||||
// artefacts that browsers and applications consume: CSS custom properties,
|
||||
// resolved JSON tokens, and a flat variable map.
|
||||
//
|
||||
// The package is deliberately pure. It performs no I/O, holds no global state
|
||||
// and is safe for concurrent use, which lets the HTTP layer cache rendered
|
||||
// themes aggressively and pre-warm them at start-up.
|
||||
package tokenengine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// Kind is a token value type. The set mirrors the W3C Design Tokens Community
|
||||
// Group format so that exported JSON is portable to other token tooling.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindColor Kind = "color"
|
||||
KindDimension Kind = "dimension"
|
||||
KindFontFamily Kind = "fontFamily"
|
||||
KindFontSize Kind = "fontSize"
|
||||
KindFontWeight Kind = "fontWeight"
|
||||
KindLineHeight Kind = "lineHeight"
|
||||
KindLetterSpacing Kind = "letterSpacing"
|
||||
KindDuration Kind = "duration"
|
||||
KindCubicBezier Kind = "cubicBezier"
|
||||
KindNumber Kind = "number"
|
||||
KindShadow Kind = "shadow"
|
||||
KindFontStyle Kind = "fontStyle"
|
||||
)
|
||||
|
||||
// Valid reports whether k is a known kind.
|
||||
func (k Kind) Valid() bool {
|
||||
switch k {
|
||||
case KindColor, KindDimension, KindFontFamily, KindFontSize, KindFontWeight,
|
||||
KindLineHeight, KindLetterSpacing, KindDuration, KindCubicBezier,
|
||||
KindNumber, KindShadow, KindFontStyle:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UnitSuffix is appended to raw numeric values of unit-bearing kinds when
|
||||
// rendering CSS, so a bare `4` in a `space` group becomes `4px`.
|
||||
func (k Kind) UnitSuffix() string {
|
||||
switch k {
|
||||
case KindDimension, KindFontSize, KindLetterSpacing:
|
||||
return "px"
|
||||
case KindDuration:
|
||||
return "ms"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ValueKey and TypeKey are the DTCG property names.
|
||||
const (
|
||||
ValueKey = "$value"
|
||||
TypeKey = "$type"
|
||||
// DescKey carries human documentation for a token.
|
||||
DescKey = "$description"
|
||||
// ExtensionsKey holds non-standard metadata that the engine ignores.
|
||||
ExtensionsKey = "$extensions"
|
||||
// SchemaKey identifies the token document format.
|
||||
SchemaKey = "$schema"
|
||||
// AppearancesKey holds per-appearance token overrides. It is the only
|
||||
// structural key that is not itself a token group.
|
||||
AppearancesKey = "$appearances"
|
||||
)
|
||||
|
||||
// SchemaID identifies the canonical document format.
|
||||
const SchemaID = "https://urpi.be/schemas/go-bananas/tokens/v1"
|
||||
|
||||
// VariablePrefix namespaces every custom property this engine emits so that a
|
||||
// consuming application can never collide with go-bananas' own CSS.
|
||||
const VariablePrefix = "--bn"
|
||||
|
||||
// ThemeAttribute is the data attribute that scopes a theme to a subtree.
|
||||
// Setting it on <html> or <body> is the supported way to apply a theme.
|
||||
const ThemeAttribute = "data-bananas-theme"
|
||||
|
||||
// AppearanceAttribute selects the colour scheme within a themed subtree.
|
||||
const AppearanceAttribute = "data-bananas-appearance"
|
||||
|
||||
// darkClass is the conventional class name used by Tailwind and most design
|
||||
// systems for "this subtree is dark". The generated CSS honours it in addition
|
||||
// to the explicit appearance attribute so that consumers do not have to choose.
|
||||
const darkClass = "dark"
|
||||
|
||||
// GroupSpec describes one top-level token group.
|
||||
type GroupSpec struct {
|
||||
// Name is the group key as it appears in the document.
|
||||
Name string
|
||||
// Kind is the default type applied to leaves that omit "$type".
|
||||
Kind Kind
|
||||
// Freeform allows an arbitrary number of nested levels below the group.
|
||||
Freeform bool
|
||||
// Required is the minimum depth: paths listed here must exist.
|
||||
Required []string
|
||||
// Recommended paths are validated when present but never required. They
|
||||
// drive warnings in the editor rather than hard failures.
|
||||
Recommended []string
|
||||
// Description is shown in the editor UI and emitted as $description.
|
||||
Description string
|
||||
}
|
||||
|
||||
// paletteShades is the canonical 11-step scale every generated palette defines.
|
||||
var paletteShades = []string{"50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"}
|
||||
|
||||
// StandardPaletteShades returns a copy of the canonical scale steps.
|
||||
func StandardPaletteShades() []string {
|
||||
out := make([]string, len(paletteShades))
|
||||
copy(out, paletteShades)
|
||||
return out
|
||||
}
|
||||
|
||||
// Groups is the canonical group registry, in the order the editor renders them.
|
||||
var Groups = []GroupSpec{
|
||||
{
|
||||
Name: "color",
|
||||
Kind: KindColor,
|
||||
Freeform: true,
|
||||
Description: "Raw palettes and semantic colour roles. Semantic roles are what components consume.",
|
||||
Recommended: []string{
|
||||
"palette.brand." + "500",
|
||||
"palette.brand.600",
|
||||
"palette.neutral.500",
|
||||
"palette.neutral.900",
|
||||
"semantic.background",
|
||||
"semantic.surface",
|
||||
"semantic.surface-raised",
|
||||
"semantic.border",
|
||||
"semantic.border-strong",
|
||||
"semantic.text",
|
||||
"semantic.text-muted",
|
||||
"semantic.text-subtle",
|
||||
"semantic.text-inverted",
|
||||
"semantic.primary",
|
||||
"semantic.primary-hover",
|
||||
"semantic.primary-active",
|
||||
"semantic.on-primary",
|
||||
"semantic.secondary",
|
||||
"semantic.accent",
|
||||
"semantic.on-accent",
|
||||
"semantic.success",
|
||||
"semantic.warning",
|
||||
"semantic.danger",
|
||||
"semantic.info",
|
||||
"semantic.overlay",
|
||||
"semantic.focus-ring",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "font",
|
||||
Kind: KindDimension,
|
||||
Freeform: true,
|
||||
Recommended: []string{"family.sans", "family.mono", "size.sm", "size.md", "size.lg", "size.xl", "weight.normal", "weight.medium", "weight.semibold", "weight.bold", "lineHeight.tight", "lineHeight.normal", "lineHeight.relaxed", "letterSpacing.tight", "letterSpacing.normal", "letterSpacing.wide"},
|
||||
},
|
||||
{
|
||||
Name: "space",
|
||||
Kind: KindDimension,
|
||||
Freeform: true,
|
||||
Recommended: []string{"0", "1", "2", "3", "4", "5", "6", "8", "10", "12", "16"},
|
||||
},
|
||||
{
|
||||
Name: "radius",
|
||||
Kind: KindDimension,
|
||||
Freeform: true,
|
||||
Recommended: []string{"none", "sm", "md", "lg", "xl", "full"},
|
||||
},
|
||||
{
|
||||
Name: "border",
|
||||
Kind: KindDimension,
|
||||
Freeform: true,
|
||||
Recommended: []string{"width.thin", "width.default", "width.thick", "color.default", "color.strong"},
|
||||
},
|
||||
{
|
||||
Name: "shadow",
|
||||
Kind: KindShadow,
|
||||
Freeform: true,
|
||||
Recommended: []string{"none", "sm", "md", "lg", "xl"},
|
||||
},
|
||||
{
|
||||
Name: "duration",
|
||||
Kind: KindDuration,
|
||||
Freeform: true,
|
||||
Recommended: []string{"instant", "fast", "normal", "slow"},
|
||||
},
|
||||
{
|
||||
Name: "ease",
|
||||
Kind: KindCubicBezier,
|
||||
Freeform: true,
|
||||
Recommended: []string{"linear", "standard", "entrance", "exit"},
|
||||
},
|
||||
{
|
||||
Name: "zIndex",
|
||||
Kind: KindNumber,
|
||||
Freeform: true,
|
||||
Recommended: []string{"base", "dropdown", "sticky", "overlay", "modal", "toast"},
|
||||
},
|
||||
{
|
||||
Name: "opacity",
|
||||
Kind: KindNumber,
|
||||
Freeform: true,
|
||||
Recommended: []string{"disabled", "muted", "strong"},
|
||||
},
|
||||
{
|
||||
Name: "breakpoint",
|
||||
Kind: KindDimension,
|
||||
Freeform: true,
|
||||
Recommended: []string{"sm", "md", "lg", "xl", "2xl"},
|
||||
},
|
||||
}
|
||||
|
||||
// ReservedKeys are document-level keys that are not token groups.
|
||||
var ReservedKeys = map[string]struct{}{
|
||||
SchemaKey: {},
|
||||
AppearancesKey: {},
|
||||
DescKey: {},
|
||||
ExtensionsKey: {},
|
||||
}
|
||||
|
||||
// LookupGroup returns the spec for a group name.
|
||||
func LookupGroup(name string) (GroupSpec, bool) {
|
||||
for _, g := range Groups {
|
||||
if g.Name == name {
|
||||
return g, true
|
||||
}
|
||||
}
|
||||
return GroupSpec{}, false
|
||||
}
|
||||
|
||||
// GroupNames returns the canonical group names in render order.
|
||||
func GroupNames() []string {
|
||||
out := make([]string, len(Groups))
|
||||
for i, g := range Groups {
|
||||
out[i] = g.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// paletteNames returns the palette identifiers defined in a document, sorted so
|
||||
// that generated CSS is byte-stable.
|
||||
func paletteNames(doc map[string]any) []string {
|
||||
colorGroup, ok := asMap(doc["color"])
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
for key := range colorGroup {
|
||||
if key == "semantic" {
|
||||
continue
|
||||
}
|
||||
if _, isGroup := asMap(colorGroup[key]); isGroup {
|
||||
names = append(names, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// violation is an internal validation finding.
|
||||
type violation struct {
|
||||
// Path is the dotted token path, e.g. "color.semantic.background".
|
||||
Path string
|
||||
// Severity separates blocking problems from style advice.
|
||||
Severity Severity
|
||||
// Message explains the problem in one sentence.
|
||||
Message string
|
||||
// Hint suggests a concrete fix.
|
||||
Hint string
|
||||
}
|
||||
|
||||
// Severity classifies a validation finding.
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
// SeverityError blocks publication.
|
||||
SeverityError Severity = "error"
|
||||
// SeverityWarning does not block publication but should be addressed.
|
||||
SeverityWarning Severity = "warning"
|
||||
// SeverityInfo is purely advisory.
|
||||
SeverityInfo Severity = "info"
|
||||
)
|
||||
|
||||
// Issue is a single validation finding, safe to serialise straight to the UI.
|
||||
type Issue struct {
|
||||
Path string `json:"path"`
|
||||
Severity Severity `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// ValidationResult aggregates every finding for a document.
|
||||
type ValidationResult struct {
|
||||
// Valid is true when there are no error-severity issues.
|
||||
Valid bool `json:"valid"`
|
||||
// TokenCount is the number of leaf tokens discovered.
|
||||
TokenCount int `json:"tokenCount"`
|
||||
// Issues is ordered by severity then path so output is deterministic.
|
||||
Issues []Issue `json:"issues"`
|
||||
}
|
||||
|
||||
// Errors returns only the blocking issues.
|
||||
func (v ValidationResult) Errors() []Issue {
|
||||
out := make([]Issue, 0, len(v.Issues))
|
||||
for _, i := range v.Issues {
|
||||
if i.Severity == SeverityError {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Warnings returns only the advisory issues.
|
||||
func (v ValidationResult) Warnings() []Issue {
|
||||
out := make([]Issue, 0, len(v.Issues))
|
||||
for _, i := range v.Issues {
|
||||
if i.Severity == SeverityWarning {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// severityRank orders severities for deterministic sorting.
|
||||
func severityRank(s Severity) int {
|
||||
switch s {
|
||||
case SeverityError:
|
||||
return 0
|
||||
case SeverityWarning:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// asMap extracts a nested object from an untyped token document.
|
||||
//
|
||||
// Both a plain map and a domain.TokenDoc are accepted. The distinction matters:
|
||||
// a type assertion to map[string]any fails for a named map type, so without this
|
||||
// second case every document handed to the engine as a domain.TokenDoc would be
|
||||
// silently reported as "not a group".
|
||||
func asMap(v any) (map[string]any, bool) {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
return t, true
|
||||
case domain.TokenDoc:
|
||||
return map[string]any(t), true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// keyAliases maps authored camelCase token keys onto their kebab-case CSS
|
||||
// equivalents. Only keys the DTCG spec spells in camelCase need an entry; the
|
||||
// mapping is an explicit allow-list rather than a generic camelCase splitter so
|
||||
// that a token genuinely named "brandDark" cannot be silently rewritten.
|
||||
var keyAliases = map[string]string{
|
||||
"lineHeight": "line-height",
|
||||
"fontSize": "font-size",
|
||||
"letterSpacing": "letter-spacing",
|
||||
"fontFamily": "font-family",
|
||||
"fontWeight": "font-weight",
|
||||
"cubicBezier": "cubic-bezier",
|
||||
"borderRadius": "border-radius",
|
||||
"zIndex": "z-index",
|
||||
"surfaceMuted": "surface-muted",
|
||||
"fontStyle": "font-style",
|
||||
"lineScale": "line-scale",
|
||||
"seedVersion": "seed-version",
|
||||
}
|
||||
|
||||
// normalizeKey applies the alias table to one authored token key.
|
||||
func normalizeKey(key string) string {
|
||||
if alias, ok := keyAliases[key]; ok {
|
||||
return alias
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// isLeaf reports whether a node is a token leaf rather than a nested group.
|
||||
func isLeaf(node map[string]any) bool {
|
||||
_, hasValue := node[ValueKey]
|
||||
return hasValue
|
||||
}
|
||||
|
||||
// cssVarSegment normalises one path segment for use in a custom property name.
|
||||
func cssVarSegment(s string) string {
|
||||
s = normalizeKey(s)
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
prevDash := false
|
||||
for _, r := range strings.ToLower(s) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
prevDash = false
|
||||
case r == '-' || r == '_' || r == ' ' || r == '.' || r == '/':
|
||||
// Collapse every separator class into a single dash so that
|
||||
// "line Height" and "line-height" cannot produce two variables.
|
||||
if !prevDash && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
prevDash = true
|
||||
}
|
||||
default:
|
||||
if !prevDash && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
prevDash = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
|
||||
// VariableName builds the CSS custom property for a dotted token path.
|
||||
func VariableName(path string) string {
|
||||
segments := strings.Split(path, ".")
|
||||
cleaned := make([]string, 0, len(segments))
|
||||
for _, s := range segments {
|
||||
if c := cssVarSegment(s); c != "" {
|
||||
cleaned = append(cleaned, c)
|
||||
}
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
return VariablePrefix
|
||||
}
|
||||
return VariablePrefix + "-" + strings.Join(cleaned, "-")
|
||||
}
|
||||
|
||||
// selectorSafe converts a token path into something usable inside a CSS
|
||||
// attribute selector value.
|
||||
func selectorSafe(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
return r
|
||||
default:
|
||||
return '-'
|
||||
}
|
||||
}, s)
|
||||
}
|
||||
|
||||
// NumberHint returns the canonical shade list, exposed for the editor's palette
|
||||
// builder so the UI and the validator never disagree.
|
||||
func NumberHint() []string { return StandardPaletteShades() }
|
||||
|
||||
// validateKindValue checks a single leaf value against its kind, returning an
|
||||
// error describing the problem or nil when the value is acceptable.
|
||||
func validateKindValue(kind Kind, value any) error {
|
||||
switch kind {
|
||||
case KindColor:
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected a colour string, got %T", value)
|
||||
}
|
||||
if _, err := ParseColor(s); err != nil {
|
||||
return err
|
||||
}
|
||||
case KindDimension, KindFontSize, KindLetterSpacing:
|
||||
if err := validateCSSLength(value); err != nil {
|
||||
return err
|
||||
}
|
||||
case KindDuration:
|
||||
if err := validateCSSLength(value); err != nil {
|
||||
return err
|
||||
}
|
||||
case KindNumber, KindFontWeight, KindLineHeight:
|
||||
if err := validateNumeric(value); err != nil {
|
||||
return err
|
||||
}
|
||||
case KindCubicBezier:
|
||||
parts, ok := value.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected a 4-number array, got %T", value)
|
||||
}
|
||||
if len(parts) != 4 {
|
||||
return fmt.Errorf("expected 4 numbers, got %d", len(parts))
|
||||
}
|
||||
for i, p := range parts {
|
||||
if err := validateNumeric(p); err != nil {
|
||||
return fmt.Errorf("component %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
case KindShadow:
|
||||
if _, ok := value.(string); !ok {
|
||||
return fmt.Errorf("expected a CSS box-shadow string, got %T", value)
|
||||
}
|
||||
case KindFontFamily:
|
||||
if _, ok := value.(string); !ok {
|
||||
return fmt.Errorf("expected a font stack string, got %T", value)
|
||||
}
|
||||
case KindFontStyle:
|
||||
if _, ok := value.(string); !ok {
|
||||
return fmt.Errorf("expected a font style keyword, got %T", value)
|
||||
}
|
||||
default:
|
||||
// Unknown kinds are tolerated so that consumers can introduce their own
|
||||
// token types without the engine needing a release first.
|
||||
}
|
||||
return nil
|
||||
}
|
||||
751
internal/tokenengine/tokens.go
Normal file
751
internal/tokenengine/tokens.go
Normal file
|
|
@ -0,0 +1,751 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// FlatToken is one resolved token: its dotted path, its resolved type and the
|
||||
// exact CSS-ready value.
|
||||
type FlatToken struct {
|
||||
// Path is the dotted token path, e.g. "color.semantic.background".
|
||||
Path string
|
||||
// Kind is the resolved token type.
|
||||
Kind Kind
|
||||
// Value is the raw author-supplied value.
|
||||
Value any
|
||||
// CSS is the value as it appears in a custom property declaration.
|
||||
CSS string
|
||||
// Description is the token's $description, if any.
|
||||
Description string
|
||||
// Derived marks tokens computed by the engine rather than authored.
|
||||
Derived bool
|
||||
}
|
||||
|
||||
// Doc is a parsed, resolved token document for one appearance.
|
||||
type Doc struct {
|
||||
// Theme is the theme the document was derived from.
|
||||
Theme *domain.Theme
|
||||
// Appearance is the colour scheme that was resolved.
|
||||
Appearance domain.Appearance
|
||||
// Tokens are the leaves in deterministic path order.
|
||||
Tokens []FlatToken
|
||||
// byPath indexes Tokens for O(1) lookup.
|
||||
byPath map[string]FlatToken
|
||||
// palettes lists palette names discovered in the document.
|
||||
palettes []string
|
||||
}
|
||||
|
||||
// Lookup returns a token by dotted path.
|
||||
func (d *Doc) Lookup(path string) (FlatToken, bool) {
|
||||
t, ok := d.byPath[path]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// Palettes returns the palette names present in the document.
|
||||
func (d *Doc) Palettes() []string { return d.palettes }
|
||||
|
||||
// PalettesOf returns the shade steps defined for one palette, sorted so that
|
||||
// scale output is deterministic.
|
||||
func (d *Doc) PalettesOf(palette string) []string {
|
||||
var steps []string
|
||||
prefix := "color." + palette + "."
|
||||
for _, t := range d.Tokens {
|
||||
if strings.HasPrefix(t.Path, prefix) {
|
||||
steps = append(steps, strings.TrimPrefix(t.Path, prefix))
|
||||
}
|
||||
}
|
||||
sort.Slice(steps, func(i, j int) bool { return shadeLess(steps[i], steps[j]) })
|
||||
return steps
|
||||
}
|
||||
|
||||
// Variables renders the document as CSS custom property name to value.
|
||||
func (d *Doc) Variables() map[string]string {
|
||||
out := make(map[string]string, len(d.Tokens))
|
||||
for _, t := range d.Tokens {
|
||||
out[VariableName(t.Path)] = t.CSS
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// VariableOrder returns the CSS variable names in deterministic order, which is
|
||||
// the order the generated stylesheet uses.
|
||||
func (d *Doc) VariableOrder() []string {
|
||||
out := make([]string, 0, len(d.Tokens))
|
||||
for _, t := range d.Tokens {
|
||||
out = append(out, VariableName(t.Path))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// shadeLess orders numeric shades numerically and anything else lexically, so
|
||||
// 50, 100, 200 ... 950 and custom names both come out sensibly.
|
||||
func shadeLess(a, b string) bool {
|
||||
an, aerr := strconv.Atoi(a)
|
||||
bn, berr := strconv.Atoi(b)
|
||||
if aerr == nil && berr == nil {
|
||||
return an < bn
|
||||
}
|
||||
if aerr == nil {
|
||||
return true
|
||||
}
|
||||
if berr == nil {
|
||||
return false
|
||||
}
|
||||
return a < b
|
||||
}
|
||||
|
||||
// Resolve merges the base token document with the overrides for the requested
|
||||
// appearance and returns a flat, ordered token list.
|
||||
//
|
||||
// Overrides are a partial document of the same shape as the base, deep-merged
|
||||
// one level at a time. A leaf in the override replaces the corresponding leaf
|
||||
// in the base; a nested object in the override merges with its counterpart.
|
||||
func Resolve(theme *domain.Theme, appearance domain.Appearance) *Doc {
|
||||
base := theme.Tokens.Clone()
|
||||
if base == nil {
|
||||
base = domain.TokenDoc{}
|
||||
}
|
||||
merged := applyOverrides(base, overridesFor(base, appearance))
|
||||
|
||||
doc := &Doc{
|
||||
Theme: theme,
|
||||
Appearance: appearance,
|
||||
byPath: make(map[string]FlatToken),
|
||||
palettes: paletteNames(merged),
|
||||
}
|
||||
collect(merged, "", nil, doc)
|
||||
sort.Slice(doc.Tokens, func(i, j int) bool { return doc.Tokens[i].Path < doc.Tokens[j].Path })
|
||||
for _, t := range doc.Tokens {
|
||||
doc.byPath[t.Path] = t
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
// overridesFor extracts the override sub-document for one appearance.
|
||||
func overridesFor(doc domain.TokenDoc, appearance domain.Appearance) map[string]any {
|
||||
appearances, ok := asMap(doc[AppearancesKey])
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
override, _ := asMap(appearances[string(appearance)])
|
||||
return override
|
||||
}
|
||||
|
||||
// applyOverrides deep-merges override into base, returning a new document.
|
||||
func applyOverrides(base domain.TokenDoc, override map[string]any) domain.TokenDoc {
|
||||
out := make(domain.TokenDoc, len(base))
|
||||
for k, v := range base {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range override {
|
||||
if overrideMap, ok := asMap(v); ok {
|
||||
if baseMap, ok := asMap(out[k]); ok {
|
||||
out[k] = map[string]any(applyOverrides(domain.TokenDoc(baseMap), overrideMap))
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// inheritedKind carries the type down the tree so that a group may declare
|
||||
// "$type" once and have every leaf inherit it, as DTCG specifies.
|
||||
func collect(node map[string]any, prefix string, inherited *Kind, doc *Doc) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if isLeaf(node) {
|
||||
collectLeaf(node, prefix, inherited, doc)
|
||||
return
|
||||
}
|
||||
if raw, ok := node[TypeKey].(string); ok {
|
||||
k := Kind(raw)
|
||||
inherited = &k
|
||||
}
|
||||
keys := make([]string, 0, len(node))
|
||||
for k := range node {
|
||||
if strings.HasPrefix(k, "$") {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
child, ok := asMap(node[k])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
path := k
|
||||
if prefix != "" {
|
||||
path = prefix + "." + k
|
||||
}
|
||||
collect(child, path, inherited, doc)
|
||||
}
|
||||
}
|
||||
|
||||
// collectLeaf records a single token leaf.
|
||||
func collectLeaf(node map[string]any, path string, inherited *Kind, doc *Doc) {
|
||||
kind := Kind("")
|
||||
if inherited != nil {
|
||||
kind = *inherited
|
||||
}
|
||||
if raw, ok := node[TypeKey].(string); ok {
|
||||
kind = Kind(raw)
|
||||
}
|
||||
value := node[ValueKey]
|
||||
|
||||
if kind == "" {
|
||||
kind = inferKind(value)
|
||||
}
|
||||
if kind == "" {
|
||||
return
|
||||
}
|
||||
|
||||
t := FlatToken{
|
||||
Path: path,
|
||||
Kind: kind,
|
||||
Value: value,
|
||||
CSS: renderValue(kind, value),
|
||||
}
|
||||
if d, ok := node[DescKey].(string); ok {
|
||||
t.Description = d
|
||||
}
|
||||
doc.Tokens = append(doc.Tokens, t)
|
||||
doc.byPath[path] = t
|
||||
}
|
||||
|
||||
// inferKind guesses a leaf's type when neither the leaf nor an ancestor
|
||||
// declared one. Guessing keeps hand-written documents usable; validation still
|
||||
// reports the inferred type so authors can make it explicit.
|
||||
func inferKind(v any) Kind {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if _, err := ParseColor(t); err == nil && strings.HasPrefix(strings.TrimSpace(t), "#") {
|
||||
return KindColor
|
||||
}
|
||||
if strings.ContainsAny(t, "()") {
|
||||
return KindShadow
|
||||
}
|
||||
return KindFontFamily
|
||||
case float64, int, json.Number:
|
||||
return KindNumber
|
||||
case []any:
|
||||
return KindCubicBezier
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// renderValue converts a token value into its CSS form, appending the
|
||||
// canonical unit for unit-bearing kinds when the author omitted it.
|
||||
func renderValue(kind Kind, value any) string {
|
||||
switch kind {
|
||||
case KindDimension, KindFontSize, KindLetterSpacing, KindDuration:
|
||||
if n, ok := toFloat(value); ok {
|
||||
return FormatNumber(n) + kind.UnitSuffix()
|
||||
}
|
||||
case KindNumber, KindFontWeight, KindLineHeight:
|
||||
if n, ok := toFloat(value); ok {
|
||||
return FormatNumber(n)
|
||||
}
|
||||
}
|
||||
return stringify(value)
|
||||
}
|
||||
|
||||
// stringify renders any supported value as a CSS literal.
|
||||
func stringify(value any) string {
|
||||
switch t := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
case float64:
|
||||
return FormatNumber(t)
|
||||
case int:
|
||||
return FormatNumber(float64(t))
|
||||
case json.Number:
|
||||
return t.String()
|
||||
case []any:
|
||||
parts := make([]string, len(t))
|
||||
for i, p := range t {
|
||||
parts[i] = stringify(p)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
default:
|
||||
// Complex values are stored as pre-formatted CSS strings by the editor,
|
||||
// so this is the only safe rendering.
|
||||
return fmt.Sprint(t)
|
||||
}
|
||||
}
|
||||
|
||||
func toFloat(v any) (float64, bool) {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t, true
|
||||
case int:
|
||||
return float64(t), true
|
||||
case int64:
|
||||
return float64(t), true
|
||||
case json.Number:
|
||||
f, err := t.Float64()
|
||||
return f, err == nil
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(t), 64)
|
||||
return f, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// validateNumeric accepts a JSON number or a numeric string.
|
||||
func validateNumeric(v any) error {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
if math.IsNaN(t) || math.IsInf(t, 0) {
|
||||
return fmt.Errorf("value must be finite")
|
||||
}
|
||||
return nil
|
||||
case int, int64:
|
||||
return nil
|
||||
case json.Number:
|
||||
f, err := t.Float64()
|
||||
if err != nil {
|
||||
return fmt.Errorf("value %q is not numeric", t.String())
|
||||
}
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return fmt.Errorf("value must be finite")
|
||||
}
|
||||
return nil
|
||||
case string:
|
||||
if _, err := strconv.ParseFloat(strings.TrimSpace(t), 64); err != nil {
|
||||
return fmt.Errorf("value %q is not numeric", t)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("expected a number, got %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
// cssLengthUnits are the units accepted for a bare dimension value.
|
||||
var cssLengthUnits = []string{"px", "rem", "em", "%", "vh", "vw", "vmin", "vmax", "ch", "ex", "cm", "mm", "in", "pt", "pc", "svh", "lvh", "dvh", "ms", "s", "fr"}
|
||||
|
||||
// validateCSSLength accepts a number, a numeric string with a unit, or one of
|
||||
// the CSS global keywords.
|
||||
func validateCSSLength(v any) error {
|
||||
if err := validateNumeric(v); err != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
trimmed := strings.ToLower(strings.TrimSpace(s))
|
||||
switch trimmed {
|
||||
case "auto", "min-content", "max-content", "fit-content", "inherit", "initial", "unset", "normal":
|
||||
return nil
|
||||
}
|
||||
for _, unit := range cssLengthUnits {
|
||||
if strings.HasSuffix(trimmed, unit) {
|
||||
if _, err := strconv.ParseFloat(strings.TrimSuffix(trimmed, unit), 64); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "clamp(") || strings.HasPrefix(trimmed, "calc(") ||
|
||||
strings.HasPrefix(trimmed, "min(") || strings.HasPrefix(trimmed, "max(") {
|
||||
// Function-based values are opaque to the validator; the browser
|
||||
// is the authority and will discard anything malformed.
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("value %q is not a valid length", s)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks a token document and returns every finding.
|
||||
func Validate(doc domain.TokenDoc) ValidationResult {
|
||||
result := ValidationResult{Valid: true, Issues: []Issue{}}
|
||||
|
||||
issues := make([]violation, 0, 32)
|
||||
known := make(map[string]GroupSpec, len(Groups))
|
||||
for _, g := range Groups {
|
||||
known[g.Name] = g
|
||||
}
|
||||
|
||||
for key, raw := range doc {
|
||||
if _, reserved := ReservedKeys[key]; reserved {
|
||||
continue
|
||||
}
|
||||
node, ok := asMap(raw)
|
||||
if !ok {
|
||||
issues = append(issues, violation{
|
||||
Path: key, Severity: SeverityError,
|
||||
Message: fmt.Sprintf("group %q must be an object", key),
|
||||
Hint: "top-level keys are token groups, or one of " + strings.Join(sortedReservedKeys(), ", "),
|
||||
})
|
||||
continue
|
||||
}
|
||||
spec, ok := known[key]
|
||||
if !ok {
|
||||
issues = append(issues, violation{
|
||||
Path: key, Severity: SeverityError,
|
||||
Message: fmt.Sprintf("unknown token group %q", key),
|
||||
Hint: "known groups: " + strings.Join(GroupNames(), ", "),
|
||||
})
|
||||
continue
|
||||
}
|
||||
validateGroup(spec, node, key, &issues)
|
||||
}
|
||||
|
||||
if appearances, ok := asMap(doc[AppearancesKey]); ok {
|
||||
for name, raw := range appearances {
|
||||
if name != string(domain.AppearanceLight) && name != string(domain.AppearanceDark) {
|
||||
issues = append(issues, violation{
|
||||
Path: AppearancesKey + "." + name, Severity: SeverityError,
|
||||
Message: fmt.Sprintf("unknown appearance %q", name),
|
||||
Hint: "use \"light\" or \"dark\"",
|
||||
})
|
||||
continue
|
||||
}
|
||||
node, ok := asMap(raw)
|
||||
if !ok {
|
||||
issues = append(issues, violation{
|
||||
Path: AppearancesKey + "." + name, Severity: SeverityError,
|
||||
Message: "appearance overrides must be an object",
|
||||
})
|
||||
continue
|
||||
}
|
||||
for group, groupRaw := range node {
|
||||
spec, ok := known[group]
|
||||
if !ok {
|
||||
issues = append(issues, violation{
|
||||
Path: AppearancesKey + "." + name + "." + group, Severity: SeverityError,
|
||||
Message: fmt.Sprintf("unknown token group %q", group),
|
||||
})
|
||||
continue
|
||||
}
|
||||
groupNode, ok := asMap(groupRaw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Overrides are partial by design: only value validity is
|
||||
// checked, never completeness.
|
||||
validateLeaves(spec, groupNode, spec.Kind, group, AppearancesKey+"."+name+"."+group, &issues)
|
||||
}
|
||||
}
|
||||
} else if _, present := doc[AppearancesKey]; present {
|
||||
issues = append(issues, violation{
|
||||
Path: AppearancesKey, Severity: SeverityError,
|
||||
Message: AppearancesKey + " must be an object keyed by appearance",
|
||||
Hint: `example: {"dark": {"color": {"semantic": {"background": {"$value": "#0b0f19"}}}}}`,
|
||||
})
|
||||
}
|
||||
|
||||
// A theme with no dark overrides will render identically in both schemes,
|
||||
// which is almost always a mistake for a theme that declares a dark
|
||||
// appearance as its default.
|
||||
appearancesMap, _ := asMap(doc[AppearancesKey])
|
||||
if _, hasDark := asMap(appearancesMap[string(domain.AppearanceDark)]); !hasDark {
|
||||
issues = append(issues, violation{
|
||||
Path: AppearancesKey, Severity: SeverityWarning,
|
||||
Message: "no dark appearance overrides are defined",
|
||||
Hint: `add {"$appearances": {"dark": {...}}} so the dark scheme is intentional rather than accidental`,
|
||||
})
|
||||
}
|
||||
|
||||
resolved := countLeaves(doc)
|
||||
result.TokenCount = resolved
|
||||
result.Issues = toIssues(issues)
|
||||
for _, i := range result.Issues {
|
||||
if i.Severity == SeverityError {
|
||||
result.Valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// toIssues converts and deterministically orders internal violations.
|
||||
func toIssues(in []violation) []Issue {
|
||||
out := make([]Issue, 0, len(in))
|
||||
for _, v := range in {
|
||||
out = append(out, Issue(v))
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if ri, rj := severityRank(out[i].Severity), severityRank(out[j].Severity); ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return out[i].Path < out[j].Path
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// sortedReservedKeys returns the reserved document keys in sorted order.
|
||||
func sortedReservedKeys() []string {
|
||||
out := make([]string, 0, len(ReservedKeys))
|
||||
for k := range ReservedKeys {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// validateGroup checks one group's required and recommended tokens.
|
||||
func validateGroup(spec GroupSpec, node map[string]any, prefix string, issues *[]violation) {
|
||||
validateLeaves(spec, node, spec.Kind, prefix, prefix, issues)
|
||||
for _, path := range spec.Recommended {
|
||||
if _, err := LookupIn(node, path); err != nil {
|
||||
*issues = append(*issues, violation{
|
||||
Path: prefix + "." + path, Severity: SeverityWarning,
|
||||
Message: fmt.Sprintf("recommended token %q is missing", path),
|
||||
Hint: spec.Description,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateLeaves walks a group tree checking every leaf value's validity.
|
||||
func validateLeaves(spec GroupSpec, node map[string]any, kindDefault Kind, prefix, path string, issues *[]violation) {
|
||||
if isLeaf(node) {
|
||||
kind := kindDefault
|
||||
if raw, ok := node[TypeKey].(string); ok {
|
||||
kind = Kind(raw)
|
||||
} else if inferred := inferKind(node[ValueKey]); inferred != "" && kind == kindDefault {
|
||||
kind = inferred
|
||||
}
|
||||
if !kind.Valid() {
|
||||
*issues = append(*issues, violation{
|
||||
Path: path, Severity: SeverityError,
|
||||
Message: fmt.Sprintf("unknown $type %q", kind),
|
||||
Hint: "omit $type to inherit the group's type",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := validateKindValue(kind, node[ValueKey]); err != nil {
|
||||
*issues = append(*issues, violation{
|
||||
Path: path, Severity: SeverityError,
|
||||
Message: err.Error(),
|
||||
Hint: fmt.Sprintf("expected a %s value", kind),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
inherited := kindDefault
|
||||
if raw, ok := node[TypeKey].(string); ok {
|
||||
inherited = Kind(raw)
|
||||
}
|
||||
for _, key := range sortedMapKeys(node) {
|
||||
if strings.HasPrefix(key, "$") {
|
||||
continue
|
||||
}
|
||||
child, ok := asMap(node[key])
|
||||
if !ok {
|
||||
*issues = append(*issues, violation{
|
||||
Path: path + "." + key, Severity: SeverityError,
|
||||
Message: fmt.Sprintf("token must be an object with a %q property, got %T", ValueKey, node[key]),
|
||||
Hint: `example: {"$type": "color", "$value": "#6366f1"}`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
validateLeaves(spec, child, inherited, prefix, path+"."+key, issues)
|
||||
}
|
||||
}
|
||||
|
||||
// sortedMapKeys returns the keys of m in sorted order.
|
||||
func sortedMapKeys(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// LookupIn walks a token document by dotted path and returns the leaf.
|
||||
func LookupIn(doc map[string]any, path string) (map[string]any, error) {
|
||||
var current any = doc
|
||||
for _, segment := range strings.Split(path, ".") {
|
||||
node, ok := asMap(current)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("path %q: %q is not a group", path, segment)
|
||||
}
|
||||
next, exists := node[segment]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("path %q: %q not found", path, segment)
|
||||
}
|
||||
current = next
|
||||
}
|
||||
leaf, ok := asMap(current)
|
||||
if !ok || !isLeaf(leaf) {
|
||||
return nil, fmt.Errorf("path %q is not a token leaf", path)
|
||||
}
|
||||
return leaf, nil
|
||||
}
|
||||
|
||||
// SetIn writes a token leaf at a dotted path, creating intermediate groups.
|
||||
func SetIn(doc domain.TokenDoc, path string, value any, kind Kind, description string) error {
|
||||
segments := strings.Split(path, ".")
|
||||
if len(segments) == 0 || segments[0] == "" {
|
||||
return fmt.Errorf("%w: empty token path", domain.ErrInvalid)
|
||||
}
|
||||
// Reject paths that would collide with structural keys.
|
||||
for i, seg := range segments {
|
||||
if _, reserved := ReservedKeys[seg]; reserved && i == 0 {
|
||||
return fmt.Errorf("%w: %q is a reserved key", domain.ErrInvalid, seg)
|
||||
}
|
||||
if seg == "" {
|
||||
return fmt.Errorf("%w: token path %q has an empty segment", domain.ErrInvalid, path)
|
||||
}
|
||||
}
|
||||
if _, ok := LookupGroup(segments[0]); !ok {
|
||||
return fmt.Errorf("%w: unknown token group %q", domain.ErrInvalid, segments[0])
|
||||
}
|
||||
|
||||
current := doc
|
||||
for _, seg := range segments[:len(segments)-1] {
|
||||
child, ok := asMap(current[seg])
|
||||
if !ok {
|
||||
child = map[string]any{}
|
||||
current[seg] = child
|
||||
}
|
||||
current = child
|
||||
}
|
||||
leaf := map[string]any{ValueKey: value, TypeKey: string(kind)}
|
||||
if description != "" {
|
||||
leaf[DescKey] = description
|
||||
}
|
||||
current[segments[len(segments)-1]] = leaf
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteIn removes a token leaf at a dotted path. It reports whether the token
|
||||
// existed so callers can distinguish a no-op delete from a real one.
|
||||
func DeleteIn(doc domain.TokenDoc, path string) (bool, error) {
|
||||
segments := strings.Split(path, ".")
|
||||
if len(segments) < 2 {
|
||||
return false, fmt.Errorf("%w: refusing to delete top-level group %q", domain.ErrInvalid, path)
|
||||
}
|
||||
current := any(doc)
|
||||
for _, seg := range segments[:len(segments)-1] {
|
||||
node, ok := asMap(current)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
child, exists := node[seg]
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
current = child
|
||||
}
|
||||
node, ok := asMap(current)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
last := segments[len(segments)-1]
|
||||
if _, exists := node[last]; !exists {
|
||||
return false, nil
|
||||
}
|
||||
delete(node, last)
|
||||
pruneEmpty(node)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// pruneEmpty removes group objects left empty by a delete, so that the stored
|
||||
// document does not accumulate `{}` noise that would fail validation.
|
||||
func pruneEmpty(node map[string]any) {
|
||||
for key, raw := range node {
|
||||
child, ok := asMap(raw)
|
||||
if !ok || len(child) == 0 {
|
||||
if ok && len(child) == 0 {
|
||||
delete(node, key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
pruneEmpty(child)
|
||||
if len(child) == 0 {
|
||||
delete(node, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// countLeaves counts leaf tokens, excluding appearance overrides.
|
||||
func countLeaves(node map[string]any) int {
|
||||
total := 0
|
||||
for key, raw := range node {
|
||||
if key == AppearancesKey || strings.HasPrefix(key, "$") {
|
||||
continue
|
||||
}
|
||||
child, ok := asMap(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if isLeaf(child) {
|
||||
total++
|
||||
continue
|
||||
}
|
||||
total += countLeaves(child)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// RenameToken moves a token leaf to a new dotted path, preserving its value and
|
||||
// description. It refuses to clobber an existing token unless overwrite is set.
|
||||
func RenameToken(doc domain.TokenDoc, from, to string, overwrite bool) error {
|
||||
leaf, err := LookupIn(doc, from)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", domain.ErrNotFound, err.Error())
|
||||
}
|
||||
if _, err := LookupIn(doc, to); err == nil && !overwrite {
|
||||
return fmt.Errorf("%w: token %q already exists", domain.ErrConflict, to)
|
||||
}
|
||||
if _, err := DeleteIn(doc, from); err != nil {
|
||||
return err
|
||||
}
|
||||
value := leaf[ValueKey]
|
||||
description, _ := leaf[DescKey].(string)
|
||||
kind, ok := leaf[TypeKey].(string)
|
||||
if !ok {
|
||||
kind = string(inferKind(value))
|
||||
}
|
||||
return SetIn(doc, to, value, Kind(kind), description)
|
||||
}
|
||||
|
||||
// EnsureDefaults fills any missing recommended token with the supplied default.
|
||||
// It is used when duplicating a theme so the copy is immediately usable, and is
|
||||
// deliberately not used on the request path for existing themes.
|
||||
func EnsureDefaults(doc domain.TokenDoc, defaults domain.TokenDoc) int {
|
||||
applied := 0
|
||||
for key, raw := range defaults {
|
||||
if _, exists := doc[key]; exists {
|
||||
continue
|
||||
}
|
||||
if err := deepMergeMissing(doc, key, raw); err == nil {
|
||||
applied++
|
||||
}
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
// deepMergeMissing merges src into dst only where dst has no value.
|
||||
func deepMergeMissing(dst domain.TokenDoc, key string, src any) error {
|
||||
srcMap, srcOK := asMap(src)
|
||||
dstMap, dstOK := asMap(dst[key])
|
||||
if srcOK && dstOK && !isLeaf(srcMap) {
|
||||
for k, v := range srcMap {
|
||||
if err := deepMergeMissing(dstMap, k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
dst[key] = src
|
||||
return nil
|
||||
}
|
||||
251
internal/tokenengine/tokens_test.go
Normal file
251
internal/tokenengine/tokens_test.go
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// testTheme builds a minimal but complete theme for engine tests.
|
||||
func testTheme() *domain.Theme {
|
||||
return &domain.Theme{
|
||||
Slug: "acme",
|
||||
Name: "Acme",
|
||||
Status: domain.StatusPublished,
|
||||
DefaultAppearance: domain.AppearanceLight,
|
||||
Version: 3,
|
||||
Tokens: domain.TokenDoc{
|
||||
"color": map[string]any{
|
||||
"palette": map[string]any{
|
||||
"brand": map[string]any{
|
||||
"500": map[string]any{TypeKey: string(KindColor), ValueKey: "#4f46e5"},
|
||||
"600": map[string]any{TypeKey: string(KindColor), ValueKey: "#4338ca"},
|
||||
},
|
||||
},
|
||||
"semantic": map[string]any{
|
||||
"background": map[string]any{TypeKey: string(KindColor), ValueKey: "#ffffff"},
|
||||
"surface": map[string]any{TypeKey: string(KindColor), ValueKey: "#f8fafc"},
|
||||
"text": map[string]any{TypeKey: string(KindColor), ValueKey: "#0f172a"},
|
||||
"primary": map[string]any{TypeKey: string(KindColor), ValueKey: "#4f46e5"},
|
||||
},
|
||||
},
|
||||
"font": map[string]any{
|
||||
"family": map[string]any{
|
||||
"sans": map[string]any{TypeKey: string(KindFamilyAlias()), ValueKey: "Inter, sans-serif"},
|
||||
},
|
||||
},
|
||||
"space": map[string]any{
|
||||
"4": map[string]any{TypeKey: string(KindDimension), ValueKey: 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// KindFamilyAlias exists only so the fixture reads clearly; it resolves to the
|
||||
// canonical font-family kind.
|
||||
func KindFamilyAlias() Kind { return KindFontFamily }
|
||||
|
||||
func TestResolveFlattensNestedTokens(t *testing.T) {
|
||||
doc := Resolve(testTheme(), domain.AppearanceLight)
|
||||
|
||||
if got, ok := doc.Lookup("color.semantic.background"); !ok {
|
||||
t.Fatalf("expected color.semantic.background to resolve, got %d tokens", len(doc.Tokens))
|
||||
} else if got.CSS != "#ffffff" {
|
||||
t.Errorf("background = %q, want #ffffff", got.CSS)
|
||||
}
|
||||
|
||||
space, ok := doc.Lookup("space.4")
|
||||
if !ok {
|
||||
t.Fatal("expected space.4 to resolve")
|
||||
}
|
||||
// A bare number in a dimension group must pick up the px suffix.
|
||||
if space.CSS != "1px" {
|
||||
t.Errorf("space.4 = %q, want 1px", space.CSS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIsDeterministic(t *testing.T) {
|
||||
first := Resolve(testTheme(), domain.AppearanceLight)
|
||||
second := Resolve(testTheme(), domain.AppearanceLight)
|
||||
|
||||
if len(first.Tokens) != len(second.Tokens) {
|
||||
t.Fatalf("token count differs: %d vs %d", len(first.Tokens), len(second.Tokens))
|
||||
}
|
||||
for i := range first.Tokens {
|
||||
if first.Tokens[i].Path != second.Tokens[i].Path {
|
||||
t.Fatalf("token %d out of order: %q vs %q", i, first.Tokens[i].Path, second.Tokens[i].Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppearanceOverridesWinOverBase(t *testing.T) {
|
||||
theme := testTheme()
|
||||
theme.Tokens[AppearancesKey] = map[string]any{
|
||||
"dark": map[string]any{
|
||||
"color": map[string]any{
|
||||
"semantic": map[string]any{
|
||||
"background": map[string]any{TypeKey: string(KindColor), ValueKey: "#0b1220"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
light := Resolve(theme, domain.AppearanceLight)
|
||||
dark := Resolve(theme, domain.AppearanceDark)
|
||||
|
||||
if got, _ := light.Lookup("color.semantic.background"); got.CSS != "#ffffff" {
|
||||
t.Errorf("light background = %q, want the base value #ffffff", got.CSS)
|
||||
}
|
||||
if got, _ := dark.Lookup("color.semantic.background"); got.CSS != "#0b1220" {
|
||||
t.Errorf("dark background = %q, want the override #0b1220", got.CSS)
|
||||
}
|
||||
// Tokens not overridden must fall through to the base value.
|
||||
if got, _ := dark.Lookup("color.semantic.primary"); got.CSS != "#4f46e5" {
|
||||
t.Errorf("dark primary = %q, want the base value #4f46e5", got.CSS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderEmitsScopedSelectorsAndDerivedTokens(t *testing.T) {
|
||||
css := Render(testTheme(), RenderOptions{
|
||||
Slug: "acme",
|
||||
Appearance: domain.AppearanceLight,
|
||||
Derive: DefaultDerive(),
|
||||
Components: true,
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
`[data-bananas-theme="acme"]`,
|
||||
`[data-bananas-theme="acme"][data-bananas-appearance=dark]`,
|
||||
"--bn-color-semantic-background: #ffffff;",
|
||||
// Derived foreground must pick white on a dark primary.
|
||||
"--bn-color-on-semantic-primary: #ffffff;",
|
||||
"--bn-color-semantic-primary-hover:",
|
||||
"--bn-space-4: 1px;",
|
||||
"@layer bananas",
|
||||
".bn-btn",
|
||||
`.dark [data-bananas-theme="acme"]`,
|
||||
} {
|
||||
if !strings.Contains(css, want) {
|
||||
t.Errorf("rendered CSS missing %q\n---\n%s", want, css)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMinifiedOmitsCommentsAndWhitespace(t *testing.T) {
|
||||
css := Render(testTheme(), RenderOptions{Slug: "acme", Minify: true, Derive: DefaultDerive()})
|
||||
|
||||
if strings.Contains(css, "/*") {
|
||||
t.Error("minified CSS must not contain comments")
|
||||
}
|
||||
if strings.Contains(css, "\n\n") {
|
||||
t.Error("minified CSS must not contain blank lines")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsACompleteTheme(t *testing.T) {
|
||||
result := Validate(testTheme().Tokens)
|
||||
for _, issue := range result.Errors() {
|
||||
// The fixture is intentionally sparse; it must only trip the "unknown
|
||||
// group" rule if the schema drifted, never on the tokens it defines.
|
||||
if strings.Contains(issue.Message, "unknown token group") {
|
||||
t.Errorf("unexpected schema error: %+v", issue)
|
||||
}
|
||||
}
|
||||
if result.TokenCount != 8 {
|
||||
t.Errorf("TokenCount = %d, want 8", result.TokenCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsMalformedColour(t *testing.T) {
|
||||
doc := testTheme().Tokens
|
||||
doc["color"].(map[string]any)["semantic"].(map[string]any)["primary"] = map[string]any{
|
||||
TypeKey: string(KindColor), ValueKey: "#nothex",
|
||||
}
|
||||
|
||||
result := Validate(doc)
|
||||
if result.Valid {
|
||||
t.Fatal("expected validation to fail on a malformed colour")
|
||||
}
|
||||
found := false
|
||||
for _, issue := range result.Issues {
|
||||
if issue.Path == "color.semantic.primary" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected an issue on color.semantic.primary, got %+v", result.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsUnknownGroup(t *testing.T) {
|
||||
doc := testTheme().Tokens
|
||||
doc["colour"] = map[string]any{"typo": map[string]any{ValueKey: "#fff"}}
|
||||
|
||||
result := Validate(doc)
|
||||
if result.Valid {
|
||||
t.Fatal("expected validation to reject an unknown top-level group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariableNameNormalisation(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"color.semantic.background": "--bn-color-semantic-background",
|
||||
"space.4": "--bn-space-4",
|
||||
"font.lineHeight.tight": "--bn-font-line-height-tight",
|
||||
"font.line height": "--bn-font-line-height",
|
||||
"weird.Name_Here": "--bn-weird-name-here",
|
||||
}
|
||||
for path, want := range cases {
|
||||
if got := VariableName(path); got != want {
|
||||
t.Errorf("VariableName(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAndDeleteToken(t *testing.T) {
|
||||
doc := testTheme().Tokens
|
||||
|
||||
if err := SetIn(doc, "space.6", 1.5, KindDimension, ""); err != nil {
|
||||
t.Fatalf("SetIn: %v", err)
|
||||
}
|
||||
if got, err := LookupIn(doc, "space.6"); err != nil {
|
||||
t.Fatalf("LookupIn: %v", err)
|
||||
} else if got[ValueKey] != 1.5 {
|
||||
t.Errorf("space.6 = %v, want 1.5", got[ValueKey])
|
||||
}
|
||||
|
||||
deleted, err := DeleteIn(doc, "space.6")
|
||||
if err != nil || !deleted {
|
||||
t.Fatalf("DeleteIn = %v, %v; want true, nil", deleted, err)
|
||||
}
|
||||
if _, err := LookupIn(doc, "space.6"); err == nil {
|
||||
t.Error("token should be gone after DeleteIn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetInRejectsUnknownGroup(t *testing.T) {
|
||||
doc := domain.TokenDoc{}
|
||||
err := SetIn(doc, "colour.typo", "#fff", KindColor, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected SetIn to reject an unknown group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONPayloadCarriesFlatAndNestedForms(t *testing.T) {
|
||||
payload := JSONPayload(testTheme(), domain.AppearanceDark)
|
||||
|
||||
if payload.Theme.Slug != "acme" {
|
||||
t.Errorf("slug = %q, want acme", payload.Theme.Slug)
|
||||
}
|
||||
if payload.Variable["--bn-color-semantic-primary"] != "#4f46e5" {
|
||||
t.Errorf("flat map missing primary: %v", payload.Variable)
|
||||
}
|
||||
semantic, ok := payload.Tokens["color"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("nested tokens missing the color group")
|
||||
}
|
||||
if _, ok := semantic["semantic"]; !ok {
|
||||
t.Error("nested tokens missing the semantic group")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue