diff --git a/pkg/tui/components/message/message.go b/pkg/tui/components/message/message.go index 533816dc4..39fdf9c89 100644 --- a/pkg/tui/components/message/message.go +++ b/pkg/tui/components/message/message.go @@ -178,7 +178,7 @@ func (mv *messageModel) senderPrefix(sender string) string { if sender == "" { return "" } - return styles.AgentBadgeStyle.MarginLeft(2).Render(sender) + "\n\n" + return styles.AgentBadgeStyleFor(sender).MarginLeft(2).Render(sender) + "\n\n" } // sameAgentAsPrevious returns true if the previous message was from the same agent diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go index 81ba4e53f..4b3f2bd74 100644 --- a/pkg/tui/components/sidebar/sidebar.go +++ b/pkg/tui/components/sidebar/sidebar.go @@ -1091,17 +1091,17 @@ func (m *model) agentInfo(contentWidth int) string { } func (m *model) renderAgentEntry(content *strings.Builder, agent runtime.AgentDetails, isCurrent bool, index, contentWidth int) { + agentStyle := styles.AgentAccentStyleFor(agent.Name) var prefix string if isCurrent { if m.workingAgent == agent.Name { - // Style the spinner with the same green as the agent name - prefix = styles.TabAccentStyle.Render(m.spinner.View()) + " " + prefix = agentStyle.Render(m.spinner.View()) + " " } else { - prefix = styles.TabAccentStyle.Render("▶") + " " + prefix = agentStyle.Render("▶") + " " } } // Agent name - agentNameText := prefix + styles.TabAccentStyle.Render(agent.Name) + agentNameText := prefix + agentStyle.Render(agent.Name) // Shortcut hint (^1, ^2, etc.) - show for agents 1-9 var shortcutHint string if index >= 0 && index < 9 { diff --git a/pkg/tui/components/tabbar/tab.go b/pkg/tui/components/tabbar/tab.go index 96976343e..2c5fe2b07 100644 --- a/pkg/tui/components/tabbar/tab.go +++ b/pkg/tui/components/tabbar/tab.go @@ -1,9 +1,7 @@ package tabbar import ( - "fmt" "image/color" - "math" "charm.land/lipgloss/v2" @@ -26,17 +24,6 @@ const ( // attentionIndicator is shown before the title when the tab needs attention, // replacing the running indicator to signal that user action is required. attentionIndicator = "! " - // mutedContrastStrength controls how much the muted foreground shifts - // away from the background (0.0 = invisible, 1.0 = full black/white). - mutedContrastStrength = 0.45 - // minIndicatorContrast is the minimum WCAG contrast ratio required for - // semantic indicator colors (running dot, attention bang). If the themed - // color doesn't meet this threshold against the tab background, it is - // automatically boosted while preserving its hue. - minIndicatorContrast = 4.5 - // maxBoostSteps limits the blend iterations in ensureContrast to avoid - // infinite loops on degenerate inputs. - maxBoostSteps = 20 // dragSourceColorBoost controls how much the drag source tab is blended toward // the active tab colors when it is not the active tab. @@ -67,106 +54,6 @@ func (t Tab) Width() int { return t.width } // and the close-button click area begins. func (t Tab) MainZoneEnd() int { return t.mainZoneEnd } -// --- Color helpers --- - -// sRGBLuminance returns the relative luminance of an sRGB color using the -// WCAG 2.x formula (linearized channel values, ITU-R BT.709 coefficients). -func sRGBLuminance(r, g, b float64) float64 { - linearize := func(c float64) float64 { - if c <= 0.03928 { - return c / 12.92 - } - return math.Pow((c+0.055)/1.055, 2.4) - } - return 0.2126*linearize(r) + 0.7152*linearize(g) + 0.0722*linearize(b) -} - -// colorToLinear extracts normalized [0,1] sRGB components from a color.Color. -func colorToLinear(c color.Color) (float64, float64, float64) { - r, g, b, _ := c.RGBA() - return float64(r) / 65535, float64(g) / 65535, float64(b) / 65535 -} - -// contrastRatio returns the WCAG 2.x contrast ratio between two colors. -func contrastRatio(fg, bg color.Color) float64 { - r1, g1, b1 := colorToLinear(fg) - r2, g2, b2 := colorToLinear(bg) - l1 := sRGBLuminance(r1, g1, b1) - l2 := sRGBLuminance(r2, g2, b2) - lighter := max(l1, l2) - darker := min(l1, l2) - return (lighter + 0.05) / (darker + 0.05) -} - -// toHexColor formats normalized [0,1] RGB components as a lipgloss color. -func toHexColor(r, g, b float64) color.Color { - clamp := func(v float64) int { - if v < 0 { - return 0 - } - if v > 1 { - return 255 - } - return int(v * 255) - } - return lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", clamp(r), clamp(g), clamp(b))) -} - -// mutedContrastFg returns a foreground color that is visible but subtle against -// the given background. It blends the background toward white (for dark bg) or -// black (for light bg) by mutedContrastStrength. -func mutedContrastFg(bg color.Color) color.Color { - rf, gf, bf := colorToLinear(bg) - - // Perceived luminance for direction decision (BT.601 for perceptual balance). - lum := 0.299*rf + 0.587*gf + 0.114*bf - - var tgt float64 - if lum > 0.5 { - tgt = 0.0 - } else { - tgt = 1.0 - } - - s := mutedContrastStrength - return toHexColor(rf+(tgt-rf)*s, gf+(tgt-gf)*s, bf+(tgt-bf)*s) -} - -// ensureContrast returns fg unchanged if it already meets minIndicatorContrast -// against bg. Otherwise it progressively blends fg toward white (on dark bg) or -// black (on light bg) until the threshold is met, preserving the original hue. -func ensureContrast(fg, bg color.Color) color.Color { - if contrastRatio(fg, bg) >= minIndicatorContrast { - return fg - } - - rf, gf, bf := colorToLinear(fg) - bgR, bgG, bgB := colorToLinear(bg) - bgLum := sRGBLuminance(bgR, bgG, bgB) - - // Blend toward white on dark backgrounds, toward black on light ones. - var tR, tG, tB float64 - if bgLum > 0.5 { - tR, tG, tB = 0, 0, 0 - } else { - tR, tG, tB = 1, 1, 1 - } - - for step := 1; step <= maxBoostSteps; step++ { - t := float64(step) / float64(maxBoostSteps) - nr := rf + (tR-rf)*t - ng := gf + (tG-gf)*t - nb := bf + (tB-bf)*t - candidate := toHexColor(nr, ng, nb) - if contrastRatio(candidate, bg) >= minIndicatorContrast { - return candidate - } - } - - // Fallback: full contrast direction (should always meet the threshold). - return toHexColor(tR, tG, tB) -} - // dragRole describes a tab's role during a drag-and-drop operation. type dragRole int @@ -178,9 +65,9 @@ const ( // blendColors mixes two colors by the given ratio (0 = a, 1 = b). func blendColors(a, b color.Color, ratio float64) color.Color { - ar, ag, ab := colorToLinear(a) - br, bg, bb := colorToLinear(b) - return toHexColor( + ar, ag, ab := styles.ColorToRGB(a) + br, bg, bb := styles.ColorToRGB(b) + return styles.RGBToColor( ar+(br-ar)*ratio, ag+(bg-ag)*ratio, ab+(bb-ab)*ratio, @@ -225,7 +112,7 @@ func renderTab(info messages.TabInfo, maxTitleLen, animFrame int, role dragRole) } // Close button color derived from this tab's background. - closeFg := mutedContrastFg(bgColor) + closeFg := styles.MutedContrastFg(bgColor) // Fade all foreground elements when this tab is a bystander during drag. if role == dragRoleBystander { @@ -250,13 +137,13 @@ func renderTab(info messages.TabInfo, maxTitleLen, animFrame int, role dragRole) case info.NeedsAttention: // Attention takes priority over running: replace the streaming dot // with a warning-colored indicator so it's obvious the tab needs action. - attnFg := ensureContrast(styles.Warning, bgColor) + attnFg := styles.EnsureContrast(styles.Warning, bgColor) if role == dragRoleBystander { attnFg = blendColors(attnFg, bgColor, dragBystanderDimAmount) } content += lipgloss.NewStyle().Foreground(attnFg).Background(bgColor).Bold(true).Render(attentionIndicator) case info.IsRunning && !info.IsActive: - runFg := ensureContrast(styles.TabAccentFg, bgColor) + runFg := styles.EnsureContrast(styles.TabAccentFg, bgColor) if role == dragRoleBystander { runFg = blendColors(runFg, bgColor, dragBystanderDimAmount) } diff --git a/pkg/tui/components/tabbar/tabbar.go b/pkg/tui/components/tabbar/tabbar.go index 12a5bbb38..dfa9f5db3 100644 --- a/pkg/tui/components/tabbar/tabbar.go +++ b/pkg/tui/components/tabbar/tabbar.go @@ -408,11 +408,11 @@ func (t *TabBar) View() string { } // Compute "+" and arrow colors dynamically from the terminal background. - chromeFg := mutedContrastFg(styles.Background) + chromeFg := styles.MutedContrastFg(styles.Background) plusStyle := lipgloss.NewStyle().Foreground(chromeFg) arrowStyle := lipgloss.NewStyle().Foreground(chromeFg) // Attention arrow style: warning-colored and bold so off-screen attention tabs are obvious. - attnArrowStyle := lipgloss.NewStyle().Foreground(ensureContrast(styles.Warning, styles.Background)).Bold(true) + attnArrowStyle := lipgloss.NewStyle().Foreground(styles.EnsureContrast(styles.Warning, styles.Background)).Bold(true) var line string var cursor int @@ -436,7 +436,7 @@ func (t *TabBar) View() string { var dropLine string visualDrop := noTab if t.drag.active && !t.drag.isNoOp() { - dropFg := ensureContrast(styles.TabAccentFg, styles.Background) + dropFg := styles.EnsureContrast(styles.TabAccentFg, styles.Background) dropLine = lipgloss.NewStyle().Foreground(dropFg).Render(dropIndicator) visualDrop = t.drag.dropIdx } diff --git a/pkg/tui/components/tool/handoff/handoff.go b/pkg/tui/components/tool/handoff/handoff.go index d95cc09cc..63f39b496 100644 --- a/pkg/tui/components/tool/handoff/handoff.go +++ b/pkg/tui/components/tool/handoff/handoff.go @@ -22,5 +22,5 @@ func render(msg *types.Message, _ spinner.Spinner, _ service.SessionStateReader, return "" } - return styles.AgentBadgeStyle.MarginLeft(2).Render(msg.Sender) + " ─► " + styles.AgentBadgeStyle.Render(params.Agent) + return styles.AgentBadgeStyleFor(msg.Sender).MarginLeft(2).Render(msg.Sender) + " ─► " + styles.AgentBadgeStyleFor(params.Agent).Render(params.Agent) } diff --git a/pkg/tui/components/tool/transfertask/transfertask.go b/pkg/tui/components/tool/transfertask/transfertask.go index 90e32a382..6eef2a607 100644 --- a/pkg/tui/components/tool/transfertask/transfertask.go +++ b/pkg/tui/components/tool/transfertask/transfertask.go @@ -25,9 +25,9 @@ func render(msg *types.Message, _ spinner.Spinner, _ service.SessionStateReader, return "" } - header := styles.AgentBadgeStyle.MarginLeft(2).Render(msg.Sender) + + header := styles.AgentBadgeStyleFor(msg.Sender).MarginLeft(2).Render(msg.Sender) + " calls " + - styles.AgentBadgeStyle.Render(params.Agent) + styles.AgentBadgeStyleFor(params.Agent).Render(params.Agent) // Calculate the icon with its margin icon := styles.ToolCompletedIcon.Render("✓") diff --git a/pkg/tui/service/sessionstate.go b/pkg/tui/service/sessionstate.go index 3b41f2f1a..694447940 100644 --- a/pkg/tui/service/sessionstate.go +++ b/pkg/tui/service/sessionstate.go @@ -3,6 +3,7 @@ package service import ( "github.com/docker/cagent/pkg/runtime" "github.com/docker/cagent/pkg/session" + "github.com/docker/cagent/pkg/tui/styles" "github.com/docker/cagent/pkg/tui/types" "github.com/docker/cagent/pkg/userconfig" ) @@ -116,6 +117,12 @@ func (s *SessionState) AvailableAgents() []runtime.AgentDetails { func (s *SessionState) SetAvailableAgents(availableAgents []runtime.AgentDetails) { s.availableAgents = availableAgents + + names := make([]string, len(availableAgents)) + for i, a := range availableAgents { + names[i] = a.Name + } + styles.SetAgentOrder(names) } func (s *SessionState) GetCurrentAgent() runtime.AgentDetails { diff --git a/pkg/tui/styles/agent_colors.go b/pkg/tui/styles/agent_colors.go new file mode 100644 index 000000000..29d38391b --- /dev/null +++ b/pkg/tui/styles/agent_colors.go @@ -0,0 +1,162 @@ +package styles + +import ( + "image/color" + "sync" + + "charm.land/lipgloss/v2" +) + +// AgentBadgeColors holds the resolved foreground and background colors for an agent badge. +type AgentBadgeColors struct { + Fg color.Color + Bg color.Color +} + +// cachedBadgeStyle holds a precomputed badge style for a palette index. +type cachedBadgeStyle struct { + colors AgentBadgeColors + style lipgloss.Style +} + +// agentRegistry maps agent names to their index in the team list and holds +// precomputed styles for each palette entry. +var agentRegistry struct { + sync.RWMutex + indices map[string]int + badgeStyles []cachedBadgeStyle + accentStyles []lipgloss.Style +} + +// SetAgentOrder updates the agent name → index mapping and rebuilds the style cache. +// Call this when the team info changes (e.g., on TeamInfoEvent). +func SetAgentOrder(agentNames []string) { + agentRegistry.Lock() + defer agentRegistry.Unlock() + + agentRegistry.indices = make(map[string]int, len(agentNames)) + for i, name := range agentNames { + agentRegistry.indices[name] = i + } + + rebuildAgentColorCache() +} + +// rebuildAgentColorCache precomputes badge and accent styles from the current theme's hues. +// Must be called with agentRegistry.Lock held. +func rebuildAgentColorCache() { + theme := CurrentTheme() + + hues := theme.Colors.AgentHues + if len(hues) == 0 { + hues = DefaultAgentHues + } + + bg := lipgloss.Color(theme.Colors.Background) + badgeColors := GenerateBadgePalette(hues, bg) + accentColors := GenerateAccentPalette(hues, bg) + + agentRegistry.badgeStyles = make([]cachedBadgeStyle, len(badgeColors)) + for i, bgColor := range badgeColors { + r, g, b := ColorToRGB(bgColor) + bgHex := RGBToHex(r, g, b) + fgHex := BestForegroundHex( + bgHex, + theme.Colors.TextBright, + theme.Colors.Background, + "#000000", + "#ffffff", + ) + colors := AgentBadgeColors{ + Fg: lipgloss.Color(fgHex), + Bg: bgColor, + } + agentRegistry.badgeStyles[i] = cachedBadgeStyle{ + colors: colors, + style: BaseStyle. + Foreground(colors.Fg). + Background(colors.Bg). + Padding(0, 1), + } + } + + agentRegistry.accentStyles = make([]lipgloss.Style, len(accentColors)) + for i, c := range accentColors { + agentRegistry.accentStyles[i] = BaseStyle.Foreground(c) + } +} + +// InvalidateAgentColorCache rebuilds the cached agent styles. +// Call this after a theme change so colors are recalculated against the new background. +func InvalidateAgentColorCache() { + agentRegistry.Lock() + defer agentRegistry.Unlock() + + rebuildAgentColorCache() +} + +// AgentBadgeColorsFor returns the badge foreground/background colors for a given agent name. +func AgentBadgeColorsFor(agentName string) AgentBadgeColors { + agentRegistry.RLock() + defer agentRegistry.RUnlock() + + idx, ok := agentRegistry.indices[agentName] + if !ok { + return AgentBadgeColors{ + Fg: lipgloss.Color("#ffffff"), + Bg: lipgloss.Color("#1D63ED"), + } + } + + size := len(agentRegistry.badgeStyles) + if size > 0 { + return agentRegistry.badgeStyles[idx%size].colors + } + + return AgentBadgeColors{ + Fg: lipgloss.Color("#ffffff"), + Bg: lipgloss.Color("#1D63ED"), + } +} + +// AgentBadgeStyleFor returns a lipgloss badge style colored for the given agent. +func AgentBadgeStyleFor(agentName string) lipgloss.Style { + agentRegistry.RLock() + defer agentRegistry.RUnlock() + + idx, ok := agentRegistry.indices[agentName] + if !ok { + return BaseStyle. + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#1D63ED")). + Padding(0, 1) + } + + size := len(agentRegistry.badgeStyles) + if size > 0 { + return agentRegistry.badgeStyles[idx%size].style + } + + return BaseStyle. + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#1D63ED")). + Padding(0, 1) +} + +// AgentAccentStyleFor returns a foreground-only style for agent names (used in sidebar). +func AgentAccentStyleFor(agentName string) lipgloss.Style { + agentRegistry.RLock() + defer agentRegistry.RUnlock() + + idx, ok := agentRegistry.indices[agentName] + if !ok { + return BaseStyle.Foreground(lipgloss.Color("#98C379")) + } + + size := len(agentRegistry.accentStyles) + if size > 0 { + return agentRegistry.accentStyles[idx%size] + } + + return BaseStyle.Foreground(lipgloss.Color("#98C379")) +} diff --git a/pkg/tui/styles/agent_colors_test.go b/pkg/tui/styles/agent_colors_test.go new file mode 100644 index 000000000..d95e36492 --- /dev/null +++ b/pkg/tui/styles/agent_colors_test.go @@ -0,0 +1,392 @@ +package styles + +import ( + "fmt" + "testing" + + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Agent registry and color assignment tests --- + +func TestAgentBadgeStyleFor_UsesRegisteredOrder(t *testing.T) { + SetAgentOrder([]string{"root", "git-agent", "docs-writer"}) + defer SetAgentOrder(nil) + + // Each agent should get a distinct style based on its position. + r1 := AgentBadgeStyleFor("root").Render("x") + r2 := AgentBadgeStyleFor("git-agent").Render("x") + r3 := AgentBadgeStyleFor("docs-writer").Render("x") + assert.NotEqual(t, r1, r2) + assert.NotEqual(t, r2, r3) + assert.NotEqual(t, r1, r3) +} + +func TestAgentBadgeStyleFor_UnknownAgentReturnsFallback(t *testing.T) { + SetAgentOrder([]string{"root", "git-agent"}) + defer SetAgentOrder(nil) + + // Unknown agent should get the fallback style, same as calling with no registration. + s := AgentBadgeStyleFor("unknown-agent").Render("x") + require.NotEmpty(t, s) +} + +func TestAgentBadgeStyleFor_WrapsAroundPaletteSize(t *testing.T) { + agentRegistry.RLock() + size := len(agentRegistry.badgeStyles) + agentRegistry.RUnlock() + require.Positive(t, size) + + agents := make([]string, size+3) + for i := range agents { + agents[i] = fmt.Sprintf("agent-%d", i) + } + SetAgentOrder(agents) + defer SetAgentOrder(nil) + + // The last agent wraps around, so it should match the style at (size+2)%size. + last := AgentBadgeStyleFor(agents[len(agents)-1]).Render("x") + wrapped := AgentBadgeStyleFor(agents[(size+2)%size]).Render("x") + assert.Equal(t, last, wrapped) +} + +func TestAgentBadgeStyleFor_EmptyRegistryReturnsFallback(t *testing.T) { + SetAgentOrder(nil) + defer SetAgentOrder(nil) + + s := AgentBadgeStyleFor("anything").Render("x") + require.NotEmpty(t, s) +} + +func TestSetAgentOrder_UpdatesRegistry(t *testing.T) { + SetAgentOrder([]string{"a", "b", "c"}) + defer SetAgentOrder(nil) + + styleA1 := AgentBadgeStyleFor("a").Render("x") + styleC1 := AgentBadgeStyleFor("c").Render("x") + assert.NotEqual(t, styleA1, styleC1) + + // Swap order: a and c should exchange styles. + SetAgentOrder([]string{"c", "b", "a"}) + styleA2 := AgentBadgeStyleFor("a").Render("x") + styleC2 := AgentBadgeStyleFor("c").Render("x") + assert.Equal(t, styleA1, styleC2, "c at index 0 should match a's previous index-0 style") + assert.Equal(t, styleC1, styleA2, "a at index 2 should match c's previous index-2 style") +} + +// --- Style rendering tests --- + +func TestAgentBadgeStyleFor_ProducesDifferentStylesPerIndex(t *testing.T) { + SetAgentOrder([]string{"root", "docs-writer"}) + defer SetAgentOrder(nil) + + rendered1 := AgentBadgeStyleFor("root").Render("root") + rendered2 := AgentBadgeStyleFor("docs-writer").Render("docs-writer") + + require.NotEmpty(t, rendered1) + require.NotEmpty(t, rendered2) + assert.NotEqual(t, rendered1, rendered2) +} + +func TestAgentBadgeStyleFor_Deterministic(t *testing.T) { + SetAgentOrder([]string{"root"}) + defer SetAgentOrder(nil) + + s1 := AgentBadgeStyleFor("root").Render("root") + s2 := AgentBadgeStyleFor("root").Render("root") + assert.Equal(t, s1, s2) +} + +func TestAgentAccentStyleFor_Deterministic(t *testing.T) { + SetAgentOrder([]string{"root"}) + defer SetAgentOrder(nil) + + s1 := AgentAccentStyleFor("root").Render("root") + s2 := AgentAccentStyleFor("root").Render("root") + assert.Equal(t, s1, s2) +} + +func TestAgentBadgeColorsFor_HasFgAndBg(t *testing.T) { + SetAgentOrder([]string{"root"}) + defer SetAgentOrder(nil) + + colors := AgentBadgeColorsFor("root") + assert.NotNil(t, colors.Fg) + assert.NotNil(t, colors.Bg) +} + +// --- Cache tests --- + +func TestSetAgentOrder_PopulatesCache(t *testing.T) { + SetAgentOrder([]string{"root", "docs-writer"}) + defer SetAgentOrder(nil) + + agentRegistry.RLock() + defer agentRegistry.RUnlock() + + assert.NotEmpty(t, agentRegistry.badgeStyles) + assert.NotEmpty(t, agentRegistry.accentStyles) + assert.Len(t, agentRegistry.accentStyles, len(agentRegistry.badgeStyles)) +} + +func TestInvalidateAgentColorCache_RebuildsCachedStyles(t *testing.T) { + SetAgentOrder([]string{"root"}) + defer SetAgentOrder(nil) + + before := AgentBadgeStyleFor("root").Render("root") + InvalidateAgentColorCache() + after := AgentBadgeStyleFor("root").Render("root") + + assert.Equal(t, before, after, "cache rebuild with same theme should produce identical styles") +} + +func TestAgentBadgeStyleFor_UsesCachedStyle(t *testing.T) { + SetAgentOrder([]string{"a", "b"}) + defer SetAgentOrder(nil) + + for range 100 { + s := AgentBadgeStyleFor("b").Render("b") + require.NotEmpty(t, s) + } +} + +// --- Layer 1: WCAG contrast validation across all themes --- + +const ( + // minBadgeContrast is the WCAG AA minimum for normal text. + minBadgeContrast = 4.5 + // minAccentContrast is the WCAG AA minimum for large/bold text. + minAccentContrast = 3.0 +) + +func TestAllBuiltinThemes_AgentBadgeContrast(t *testing.T) { + t.Parallel() + + refs, err := listBuiltinThemeRefs() + require.NoError(t, err) + require.NotEmpty(t, refs) + + for _, ref := range refs { + t.Run(ref, func(t *testing.T) { + t.Parallel() + + theme, err := LoadTheme(ref) + require.NoError(t, err) + + hues := theme.Colors.AgentHues + if len(hues) == 0 { + hues = DefaultAgentHues + } + + bg := lipgloss.Color(theme.Colors.Background) + badgeColors := GenerateBadgePalette(hues, bg) + + for i, badgeBg := range badgeColors { + r, g, b := ColorToRGB(badgeBg) + bgHex := RGBToHex(r, g, b) + fgHex := BestForegroundHex( + bgHex, + theme.Colors.TextBright, + theme.Colors.Background, + "#000000", + "#ffffff", + ) + fg := lipgloss.Color(fgHex) + + ratio := ContrastRatio(fg, badgeBg) + assert.GreaterOrEqual(t, ratio, minBadgeContrast, + "badge %d (bg=%s, fg=%s) contrast %.2f < %.1f in theme %s", + i, bgHex, fgHex, ratio, minBadgeContrast, ref) + } + }) + } +} + +func TestAllBuiltinThemes_AgentAccentContrast(t *testing.T) { + t.Parallel() + + refs, err := listBuiltinThemeRefs() + require.NoError(t, err) + require.NotEmpty(t, refs) + + for _, ref := range refs { + t.Run(ref, func(t *testing.T) { + t.Parallel() + + theme, err := LoadTheme(ref) + require.NoError(t, err) + + hues := theme.Colors.AgentHues + if len(hues) == 0 { + hues = DefaultAgentHues + } + + bg := lipgloss.Color(theme.Colors.Background) + accentColors := GenerateAccentPalette(hues, bg) + + for i, accent := range accentColors { + ratio := ContrastRatio(accent, bg) + r, g, b := ColorToRGB(accent) + hex := RGBToHex(r, g, b) + assert.GreaterOrEqual(t, ratio, minAccentContrast, + "accent %d (%s) contrast %.2f < %.1f against bg %s in theme %s", + i, hex, ratio, minAccentContrast, theme.Colors.Background, ref) + } + }) + } +} + +// --- Layer 1: Pairwise color distinctness across all themes --- + +const ( + // minColorDistance is the minimum CIE76 ΔE between adjacent palette entries. + // Below ~15 colors become hard to distinguish at a glance. + minColorDistance = 10.0 +) + +func TestAllBuiltinThemes_AgentBadgeDistinctness(t *testing.T) { + t.Parallel() + + refs, err := listBuiltinThemeRefs() + require.NoError(t, err) + require.NotEmpty(t, refs) + + for _, ref := range refs { + t.Run(ref, func(t *testing.T) { + t.Parallel() + + theme, err := LoadTheme(ref) + require.NoError(t, err) + + hues := theme.Colors.AgentHues + if len(hues) == 0 { + hues = DefaultAgentHues + } + + bg := lipgloss.Color(theme.Colors.Background) + palette := GenerateBadgePalette(hues, bg) + + for i := range palette { + for j := i + 1; j < len(palette); j++ { + dist := ColorDistanceCIE76(palette[i], palette[j]) + assert.GreaterOrEqual(t, dist, minColorDistance, + "badge colors %d and %d are too similar (ΔE=%.1f) in theme %s", + i, j, dist, ref) + } + } + }) + } +} + +func TestAllBuiltinThemes_AgentAccentDistinctness(t *testing.T) { + t.Parallel() + + refs, err := listBuiltinThemeRefs() + require.NoError(t, err) + require.NotEmpty(t, refs) + + for _, ref := range refs { + t.Run(ref, func(t *testing.T) { + t.Parallel() + + theme, err := LoadTheme(ref) + require.NoError(t, err) + + hues := theme.Colors.AgentHues + if len(hues) == 0 { + hues = DefaultAgentHues + } + + bg := lipgloss.Color(theme.Colors.Background) + palette := GenerateAccentPalette(hues, bg) + + for i := range palette { + for j := i + 1; j < len(palette); j++ { + dist := ColorDistanceCIE76(palette[i], palette[j]) + assert.GreaterOrEqual(t, dist, minColorDistance, + "accent colors %d and %d are too similar (ΔE=%.1f) in theme %s", + i, j, dist, ref) + } + } + }) + } +} + +// --- Layer 2: Color audit report (run with -v) --- + +func TestAgentColorAuditReport(t *testing.T) { + t.Parallel() + + refs, err := listBuiltinThemeRefs() + require.NoError(t, err) + + for _, ref := range refs { + t.Run(ref, func(t *testing.T) { + t.Parallel() + + theme, err := LoadTheme(ref) + require.NoError(t, err) + + hues := theme.Colors.AgentHues + if len(hues) == 0 { + hues = DefaultAgentHues + } + + bg := lipgloss.Color(theme.Colors.Background) + badges := GenerateBadgePalette(hues, bg) + accents := GenerateAccentPalette(hues, bg) + + t.Logf("\n=== Agent Color Audit: %s (bg: %s) ===", theme.Name, theme.Colors.Background) + t.Logf("%-5s %-10s %-10s %-10s %-12s %-10s %-10s", + "Idx", "Hue", "Badge", "Badge FG", "Badge CR", "Accent", "Accent CR") + t.Logf("%-5s %-10s %-10s %-10s %-12s %-10s %-10s", + "---", "---", "---", "---", "---", "---", "---") + + for i := range hues { + br, bg2, bb := ColorToRGB(badges[i]) + badgeHex := RGBToHex(br, bg2, bb) + + fgHex := BestForegroundHex(badgeHex, + theme.Colors.TextBright, theme.Colors.Background, + "#000000", "#ffffff") + fg := lipgloss.Color(fgHex) + badgeCR := ContrastRatio(fg, badges[i]) + + ar, ag, ab := ColorToRGB(accents[i]) + accentHex := RGBToHex(ar, ag, ab) + accentCR := ContrastRatio(accents[i], bg) + + badgeStatus := "✓" + if badgeCR < minBadgeContrast { + badgeStatus = "✗" + } + accentStatus := "✓" + if accentCR < minAccentContrast { + accentStatus = "✗" + } + + t.Logf("%-5d %-10.0f %-10s %-10s %s %-9.2f %-10s %s %.2f", + i, hues[i], badgeHex, fgHex, badgeStatus, badgeCR, + accentHex, accentStatus, accentCR) + } + + // Log minimum pairwise distances + minBadgeDist := 999.0 + minAccentDist := 999.0 + for i := range badges { + for j := i + 1; j < len(badges); j++ { + if d := ColorDistanceCIE76(badges[i], badges[j]); d < minBadgeDist { + minBadgeDist = d + } + if d := ColorDistanceCIE76(accents[i], accents[j]); d < minAccentDist { + minAccentDist = d + } + } + } + t.Logf("\nMin badge pairwise ΔE: %.1f (threshold: %.1f)", minBadgeDist, minColorDistance) + t.Logf("Min accent pairwise ΔE: %.1f (threshold: %.1f)", minAccentDist, minColorDistance) + }) + } +} diff --git a/pkg/tui/styles/colorutil.go b/pkg/tui/styles/colorutil.go new file mode 100644 index 000000000..3349d7b31 --- /dev/null +++ b/pkg/tui/styles/colorutil.go @@ -0,0 +1,422 @@ +package styles + +import ( + "fmt" + "image/color" + "math" + "strconv" + "strings" + + "charm.land/lipgloss/v2" +) + +// --- Hex parsing --- + +// ParseHexRGB parses a hex color string (#RGB or #RRGGBB) into normalized [0,1] sRGB components. +func ParseHexRGB(hex string) (r, g, b float64, ok bool) { + if !strings.HasPrefix(hex, "#") { + return 0, 0, 0, false + } + + h := strings.TrimPrefix(hex, "#") + if len(h) == 3 { + h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]}) + } + if len(h) != 6 { + return 0, 0, 0, false + } + + r8, err := strconv.ParseUint(h[0:2], 16, 8) + if err != nil { + return 0, 0, 0, false + } + g8, err := strconv.ParseUint(h[2:4], 16, 8) + if err != nil { + return 0, 0, 0, false + } + b8, err := strconv.ParseUint(h[4:6], 16, 8) + if err != nil { + return 0, 0, 0, false + } + + return float64(r8) / 255.0, float64(g8) / 255.0, float64(b8) / 255.0, true +} + +// ColorToRGB extracts normalized [0,1] sRGB components from a color.Color. +func ColorToRGB(c color.Color) (r, g, b float64) { + ri, gi, bi, _ := c.RGBA() + return float64(ri) / 65535, float64(gi) / 65535, float64(bi) / 65535 +} + +// RGBToHex formats normalized [0,1] sRGB components as a hex color string. +func RGBToHex(r, g, b float64) string { + return fmt.Sprintf("#%02x%02x%02x", clamp8(r), clamp8(g), clamp8(b)) +} + +// RGBToColor converts normalized [0,1] sRGB components to a lipgloss color. +func RGBToColor(r, g, b float64) color.Color { + return lipgloss.Color(RGBToHex(r, g, b)) +} + +func clamp8(v float64) int { + if v < 0 { + return 0 + } + if v > 1 { + return 255 + } + return int(v*255 + 0.5) +} + +// --- sRGB linearization --- + +// SRGBToLinear converts an sRGB component [0,1] to linear light. +func SRGBToLinear(c float64) float64 { + if c <= 0.03928 { + return c / 12.92 + } + return math.Pow((c+0.055)/1.055, 2.4) +} + +// LinearToSRGB converts a linear light component [0,1] to sRGB. +func LinearToSRGB(c float64) float64 { + if c <= 0.0031308 { + return c * 12.92 + } + return 1.055*math.Pow(c, 1.0/2.4) - 0.055 +} + +// --- Luminance & contrast --- + +// RelativeLuminance returns the WCAG 2.x relative luminance of an sRGB color. +func RelativeLuminance(r, g, b float64) float64 { + return 0.2126*SRGBToLinear(r) + 0.7152*SRGBToLinear(g) + 0.0722*SRGBToLinear(b) +} + +// RelativeLuminanceHex returns the relative luminance for a hex color string. +func RelativeLuminanceHex(hex string) (float64, bool) { + r, g, b, ok := ParseHexRGB(hex) + if !ok { + return 0, false + } + return RelativeLuminance(r, g, b), true +} + +// RelativeLuminanceColor returns the relative luminance for a color.Color. +func RelativeLuminanceColor(c color.Color) float64 { + r, g, b := ColorToRGB(c) + return RelativeLuminance(r, g, b) +} + +// ContrastRatio returns the WCAG 2.x contrast ratio between two colors. +func ContrastRatio(fg, bg color.Color) float64 { + l1 := RelativeLuminanceColor(fg) + l2 := RelativeLuminanceColor(bg) + lighter := max(l1, l2) + darker := min(l1, l2) + return (lighter + 0.05) / (darker + 0.05) +} + +// ContrastRatioHex returns the WCAG contrast ratio between two hex color strings. +func ContrastRatioHex(fgHex, bgHex string) (float64, bool) { + fgLum, ok := RelativeLuminanceHex(fgHex) + if !ok { + return 0, false + } + bgLum, ok := RelativeLuminanceHex(bgHex) + if !ok { + return 0, false + } + + l1, l2 := fgLum, bgLum + if l2 > l1 { + l1, l2 = l2, l1 + } + return (l1 + 0.05) / (l2 + 0.05), true +} + +// BestForegroundHex picks the candidate hex color with the highest contrast ratio against bgHex. +func BestForegroundHex(bgHex string, candidates ...string) string { + if len(candidates) == 0 { + return "" + } + best := candidates[0] + bestRatio := -1.0 + + for _, cand := range candidates { + ratio, ok := ContrastRatioHex(cand, bgHex) + if !ok { + continue + } + if ratio > bestRatio { + bestRatio = ratio + best = cand + } + } + return best +} + +// --- Dynamic contrast helpers --- + +const ( + // MutedContrastStrength controls how much the muted foreground shifts + // away from the background (0.0 = invisible, 1.0 = full black/white). + MutedContrastStrength = 0.45 + + // MinIndicatorContrast is the minimum WCAG contrast ratio for semantic + // indicator colors (running dot, attention bang). + MinIndicatorContrast = 4.5 + + // maxBoostSteps limits blend iterations in EnsureContrast. + maxBoostSteps = 20 +) + +// MutedContrastFg returns a foreground color that is visible but subtle against +// the given background. It blends the background toward white (for dark bg) or +// black (for light bg) by MutedContrastStrength. +func MutedContrastFg(bg color.Color) color.Color { + rf, gf, bf := ColorToRGB(bg) + lum := 0.299*rf + 0.587*gf + 0.114*bf + + var tgt float64 + if lum > 0.5 { + tgt = 0.0 + } else { + tgt = 1.0 + } + + s := MutedContrastStrength + return RGBToColor(rf+(tgt-rf)*s, gf+(tgt-gf)*s, bf+(tgt-bf)*s) +} + +// EnsureContrast returns fg unchanged if it already meets MinIndicatorContrast +// against bg. Otherwise it progressively blends fg toward white (on dark bg) or +// black (on light bg) until the threshold is met, preserving the original hue direction. +func EnsureContrast(fg, bg color.Color) color.Color { + if ContrastRatio(fg, bg) >= MinIndicatorContrast { + return fg + } + + rf, gf, bf := ColorToRGB(fg) + bgR, bgG, bgB := ColorToRGB(bg) + bgLum := RelativeLuminance(bgR, bgG, bgB) + + var tR, tG, tB float64 + if bgLum > 0.5 { + tR, tG, tB = 0, 0, 0 + } else { + tR, tG, tB = 1, 1, 1 + } + + for step := 1; step <= maxBoostSteps; step++ { + t := float64(step) / float64(maxBoostSteps) + nr := rf + (tR-rf)*t + ng := gf + (tG-gf)*t + nb := bf + (tB-bf)*t + candidate := RGBToColor(nr, ng, nb) + if ContrastRatio(candidate, bg) >= MinIndicatorContrast { + return candidate + } + } + + return RGBToColor(tR, tG, tB) +} + +// --- HSL conversion --- + +// RGBToHSL converts normalized [0,1] sRGB to HSL. +// H is in [0,360), S and L are in [0,1]. +func RGBToHSL(r, g, b float64) (h, s, l float64) { + maxC := max(r, max(g, b)) + minC := min(r, min(g, b)) + l = (maxC + minC) / 2 + + if maxC == minC { + return 0, 0, l + } + + d := maxC - minC + if l > 0.5 { + s = d / (2.0 - maxC - minC) + } else { + s = d / (maxC + minC) + } + + switch maxC { + case r: + h = (g - b) / d + if g < b { + h += 6 + } + case g: + h = (b-r)/d + 2 + case b: + h = (r-g)/d + 4 + } + h *= 60 + + return h, s, l +} + +// HSLToRGB converts HSL to normalized [0,1] sRGB. +// H is in [0,360), S and L are in [0,1]. +func HSLToRGB(h, s, l float64) (r, g, b float64) { + if s == 0 { + return l, l, l + } + + var q float64 + if l < 0.5 { + q = l * (1 + s) + } else { + q = l + s - l*s + } + p := 2*l - q + + h /= 360 + r = hueToRGB(p, q, h+1.0/3.0) + g = hueToRGB(p, q, h) + b = hueToRGB(p, q, h-1.0/3.0) + return r, g, b +} + +func hueToRGB(p, q, t float64) float64 { + if t < 0 { + t++ + } + if t > 1 { + t-- + } + switch { + case t < 1.0/6.0: + return p + (q-p)*6*t + case t < 1.0/2.0: + return q + case t < 2.0/3.0: + return p + (q-p)*(2.0/3.0-t)*6 + default: + return p + } +} + +// --- Palette generation --- + +// DefaultAgentHues provides 16 well-spaced default hue values for agent colors. +var DefaultAgentHues = []float64{ + 220, // Blue + 280, // Purple + 170, // Teal + 30, // Orange + 330, // Pink + 140, // Green + 200, // Steel blue + 260, // Deep purple + 50, // Gold + 0, // Red + 185, // Dark teal + 20, // Burnt orange + 235, // Indigo + 295, // Plum + 155, // Forest green + 340, // Crimson +} + +// GenerateBadgePalette generates badge background colors from hues, adapting +// saturation and lightness based on the theme background. +// Dark backgrounds get lighter, more saturated badges; light backgrounds get darker ones. +func GenerateBadgePalette(hues []float64, bg color.Color) []color.Color { + bgR, bgG, bgB := ColorToRGB(bg) + bgLum := RelativeLuminance(bgR, bgG, bgB) + + isDark := bgLum < 0.5 + + colors := make([]color.Color, len(hues)) + for i, hue := range hues { + var s, l float64 + if isDark { + s = 0.65 + 0.10*math.Sin(float64(i)*0.7) + l = 0.42 + 0.06*math.Cos(float64(i)*0.9) + } else { + s = 0.60 + 0.10*math.Sin(float64(i)*0.7) + l = 0.38 + 0.06*math.Cos(float64(i)*0.9) + } + + r, g, b := HSLToRGB(hue, s, l) + colors[i] = lipgloss.Color(RGBToHex(r, g, b)) + } + return colors +} + +// GenerateAccentPalette generates sidebar accent foreground colors from hues, +// adapting to the theme background for readability. +// Dark backgrounds get brighter accents; light backgrounds get darker ones. +func GenerateAccentPalette(hues []float64, bg color.Color) []color.Color { + bgR, bgG, bgB := ColorToRGB(bg) + bgLum := RelativeLuminance(bgR, bgG, bgB) + + isDark := bgLum < 0.5 + + colors := make([]color.Color, len(hues)) + for i, hue := range hues { + var s, l float64 + if isDark { + s = 0.55 + 0.15*math.Sin(float64(i)*0.5) + l = 0.68 + 0.08*math.Cos(float64(i)*0.7) + } else { + s = 0.70 + 0.15*math.Sin(float64(i)*0.5) + l = 0.30 + 0.06*math.Cos(float64(i)*0.7) + } + + r, g, b := HSLToRGB(hue, s, l) + colors[i] = lipgloss.Color(RGBToHex(r, g, b)) + } + return colors +} + +// --- Perceptual distance --- + +// ColorDistanceCIE76 returns the Euclidean distance between two colors in CIELAB space. +// A value below ~25 means colors may be hard to distinguish at a glance. +func ColorDistanceCIE76(c1, c2 color.Color) float64 { + l1, a1, b1 := colorToLab(c1) + l2, a2, b2 := colorToLab(c2) + dl := l1 - l2 + da := a1 - a2 + db := b1 - b2 + return math.Sqrt(dl*dl + da*da + db*db) +} + +// colorToLab converts a color.Color to CIELAB via XYZ (D65 illuminant). +func colorToLab(c color.Color) (l, a, b float64) { + r, g, bl := ColorToRGB(c) + // sRGB to linear + rl := SRGBToLinear(r) + gl := SRGBToLinear(g) + bll := SRGBToLinear(bl) + + // Linear RGB to XYZ (D65) + x := 0.4124564*rl + 0.3575761*gl + 0.1804375*bll + y := 0.2126729*rl + 0.7151522*gl + 0.0721750*bll + z := 0.0193339*rl + 0.1191920*gl + 0.9503041*bll + + // XYZ to Lab (D65 white point) + x /= 0.95047 + y /= 1.00000 + z /= 1.08883 + + x = labF(x) + y = labF(y) + z = labF(z) + + l = 116*y - 16 + a = 500 * (x - y) + b = 200 * (y - z) + return l, a, b +} + +func labF(t float64) float64 { + if t > 0.008856 { + return math.Cbrt(t) + } + return 7.787*t + 16.0/116.0 +} diff --git a/pkg/tui/styles/colorutil_test.go b/pkg/tui/styles/colorutil_test.go new file mode 100644 index 000000000..c10f20862 --- /dev/null +++ b/pkg/tui/styles/colorutil_test.go @@ -0,0 +1,332 @@ +package styles + +import ( + "math" + "testing" + + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Hex parsing --- + +func TestParseHexRGB_Valid6Digit(t *testing.T) { + t.Parallel() + r, g, b, ok := ParseHexRGB("#FF8000") + require.True(t, ok) + assert.InDelta(t, 1.0, r, 0.01) + assert.InDelta(t, 0.502, g, 0.01) + assert.InDelta(t, 0.0, b, 0.01) +} + +func TestParseHexRGB_Valid3Digit(t *testing.T) { + t.Parallel() + r, g, b, ok := ParseHexRGB("#F00") + require.True(t, ok) + assert.InDelta(t, 1.0, r, 0.01) + assert.InDelta(t, 0.0, g, 0.01) + assert.InDelta(t, 0.0, b, 0.01) +} + +func TestParseHexRGB_Invalid(t *testing.T) { + t.Parallel() + for _, input := range []string{"", "FF0000", "#GG0000", "#FF00", "#FF000000"} { + _, _, _, ok := ParseHexRGB(input) + assert.False(t, ok, "expected failure for %q", input) + } +} + +// --- RGB ↔ Hex roundtrip --- + +func TestRGBToHex_Roundtrip(t *testing.T) { + t.Parallel() + hex := RGBToHex(0.2, 0.4, 0.6) + r, g, b, ok := ParseHexRGB(hex) + require.True(t, ok) + assert.InDelta(t, 0.2, r, 0.01) + assert.InDelta(t, 0.4, g, 0.01) + assert.InDelta(t, 0.6, b, 0.01) +} + +func TestRGBToHex_BlackWhite(t *testing.T) { + t.Parallel() + assert.Equal(t, "#000000", RGBToHex(0, 0, 0)) + assert.Equal(t, "#ffffff", RGBToHex(1, 1, 1)) +} + +// --- sRGB linearization roundtrip --- + +func TestLinearization_Roundtrip(t *testing.T) { + t.Parallel() + for _, v := range []float64{0, 0.1, 0.5, 0.9, 1.0} { + result := LinearToSRGB(SRGBToLinear(v)) + assert.InDelta(t, v, result, 0.001, "roundtrip failed for %f", v) + } +} + +// --- Luminance --- + +func TestRelativeLuminance_BlackWhite(t *testing.T) { + t.Parallel() + assert.InDelta(t, 0.0, RelativeLuminance(0, 0, 0), 0.001) + assert.InDelta(t, 1.0, RelativeLuminance(1, 1, 1), 0.001) +} + +func TestRelativeLuminanceHex(t *testing.T) { + t.Parallel() + lum, ok := RelativeLuminanceHex("#ffffff") + require.True(t, ok) + assert.InDelta(t, 1.0, lum, 0.001) + + lum, ok = RelativeLuminanceHex("#000000") + require.True(t, ok) + assert.InDelta(t, 0.0, lum, 0.001) +} + +// --- Contrast ratio --- + +func TestContrastRatio_BlackWhite(t *testing.T) { + t.Parallel() + black := lipgloss.Color("#000000") + white := lipgloss.Color("#ffffff") + ratio := ContrastRatio(black, white) + assert.InDelta(t, 21.0, ratio, 0.1) +} + +func TestContrastRatio_SameColor(t *testing.T) { + t.Parallel() + c := lipgloss.Color("#808080") + ratio := ContrastRatio(c, c) + assert.InDelta(t, 1.0, ratio, 0.001) +} + +func TestContrastRatioHex(t *testing.T) { + t.Parallel() + ratio, ok := ContrastRatioHex("#000000", "#ffffff") + require.True(t, ok) + assert.InDelta(t, 21.0, ratio, 0.1) +} + +func TestBestForegroundHex(t *testing.T) { + t.Parallel() + // On dark background, white should win + best := BestForegroundHex("#000000", "#333333", "#ffffff") + assert.Equal(t, "#ffffff", best) + + // On light background, black should win + best = BestForegroundHex("#ffffff", "#000000", "#cccccc") + assert.Equal(t, "#000000", best) +} + +// --- HSL conversion --- + +func TestRGBToHSL_Red(t *testing.T) { + t.Parallel() + h, s, l := RGBToHSL(1, 0, 0) + assert.InDelta(t, 0, h, 0.1) + assert.InDelta(t, 1.0, s, 0.01) + assert.InDelta(t, 0.5, l, 0.01) +} + +func TestRGBToHSL_Green(t *testing.T) { + t.Parallel() + h, s, l := RGBToHSL(0, 1, 0) + assert.InDelta(t, 120, h, 0.1) + assert.InDelta(t, 1.0, s, 0.01) + assert.InDelta(t, 0.5, l, 0.01) +} + +func TestRGBToHSL_Blue(t *testing.T) { + t.Parallel() + h, s, l := RGBToHSL(0, 0, 1) + assert.InDelta(t, 240, h, 0.1) + assert.InDelta(t, 1.0, s, 0.01) + assert.InDelta(t, 0.5, l, 0.01) +} + +func TestRGBToHSL_Gray(t *testing.T) { + t.Parallel() + h, s, l := RGBToHSL(0.5, 0.5, 0.5) + _ = h // hue is undefined for gray + assert.InDelta(t, 0.0, s, 0.01) + assert.InDelta(t, 0.5, l, 0.01) +} + +func TestHSLToRGB_Roundtrip(t *testing.T) { + t.Parallel() + testCases := []struct { + r, g, b float64 + }{ + {1, 0, 0}, + {0, 1, 0}, + {0, 0, 1}, + {0.5, 0.5, 0.5}, + {0.2, 0.6, 0.8}, + } + for _, tc := range testCases { + h, s, l := RGBToHSL(tc.r, tc.g, tc.b) + r, g, b := HSLToRGB(h, s, l) + assert.InDelta(t, tc.r, r, 0.01, "r mismatch for input %v", tc) + assert.InDelta(t, tc.g, g, 0.01, "g mismatch for input %v", tc) + assert.InDelta(t, tc.b, b, 0.01, "b mismatch for input %v", tc) + } +} + +// --- Dynamic contrast helpers --- + +func TestMutedContrastFg_DarkBg(t *testing.T) { + t.Parallel() + bg := lipgloss.Color("#1C1C22") + fg := MutedContrastFg(bg) + // Should produce a lighter color than the background + bgLum := RelativeLuminanceColor(bg) + fgLum := RelativeLuminanceColor(fg) + assert.Greater(t, fgLum, bgLum) +} + +func TestMutedContrastFg_LightBg(t *testing.T) { + t.Parallel() + bg := lipgloss.Color("#eff1f5") + fg := MutedContrastFg(bg) + // Should produce a darker color than the background + bgLum := RelativeLuminanceColor(bg) + fgLum := RelativeLuminanceColor(fg) + assert.Less(t, fgLum, bgLum) +} + +func TestEnsureContrast_AlreadySufficient(t *testing.T) { + t.Parallel() + fg := lipgloss.Color("#ffffff") + bg := lipgloss.Color("#000000") + result := EnsureContrast(fg, bg) + // White on black already has 21:1, should be unchanged + r1, g1, b1 := ColorToRGB(fg) + r2, g2, b2 := ColorToRGB(result) + assert.InDelta(t, r1, r2, 0.01) + assert.InDelta(t, g1, g2, 0.01) + assert.InDelta(t, b1, b2, 0.01) +} + +func TestEnsureContrast_BoostsLowContrast(t *testing.T) { + t.Parallel() + fg := lipgloss.Color("#333333") + bg := lipgloss.Color("#222222") + result := EnsureContrast(fg, bg) + ratio := ContrastRatio(result, bg) + assert.GreaterOrEqual(t, ratio, MinIndicatorContrast) +} + +// --- Palette generation --- + +func TestGenerateBadgePalette_CorrectLength(t *testing.T) { + t.Parallel() + bg := lipgloss.Color("#1C1C22") + palette := GenerateBadgePalette(DefaultAgentHues, bg) + assert.Len(t, palette, len(DefaultAgentHues)) +} + +func TestGenerateBadgePalette_AllDistinct(t *testing.T) { + t.Parallel() + bg := lipgloss.Color("#1C1C22") + palette := GenerateBadgePalette(DefaultAgentHues, bg) + hexSet := make(map[string]bool) + for _, c := range palette { + r, g, b := ColorToRGB(c) + hex := RGBToHex(r, g, b) + hexSet[hex] = true + } + assert.Len(t, hexSet, len(palette), "all generated colors should be distinct") +} + +func TestGenerateAccentPalette_CorrectLength(t *testing.T) { + t.Parallel() + bg := lipgloss.Color("#1C1C22") + palette := GenerateAccentPalette(DefaultAgentHues, bg) + assert.Len(t, palette, len(DefaultAgentHues)) +} + +func TestGenerateBadgePalette_DarkVsLight(t *testing.T) { + t.Parallel() + darkBg := lipgloss.Color("#1C1C22") + lightBg := lipgloss.Color("#eff1f5") + darkPalette := GenerateBadgePalette(DefaultAgentHues[:1], darkBg) + lightPalette := GenerateBadgePalette(DefaultAgentHues[:1], lightBg) + + // Same hue should produce different lightness for dark vs light bg + darkLum := RelativeLuminanceColor(darkPalette[0]) + lightLum := RelativeLuminanceColor(lightPalette[0]) + assert.Greater(t, math.Abs(darkLum-lightLum), 0.01, "dark and light themes should produce different badge lightness") +} + +// --- Perceptual distance --- + +func TestColorDistanceCIE76_Identical(t *testing.T) { + t.Parallel() + c := lipgloss.Color("#FF0000") + assert.InDelta(t, 0, ColorDistanceCIE76(c, c), 0.001) +} + +func TestColorDistanceCIE76_BlackWhite(t *testing.T) { + t.Parallel() + black := lipgloss.Color("#000000") + white := lipgloss.Color("#ffffff") + dist := ColorDistanceCIE76(black, white) + assert.Greater(t, dist, 50.0, "black and white should be very far apart in CIELAB") +} + +func TestColorDistanceCIE76_SimilarColors(t *testing.T) { + t.Parallel() + c1 := lipgloss.Color("#FF0000") + c2 := lipgloss.Color("#FF1100") + dist := ColorDistanceCIE76(c1, c2) + assert.Less(t, dist, 10.0, "very similar colors should have small distance") +} + +// --- ColorToRGB --- + +func TestColorToRGB_KnownValues(t *testing.T) { + t.Parallel() + c := lipgloss.Color("#ff0000") + r, g, b := ColorToRGB(c) + assert.InDelta(t, 1.0, r, 0.01) + assert.InDelta(t, 0.0, g, 0.01) + assert.InDelta(t, 0.0, b, 0.01) +} + +// --- DefaultAgentHues --- + +func TestDefaultAgentHues_Length(t *testing.T) { + t.Parallel() + assert.Len(t, DefaultAgentHues, 16) +} + +func TestDefaultAgentHues_InRange(t *testing.T) { + t.Parallel() + for i, h := range DefaultAgentHues { + assert.GreaterOrEqual(t, h, 0.0, "hue %d out of range", i) + assert.Less(t, h, 360.0, "hue %d out of range", i) + } +} + +// --- Helper to verify color.Color interface --- + +func TestRGBToColor_ImplementsInterface(t *testing.T) { + t.Parallel() + c := RGBToColor(0.5, 0.5, 0.5) + r, g, b, a := c.RGBA() + assert.NotZero(t, r) + assert.NotZero(t, g) + assert.NotZero(t, b) + assert.NotZero(t, a) +} + +// --- CIELAB internals --- + +func TestLabF_BelowThreshold(t *testing.T) { + t.Parallel() + // labF should handle very small values + result := labF(0.001) + assert.False(t, math.IsNaN(result)) + assert.False(t, math.IsInf(result, 0)) +} diff --git a/pkg/tui/styles/theme.go b/pkg/tui/styles/theme.go index cd6670258..9796ba52e 100644 --- a/pkg/tui/styles/theme.go +++ b/pkg/tui/styles/theme.go @@ -3,11 +3,9 @@ package styles import ( "embed" "fmt" - "math" "os" "path/filepath" "slices" - "strconv" "strings" "sync" "sync/atomic" @@ -135,6 +133,9 @@ type ThemeColors struct { BadgeAccent string `yaml:"badge_accent,omitempty"` // Accent badge (e.g., purple highlights) BadgeInfo string `yaml:"badge_info,omitempty"` // Info badge (e.g., cyan) BadgeSuccess string `yaml:"badge_success,omitempty"` // Success badge (e.g., green) + + // Agent colors + AgentHues []float64 `yaml:"agent_hues,omitempty"` // Hue values (0-360) for agent color generation } // ChromaColors contains syntax highlighting colors (for code blocks). @@ -758,6 +759,10 @@ func mergeColors(base, override ThemeColors) ThemeColors { if override.BadgeSuccess != "" { result.BadgeSuccess = override.BadgeSuccess } + // Agent colors + if len(override.AgentHues) > 0 { + result.AgentHues = override.AgentHues + } return result } @@ -919,7 +924,7 @@ func ApplyTheme(theme *Theme) { PlaceholderColor = lipgloss.Color(c.Placeholder) // Badge colors AgentBadgeBg = MobyBlue - AgentBadgeFg = lipgloss.Color(bestForegroundHex( + AgentBadgeFg = lipgloss.Color(BestForegroundHex( c.Brand, c.TextBright, c.Background, @@ -945,6 +950,9 @@ func ApplyTheme(theme *Theme) { // Rebuild all derived styles rebuildStyles() + // Rebuild cached agent color styles with new theme contrast values + InvalidateAgentColorCache() + // Clear style sequence cache (used by RenderComposite) clearStyleSeqCache() } @@ -1217,92 +1225,6 @@ func rebuildStyles() { SpinnerTextDimmestStyle = BaseStyle.Foreground(Accent) } -func bestForegroundHex(bgHex string, candidates ...string) string { - if len(candidates) == 0 { - return "" - } - best := candidates[0] - bestRatio := -1.0 - - for _, cand := range candidates { - ratio, ok := contrastRatioHex(cand, bgHex) - if !ok { - continue - } - if ratio > bestRatio { - bestRatio = ratio - best = cand - } - } - - return best -} - -func contrastRatioHex(fgHex, bgHex string) (float64, bool) { - fgLum, ok := relativeLuminanceHex(fgHex) - if !ok { - return 0, false - } - bgLum, ok := relativeLuminanceHex(bgHex) - if !ok { - return 0, false - } - - L1, L2 := fgLum, bgLum - if L2 > L1 { - L1, L2 = L2, L1 - } - - return (L1 + 0.05) / (L2 + 0.05), true -} - -func relativeLuminanceHex(hex string) (float64, bool) { - r, g, b, ok := parseHexRGB01(hex) - if !ok { - return 0, false - } - - // WCAG 2.x relative luminance for sRGB - rl := 0.2126*srgbToLinear(r) + 0.7152*srgbToLinear(g) + 0.0722*srgbToLinear(b) - return rl, true -} - -func srgbToLinear(c float64) float64 { - if c <= 0.03928 { - return c / 12.92 - } - return math.Pow((c+0.055)/1.055, 2.4) -} - -func parseHexRGB01(hex string) (float64, float64, float64, bool) { - if !strings.HasPrefix(hex, "#") { - return 0, 0, 0, false - } - - h := strings.TrimPrefix(hex, "#") - if len(h) == 3 { - h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]}) - } - if len(h) != 6 { - return 0, 0, 0, false - } - - r8, err := strconv.ParseUint(h[0:2], 16, 8) - if err != nil { - return 0, 0, 0, false - } - g8, err := strconv.ParseUint(h[2:4], 16, 8) - if err != nil { - return 0, 0, 0, false - } - b8, err := strconv.ParseUint(h[4:6], 16, 8) - if err != nil { - return 0, 0, 0, false - } - - return float64(r8) / 255.0, float64(g8) / 255.0, float64(b8) / 255.0, true -} - // init applies the default theme at package initialization time. // This ensures color variables are set before any code uses them, // including tests that don't explicitly call ApplyTheme(). diff --git a/pkg/tui/styles/theme_test.go b/pkg/tui/styles/theme_test.go index a66b989ff..7fa40c745 100644 --- a/pkg/tui/styles/theme_test.go +++ b/pkg/tui/styles/theme_test.go @@ -281,28 +281,38 @@ func TestDefaultTheme_AllColorsPopulated(t *testing.T) { func TestMergeColors_HandlesAllFields(t *testing.T) { t.Parallel() - // Create a base with all fields set to "BASE" + // Create a base with all string fields set to "BASE" base := ThemeColors{} baseVal := reflect.ValueOf(&base).Elem() for _, field := range baseVal.Fields() { - field.SetString("BASE") + if field.Kind() == reflect.String { + field.SetString("BASE") + } } + base.AgentHues = []float64{10, 20} - // Create an override with all fields set to "OVERRIDE" + // Create an override with all string fields set to "OVERRIDE" override := ThemeColors{} overrideVal := reflect.ValueOf(&override).Elem() for _, field := range overrideVal.Fields() { - field.SetString("OVERRIDE") + if field.Kind() == reflect.String { + field.SetString("OVERRIDE") + } } + override.AgentHues = []float64{30, 40, 50} // Merge should replace all base values with override values merged := mergeColors(base, override) mergedVal := reflect.ValueOf(merged) for field, value := range mergedVal.Fields() { - assert.Equal(t, "OVERRIDE", value.String(), - "mergeColors() doesn't handle ThemeColors.%s - add merge logic in mergeColors()", field.Name) + if value.Kind() == reflect.String { + assert.Equal(t, "OVERRIDE", value.String(), + "mergeColors() doesn't handle ThemeColors.%s - add merge logic in mergeColors()", field.Name) + } } + assert.Equal(t, []float64{30, 40, 50}, merged.AgentHues, + "mergeColors() doesn't handle ThemeColors.AgentHues") } // TestMergeChromaColors_HandlesAllFields ensures mergeChromaColors handles every ChromaColors field. diff --git a/pkg/tui/styles/themes/default.yaml b/pkg/tui/styles/themes/default.yaml index 3e2c72a18..566bf9535 100644 --- a/pkg/tui/styles/themes/default.yaml +++ b/pkg/tui/styles/themes/default.yaml @@ -62,6 +62,9 @@ colors: badge_info: "#7DCFFF" badge_success: "#9ECE6A" + # Agent colors (hue values 0-360 for dynamic palette generation) + agent_hues: [220, 280, 170, 30, 330, 140, 200, 260, 50, 0, 185, 20, 235, 295, 155, 340] + chroma: # Syntax highlighting colors (Monokai-inspired) error_fg: "#F1F1F1"