package dank16 import ( "fmt" "math" "github.com/lucasb-eyer/go-colorful" ) type RGB struct { R, G, B float64 } type HSV struct { H, S, V float64 } type ColorInfo struct { Hex string `json:"hex"` HexStripped string `json:"hex_stripped"` R int `json:"r"` G int `json:"g"` B int `json:"b"` } type VariantColorValue struct { Hex string `json:"hex"` HexStripped string `json:"hex_stripped"` } type VariantColorInfo struct { Dark VariantColorValue `json:"dark"` Light VariantColorValue `json:"light"` Default VariantColorValue `json:"default"` } type Palette struct { Color0 ColorInfo `json:"color0"` Color1 ColorInfo `json:"color1"` Color2 ColorInfo `json:"color2"` Color3 ColorInfo `json:"color3"` Color4 ColorInfo `json:"color4"` Color5 ColorInfo `json:"color5"` Color6 ColorInfo `json:"color6"` Color7 ColorInfo `json:"color7"` Color8 ColorInfo `json:"color8"` Color9 ColorInfo `json:"color9"` Color10 ColorInfo `json:"color10"` Color11 ColorInfo `json:"color11"` Color12 ColorInfo `json:"color12"` Color13 ColorInfo `json:"color13"` Color14 ColorInfo `json:"color14"` Color15 ColorInfo `json:"color15"` } type VariantPalette struct { Color0 VariantColorInfo `json:"color0"` Color1 VariantColorInfo `json:"color1"` Color2 VariantColorInfo `json:"color2"` Color3 VariantColorInfo `json:"color3"` Color4 VariantColorInfo `json:"color4"` Color5 VariantColorInfo `json:"color5"` Color6 VariantColorInfo `json:"color6"` Color7 VariantColorInfo `json:"color7"` Color8 VariantColorInfo `json:"color8"` Color9 VariantColorInfo `json:"color9"` Color10 VariantColorInfo `json:"color10"` Color11 VariantColorInfo `json:"color11"` Color12 VariantColorInfo `json:"color12"` Color13 VariantColorInfo `json:"color13"` Color14 VariantColorInfo `json:"color14"` Color15 VariantColorInfo `json:"color15"` } func NewColorInfo(hex string) ColorInfo { rgb := HexToRGB(hex) stripped := hex if len(hex) > 0 && hex[0] == '#' { stripped = hex[1:] } return ColorInfo{ Hex: hex, HexStripped: stripped, R: int(math.Round(rgb.R * 255)), G: int(math.Round(rgb.G * 255)), B: int(math.Round(rgb.B * 255)), } } func HexToRGB(hex string) RGB { if hex[0] == '#' { hex = hex[1:] } var r, g, b uint8 fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b) return RGB{ R: float64(r) / 255.0, G: float64(g) / 255.0, B: float64(b) / 255.0, } } func RGBToHex(rgb RGB) string { r := math.Max(0, math.Min(1, rgb.R)) g := math.Max(0, math.Min(1, rgb.G)) b := math.Max(0, math.Min(1, rgb.B)) return fmt.Sprintf("#%02x%02x%02x", int(r*255), int(g*255), int(b*255)) } func RGBToHSV(rgb RGB) HSV { max := math.Max(math.Max(rgb.R, rgb.G), rgb.B) min := math.Min(math.Min(rgb.R, rgb.G), rgb.B) delta := max - min var h float64 switch { case delta == 0: h = 0 case max == rgb.R: h = math.Mod((rgb.G-rgb.B)/delta, 6.0) / 6.0 case max == rgb.G: h = ((rgb.B-rgb.R)/delta + 2.0) / 6.0 default: h = ((rgb.R-rgb.G)/delta + 4.0) / 6.0 } if h < 0 { h += 1.0 } var s float64 if max == 0 { s = 0 } else { s = delta / max } return HSV{H: h, S: s, V: max} } func HSVToRGB(hsv HSV) RGB { h := hsv.H * 6.0 c := hsv.V * hsv.S x := c * (1.0 - math.Abs(math.Mod(h, 2.0)-1.0)) m := hsv.V - c var r, g, b float64 switch int(h) { case 0: r, g, b = c, x, 0 case 1: r, g, b = x, c, 0 case 2: r, g, b = 0, c, x case 3: r, g, b = 0, x, c case 4: r, g, b = x, 0, c case 5: r, g, b = c, 0, x default: r, g, b = c, 0, x } return RGB{R: r + m, G: g + m, B: b + m} } func sRGBToLinear(c float64) float64 { if c <= 0.04045 { return c / 12.92 } return math.Pow((c+0.055)/1.055, 2.4) } func Luminance(hex string) float64 { rgb := HexToRGB(hex) return 0.2126*sRGBToLinear(rgb.R) + 0.7152*sRGBToLinear(rgb.G) + 0.0722*sRGBToLinear(rgb.B) } func ContrastRatio(hexFg, hexBg string) float64 { lumFg := Luminance(hexFg) lumBg := Luminance(hexBg) lighter := math.Max(lumFg, lumBg) darker := math.Min(lumFg, lumBg) return (lighter + 0.05) / (darker + 0.05) } func getLstar(hex string) float64 { rgb := HexToRGB(hex) col := colorful.Color{R: rgb.R, G: rgb.G, B: rgb.B} L, _, _ := col.Lab() return L * 100.0 // go-colorful uses 0-1, we need 0-100 for DPS } // Lab to hex, clamping if needed func labToHex(L, a, b float64) string { c := colorful.Lab(L/100.0, a, b) // back to 0-1 for go-colorful r, g, b2 := c.Clamped().RGB255() return fmt.Sprintf("#%02x%02x%02x", r, g, b2) } func DeltaPhiStar(hexFg, hexBg string, negativePolarity bool) float64 { Lf := getLstar(hexFg) Lb := getLstar(hexBg) phi := 1.618 inv := 0.618 lc := math.Pow(math.Abs(math.Pow(Lb, phi)-math.Pow(Lf, phi)), inv)*1.414 - 40 if negativePolarity { lc += 5 } return lc } func DeltaPhiStarContrast(hexFg, hexBg string, isLightMode bool) float64 { negativePolarity := !isLightMode return DeltaPhiStar(hexFg, hexBg, negativePolarity) } func EnsureContrast(hexColor, hexBg string, minRatio float64, isLightMode bool) string { currentRatio := ContrastRatio(hexColor, hexBg) if currentRatio >= minRatio { return hexColor } rgb := HexToRGB(hexColor) hsv := RGBToHSV(rgb) for step := 1; step < 30; step++ { delta := float64(step) * 0.02 if isLightMode { newV := math.Max(0, hsv.V-delta) candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV})) if ContrastRatio(candidate, hexBg) >= minRatio { return candidate } newV = math.Min(1, hsv.V+delta) candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV})) if ContrastRatio(candidate, hexBg) >= minRatio { return candidate } } else { newV := math.Min(1, hsv.V+delta) candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV})) if ContrastRatio(candidate, hexBg) >= minRatio { return candidate } newV = math.Max(0, hsv.V-delta) candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV})) if ContrastRatio(candidate, hexBg) >= minRatio { return candidate } } } return hexColor } func EnsureContrastDPS(hexColor, hexBg string, minLc float64, isLightMode bool) string { currentLc := DeltaPhiStarContrast(hexColor, hexBg, isLightMode) if currentLc >= minLc { return hexColor } rgb := HexToRGB(hexColor) hsv := RGBToHSV(rgb) for step := 1; step < 50; step++ { delta := float64(step) * 0.015 if isLightMode { newV := math.Max(0, hsv.V-delta) candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV})) if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc { return candidate } newV = math.Min(1, hsv.V+delta) candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV})) if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc { return candidate } } else { newV := math.Min(1, hsv.V+delta) candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV})) if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc { return candidate } newV = math.Max(0, hsv.V-delta) candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV})) if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc { return candidate } } } return hexColor } // Nudge L* until contrast is good enough. Keeps hue intact unlike HSV fiddling. func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode bool) string { current := DeltaPhiStarContrast(hexColor, hexBg, isLightMode) if current >= minLc { return hexColor } fg := HexToRGB(hexColor) cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B} Lf, af, bf := cf.Lab() dir := 1.0 if isLightMode { dir = -1.0 // light mode = darker text } step := 0.5 for range 120 { Lf = math.Max(0, math.Min(100, Lf+dir*step)) cand := labToHex(Lf, af, bf) if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc { return cand } } return hexColor } // Bidirectional contrast - tries both lighter and darker, picks closest to original func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLightMode bool) string { current := DeltaPhiStarContrast(hexColor, hexBg, isLightMode) if current >= minLc { return hexColor } fg := HexToRGB(hexColor) cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B} origL, af, bf := cf.Lab() var darkerResult, lighterResult string darkerL, lighterL := origL, origL darkerFound, lighterFound := false, false step := 0.5 for i := range 120 { if !darkerFound { darkerL = math.Max(0, origL-float64(i)*step) cand := labToHex(darkerL, af, bf) if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc { darkerResult = cand darkerFound = true } } if !lighterFound { lighterL = math.Min(100, origL+float64(i)*step) cand := labToHex(lighterL, af, bf) if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc { lighterResult = cand lighterFound = true } } if darkerFound && lighterFound { break } } if darkerFound && lighterFound { if math.Abs(darkerL-origL) <= math.Abs(lighterL-origL) { return darkerResult } return lighterResult } if darkerFound { return darkerResult } if lighterFound { return lighterResult } return hexColor } type PaletteOptions struct { IsLight bool Background string UseDPS bool } func ensureContrastAuto(hexColor, hexBg string, target float64, opts PaletteOptions) string { if opts.UseDPS { return EnsureContrastDPSLstar(hexColor, hexBg, target, opts.IsLight) } return EnsureContrast(hexColor, hexBg, target, opts.IsLight) } func ensureContrastBidirectional(hexColor, hexBg string, target float64, opts PaletteOptions) string { if opts.UseDPS { return EnsureContrastDPSBidirectional(hexColor, hexBg, target, opts.IsLight) } return EnsureContrast(hexColor, hexBg, target, opts.IsLight) } func blendHue(base, target, factor float64) float64 { diff := target - base if diff > 0.5 { diff -= 1.0 } else if diff < -0.5 { diff += 1.0 } result := base + diff*factor if result < 0 { result += 1.0 } else if result >= 1.0 { result -= 1.0 } return result } func DeriveContainer(primary string, isLight bool) string { rgb := HexToRGB(primary) hsv := RGBToHSV(rgb) if isLight { containerV := math.Min(hsv.V*1.77, 1.0) containerS := hsv.S * 0.32 return RGBToHex(HSVToRGB(HSV{H: hsv.H, S: containerS, V: containerV})) } containerV := hsv.V * 0.463 containerS := math.Min(hsv.S*1.834, 1.0) return RGBToHex(HSVToRGB(HSV{H: hsv.H, S: containerS, V: containerV})) } func GeneratePalette(primaryColor string, opts PaletteOptions) Palette { baseColor := DeriveContainer(primaryColor, opts.IsLight) rgb := HexToRGB(baseColor) hsv := RGBToHSV(rgb) pr := HexToRGB(primaryColor) ph := RGBToHSV(pr) var palette Palette var normalTextTarget, secondaryTarget float64 if opts.UseDPS { normalTextTarget = 40.0 secondaryTarget = 35.0 } else { normalTextTarget = 4.5 secondaryTarget = 3.0 } var bgColor string if opts.Background != "" { bgColor = opts.Background } else if opts.IsLight { bgColor = "#f8f8f8" } else { bgColor = "#1a1a1a" } palette.Color0 = NewColorInfo(bgColor) baseSat := math.Max(ph.S, 0.5) baseVal := math.Max(ph.V, 0.5) redH := blendHue(0.0, ph.H, 0.12) greenH := blendHue(0.33, ph.H, 0.10) yellowH := blendHue(0.14, ph.H, 0.04) accentTarget := secondaryTarget * 0.7 if opts.IsLight { redS := math.Min(baseSat*1.2, 1.0) redV := baseVal * 0.95 palette.Color1 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: redH, S: redS, V: redV})), bgColor, normalTextTarget, opts)) greenS := math.Min(baseSat*1.3, 1.0) greenV := baseVal * 0.75 palette.Color2 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: greenH, S: greenS, V: greenV})), bgColor, normalTextTarget, opts)) yellowS := math.Min(baseSat*1.5, 1.0) yellowV := math.Min(baseVal*1.2, 1.0) palette.Color3 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, accentTarget, opts)) blueS := math.Min(ph.S*1.05, 1.0) blueV := math.Min(ph.V*1.05, 1.0) palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts)) // Color5 matches primary_container exactly (light container in light mode) container5 := DeriveContainer(primaryColor, true) palette.Color5 = NewColorInfo(container5) palette.Color6 = NewColorInfo(primaryColor) gray7S := baseSat * 0.08 gray7V := baseVal * 0.28 palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts)) gray8S := baseSat * 0.05 gray8V := baseVal * 0.85 dimTarget := secondaryTarget * 0.5 palette.Color8 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, dimTarget, opts)) brightRedS := math.Min(baseSat*1.0, 1.0) brightRedV := math.Min(baseVal*1.2, 1.0) palette.Color9 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: redH, S: brightRedS, V: brightRedV})), bgColor, accentTarget, opts)) brightGreenS := math.Min(baseSat*1.1, 1.0) brightGreenV := math.Min(baseVal*1.1, 1.0) palette.Color10 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: greenH, S: brightGreenS, V: brightGreenV})), bgColor, accentTarget, opts)) brightYellowS := math.Min(baseSat*1.4, 1.0) brightYellowV := math.Min(baseVal*1.3, 1.0) palette.Color11 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: brightYellowS, V: brightYellowV})), bgColor, accentTarget, opts)) brightBlueS := math.Min(ph.S*1.1, 1.0) brightBlueV := math.Min(ph.V*1.15, 1.0) palette.Color12 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightBlueS, V: brightBlueV})), bgColor, accentTarget, opts)) lightContainer := DeriveContainer(primaryColor, true) palette.Color13 = NewColorInfo(lightContainer) brightCyanS := ph.S * 0.5 brightCyanV := math.Min(ph.V*1.3, 1.0) palette.Color14 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightCyanS, V: brightCyanV}))) white15S := baseSat * 0.04 white15V := math.Min(baseVal*1.5, 1.0) palette.Color15 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: white15S, V: white15V}))) } else { redS := math.Min(baseSat*1.1, 1.0) redV := math.Min(baseVal*1.15, 1.0) palette.Color1 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: redH, S: redS, V: redV})), bgColor, normalTextTarget, opts)) greenS := math.Min(baseSat*1.0, 1.0) greenV := math.Min(baseVal*1.0, 1.0) palette.Color2 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: greenH, S: greenS, V: greenV})), bgColor, normalTextTarget, opts)) yellowS := math.Min(baseSat*1.1, 1.0) yellowV := math.Min(baseVal*1.25, 1.0) palette.Color3 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, normalTextTarget, opts)) // Slightly more saturated variant of primary blueS := math.Min(ph.S*1.2, 1.0) blueV := ph.V * 0.95 palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts)) // Color5 matches primary_container exactly (dark container in dark mode) darkContainer := DeriveContainer(primaryColor, false) palette.Color5 = NewColorInfo(darkContainer) palette.Color6 = NewColorInfo(primaryColor) gray7S := baseSat * 0.12 gray7V := math.Min(baseVal*1.05, 1.0) palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts)) gray8S := baseSat * 0.15 gray8V := baseVal * 0.65 palette.Color8 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, secondaryTarget, opts)) brightRedS := math.Min(baseSat*0.75, 1.0) brightRedV := math.Min(baseVal*1.35, 1.0) palette.Color9 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: redH, S: brightRedS, V: brightRedV})), bgColor, accentTarget, opts)) brightGreenS := math.Min(baseSat*0.7, 1.0) brightGreenV := math.Min(baseVal*1.2, 1.0) palette.Color10 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: greenH, S: brightGreenS, V: brightGreenV})), bgColor, accentTarget, opts)) brightYellowS := math.Min(baseSat*0.7, 1.0) brightYellowV := math.Min(baseVal*1.5, 1.0) palette.Color11 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: brightYellowS, V: brightYellowV})), bgColor, accentTarget, opts)) // Create a gradient of primary variants: Color12 -> Color13 -> Color14 -> Color15 (near white) // Color12: Start of the lighter gradient - slightly desaturated brightBlueS := ph.S * 0.85 brightBlueV := math.Min(ph.V*1.1, 1.0) palette.Color12 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightBlueS, V: brightBlueV})), bgColor, accentTarget, opts)) // Medium-high saturation pastel primary color13S := ph.S * 0.7 color13V := math.Min(ph.V*1.3, 1.0) palette.Color13 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: ph.H, S: color13S, V: color13V}))) // Lower saturation, lighter variant color14S := ph.S * 0.45 color14V := math.Min(ph.V*1.4, 1.0) palette.Color14 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: ph.H, S: color14S, V: color14V}))) white15S := baseSat * 0.05 white15V := math.Min(baseVal*1.45, 1.0) palette.Color15 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: white15S, V: white15V})), bgColor, normalTextTarget, opts)) } return palette } type VariantOptions struct { PrimaryDark string PrimaryLight string Background string UseDPS bool IsLightMode bool } func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo { darkVal := VariantColorValue{Hex: dark.Hex, HexStripped: dark.HexStripped} lightVal := VariantColorValue{Hex: light.Hex, HexStripped: light.HexStripped} defaultVal := darkVal if isLightMode { defaultVal = lightVal } return VariantColorInfo{ Dark: darkVal, Light: lightVal, Default: defaultVal, } } func GenerateVariantPalette(opts VariantOptions) VariantPalette { darkOpts := PaletteOptions{IsLight: false, Background: opts.Background, UseDPS: opts.UseDPS} lightOpts := PaletteOptions{IsLight: true, Background: opts.Background, UseDPS: opts.UseDPS} dark := GeneratePalette(opts.PrimaryDark, darkOpts) light := GeneratePalette(opts.PrimaryLight, lightOpts) return VariantPalette{ Color0: mergeColorInfo(dark.Color0, light.Color0, opts.IsLightMode), Color1: mergeColorInfo(dark.Color1, light.Color1, opts.IsLightMode), Color2: mergeColorInfo(dark.Color2, light.Color2, opts.IsLightMode), Color3: mergeColorInfo(dark.Color3, light.Color3, opts.IsLightMode), Color4: mergeColorInfo(dark.Color4, light.Color4, opts.IsLightMode), Color5: mergeColorInfo(dark.Color5, light.Color5, opts.IsLightMode), Color6: mergeColorInfo(dark.Color6, light.Color6, opts.IsLightMode), Color7: mergeColorInfo(dark.Color7, light.Color7, opts.IsLightMode), Color8: mergeColorInfo(dark.Color8, light.Color8, opts.IsLightMode), Color9: mergeColorInfo(dark.Color9, light.Color9, opts.IsLightMode), Color10: mergeColorInfo(dark.Color10, light.Color10, opts.IsLightMode), Color11: mergeColorInfo(dark.Color11, light.Color11, opts.IsLightMode), Color12: mergeColorInfo(dark.Color12, light.Color12, opts.IsLightMode), Color13: mergeColorInfo(dark.Color13, light.Color13, opts.IsLightMode), Color14: mergeColorInfo(dark.Color14, light.Color14, opts.IsLightMode), Color15: mergeColorInfo(dark.Color15, light.Color15, opts.IsLightMode), } }