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

This commit is contained in:
Daniel Noulet 2026-10-07 15:11:35 +02:00
commit 951dd8d26b
175 changed files with 50041 additions and 0 deletions

View 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
}

View 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)
}
}

View 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
}

View 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
View 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
}

View 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
}
}

View 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
}

View 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
}

View 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
}

View 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")
}
}