diff --git a/core/internal/dank16/dank16.go b/core/internal/dank16/dank16.go index f0705bbf1..62d5aa131 100644 --- a/core/internal/dank16/dank16.go +++ b/core/internal/dank16/dank16.go @@ -3,6 +3,7 @@ package dank16 import ( "fmt" "math" + "strconv" "github.com/lucasb-eyer/go-colorful" ) @@ -11,27 +12,38 @@ type RGB struct { R, G, B float64 } +type HSL struct { + H, S, L 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"` + Hex string `json:"hex"` + HexStripped string `json:"hex_stripped"` + HexAlpha string `json:"hex_alpha"` + HexAlphaStripped string `json:"hex_alpha_stripped"` + AlphaHex string `json:"alpha_hex"` + AlphaHexStripped string `json:"alpha_hex_stripped"` + RGB string `json:"rgb"` + RGBA string `json:"rgba"` + HSL string `json:"hsl"` + HSLA string `json:"hsla"` + R string `json:"red"` + G string `json:"green"` + B string `json:"blue"` + A string `json:"alpha"` + H string `json:"hue"` + S string `json:"saturation"` + L string `json:"lightness"` } type VariantColorInfo struct { - Dark VariantColorValue `json:"dark"` - Light VariantColorValue `json:"light"` - Default VariantColorValue `json:"default"` + Dark ColorInfo `json:"dark"` + Light ColorInfo `json:"light"` + Default ColorInfo `json:"default"` } type Palette struct { @@ -74,16 +86,41 @@ type VariantPalette struct { func NewColorInfo(hex string) ColorInfo { rgb := HexToRGB(hex) + hsl := RGBToHSL(rgb) + + var r, g, b, a, h, s, l string + + r = strconv.FormatInt(int64(math.Round(rgb.R*255)), 10) + g = strconv.FormatInt(int64(math.Round(rgb.G*255)), 10) + b = strconv.FormatInt(int64(math.Round(rgb.B*255)), 10) + a = "1.0" + + h = strconv.FormatFloat(math.Round(hsl.H*3600)/10, 'f', 1, 64) + s = fmt.Sprintf("%.1f%%", math.Round(hsl.S*1000.0)/10.0) + l = fmt.Sprintf("%.1f%%", math.Round(hsl.L*1000.0)/10.0) + 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)), + Hex: hex, + HexStripped: stripped, + HexAlpha: hex + "ff", + HexAlphaStripped: stripped + "ff", + AlphaHex: "#ff" + stripped, + AlphaHexStripped: "ff" + stripped, + RGB: fmt.Sprintf("rgb(%s, %s, %s)", r, g, b), + RGBA: fmt.Sprintf("rgba(%s, %s, %s, %s)", r, g, b, a), + HSL: fmt.Sprintf("hsl(%s, %s, %s)", h, s, l), + HSLA: fmt.Sprintf("hsla(%s, %s, %s, %s)", h, s, l, a), + R: r, + G: g, + B: b, + A: a, + H: h, + S: s, + L: l, } } @@ -138,6 +175,35 @@ func RGBToHSV(rgb RGB) HSV { return HSV{H: h, S: s, V: max} } +func RGBToHSL(rgb RGB) HSL { + 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, s, l float64 + + l = (max + min) / 2 + if delta == 0 { + return HSL{H: 0, S: 0, L: l} + } + + switch max { + case rgb.R: + h = math.Mod((rgb.G-rgb.B)/delta, 6.0) / 6.0 + case 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 + } + + s = delta / (1 - math.Abs((2*l)-1)) + + return HSL{H: h, S: s, L: l} +} + func HSVToRGB(hsv HSV) RGB { h := hsv.H * 6.0 c := hsv.V * hsv.S @@ -623,17 +689,14 @@ type VariantOptions struct { } 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 + defaultVal := dark if isLightMode { - defaultVal = lightVal + defaultVal = light } return VariantColorInfo{ - Dark: darkVal, - Light: lightVal, + Dark: dark, + Light: light, Default: defaultVal, } } diff --git a/core/internal/dank16/dank16_test.go b/core/internal/dank16/dank16_test.go index a73acbb8c..da163d42c 100644 --- a/core/internal/dank16/dank16_test.go +++ b/core/internal/dank16/dank16_test.go @@ -1,6 +1,8 @@ package dank16 import ( + "bytes" + "encoding/json" "math" "testing" ) @@ -91,6 +93,54 @@ func TestRGBToHex(t *testing.T) { } } +func TestRGBToHSL(t *testing.T) { + tests := []struct { + name string + input RGB + expected HSL + }{ + { + name: "black", + input: RGB{R: 0.0, G: 0.0, B: 0.0}, + expected: HSL{H: 0.0, S: 0.0, L: 0.0}, + }, + { + name: "white", + input: RGB{R: 1.0, G: 1.0, B: 1.0}, + expected: HSL{H: 0.0, S: 0.0, L: 1.0}, + }, + { + name: "red", + input: RGB{R: 1.0, G: 0.0, B: 0.0}, + expected: HSL{H: 0.0, S: 1.0, L: 0.5}, + }, + { + name: "green", + input: RGB{R: 0.0, G: 1.0, B: 0.0}, + expected: HSL{H: 120.0 / 360.0, S: 1.0, L: 0.5}, + }, + { + name: "blue", + input: RGB{R: 0.0, G: 0.0, B: 1.0}, + expected: HSL{H: 240.0 / 360.0, S: 1.0, L: 0.5}, + }, + { + name: "magenta", + input: RGB{R: 1.0, G: 0.0, B: 1.0}, + expected: HSL{H: 300 / 360.0, S: 1.0, L: 0.5}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := RGBToHSL(tt.input) + if !floatEqual(result.H, tt.expected.H) || !floatEqual(result.S, tt.expected.S) || !floatEqual(result.L, tt.expected.L) { + t.Errorf("RGBToHSL(%v) = %v, expected %v", tt.input, result, tt.expected) + } + }) + } +} + func TestRGBToHSV(t *testing.T) { tests := []struct { name string @@ -309,31 +359,41 @@ func TestEnsureContrast(t *testing.T) { func TestGeneratePalette(t *testing.T) { tests := []struct { - name string - base string - opts PaletteOptions + name string + base string + backgroundHex string + backgroundHSL string + opts PaletteOptions }{ { - name: "dark theme default", - base: "#625690", - opts: PaletteOptions{IsLight: false}, + name: "dark theme default", + base: "#625690", + backgroundHex: "#1a1a1a", + backgroundHSL: "hsl(0.0, 0.0%, 10.2%)", + opts: PaletteOptions{IsLight: false}, }, { - name: "light theme default", - base: "#625690", - opts: PaletteOptions{IsLight: true}, + name: "light theme default", + base: "#625690", + backgroundHex: "#f8f8f8", + backgroundHSL: "hsl(0.0, 0.0%, 97.3%)", + opts: PaletteOptions{IsLight: true}, }, { - name: "light theme with custom background", - base: "#625690", + name: "light theme with custom background", + base: "#625690", + backgroundHex: "#fafafa", + backgroundHSL: "hsl(0.0, 0.0%, 98.0%)", opts: PaletteOptions{ IsLight: true, Background: "#fafafa", }, }, { - name: "dark theme with custom background", - base: "#625690", + name: "dark theme with custom background", + base: "#625690", + backgroundHex: "#0a0a0a", + backgroundHSL: "hsl(0.0, 0.0%, 3.9%)", opts: PaletteOptions{ IsLight: false, Background: "#0a0a0a", @@ -358,12 +418,12 @@ func TestGeneratePalette(t *testing.T) { } } - if tt.opts.Background != "" && result.Color0.Hex != tt.opts.Background { - t.Errorf("Background color = %s, expected %s", result.Color0.Hex, tt.opts.Background) - } else if !tt.opts.IsLight && tt.opts.Background == "" && result.Color0.Hex != "#1a1a1a" { - t.Errorf("Dark mode background = %s, expected #1a1a1a", result.Color0.Hex) - } else if tt.opts.IsLight && tt.opts.Background == "" && result.Color0.Hex != "#f8f8f8" { - t.Errorf("Light mode background = %s, expected #f8f8f8", result.Color0.Hex) + if result.Color0.Hex != tt.backgroundHex { + t.Errorf("Background color hex = %s, expected %s", result.Color0.Hex, tt.backgroundHex) + } + + if result.Color0.HSL != tt.backgroundHSL { + t.Errorf("Background color hsl = %s, expected %s", result.Color0.HSL, tt.backgroundHSL) } // Color15 is now derived from primary, so just verify it's a valid color @@ -384,6 +444,59 @@ func TestGeneratePalette(t *testing.T) { } } +func TestFormatOutput(t *testing.T) { + tests := []struct { + in string + out []byte + }{ + { + in: "#000000", + out: []byte(`{"hex":"#000000","hex_stripped":"000000","hex_alpha":"#000000ff","hex_alpha_stripped":"000000ff","alpha_hex":"#ff000000","alpha_hex_stripped":"ff000000","rgb":"rgb(0, 0, 0)","rgba":"rgba(0, 0, 0, 1.0)","hsl":"hsl(0.0, 0.0%, 0.0%)","hsla":"hsla(0.0, 0.0%, 0.0%, 1.0)","red":"0","green":"0","blue":"0","alpha":"1.0","hue":"0.0","saturation":"0.0%","lightness":"0.0%"}`), + }, + { + in: "#ffffff", + out: []byte(`{"hex":"#ffffff","hex_stripped":"ffffff","hex_alpha":"#ffffffff","hex_alpha_stripped":"ffffffff","alpha_hex":"#ffffffff","alpha_hex_stripped":"ffffffff","rgb":"rgb(255, 255, 255)","rgba":"rgba(255, 255, 255, 1.0)","hsl":"hsl(0.0, 0.0%, 100.0%)","hsla":"hsla(0.0, 0.0%, 100.0%, 1.0)","red":"255","green":"255","blue":"255","alpha":"1.0","hue":"0.0","saturation":"0.0%","lightness":"100.0%"}`), + }, + { + in: "#ff0000", + out: []byte(`{"hex":"#ff0000","hex_stripped":"ff0000","hex_alpha":"#ff0000ff","hex_alpha_stripped":"ff0000ff","alpha_hex":"#ffff0000","alpha_hex_stripped":"ffff0000","rgb":"rgb(255, 0, 0)","rgba":"rgba(255, 0, 0, 1.0)","hsl":"hsl(0.0, 100.0%, 50.0%)","hsla":"hsla(0.0, 100.0%, 50.0%, 1.0)","red":"255","green":"0","blue":"0","alpha":"1.0","hue":"0.0","saturation":"100.0%","lightness":"50.0%"}`), + }, + { + in: "#00ff00", + out: []byte(`{"hex":"#00ff00","hex_stripped":"00ff00","hex_alpha":"#00ff00ff","hex_alpha_stripped":"00ff00ff","alpha_hex":"#ff00ff00","alpha_hex_stripped":"ff00ff00","rgb":"rgb(0, 255, 0)","rgba":"rgba(0, 255, 0, 1.0)","hsl":"hsl(120.0, 100.0%, 50.0%)","hsla":"hsla(120.0, 100.0%, 50.0%, 1.0)","red":"0","green":"255","blue":"0","alpha":"1.0","hue":"120.0","saturation":"100.0%","lightness":"50.0%"}`), + }, + { + in: "#0000ff", + out: []byte(`{"hex":"#0000ff","hex_stripped":"0000ff","hex_alpha":"#0000ffff","hex_alpha_stripped":"0000ffff","alpha_hex":"#ff0000ff","alpha_hex_stripped":"ff0000ff","rgb":"rgb(0, 0, 255)","rgba":"rgba(0, 0, 255, 1.0)","hsl":"hsl(240.0, 100.0%, 50.0%)","hsla":"hsla(240.0, 100.0%, 50.0%, 1.0)","red":"0","green":"0","blue":"255","alpha":"1.0","hue":"240.0","saturation":"100.0%","lightness":"50.0%"}`), + }, + { + in: "#625690", + out: []byte(`{"hex":"#625690","hex_stripped":"625690","hex_alpha":"#625690ff","hex_alpha_stripped":"625690ff","alpha_hex":"#ff625690","alpha_hex_stripped":"ff625690","rgb":"rgb(98, 86, 144)","rgba":"rgba(98, 86, 144, 1.0)","hsl":"hsl(252.4, 25.2%, 45.1%)","hsla":"hsla(252.4, 25.2%, 45.1%, 1.0)","red":"98","green":"86","blue":"144","alpha":"1.0","hue":"252.4","saturation":"25.2%","lightness":"45.1%"}`), + }, + { + in: "#ff00ff", + out: []byte(`{"hex":"#ff00ff","hex_stripped":"ff00ff","hex_alpha":"#ff00ffff","hex_alpha_stripped":"ff00ffff","alpha_hex":"#ffff00ff","alpha_hex_stripped":"ffff00ff","rgb":"rgb(255, 0, 255)","rgba":"rgba(255, 0, 255, 1.0)","hsl":"hsl(300.0, 100.0%, 50.0%)","hsla":"hsla(300.0, 100.0%, 50.0%, 1.0)","red":"255","green":"0","blue":"255","alpha":"1.0","hue":"300.0","saturation":"100.0%","lightness":"50.0%"}`), + }, + { + in: "#888888", + out: []byte(`{"hex":"#888888","hex_stripped":"888888","hex_alpha":"#888888ff","hex_alpha_stripped":"888888ff","alpha_hex":"#ff888888","alpha_hex_stripped":"ff888888","rgb":"rgb(136, 136, 136)","rgba":"rgba(136, 136, 136, 1.0)","hsl":"hsl(0.0, 0.0%, 53.3%)","hsla":"hsla(0.0, 0.0%, 53.3%, 1.0)","red":"136","green":"136","blue":"136","alpha":"1.0","hue":"0.0","saturation":"0.0%","lightness":"53.3%"}`), + }, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + ci := NewColorInfo(tt.in) + out, err := json.Marshal(ci) + if err != nil { + t.Error("Failed encoding", err) + } + if !bytes.Equal(out, tt.out) { + t.Errorf("Color output mismatch, got = %s, expected %s", out, tt.out) + } + }) + } +} + func TestRoundTripConversion(t *testing.T) { testColors := []string{"#000000", "#ffffff", "#ff0000", "#00ff00", "#0000ff", "#625690", "#808080"}