dank16: use the real primary_container and fix light-mode derivation

fixes #3405
fixes #3406
ref #3408
port 1.6
This commit is contained in:
bbedward
2026-09-16 10:05:30 -04:00
parent 8c975630ef
commit 641d65cbd9
4 changed files with 176 additions and 41 deletions
+5 -5
View File
@@ -79,11 +79,11 @@ func runDank16(cmd *cobra.Command, args []string) {
primaryLight = primaryColor
}
variantOpts := dank16.VariantOptions{
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
Background: background,
UseDPS: contrastAlgo == "dps",
IsLightMode: isLight,
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
BackgroundDark: background,
UseDPS: contrastAlgo == "dps",
IsLightMode: isLight,
}
variantColors := dank16.GenerateVariantPalette(variantOpts)
fmt.Print(dank16.GenerateVariantJSON(variantColors))
+40 -24
View File
@@ -455,6 +455,7 @@ func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLig
type PaletteOptions struct {
IsLight bool
Background string
Container string
UseDPS bool
}
@@ -520,8 +521,18 @@ func DeriveContainer(primary string, isLight bool) string {
return RGBToHex(HSVToRGB(HSV{H: hsv.H, S: containerS, V: containerV}))
}
// DeriveContainer only approximates the Material tone pairing, so a caller that
// has the real primary_container should pass it: color5 and color6 are the
// primary/container pair and their contrast with each other is the invariant.
func resolveContainer(primaryColor string, opts PaletteOptions) string {
if opts.Container != "" {
return opts.Container
}
return DeriveContainer(primaryColor, opts.IsLight)
}
func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
baseColor := DeriveContainer(primaryColor, opts.IsLight)
baseColor := resolveContainer(primaryColor, opts)
rgb := HexToRGB(baseColor)
hsv := RGBToHSV(rgb)
@@ -531,13 +542,20 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
var palette Palette
var normalTextTarget, secondaryTarget float64
if opts.UseDPS {
// Delta Phi Star is not symmetric across polarity: on a light background the
// same Lc buys far less real contrast, so the light targets are the numbers
// that land on the same measured WCAG ratios the dark ones already deliver.
var normalTextTarget, accentTarget float64
switch {
case opts.UseDPS && opts.IsLight:
normalTextTarget = 70.0
accentTarget = 58.0
case opts.UseDPS:
normalTextTarget = 40.0
secondaryTarget = 35.0
} else {
accentTarget = 24.5
default:
normalTextTarget = 4.5
secondaryTarget = 3.0
accentTarget = 2.1
}
var bgColor string
@@ -557,8 +575,6 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
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
@@ -570,15 +586,13 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
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))
palette.Color3 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, normalTextTarget, 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.Color5 = NewColorInfo(baseColor)
palette.Color6 = NewColorInfo(primaryColor)
@@ -604,8 +618,9 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
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)
color13S := ph.S * 0.7
color13V := math.Min(ph.V*1.3, 1.0)
palette.Color13 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: color13S, V: color13V})), bgColor, accentTarget, opts))
brightCyanS := ph.S * 0.5
brightCyanV := math.Min(ph.V*1.3, 1.0)
@@ -632,9 +647,7 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
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.Color5 = NewColorInfo(baseColor)
palette.Color6 = NewColorInfo(primaryColor)
@@ -681,11 +694,14 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
}
type VariantOptions struct {
PrimaryDark string
PrimaryLight string
Background string
UseDPS bool
IsLightMode bool
PrimaryDark string
PrimaryLight string
BackgroundDark string
BackgroundLight string
ContainerDark string
ContainerLight string
UseDPS bool
IsLightMode bool
}
func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
@@ -702,8 +718,8 @@ func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
}
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}
darkOpts := PaletteOptions{IsLight: false, Background: opts.BackgroundDark, Container: opts.ContainerDark, UseDPS: opts.UseDPS}
lightOpts := PaletteOptions{IsLight: true, Background: opts.BackgroundLight, Container: opts.ContainerLight, UseDPS: opts.UseDPS}
dark := GeneratePalette(opts.PrimaryDark, darkOpts)
light := GeneratePalette(opts.PrimaryLight, lightOpts)
+111 -1
View File
@@ -701,7 +701,8 @@ func TestGeneratePaletteWithDPS(t *testing.T) {
bgColor := result.Color0.Hex
for i := 1; i < 8; i++ {
// Skip Color5 (container) and Color6 (exact primary) - intentionally not contrast-adjusted
// Color5 is the container and Color6 the exact primary: they pair
// with each other, not with the background
if i == 5 || i == 6 {
continue
}
@@ -862,3 +863,112 @@ func TestGeneratePaletteLightColor8StaysLight(t *testing.T) {
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
}
}
func TestGeneratePaletteLightYellowMeetsNormalTextContrast(t *testing.T) {
primaries := []string{"#63568f", "#7d5800", "#116d2b", "#1c5070", "#8b3f68"}
for _, primary := range primaries {
t.Run(primary, func(t *testing.T) {
palette := GeneratePalette(primary, PaletteOptions{IsLight: true, Background: "#f5fafc", UseDPS: true})
ratio := ContrastRatio(palette.Color3.Hex, palette.Color0.Hex)
if ratio < 4.5 {
t.Errorf("light Color3 %s vs bg %s ratio %.2f, expected >= 4.5 (yellow must clear the normal-text target)",
palette.Color3.Hex, palette.Color0.Hex, ratio)
}
})
}
}
func TestGenerateVariantPaletteUsesPerVariantBackground(t *testing.T) {
const darkBg = "#0f1417"
const lightBg = "#f5fafc"
variants := GenerateVariantPalette(VariantOptions{
PrimaryDark: "#8ecff2",
PrimaryLight: "#1c5070",
BackgroundDark: darkBg,
BackgroundLight: lightBg,
UseDPS: true,
})
if variants.Color0.Dark.Hex != darkBg {
t.Errorf("dark Color0 = %s, expected the dark background %s", variants.Color0.Dark.Hex, darkBg)
}
if variants.Color0.Light.Hex != lightBg {
t.Errorf("light Color0 = %s, expected the light background %s", variants.Color0.Light.Hex, lightBg)
}
lightDim := variants.Color8.Light.Hex
if lstar := getLstar(lightDim); lstar < 60.0 {
t.Errorf("light Color8 %s has L* %.2f, expected >= 60 (derived from the dark surface it collapses to near-black)",
lightDim, lstar)
}
if ratio := ContrastRatio(lightDim, lightBg); ratio > 3.0 {
t.Errorf("light Color8 %s vs bg %s ratio %.2f, expected <= 3.0 (bright black stays dim)", lightDim, lightBg, ratio)
}
darkDim := variants.Color8.Dark.Hex
if ratio := ContrastRatio(darkDim, darkBg); ratio > 3.0 {
t.Errorf("dark Color8 %s vs bg %s ratio %.2f, expected <= 3.0", darkDim, darkBg, ratio)
}
}
func TestGeneratePaletteColor5PairsWithColor6(t *testing.T) {
tests := []struct {
name string
primary string
container string
bg string
isLight bool
}{
{"violet dark", "#cdbdff", "#4b3e76", "#141318", false},
{"violet light", "#63568f", "#e8deff", "#fdf7ff", true},
{"green dark", "#9ed49d", "#1f5027", "#101510", false},
{"green light", "#38693c", "#b9f0b8", "#f7fbf2", true},
{"amber light", "#805610", "#ffddb3", "#fff8f4", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
palette := GeneratePalette(tt.primary, PaletteOptions{
IsLight: tt.isLight,
Background: tt.bg,
Container: tt.container,
UseDPS: true,
})
if palette.Color5.Hex != tt.container {
t.Errorf("Color5 = %s, expected the supplied container %s", palette.Color5.Hex, tt.container)
}
if palette.Color6.Hex != tt.primary {
t.Errorf("Color6 = %s, expected the primary %s", palette.Color6.Hex, tt.primary)
}
pair := ContrastRatio(palette.Color5.Hex, palette.Color6.Hex)
if pair < 4.5 {
t.Errorf("Color5 %s vs Color6 %s ratio %.2f, expected >= 4.5 (the container/primary pair carries the prompt themes that use ANSI 5 on 6)",
palette.Color5.Hex, palette.Color6.Hex, pair)
}
if palette.Color5.Hex == palette.Color13.Hex {
t.Errorf("Color5 and Color13 are both %s, expected a distinct bright variant", palette.Color5.Hex)
}
if sep := ContrastRatio(palette.Color5.Hex, palette.Color13.Hex); sep < 2.0 {
t.Errorf("Color5 %s vs Color13 %s ratio %.2f, expected >= 2.0 (normal and bright must be tellable apart)",
palette.Color5.Hex, palette.Color13.Hex, sep)
}
})
}
}
func TestGeneratePaletteFallsBackToDerivedContainer(t *testing.T) {
const primary = "#cdbdff"
palette := GeneratePalette(primary, PaletteOptions{Background: "#141318", UseDPS: true})
want := DeriveContainer(primary, false)
if palette.Color5.Hex != want {
t.Errorf("Color5 = %s with no container supplied, expected the derived %s", palette.Color5.Hex, want)
}
}
+20 -11
View File
@@ -343,7 +343,7 @@ func buildOnce(opts *Options) (bool, error) {
oldColors, _ := os.ReadFile(opts.ColorsOutput())
var primaryDark, primaryLight, surface string
var primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight string
var dank16JSON string
var importArgs []string
var sourceImage string
@@ -373,7 +373,10 @@ func buildOnce(opts *Options) (bool, error) {
log.Info("Using stock/custom theme colors with matugen base")
primaryDark = extractNestedColor(opts.StockColors, "primary", "dark")
primaryLight = extractNestedColor(opts.StockColors, "primary", "light")
surface = extractNestedColor(opts.StockColors, "surface", "dark")
surfaceDark = extractNestedColor(opts.StockColors, "surface", "dark")
surfaceLight = extractNestedColor(opts.StockColors, "surface", "light")
containerDark = extractNestedColor(opts.StockColors, "primary_container", "dark")
containerLight = extractNestedColor(opts.StockColors, "primary_container", "light")
if primaryDark == "" {
return false, fmt.Errorf("failed to extract primary dark from stock colors")
@@ -382,7 +385,7 @@ func buildOnce(opts *Options) (bool, error) {
primaryLight = primaryDark
}
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode)
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode)
importData := fmt.Sprintf(`{"colors": %s, "dank16": %s}`, opts.StockColors, dank16JSON)
importArgs = []string{"--import-json-string", importData}
@@ -403,7 +406,10 @@ func buildOnce(opts *Options) (bool, error) {
primaryDark = extractMatugenColor(matJSON, "primary", "dark")
primaryLight = extractMatugenColor(matJSON, "primary", "light")
surface = extractMatugenColor(matJSON, "surface", "dark")
surfaceDark = extractMatugenColor(matJSON, "surface", "dark")
surfaceLight = extractMatugenColor(matJSON, "surface", "light")
containerDark = extractMatugenColor(matJSON, "primary_container", "dark")
containerLight = extractMatugenColor(matJSON, "primary_container", "light")
if primaryDark == "" {
return false, fmt.Errorf("failed to extract primary color")
@@ -412,7 +418,7 @@ func buildOnce(opts *Options) (bool, error) {
primaryLight = primaryDark
}
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode)
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode)
importArgs = []string{"--import-json-string", buildImportData(dank16JSON, sourceImage)}
log.Infof("Running matugen %s with dank16 injection", opts.Kind)
@@ -1125,13 +1131,16 @@ func extractNestedColor(jsonStr, colorName, variant string) string {
return color
}
func generateDank16Variants(primaryDark, primaryLight, surface string, mode ColorMode) string {
func generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight string, mode ColorMode) string {
variantOpts := dank16.VariantOptions{
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
Background: surface,
UseDPS: true,
IsLightMode: mode == ColorModeLight,
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
BackgroundDark: surfaceDark,
BackgroundLight: surfaceLight,
ContainerDark: containerDark,
ContainerLight: containerLight,
UseDPS: true,
IsLightMode: mode == ColorModeLight,
}
variantColors := dank16.GenerateVariantPalette(variantOpts)
return dank16.GenerateVariantJSON(variantColors)