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 }