initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
147
internal/tokenengine/palette.go
Normal file
147
internal/tokenengine/palette.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// RampOptions describes a generated colour ramp. It is the programmatic form of
|
||||
// "here is a brand colour, give me the other ten shades", which the editor UI
|
||||
// exposes as a one-click palette builder and which the built-in themes use to
|
||||
// stay tonally consistent.
|
||||
type RampOptions struct {
|
||||
// Hue in degrees, 0..360. Defaults to 250 (indigo) when zero-value.
|
||||
Hue float64
|
||||
// Saturation at the mid steps, in percent, 0..100.
|
||||
Saturation float64
|
||||
// Lightness at step 500, in percent, 0..100.
|
||||
Lightness float64
|
||||
// HueSpread drifts the hue from the lightest to the darkest step. Modern
|
||||
// perceptual ramps lean warm at the light end and cool at the dark end;
|
||||
// a value of 12 reproduces that effect.
|
||||
HueSpread float64
|
||||
}
|
||||
|
||||
// DefaultRampOptions returns the ramp the palette builder starts from.
|
||||
func DefaultRampOptions() RampOptions {
|
||||
return RampOptions{Hue: 250, Saturation: 78, Lightness: 55, HueSpread: 12}
|
||||
}
|
||||
|
||||
// Normalize clamps the options into usable ranges and fills in defaults.
|
||||
func (o RampOptions) Normalize() RampOptions {
|
||||
out := o
|
||||
out.Hue = math.Mod(math.Mod(out.Hue, 360)+360, 360)
|
||||
out.Saturation = clampRange(out.Saturation, 0, 100)
|
||||
out.Lightness = clampRange(out.Lightness, 20, 80)
|
||||
out.HueSpread = clampRange(out.HueSpread, 0, 40)
|
||||
return out
|
||||
}
|
||||
|
||||
func clampRange(v, lo, hi float64) float64 {
|
||||
if math.IsNaN(v) {
|
||||
return lo
|
||||
}
|
||||
return math.Max(lo, math.Min(hi, v))
|
||||
}
|
||||
|
||||
// RampEndpoints are the lightness percentages used for shade 50 and shade 950.
|
||||
// They sit just short of pure white and pure black so that the lightest step is
|
||||
// still usable as a surface and the darkest still holds text.
|
||||
const (
|
||||
RampLightest = 97.0
|
||||
RampDarkest = 12.0
|
||||
)
|
||||
|
||||
// GenerateRamp builds a canonical 11-step colour ramp as token leaves.
|
||||
//
|
||||
// Lightness is interpolated along a curve rather than linearly: the middle steps
|
||||
// carry the overwhelming majority of UI usage (a button's resting colour is
|
||||
// step 500 or 600), so they get the most separation. Saturation tapers at both
|
||||
// ends because a fully saturated near-white or near-black reads as a fluorescent
|
||||
// smear rather than a tint.
|
||||
func GenerateRamp(opts RampOptions) map[string]any {
|
||||
opts = opts.Normalize()
|
||||
shades := StandardPaletteShades()
|
||||
out := make(map[string]any, len(shades))
|
||||
|
||||
for i, shade := range shades {
|
||||
pos := float64(i) / float64(len(shades)-1)
|
||||
|
||||
light := rampLightness(pos, opts.Lightness)
|
||||
sat := opts.Saturation * saturationEnvelope(pos)
|
||||
hue := opts.Hue + opts.HueSpread*(0.5-pos)
|
||||
|
||||
out[shade] = map[string]any{
|
||||
TypeKey: string(KindColor),
|
||||
ValueKey: HSLHex(hue, sat, light),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rampLightness returns the lightness percentage for a ramp position.
|
||||
//
|
||||
// The curve blends a full linear sweep across the ramp endpoints with a sweep
|
||||
// anchored on the caller's requested step-500 lightness. The anchored sweep
|
||||
// guarantees step 500 lands exactly where the caller asked, while the linear
|
||||
// term keeps the extremes reaching near-white and near-black instead of
|
||||
// bunching up in the middle.
|
||||
func rampLightness(pos, midLight float64) float64 {
|
||||
linear := RampLightest + (RampDarkest-RampLightest)*pos
|
||||
|
||||
// Anchored sweep: walk from midLight out to the nearest endpoint.
|
||||
var anchored float64
|
||||
if pos <= 0.5 {
|
||||
anchored = midLight + (RampLightest-midLight)*2*(0.5-pos)
|
||||
} else {
|
||||
anchored = midLight - (midLight-RampDarkest)*2*(pos-0.5)
|
||||
}
|
||||
return linear*0.35 + anchored*0.65
|
||||
}
|
||||
|
||||
// saturationEnvelope tapers saturation towards both ends of the ramp.
|
||||
func saturationEnvelope(pos float64) float64 {
|
||||
// 1 at the midpoint, ~0.5 at the extremes.
|
||||
return 0.5 + 0.5*math.Sin(math.Pi*pos)
|
||||
}
|
||||
|
||||
// HSLHex converts hue/saturation/lightness (hue in degrees, the others in
|
||||
// percent) to a hex colour.
|
||||
func HSLHex(hue, saturation, light float64) string {
|
||||
h := math.Mod(math.Mod(hue, 360)+360, 360) / 360
|
||||
s := clampRange(saturation, 0, 100) / 100
|
||||
l := clampRange(light, 0, 100) / 100
|
||||
r, g, b := hslToRGB(h, s, l)
|
||||
return fmt.Sprintf("#%02x%02x%02x", int(r*255+0.5), int(g*255+0.5), int(b*255+0.5))
|
||||
}
|
||||
|
||||
// RampStep returns the hex colour at one step of a generated ramp. It exists so
|
||||
// that callers such as the semantic-group builder can reference a concrete
|
||||
// colour without materialising the whole ramp.
|
||||
func RampStep(opts RampOptions, shade string) string {
|
||||
opts = opts.Normalize()
|
||||
shades := StandardPaletteShades()
|
||||
for i, s := range shades {
|
||||
if s == shade {
|
||||
pos := float64(i) / float64(len(shades)-1)
|
||||
light := rampLightness(pos, opts.Lightness)
|
||||
sat := opts.Saturation * saturationEnvelope(pos)
|
||||
hue := opts.Hue + opts.HueSpread*(0.5-pos)
|
||||
return HSLHex(hue, sat, light)
|
||||
}
|
||||
}
|
||||
// Unknown shade: fall back to the mid lightness rather than failing, so a
|
||||
// typo in a configuration file cannot produce an invalid colour.
|
||||
return HSLHex(opts.Hue, opts.Saturation, opts.Lightness)
|
||||
}
|
||||
|
||||
// RampShades lists the canonical shade steps.
|
||||
func RampShades() []string { return StandardPaletteShades() }
|
||||
|
||||
// ParseShade extracts the numeric step from a shade name, reporting whether it
|
||||
// is a plain number.
|
||||
func ParseShade(shade string) (int, bool) {
|
||||
n, err := strconv.Atoi(shade)
|
||||
return n, err == nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue