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
147 lines
5.1 KiB
Go
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
|
|
}
|