diff --git a/core/cmd/dms/commands_dank16.go b/core/cmd/dms/commands_dank16.go index 0bbbab1cf..f4d01d21b 100644 --- a/core/cmd/dms/commands_dank16.go +++ b/core/cmd/dms/commands_dank16.go @@ -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)) diff --git a/core/internal/dank16/dank16.go b/core/internal/dank16/dank16.go index 62d5aa131..07def621b 100644 --- a/core/internal/dank16/dank16.go +++ b/core/internal/dank16/dank16.go @@ -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) diff --git a/core/internal/dank16/dank16_test.go b/core/internal/dank16/dank16_test.go index da163d42c..fc44c8088 100644 --- a/core/internal/dank16/dank16_test.go +++ b/core/internal/dank16/dank16_test.go @@ -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) + } +} diff --git a/core/internal/matugen/matugen.go b/core/internal/matugen/matugen.go index 5f9421abf..632a5e00f 100644 --- a/core/internal/matugen/matugen.go +++ b/core/internal/matugen/matugen.go @@ -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)