go-bananas/internal/tokenengine/palette.go
Daniel Noulet 951dd8d26b
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
initial commit
2026-10-07 15:11:35 +02:00

147 lines
5.1 KiB
Go

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
}