diff --git a/core/internal/dank16/dank16.go b/core/internal/dank16/dank16.go index fedd78376..f0705bbf1 100644 --- a/core/internal/dank16/dank16.go +++ b/core/internal/dank16/dank16.go @@ -313,6 +313,7 @@ func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode b fg := HexToRGB(hexColor) cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B} Lf, af, bf := cf.Lab() + Lf *= 100.0 dir := 1.0 if isLightMode { @@ -341,6 +342,7 @@ func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLig fg := HexToRGB(hexColor) cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B} origL, af, bf := cf.Lab() + origL *= 100.0 var darkerResult, lighterResult string darkerL, lighterL := origL, origL @@ -420,6 +422,24 @@ func blendHue(base, target, factor float64) float64 { return result } +// color8 sits a fixed L* offset from the background so it keeps its dim role +// regardless of primary brightness (conventional palettes put ANSI bright +// black ~2-2.5:1 from the background, e.g. catppuccin-mocha #585b70 on #1e1e2e) +func DeriveDim(bgHex string, hue, sat float64, isLight bool) string { + offset := 22.0 + if isLight { + offset = -offset + } + + bgL := getLstar(bgHex) + targetL := math.Max(0, math.Min(100, bgL+offset)) + + tint := HSVToRGB(HSV{H: hue, S: sat, V: 0.5}) + c := colorful.Color{R: tint.R, G: tint.G, B: tint.B} + _, af, bf := c.Lab() + return labToHex(targetL, af, bf) +} + func DeriveContainer(primary string, isLight bool) string { rgb := HexToRGB(primary) hsv := RGBToHSV(rgb) @@ -500,10 +520,7 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette { 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)) + palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.05, true)) brightRedS := math.Min(baseSat*1.0, 1.0) brightRedV := math.Min(baseVal*1.2, 1.0) @@ -559,9 +576,7 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette { 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)) + palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.15, false)) brightRedS := math.Min(baseSat*0.75, 1.0) brightRedV := math.Min(baseVal*1.35, 1.0) diff --git a/core/internal/dank16/dank16_test.go b/core/internal/dank16/dank16_test.go index 9e6b5a8cb..a73acbb8c 100644 --- a/core/internal/dank16/dank16_test.go +++ b/core/internal/dank16/dank16_test.go @@ -679,3 +679,73 @@ func TestContrastAlgorithmComparison(t *testing.T) { t.Logf("WCAG and DPS palettes differ in %d/16 colors", differentCount) } + +func TestEnsureContrastDPSLightModeStaysLight(t *testing.T) { + tests := []struct { + name string + result string + bg string + minLstar float64 + }{ + { + name: "bidirectional adjustment", + result: EnsureContrastDPSBidirectional("#d0ccc6", "#f8f8f8", 17.5, true), + bg: "#f8f8f8", + minLstar: 20.0, + }, + { + name: "lstar adjustment", + result: EnsureContrastDPSLstar("#c0c0c0", "#f8f8f8", 30.0, true), + bg: "#f8f8f8", + minLstar: 20.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lstar := getLstar(tt.result) + if lstar < tt.minLstar { + t.Errorf("result %s has L* %.2f on light bg %s, expected >= %.2f (collapsed to near-black)", + tt.result, lstar, tt.bg, tt.minLstar) + } + }) + } +} + +func TestGeneratePaletteColor8Dim(t *testing.T) { + hues := []string{"#e91e63", "#f59e0b", "#22c55e", "#06b6d4", "#8b5cf6", "#ef4444"} + + for _, base := range hues { + t.Run(base, func(t *testing.T) { + palette := GeneratePalette(base, PaletteOptions{IsLight: false, UseDPS: true}) + + bgRatio := ContrastRatio(palette.Color8.Hex, palette.Color0.Hex) + if bgRatio < 1.5 || bgRatio > 3.0 { + t.Errorf("Color8 %s vs bg %s ratio %.2f, expected 1.5-3.0 (bright black stays near bg)", + palette.Color8.Hex, palette.Color0.Hex, bgRatio) + } + + sepRatio := ContrastRatio(palette.Color4.Hex, palette.Color8.Hex) + if sepRatio < 2.0 { + t.Errorf("Color4 %s vs Color8 %s ratio %.2f, expected >= 2.0 (blue must not collide with bright black)", + palette.Color4.Hex, palette.Color8.Hex, sepRatio) + } + }) + } +} + +func TestGeneratePaletteLightColor8StaysLight(t *testing.T) { + palette := GeneratePalette("#f59e0b", PaletteOptions{IsLight: true, UseDPS: true}) + + lstar := getLstar(palette.Color8.Hex) + if lstar < 60.0 { + t.Errorf("light mode Color8 %s has L* %.2f, expected >= 60 (dim grey, not near-black)", + palette.Color8.Hex, lstar) + } + + bgRatio := ContrastRatio(palette.Color8.Hex, palette.Color0.Hex) + if bgRatio > 3.0 { + t.Errorf("light mode Color8 %s vs bg %s ratio %.2f, expected <= 3.0", + palette.Color8.Hex, palette.Color0.Hex, bgRatio) + } +}