Skip to main content

coding_agent_search/ui/components/
theme.rs

1//! Premium theme definitions with world-class, Stripe-level aesthetics.
2//!
3//! Design principles:
4//! - Muted, sophisticated colors that are easy on the eyes
5//! - Clear visual hierarchy with accent colors used sparingly
6//! - Consistent design language across all elements
7//! - High contrast where it matters (text legibility)
8//! - Subtle agent differentiation via tinted backgrounds
9
10use ftui::Style;
11use ftui::render::cell::PackedRgba;
12
13/// Premium color palette inspired by modern design systems.
14/// Uses low-saturation colors for comfort with refined accents for highlights.
15pub mod colors {
16    use ftui::render::cell::PackedRgba as Color;
17
18    // ═══════════════════════════════════════════════════════════════════════════
19    // BASE COLORS - The foundation of the UI
20    // ═══════════════════════════════════════════════════════════════════════════
21
22    /// Deep background - primary canvas color
23    pub const BG_DEEP: Color = Color::rgb(26, 27, 38); // #1a1b26
24
25    /// Elevated surface - cards, modals, popups
26    pub const BG_SURFACE: Color = Color::rgb(36, 40, 59); // #24283b
27
28    /// Subtle surface - hover states, selected items
29    pub const BG_HIGHLIGHT: Color = Color::rgb(41, 46, 66); // #292e42
30
31    /// Border color - subtle separators
32    pub const BORDER: Color = Color::rgb(59, 66, 97); // #3b4261
33
34    /// Border accent - focused/active elements
35    pub const BORDER_FOCUS: Color = Color::rgb(125, 145, 200); // #7d91c8
36
37    // ═══════════════════════════════════════════════════════════════════════════
38    // TEXT COLORS - Hierarchical text styling
39    // ═══════════════════════════════════════════════════════════════════════════
40
41    /// Primary text - headings, important content
42    pub const TEXT_PRIMARY: Color = Color::rgb(192, 202, 245); // #c0caf5
43
44    /// Secondary text - body content
45    pub const TEXT_SECONDARY: Color = Color::rgb(169, 177, 214); // #a9b1d6
46
47    /// Muted text - hints, placeholders, timestamps
48    /// Lightened from original Tokyo Night #565f89 to meet WCAG AA-large (3:1) contrast
49    pub const TEXT_MUTED: Color = Color::rgb(105, 114, 158); // #696e9e (WCAG AA-large compliant)
50
51    /// Disabled/inactive text
52    pub const TEXT_DISABLED: Color = Color::rgb(68, 75, 106); // #444b6a
53
54    // ═══════════════════════════════════════════════════════════════════════════
55    // ACCENT COLORS - Brand and interaction highlights
56    // ═══════════════════════════════════════════════════════════════════════════
57
58    /// Primary accent - main actions, links, focus states
59    pub const ACCENT_PRIMARY: Color = Color::rgb(122, 162, 247); // #7aa2f7
60
61    /// Secondary accent - complementary highlights
62    pub const ACCENT_SECONDARY: Color = Color::rgb(187, 154, 247); // #bb9af7
63
64    /// Tertiary accent - subtle highlights
65    pub const ACCENT_TERTIARY: Color = Color::rgb(125, 207, 255); // #7dcfff
66
67    // ═══════════════════════════════════════════════════════════════════════════
68    // SEMANTIC COLORS - Role-based coloring (muted versions)
69    // ═══════════════════════════════════════════════════════════════════════════
70
71    /// User messages - soft sage green
72    pub const ROLE_USER: Color = Color::rgb(158, 206, 106); // #9ece6a
73
74    /// Agent/Assistant messages - matches primary accent
75    pub const ROLE_AGENT: Color = Color::rgb(122, 162, 247); // #7aa2f7
76
77    /// Tool invocations - warm peach
78    pub const ROLE_TOOL: Color = Color::rgb(255, 158, 100); // #ff9e64
79
80    /// System messages - soft amber
81    pub const ROLE_SYSTEM: Color = Color::rgb(224, 175, 104); // #e0af68
82
83    // ═══════════════════════════════════════════════════════════════════════════
84    // STATUS COLORS - Feedback and state indication
85    // ═══════════════════════════════════════════════════════════════════════════
86
87    /// Success states
88    pub const STATUS_SUCCESS: Color = Color::rgb(115, 218, 202); // #73daca
89
90    /// Warning states
91    pub const STATUS_WARNING: Color = Color::rgb(224, 175, 104); // #e0af68
92
93    /// Error states
94    pub const STATUS_ERROR: Color = Color::rgb(247, 118, 142); // #f7768e
95
96    /// Info states
97    pub const STATUS_INFO: Color = Color::rgb(125, 207, 255); // #7dcfff
98
99    // ═══════════════════════════════════════════════════════════════════════════
100    // AGENT-SPECIFIC TINTS - Distinct background variations per agent
101    // ═══════════════════════════════════════════════════════════════════════════
102
103    /// Claude Code - distinct blue tint
104    pub const AGENT_CLAUDE_BG: Color = Color::rgb(24, 30, 52); // #181e34 - blue
105
106    /// Codex - distinct green tint
107    pub const AGENT_CODEX_BG: Color = Color::rgb(22, 38, 32); // #162620 - green
108
109    /// Cline - distinct cyan tint
110    pub const AGENT_CLINE_BG: Color = Color::rgb(20, 34, 42); // #14222a - cyan
111
112    /// Gemini - distinct purple tint
113    pub const AGENT_GEMINI_BG: Color = Color::rgb(34, 24, 48); // #221830 - purple
114
115    /// Amp - distinct warm/orange tint
116    pub const AGENT_AMP_BG: Color = Color::rgb(42, 28, 24); // #2a1c18 - warm
117
118    /// Aider - distinct teal tint
119    pub const AGENT_AIDER_BG: Color = Color::rgb(20, 36, 36); // #142424 - teal
120
121    /// Cursor - distinct magenta tint
122    pub const AGENT_CURSOR_BG: Color = Color::rgb(38, 24, 38); // #261826 - magenta
123
124    /// ChatGPT - distinct emerald tint
125    pub const AGENT_CHATGPT_BG: Color = Color::rgb(20, 38, 28); // #14261c - emerald
126
127    /// `OpenCode` - neutral gray
128    pub const AGENT_OPENCODE_BG: Color = Color::rgb(32, 32, 36); // #202024 - neutral
129
130    /// Factory (Droid) - warm amber tint
131    pub const AGENT_FACTORY_BG: Color = Color::rgb(36, 30, 20); // #241e14 - amber
132
133    /// Clawdbot - indigo tint
134    pub const AGENT_CLAWDBOT_BG: Color = Color::rgb(26, 24, 44); // #1a182c - indigo
135
136    /// Vibe (Mistral) - rose tint
137    pub const AGENT_VIBE_BG: Color = Color::rgb(36, 22, 30); // #24161e - rose
138
139    /// Openclaw - slate tint
140    pub const AGENT_OPENCLAW_BG: Color = Color::rgb(24, 30, 34); // #181e22 - slate
141
142    /// GitHub Copilot Chat - blue-green tint
143    pub const AGENT_COPILOT_BG: Color = Color::rgb(18, 38, 34); // #122622 - blue-green
144
145    /// Copilot CLI - navy tint
146    pub const AGENT_COPILOT_CLI_BG: Color = Color::rgb(20, 32, 44); // #14202c - navy
147
148    /// Crush - plum tint
149    pub const AGENT_CRUSH_BG: Color = Color::rgb(42, 22, 32); // #2a1620 - plum
150
151    /// Kimi Code - violet tint
152    pub const AGENT_KIMI_BG: Color = Color::rgb(30, 24, 50); // #1e1832 - violet
153
154    /// Qwen Code - moss tint
155    pub const AGENT_QWEN_BG: Color = Color::rgb(24, 36, 24); // #182418 - moss
156
157    /// Hermes Agent - dim gold tint
158    pub const AGENT_HERMES_BG: Color = Color::rgb(40, 34, 18); // #282212 - gold
159
160    // ═══════════════════════════════════════════════════════════════════════════
161    // ROLE-AWARE BACKGROUND TINTS - Subtle backgrounds per message type
162    // ═══════════════════════════════════════════════════════════════════════════
163
164    /// User message background - subtle green tint
165    pub const ROLE_USER_BG: Color = Color::rgb(26, 32, 30); // #1a201e
166
167    /// Assistant/agent message background - subtle blue tint
168    pub const ROLE_AGENT_BG: Color = Color::rgb(26, 28, 36); // #1a1c24
169
170    /// Tool invocation background - subtle orange/warm tint
171    pub const ROLE_TOOL_BG: Color = Color::rgb(32, 28, 26); // #201c1a
172
173    /// System message background - subtle amber tint
174    pub const ROLE_SYSTEM_BG: Color = Color::rgb(32, 30, 26); // #201e1a
175
176    // ═══════════════════════════════════════════════════════════════════════════
177    // GRADIENT SIMULATION COLORS - Multi-shade for depth effects
178    // ═══════════════════════════════════════════════════════════════════════════
179
180    /// Header gradient top - darkest shade
181    pub const GRADIENT_HEADER_TOP: Color = Color::rgb(22, 24, 32); // #161820
182
183    /// Header gradient middle - mid shade
184    pub const GRADIENT_HEADER_MID: Color = Color::rgb(30, 32, 44); // #1e202c
185
186    /// Header gradient bottom - lightest shade
187    pub const GRADIENT_HEADER_BOT: Color = Color::rgb(36, 40, 54); // #242836
188
189    /// Pill gradient left
190    pub const GRADIENT_PILL_LEFT: Color = Color::rgb(50, 56, 80); // #323850
191
192    /// Pill gradient center
193    pub const GRADIENT_PILL_CENTER: Color = Color::rgb(60, 68, 96); // #3c4460
194
195    /// Pill gradient right
196    pub const GRADIENT_PILL_RIGHT: Color = Color::rgb(50, 56, 80); // #323850
197
198    // ═══════════════════════════════════════════════════════════════════════════
199    // BORDER VARIANTS - For adaptive width styling
200    // ═══════════════════════════════════════════════════════════════════════════
201
202    /// Subtle border - for narrow terminals
203    pub const BORDER_MINIMAL: Color = Color::rgb(45, 50, 72); // #2d3248
204
205    /// Standard border - normal terminals
206    pub const BORDER_STANDARD: Color = Color::rgb(59, 66, 97); // #3b4261 (same as BORDER)
207
208    /// Emphasized border - for wide terminals
209    pub const BORDER_EMPHASIZED: Color = Color::rgb(75, 85, 120); // #4b5578
210}
211
212/// Complete styling for a message role (user, assistant, tool, system).
213#[derive(Clone, Copy)]
214pub struct RoleTheme {
215    /// Foreground (text) color
216    pub fg: PackedRgba,
217    /// Background tint (subtle)
218    pub bg: PackedRgba,
219    /// Border/accent color
220    pub border: PackedRgba,
221    /// Badge/indicator color
222    pub badge: PackedRgba,
223}
224
225/// Gradient shades for simulating depth effects in headers/pills.
226#[derive(Clone, Copy)]
227pub struct GradientShades {
228    /// Darkest shade (top/edges)
229    pub dark: PackedRgba,
230    /// Mid-tone shade
231    pub mid: PackedRgba,
232    /// Lightest shade (center/bottom)
233    pub light: PackedRgba,
234}
235
236impl GradientShades {
237    /// Header gradient - darkest at top, lightest at bottom
238    pub fn header() -> Self {
239        Self {
240            dark: colors::GRADIENT_HEADER_TOP,
241            mid: colors::GRADIENT_HEADER_MID,
242            light: colors::GRADIENT_HEADER_BOT,
243        }
244    }
245
246    /// Pill gradient - darker at edges, lighter in center
247    pub fn pill() -> Self {
248        Self {
249            dark: colors::GRADIENT_PILL_LEFT,
250            mid: colors::GRADIENT_PILL_CENTER,
251            light: colors::GRADIENT_PILL_RIGHT,
252        }
253    }
254
255    /// Create styles for each shade
256    pub fn styles(&self) -> (Style, Style, Style) {
257        (
258            Style::new().bg(self.dark),
259            Style::new().bg(self.mid),
260            Style::new().bg(self.light),
261        )
262    }
263}
264
265/// Terminal width classification for adaptive styling.
266#[derive(Clone, Copy, Debug, PartialEq, Eq)]
267pub enum TerminalWidth {
268    /// Narrow terminal (<80 cols) - minimal decorations
269    Narrow,
270    /// Normal terminal (80-120 cols) - standard styling
271    Normal,
272    /// Wide terminal (>120 cols) - enhanced decorations
273    Wide,
274}
275
276impl TerminalWidth {
277    /// Classify terminal width from column count
278    pub fn from_cols(cols: u16) -> Self {
279        if cols < 80 {
280            Self::Narrow
281        } else if cols <= 120 {
282            Self::Normal
283        } else {
284            Self::Wide
285        }
286    }
287
288    /// Get the appropriate border color for this width
289    pub fn border_color(self) -> PackedRgba {
290        match self {
291            Self::Narrow => colors::BORDER_MINIMAL,
292            Self::Normal => colors::BORDER_STANDARD,
293            Self::Wide => colors::BORDER_EMPHASIZED,
294        }
295    }
296
297    /// Get border style for this width
298    pub fn border_style(self) -> Style {
299        Style::new().fg(self.border_color())
300    }
301
302    /// Should we show decorative elements at this width?
303    pub fn show_decorations(self) -> bool {
304        !matches!(self, Self::Narrow)
305    }
306
307    /// Should we show extended info panels at this width?
308    pub fn show_extended_info(self) -> bool {
309        matches!(self, Self::Wide)
310    }
311}
312
313/// Adaptive border configuration based on terminal width.
314#[derive(Clone, Copy)]
315pub struct AdaptiveBorders {
316    /// Current terminal width classification
317    pub width_class: TerminalWidth,
318    /// Border color
319    pub color: PackedRgba,
320    /// Border style
321    pub style: Style,
322    /// Use double borders for emphasis
323    pub use_double: bool,
324    /// Show corner decorations
325    pub show_corners: bool,
326}
327
328impl AdaptiveBorders {
329    /// Create adaptive borders for the given terminal width
330    pub fn for_width(cols: u16) -> Self {
331        let width_class = TerminalWidth::from_cols(cols);
332        let color = width_class.border_color();
333        Self {
334            width_class,
335            color,
336            style: Style::new().fg(color),
337            use_double: matches!(width_class, TerminalWidth::Wide),
338            show_corners: width_class.show_decorations(),
339        }
340    }
341
342    /// Create borders for focused/active elements
343    pub fn focused(cols: u16) -> Self {
344        let mut borders = Self::for_width(cols);
345        borders.color = colors::BORDER_FOCUS;
346        borders.style = Style::new().fg(colors::BORDER_FOCUS);
347        borders
348    }
349}
350
351#[derive(Clone, Copy)]
352pub struct PaneTheme {
353    pub bg: PackedRgba,
354    pub fg: PackedRgba,
355    pub accent: PackedRgba,
356}
357
358#[derive(Clone, Copy)]
359pub struct ThemePalette {
360    pub accent: PackedRgba,
361    pub accent_alt: PackedRgba,
362    pub bg: PackedRgba,
363    pub fg: PackedRgba,
364    pub surface: PackedRgba,
365    pub hint: PackedRgba,
366    pub border: PackedRgba,
367    pub user: PackedRgba,
368    pub agent: PackedRgba,
369    pub tool: PackedRgba,
370    pub system: PackedRgba,
371    /// Alternating stripe colors for zebra-striping results (sux.6.3)
372    pub stripe_even: PackedRgba,
373    pub stripe_odd: PackedRgba,
374}
375
376impl ThemePalette {
377    /// Light theme - clean, minimal, professional
378    pub fn light() -> Self {
379        Self {
380            accent: PackedRgba::rgb(47, 107, 231),       // Rich blue
381            accent_alt: PackedRgba::rgb(124, 93, 198),   // Purple
382            bg: PackedRgba::rgb(250, 250, 252),          // Off-white
383            fg: PackedRgba::rgb(36, 41, 46),             // Near-black
384            surface: PackedRgba::rgb(240, 241, 245),     // Light gray
385            hint: PackedRgba::rgb(125, 134, 144),        // Medium gray (higher contrast)
386            border: PackedRgba::rgb(216, 222, 228),      // Border gray
387            user: PackedRgba::rgb(45, 138, 72),          // Forest green
388            agent: PackedRgba::rgb(47, 107, 231),        // Rich blue
389            tool: PackedRgba::rgb(207, 107, 44),         // Warm orange
390            system: PackedRgba::rgb(177, 133, 41),       // Amber
391            stripe_even: PackedRgba::rgb(250, 250, 252), // Same as bg
392            stripe_odd: PackedRgba::rgb(240, 241, 245),  // Slightly darker
393        }
394    }
395
396    /// Dark theme - premium, refined, easy on the eyes
397    pub fn dark() -> Self {
398        Self {
399            accent: colors::ACCENT_PRIMARY,
400            accent_alt: colors::ACCENT_SECONDARY,
401            bg: colors::BG_DEEP,
402            fg: colors::TEXT_PRIMARY,
403            surface: colors::BG_SURFACE,
404            hint: colors::TEXT_MUTED,
405            border: colors::BORDER,
406            user: colors::ROLE_USER,
407            agent: colors::ROLE_AGENT,
408            tool: colors::ROLE_TOOL,
409            system: colors::ROLE_SYSTEM,
410            stripe_even: colors::BG_DEEP,            // #1a1b26
411            stripe_odd: PackedRgba::rgb(30, 32, 48), // #1e2030 - slightly lighter
412        }
413    }
414
415    /// Title style - accent colored with bold modifier
416    pub fn title(self) -> Style {
417        Style::new().fg(self.accent).bold()
418    }
419
420    /// Subtle title style - less prominent headers
421    pub fn title_subtle(self) -> Style {
422        Style::new().fg(self.fg).bold()
423    }
424
425    /// Hint text style - for secondary/muted information
426    pub fn hint_style(self) -> Style {
427        Style::new().fg(self.hint)
428    }
429
430    /// Border style - for unfocused elements
431    pub fn border_style(self) -> Style {
432        Style::new().fg(self.border)
433    }
434
435    /// Focused border style - for active elements (theme-aware)
436    pub fn border_focus_style(self) -> Style {
437        Style::new().fg(self.accent)
438    }
439
440    /// Surface style - for cards, modals, elevated content
441    pub fn surface_style(self) -> Style {
442        Style::new().bg(self.surface)
443    }
444
445    /// Per-agent pane colors - distinct tinted backgrounds with consistent text colors.
446    ///
447    /// Design philosophy: Each agent gets a visually distinct background color that makes
448    /// it immediately clear which tool produced the result. Accent colors are chosen to
449    /// complement the background while remaining cohesive.
450    pub fn agent_pane(agent: &str) -> PaneTheme {
451        let slug = agent.to_lowercase().replace('-', "_");
452
453        let (bg, accent) = match slug.as_str() {
454            // Core agents with distinct color identities
455            "claude_code" | "claude" => (colors::AGENT_CLAUDE_BG, colors::ACCENT_PRIMARY), // Blue
456            "codex" => (colors::AGENT_CODEX_BG, colors::STATUS_SUCCESS),                   // Green
457            "cline" => (colors::AGENT_CLINE_BG, colors::ACCENT_TERTIARY),                  // Cyan
458            "gemini" | "gemini_cli" => (colors::AGENT_GEMINI_BG, colors::ACCENT_SECONDARY), // Purple
459            "amp" => (colors::AGENT_AMP_BG, colors::STATUS_ERROR), // Orange/Red
460            "aider" => (colors::AGENT_AIDER_BG, PackedRgba::rgb(64, 224, 208)), // Turquoise accent
461            "cursor" => (colors::AGENT_CURSOR_BG, PackedRgba::rgb(236, 72, 153)), // Pink accent
462            "chatgpt" => (colors::AGENT_CHATGPT_BG, PackedRgba::rgb(16, 163, 127)), // ChatGPT green
463            "opencode" => (colors::AGENT_OPENCODE_BG, colors::ROLE_USER), // Neutral/sage
464            "pi_agent" => (colors::AGENT_CODEX_BG, PackedRgba::rgb(255, 140, 0)), // Orange for pi
465            "factory" | "droid" => (colors::AGENT_FACTORY_BG, PackedRgba::rgb(230, 176, 60)), // Amber
466            "clawdbot" => (colors::AGENT_CLAWDBOT_BG, PackedRgba::rgb(140, 130, 240)), // Indigo
467            "vibe" | "mistral" => (colors::AGENT_VIBE_BG, PackedRgba::rgb(220, 100, 160)), // Rose
468            "openclaw" => (colors::AGENT_OPENCLAW_BG, PackedRgba::rgb(130, 190, 210)), // Slate blue
469            "copilot" => (colors::AGENT_COPILOT_BG, PackedRgba::rgb(92, 200, 120)),    // Blue-green
470            "copilot_cli" => (colors::AGENT_COPILOT_CLI_BG, PackedRgba::rgb(80, 170, 230)), // Navy
471            "crush" => (colors::AGENT_CRUSH_BG, PackedRgba::rgb(255, 120, 80)),        // Coral
472            "hermes" => (colors::AGENT_HERMES_BG, PackedRgba::rgb(240, 200, 100)),     // Gold
473            "kimi" => (colors::AGENT_KIMI_BG, PackedRgba::rgb(190, 220, 80)), // Yellow-green
474            "qwen" => (colors::AGENT_QWEN_BG, PackedRgba::rgb(80, 210, 180)), // Mint
475            _ => (colors::BG_DEEP, colors::ACCENT_PRIMARY),
476        };
477
478        PaneTheme {
479            bg,
480            fg: colors::TEXT_PRIMARY, // Consistent, legible text
481            accent,
482        }
483    }
484
485    /// Returns a small, legible icon for the given agent slug.
486    /// Icons favor deterministic single-width glyphs to avoid layout jitter and
487    /// emoji fallback artifacts in terminal renderers.
488    pub fn agent_icon(agent: &str) -> &'static str {
489        let slug = agent.to_lowercase().replace('-', "_");
490        match slug.as_str() {
491            "codex" => "◆",
492            "claude_code" | "claude" => "●",
493            "gemini" | "gemini_cli" => "◇",
494            "cline" => "■",
495            "amp" => "▲",
496            "aider" => "▼",
497            "cursor" => "◈",
498            "chatgpt" => "○",
499            "opencode" => "□",
500            "pi_agent" => "△",
501            "factory" | "droid" => "▣",
502            "clawdbot" => "⬢",
503            "vibe" | "mistral" => "✦",
504            "openclaw" => "⬡",
505            "copilot" => "◐",
506            "copilot_cli" => "◑",
507            "crush" => "✚",
508            "hermes" => "▽",
509            "kimi" => "✧",
510            "qwen" => "◒",
511            _ => "•",
512        }
513    }
514
515    /// Get a role-specific style for message rendering
516    pub fn role_style(self, role: &str) -> Style {
517        let color = match role.to_lowercase().as_str() {
518            "user" => self.user,
519            "assistant" | "agent" => self.agent,
520            "tool" => self.tool,
521            "system" => self.system,
522            _ => self.hint,
523        };
524        Style::new().fg(color)
525    }
526
527    /// Get a complete `RoleTheme` for a message role with full styling options.
528    ///
529    /// Includes foreground, background tint, border, and badge colors for
530    /// comprehensive role-aware message rendering.
531    pub fn role_theme(self, role: &str) -> RoleTheme {
532        match role.to_lowercase().as_str() {
533            "user" => RoleTheme {
534                fg: self.user,
535                bg: colors::ROLE_USER_BG,
536                border: self.user,
537                badge: colors::STATUS_SUCCESS,
538            },
539            "assistant" | "agent" => RoleTheme {
540                fg: self.agent,
541                bg: colors::ROLE_AGENT_BG,
542                border: self.agent,
543                badge: colors::ACCENT_PRIMARY,
544            },
545            "tool" => RoleTheme {
546                fg: self.tool,
547                bg: colors::ROLE_TOOL_BG,
548                border: self.tool,
549                badge: colors::ROLE_TOOL,
550            },
551            "system" => RoleTheme {
552                fg: self.system,
553                bg: colors::ROLE_SYSTEM_BG,
554                border: self.system,
555                badge: colors::STATUS_WARNING,
556            },
557            _ => RoleTheme {
558                fg: self.hint,
559                bg: self.bg,
560                border: self.border,
561                badge: self.hint,
562            },
563        }
564    }
565
566    /// Get the gradient shades for header backgrounds
567    pub fn header_gradient(&self) -> GradientShades {
568        GradientShades::header()
569    }
570
571    /// Get the gradient shades for pills/badges
572    pub fn pill_gradient(&self) -> GradientShades {
573        GradientShades::pill()
574    }
575
576    /// Get adaptive borders for the given terminal width
577    pub fn adaptive_borders(&self, cols: u16) -> AdaptiveBorders {
578        AdaptiveBorders::for_width(cols)
579    }
580
581    /// Get focused adaptive borders for the given terminal width
582    pub fn adaptive_borders_focused(&self, cols: u16) -> AdaptiveBorders {
583        AdaptiveBorders::focused(cols)
584    }
585
586    /// Highlighted text style - for search matches
587    /// Uses high-contrast background with theme-aware foreground for visibility
588    pub fn highlight_style(self) -> Style {
589        Style::new()
590            .fg(self.bg) // Dark text on light bg, light text on dark bg
591            .bg(self.accent) // Accent color background for high visibility
592            .bold()
593    }
594
595    /// Selected item style - for list selections (theme-aware)
596    pub fn selected_style(self) -> Style {
597        Style::new().bg(self.surface).bold()
598    }
599
600    /// Code block background style (theme-aware)
601    pub fn code_style(self) -> Style {
602        Style::new().bg(self.surface).fg(self.hint)
603    }
604}
605
606// ═══════════════════════════════════════════════════════════════════════════════
607// STYLE HELPERS - Common style patterns
608// ═══════════════════════════════════════════════════════════════════════════════
609
610/// Creates a subtle badge/chip style for filter indicators
611pub fn chip_style(palette: ThemePalette) -> Style {
612    Style::new().fg(palette.accent_alt).bold()
613}
614
615/// Creates a keyboard shortcut style (for help text)
616pub fn kbd_style(palette: ThemePalette) -> Style {
617    Style::new().fg(palette.accent).bold()
618}
619
620/// Creates style for score indicators based on magnitude
621pub fn score_style(score: f32, palette: ThemePalette) -> Style {
622    let color = if score >= 8.0 {
623        colors::STATUS_SUCCESS
624    } else if score >= 5.0 {
625        palette.accent
626    } else {
627        palette.hint
628    };
629
630    let base = Style::new().fg(color);
631    if score >= 8.0 {
632        base.bold()
633    } else if score < 5.0 {
634        base.dim()
635    } else {
636        base
637    }
638}
639
640// ═══════════════════════════════════════════════════════════════════════════════
641// CONTRAST UTILITIES - WCAG compliance helpers
642// ═══════════════════════════════════════════════════════════════════════════════
643
644/// Calculate relative luminance of an RGB color per WCAG 2.1.
645/// Returns a value from 0.0 (black) to 1.0 (white).
646pub fn relative_luminance(color: PackedRgba) -> f64 {
647    let (r, g, b) = (color.r(), color.g(), color.b());
648
649    fn linearize(c: u8) -> f64 {
650        let c = f64::from(c) / 255.0;
651        if c <= 0.04045 {
652            c / 12.92
653        } else {
654            ((c + 0.055) / 1.055).powf(2.4)
655        }
656    }
657
658    let r_lin = linearize(r);
659    let g_lin = linearize(g);
660    let b_lin = linearize(b);
661
662    0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin
663}
664
665/// Calculate WCAG contrast ratio between two colors.
666/// Returns a value from 1.0 (no contrast) to 21.0 (black/white).
667pub fn contrast_ratio(fg: PackedRgba, bg: PackedRgba) -> f64 {
668    let lum_fg = relative_luminance(fg);
669    let lum_bg = relative_luminance(bg);
670    let (lighter, darker) = if lum_fg > lum_bg {
671        (lum_fg, lum_bg)
672    } else {
673        (lum_bg, lum_fg)
674    };
675    (lighter + 0.05) / (darker + 0.05)
676}
677
678/// WCAG compliance level for contrast ratios.
679#[derive(Clone, Copy, Debug, PartialEq, Eq)]
680pub enum ContrastLevel {
681    /// Fails WCAG requirements (ratio < 3.0)
682    Fail,
683    /// WCAG AA for large text (ratio >= 3.0)
684    AALarge,
685    /// WCAG AA for normal text (ratio >= 4.5)
686    AA,
687    /// WCAG AAA for large text (ratio >= 4.5)
688    AAALarge,
689    /// WCAG AAA for normal text (ratio >= 7.0)
690    AAA,
691}
692
693impl ContrastLevel {
694    /// Determine WCAG compliance level from a contrast ratio
695    pub fn from_ratio(ratio: f64) -> Self {
696        if ratio >= 7.0 {
697            Self::AAA
698        } else if ratio >= 4.5 {
699            Self::AA
700        } else if ratio >= 3.0 {
701            Self::AALarge
702        } else {
703            Self::Fail
704        }
705    }
706
707    /// Check if this level meets the specified minimum requirement
708    pub fn meets(self, required: ContrastLevel) -> bool {
709        match required {
710            Self::Fail => true,
711            Self::AALarge => !matches!(self, Self::Fail),
712            Self::AA | Self::AAALarge => matches!(self, Self::AA | Self::AAALarge | Self::AAA),
713            Self::AAA => matches!(self, Self::AAA),
714        }
715    }
716
717    /// Display name for this compliance level
718    pub fn name(self) -> &'static str {
719        match self {
720            Self::Fail => "Fail",
721            Self::AALarge => "AA (large text)",
722            Self::AA => "AA",
723            Self::AAALarge => "AAA (large text)",
724            Self::AAA => "AAA",
725        }
726    }
727}
728
729/// Check contrast compliance between foreground and background colors.
730pub fn check_contrast(fg: PackedRgba, bg: PackedRgba) -> ContrastLevel {
731    ContrastLevel::from_ratio(contrast_ratio(fg, bg))
732}
733
734/// Ensure a color meets minimum contrast against a background.
735/// If the color doesn't meet the requirement, returns a suggested alternative.
736pub fn ensure_contrast(fg: PackedRgba, bg: PackedRgba, min_level: ContrastLevel) -> PackedRgba {
737    let level = check_contrast(fg, bg);
738    if level.meets(min_level) {
739        return fg;
740    }
741
742    // Try lightening or darkening the foreground
743    let bg_lum = relative_luminance(bg);
744    if bg_lum > 0.5 {
745        // Light background, use black for maximum contrast
746        PackedRgba::BLACK
747    } else {
748        // Dark background, use white for maximum contrast
749        PackedRgba::WHITE
750    }
751}
752
753// ═══════════════════════════════════════════════════════════════════════════════
754// THEME PRESETS - Popular color schemes for user preference
755// ═══════════════════════════════════════════════════════════════════════════════
756
757/// Available theme presets that users can cycle through.
758#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
759pub enum ThemePreset {
760    /// Default dark theme - Tokyo Night inspired, premium feel
761    #[default]
762    TokyoNight,
763    /// Light theme - clean, minimal, professional
764    Daylight,
765    /// Catppuccin Mocha - warm, pastel colors
766    Catppuccin,
767    /// Dracula - purple-tinted dark theme
768    Dracula,
769    /// Nord - arctic, cool blue tones
770    Nord,
771    /// Solarized Dark
772    SolarizedDark,
773    /// Solarized Light
774    SolarizedLight,
775    /// Monokai
776    Monokai,
777    /// Gruvbox Dark
778    GruvboxDark,
779    /// One Dark
780    OneDark,
781    /// Rosé Pine
782    RosePine,
783    /// Everforest
784    Everforest,
785    /// Kanagawa
786    Kanagawa,
787    /// Ayu Mirage
788    AyuMirage,
789    /// Nightfox
790    Nightfox,
791    /// Cyberpunk Aurora
792    CyberpunkAurora,
793    /// Synthwave '84
794    Synthwave84,
795    /// High Contrast - maximum contrast for accessibility (WCAG AAA)
796    HighContrast,
797    /// Colorblind - deuteranopia/protanopia accessible variant of Tokyo Night
798    /// Replaces green/orange with blue/yellow for red-green colorblind users
799    Colorblind,
800}
801
802impl ThemePreset {
803    const ALL: [Self; 19] = [
804        Self::TokyoNight,
805        Self::Daylight,
806        Self::Catppuccin,
807        Self::Dracula,
808        Self::Nord,
809        Self::SolarizedDark,
810        Self::SolarizedLight,
811        Self::Monokai,
812        Self::GruvboxDark,
813        Self::OneDark,
814        Self::RosePine,
815        Self::Everforest,
816        Self::Kanagawa,
817        Self::AyuMirage,
818        Self::Nightfox,
819        Self::CyberpunkAurora,
820        Self::Synthwave84,
821        Self::HighContrast,
822        Self::Colorblind,
823    ];
824
825    /// Get the display name for this theme preset
826    pub fn name(self) -> &'static str {
827        match self {
828            Self::TokyoNight => "Tokyo Night",
829            Self::Daylight => "Daylight",
830            Self::Catppuccin => "Catppuccin Mocha",
831            Self::Dracula => "Dracula",
832            Self::Nord => "Nord",
833            Self::SolarizedDark => "Solarized Dark",
834            Self::SolarizedLight => "Solarized Light",
835            Self::Monokai => "Monokai",
836            Self::GruvboxDark => "Gruvbox Dark",
837            Self::OneDark => "One Dark",
838            Self::RosePine => "Ros\u{e9} Pine",
839            Self::Everforest => "Everforest",
840            Self::Kanagawa => "Kanagawa",
841            Self::AyuMirage => "Ayu Mirage",
842            Self::Nightfox => "Nightfox",
843            Self::CyberpunkAurora => "Cyberpunk Aurora",
844            Self::Synthwave84 => "Synthwave '84",
845            Self::HighContrast => "High Contrast",
846            Self::Colorblind => "Colorblind",
847        }
848    }
849
850    /// Cycle to the next theme preset
851    pub fn next(self) -> Self {
852        let idx = Self::ALL.iter().position(|p| *p == self).unwrap_or(0);
853        Self::ALL[(idx + 1) % Self::ALL.len()]
854    }
855
856    /// Cycle to the previous theme preset
857    pub fn prev(self) -> Self {
858        let idx = Self::ALL.iter().position(|p| *p == self).unwrap_or(0);
859        Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
860    }
861
862    /// Convert this preset to its `ThemePalette`
863    pub fn to_palette(self) -> ThemePalette {
864        match self {
865            Self::TokyoNight => ThemePalette::dark(),
866            Self::Daylight => ThemePalette::light(),
867            Self::Catppuccin => ThemePalette::catppuccin(),
868            Self::Dracula => ThemePalette::dracula(),
869            Self::Nord => ThemePalette::nord(),
870            Self::SolarizedDark => ThemePalette::solarized_dark(),
871            Self::SolarizedLight => ThemePalette::solarized_light(),
872            Self::Monokai => ThemePalette::monokai(),
873            Self::GruvboxDark => ThemePalette::gruvbox_dark(),
874            Self::OneDark => ThemePalette::one_dark(),
875            Self::RosePine => ThemePalette::rose_pine(),
876            Self::Everforest => ThemePalette::everforest(),
877            Self::Kanagawa => ThemePalette::kanagawa(),
878            Self::AyuMirage => ThemePalette::ayu_mirage(),
879            Self::Nightfox => ThemePalette::nightfox(),
880            Self::CyberpunkAurora => ThemePalette::cyberpunk_aurora(),
881            Self::Synthwave84 => ThemePalette::synthwave_84(),
882            Self::HighContrast => ThemePalette::high_contrast(),
883            Self::Colorblind => ThemePalette::colorblind(),
884        }
885    }
886
887    /// List all available presets
888    pub fn all() -> &'static [Self] {
889        &Self::ALL
890    }
891}
892
893impl ThemePalette {
894    /// Catppuccin Mocha theme - warm, pastel colors
895    /// <https://github.com/catppuccin/catppuccin>
896    pub fn catppuccin() -> Self {
897        Self {
898            // Catppuccin Mocha palette
899            accent: PackedRgba::rgb(137, 180, 250),     // Blue
900            accent_alt: PackedRgba::rgb(203, 166, 247), // Mauve
901            bg: PackedRgba::rgb(30, 30, 46),            // Base
902            fg: PackedRgba::rgb(205, 214, 244),         // Text
903            surface: PackedRgba::rgb(49, 50, 68),       // Surface0
904            hint: PackedRgba::rgb(127, 132, 156),       // Overlay1
905            border: PackedRgba::rgb(69, 71, 90),        // Surface1
906            user: PackedRgba::rgb(166, 227, 161),       // Green
907            agent: PackedRgba::rgb(137, 180, 250),      // Blue
908            tool: PackedRgba::rgb(250, 179, 135),       // Peach
909            system: PackedRgba::rgb(249, 226, 175),     // Yellow
910            stripe_even: PackedRgba::rgb(30, 30, 46),   // Base
911            stripe_odd: PackedRgba::rgb(36, 36, 54),    // Slightly lighter
912        }
913    }
914
915    /// Dracula theme - purple-tinted dark theme
916    /// <https://draculatheme.com>/
917    pub fn dracula() -> Self {
918        Self {
919            // Dracula palette
920            accent: PackedRgba::rgb(189, 147, 249), // Purple
921            accent_alt: PackedRgba::rgb(255, 121, 198), // Pink
922            bg: PackedRgba::rgb(40, 42, 54),        // Background
923            fg: PackedRgba::rgb(248, 248, 242),     // Foreground
924            surface: PackedRgba::rgb(68, 71, 90),   // Current Line
925            hint: PackedRgba::rgb(155, 165, 200), // Lightened from Dracula comment for WCAG AA-large on surface
926            border: PackedRgba::rgb(68, 71, 90),  // Current Line
927            user: PackedRgba::rgb(80, 250, 123),  // Green
928            agent: PackedRgba::rgb(189, 147, 249), // Purple
929            tool: PackedRgba::rgb(255, 184, 108), // Orange
930            system: PackedRgba::rgb(241, 250, 140), // Yellow
931            stripe_even: PackedRgba::rgb(40, 42, 54), // Background
932            stripe_odd: PackedRgba::rgb(48, 50, 64), // Slightly lighter
933        }
934    }
935
936    /// Nord theme - arctic, cool blue tones
937    /// <https://www.nordtheme.com>/
938    pub fn nord() -> Self {
939        Self {
940            // Nord palette
941            accent: PackedRgba::rgb(136, 192, 208), // Nord8 (frost cyan)
942            accent_alt: PackedRgba::rgb(180, 142, 173), // Nord15 (aurora purple)
943            bg: PackedRgba::rgb(46, 52, 64),        // Nord0 (polar night)
944            fg: PackedRgba::rgb(236, 239, 244),     // Nord6 (snow storm)
945            surface: PackedRgba::rgb(59, 66, 82),   // Nord1
946            hint: PackedRgba::rgb(145, 155, 180), // Lightened from Nord3 for WCAG AA-large on surface
947            border: PackedRgba::rgb(67, 76, 94),  // Nord2
948            user: PackedRgba::rgb(163, 190, 140), // Nord14 (aurora green)
949            agent: PackedRgba::rgb(136, 192, 208), // Nord8 (frost cyan)
950            tool: PackedRgba::rgb(208, 135, 112), // Nord12 (aurora orange)
951            system: PackedRgba::rgb(235, 203, 139), // Nord13 (aurora yellow)
952            stripe_even: PackedRgba::rgb(46, 52, 64), // Nord0
953            stripe_odd: PackedRgba::rgb(52, 58, 72), // Slightly lighter
954        }
955    }
956
957    /// High Contrast theme - maximum contrast for accessibility
958    ///
959    /// Designed to meet WCAG AAA standards (7:1 contrast ratio).
960    /// Uses pure black/white with saturated accent colors for maximum visibility.
961    pub fn high_contrast() -> Self {
962        Self {
963            accent: PackedRgba::rgb(0, 191, 255),
964            accent_alt: PackedRgba::rgb(255, 105, 180),
965            bg: PackedRgba::BLACK,
966            fg: PackedRgba::WHITE,
967            surface: PackedRgba::rgb(28, 28, 28),
968            hint: PackedRgba::rgb(180, 180, 180),
969            border: PackedRgba::WHITE,
970            user: PackedRgba::rgb(0, 255, 127),
971            agent: PackedRgba::rgb(0, 191, 255),
972            tool: PackedRgba::rgb(255, 165, 0),
973            system: PackedRgba::rgb(255, 255, 0),
974            stripe_even: PackedRgba::BLACK,
975            stripe_odd: PackedRgba::rgb(24, 24, 24),
976        }
977    }
978
979    /// Colorblind-accessible theme - Tokyo Night base with deuteranopia/protanopia-safe colors.
980    ///
981    /// Replaces green (#9ece6a) with blue (#7aa2f7) and orange (#ff9e64) with yellow (#e0af68)
982    /// so that role colors remain distinguishable for red-green colorblind users.
983    /// Red (#f7768e) is replaced with magenta/purple (#bb9af7).
984    /// Background, text, and accent colors are unchanged from Tokyo Night.
985    pub fn colorblind() -> Self {
986        Self {
987            accent: colors::ACCENT_PRIMARY,          // #7aa2f7 (unchanged)
988            accent_alt: colors::ACCENT_SECONDARY,    // #bb9af7 (unchanged)
989            bg: colors::BG_DEEP,                     // #1a1b26 (unchanged)
990            fg: colors::TEXT_PRIMARY,                // #c0caf5 (unchanged)
991            surface: colors::BG_SURFACE,             // #24283b (unchanged)
992            hint: colors::TEXT_MUTED,                // #696e9e (unchanged)
993            border: colors::BORDER,                  // #3b4261 (unchanged)
994            user: PackedRgba::rgb(125, 207, 255), // #7dcfff cyan (was green #9ece6a — distinct from agent blue)
995            agent: colors::ROLE_AGENT,            // #7aa2f7 blue (unchanged)
996            tool: PackedRgba::rgb(224, 175, 104), // #e0af68 yellow (was orange #ff9e64)
997            system: PackedRgba::rgb(208, 154, 247), // #d09af7 light magenta (was amber #e0af68 — distinct from accent_alt/error)
998            stripe_even: colors::BG_DEEP,           // #1a1b26
999            stripe_odd: PackedRgba::rgb(30, 32, 48), // #1e2030
1000        }
1001    }
1002
1003    pub fn solarized_dark() -> Self {
1004        Self {
1005            accent: PackedRgba::rgb(38, 139, 210),      // #268bd2 blue
1006            accent_alt: PackedRgba::rgb(108, 113, 196), // #6c71c4 violet
1007            bg: PackedRgba::rgb(0, 43, 54),             // #002b36 base03
1008            fg: PackedRgba::rgb(147, 161, 161),         // #93a1a1 base1 (WCAG AA on surface)
1009            surface: PackedRgba::rgb(7, 54, 66),        // #073642 base02
1010            hint: PackedRgba::rgb(105, 127, 134), // lightened base00 (WCAG AA-large on surface)
1011            border: PackedRgba::rgb(88, 110, 117), // #586e75 base01
1012            user: PackedRgba::rgb(133, 153, 0),   // #859900 green
1013            agent: PackedRgba::rgb(38, 139, 210), // #268bd2 blue
1014            tool: PackedRgba::rgb(203, 75, 22),   // #cb4b16 orange
1015            system: PackedRgba::rgb(181, 137, 0), // #b58900 yellow
1016            stripe_even: PackedRgba::rgb(0, 43, 54),
1017            stripe_odd: PackedRgba::rgb(7, 54, 66),
1018        }
1019    }
1020
1021    pub fn solarized_light() -> Self {
1022        Self {
1023            accent: PackedRgba::rgb(38, 139, 210),
1024            accent_alt: PackedRgba::rgb(108, 113, 196),
1025            bg: PackedRgba::rgb(253, 246, 227), // #fdf6e3 base3
1026            fg: PackedRgba::rgb(86, 108, 116),  // darkened base01 (WCAG AA on surface+bg)
1027            surface: PackedRgba::rgb(238, 232, 213), // #eee8d5 base2
1028            hint: PackedRgba::rgb(115, 132, 134), // darkened base0 (WCAG AA-large on surface+bg)
1029            border: PackedRgba::rgb(147, 161, 161), // #93a1a1 base1
1030            user: PackedRgba::rgb(128, 148, 0), // darkened green (WCAG AA-large on bg)
1031            agent: PackedRgba::rgb(38, 139, 210),
1032            tool: PackedRgba::rgb(203, 75, 22),
1033            system: PackedRgba::rgb(177, 133, 0), // darkened yellow (WCAG AA-large on bg)
1034            stripe_even: PackedRgba::rgb(253, 246, 227),
1035            stripe_odd: PackedRgba::rgb(238, 232, 213),
1036        }
1037    }
1038
1039    pub fn monokai() -> Self {
1040        Self {
1041            accent: PackedRgba::rgb(166, 226, 46),      // #a6e22e green
1042            accent_alt: PackedRgba::rgb(174, 129, 255), // #ae81ff purple
1043            bg: PackedRgba::rgb(39, 40, 34),            // #272822
1044            fg: PackedRgba::rgb(248, 248, 242),         // #f8f8f2
1045            surface: PackedRgba::rgb(53, 54, 45),       // #35362d
1046            hint: PackedRgba::rgb(150, 155, 140),       // #969b8c
1047            border: PackedRgba::rgb(73, 72, 62),        // #49483e
1048            user: PackedRgba::rgb(166, 226, 46),        // green
1049            agent: PackedRgba::rgb(102, 217, 239),      // #66d9ef cyan
1050            tool: PackedRgba::rgb(253, 151, 31),        // #fd971f orange
1051            system: PackedRgba::rgb(230, 219, 116),     // #e6db74 yellow
1052            stripe_even: PackedRgba::rgb(39, 40, 34),
1053            stripe_odd: PackedRgba::rgb(48, 49, 42),
1054        }
1055    }
1056
1057    pub fn gruvbox_dark() -> Self {
1058        Self {
1059            accent: PackedRgba::rgb(250, 189, 47),      // #fabd2f yellow
1060            accent_alt: PackedRgba::rgb(211, 134, 155), // #d3869b purple
1061            bg: PackedRgba::rgb(40, 40, 40),            // #282828
1062            fg: PackedRgba::rgb(235, 219, 178),         // #ebdbb2
1063            surface: PackedRgba::rgb(50, 48, 47),       // #32302f
1064            hint: PackedRgba::rgb(146, 131, 116),       // #928374
1065            border: PackedRgba::rgb(80, 73, 69),        // #504945
1066            user: PackedRgba::rgb(184, 187, 38),        // #b8bb26 green
1067            agent: PackedRgba::rgb(131, 165, 152),      // #83a598 aqua
1068            tool: PackedRgba::rgb(254, 128, 25),        // #fe8019 orange
1069            system: PackedRgba::rgb(250, 189, 47),      // #fabd2f yellow
1070            stripe_even: PackedRgba::rgb(40, 40, 40),
1071            stripe_odd: PackedRgba::rgb(50, 48, 47),
1072        }
1073    }
1074
1075    pub fn one_dark() -> Self {
1076        Self {
1077            accent: PackedRgba::rgb(97, 175, 239),      // #61afef blue
1078            accent_alt: PackedRgba::rgb(198, 120, 221), // #c678dd purple
1079            bg: PackedRgba::rgb(40, 44, 52),            // #282c34
1080            fg: PackedRgba::rgb(171, 178, 191),         // #abb2bf
1081            surface: PackedRgba::rgb(49, 53, 63),       // #31353f
1082            hint: PackedRgba::rgb(118, 128, 150), // lightened #636d83 (WCAG AA-large on bg+surface)
1083            border: PackedRgba::rgb(62, 68, 81),  // #3e4451
1084            user: PackedRgba::rgb(152, 195, 121), // #98c379 green
1085            agent: PackedRgba::rgb(97, 175, 239), // #61afef blue
1086            tool: PackedRgba::rgb(229, 192, 123), // #e5c07b yellow
1087            system: PackedRgba::rgb(224, 108, 117), // #e06c75 red
1088            stripe_even: PackedRgba::rgb(40, 44, 52),
1089            stripe_odd: PackedRgba::rgb(49, 53, 63),
1090        }
1091    }
1092
1093    pub fn rose_pine() -> Self {
1094        Self {
1095            accent: PackedRgba::rgb(235, 188, 186),     // #ebbcba rose
1096            accent_alt: PackedRgba::rgb(196, 167, 231), // #c4a7e7 iris
1097            bg: PackedRgba::rgb(25, 23, 36),            // #191724
1098            fg: PackedRgba::rgb(224, 222, 244),         // #e0def4
1099            surface: PackedRgba::rgb(38, 35, 53),       // #26233a
1100            hint: PackedRgba::rgb(114, 110, 138), // lightened #6e6a86 (WCAG AA-large on surface)
1101            border: PackedRgba::rgb(57, 53, 82),  // #393552
1102            user: PackedRgba::rgb(156, 207, 216), // #9ccfd8 foam
1103            agent: PackedRgba::rgb(196, 167, 231), // #c4a7e7 iris
1104            tool: PackedRgba::rgb(246, 193, 119), // #f6c177 gold
1105            system: PackedRgba::rgb(235, 111, 146), // #eb6f92 love
1106            stripe_even: PackedRgba::rgb(25, 23, 36),
1107            stripe_odd: PackedRgba::rgb(33, 30, 46),
1108        }
1109    }
1110
1111    pub fn everforest() -> Self {
1112        Self {
1113            accent: PackedRgba::rgb(167, 192, 128),     // #a7c080 green
1114            accent_alt: PackedRgba::rgb(214, 153, 182), // #d699b6 purple
1115            bg: PackedRgba::rgb(39, 46, 34),            // #272e22
1116            fg: PackedRgba::rgb(211, 198, 170),         // #d3c6aa
1117            surface: PackedRgba::rgb(47, 55, 42),       // #2f372a
1118            hint: PackedRgba::rgb(135, 127, 110), // lightened #7d7564 (WCAG AA-large on surface)
1119            border: PackedRgba::rgb(68, 77, 60),  // #444d3c
1120            user: PackedRgba::rgb(131, 192, 146), // #83c092 aqua
1121            agent: PackedRgba::rgb(124, 195, 210), // #7cc3d2 blue
1122            tool: PackedRgba::rgb(219, 188, 127), // #dbbc7f yellow
1123            system: PackedRgba::rgb(230, 126, 128), // #e67e80 red
1124            stripe_even: PackedRgba::rgb(39, 46, 34),
1125            stripe_odd: PackedRgba::rgb(47, 55, 42),
1126        }
1127    }
1128
1129    pub fn kanagawa() -> Self {
1130        Self {
1131            accent: PackedRgba::rgb(126, 156, 216), // #7e9cd8 crystal blue
1132            accent_alt: PackedRgba::rgb(149, 127, 184), // #957fb8 oniviolet
1133            bg: PackedRgba::rgb(31, 31, 40),        // #1f1f28
1134            fg: PackedRgba::rgb(220, 215, 186),     // #dcd7ba
1135            surface: PackedRgba::rgb(42, 42, 54),   // #2a2a36
1136            hint: PackedRgba::rgb(119, 118, 110),   // lightened #727169 (WCAG AA-large on surface)
1137            border: PackedRgba::rgb(84, 84, 109),   // #54546d
1138            user: PackedRgba::rgb(152, 187, 108),   // #98bb6c spring green
1139            agent: PackedRgba::rgb(127, 180, 202),  // #7fb4ca wave blue
1140            tool: PackedRgba::rgb(255, 169, 98),    // #ffa962 surimi orange
1141            system: PackedRgba::rgb(195, 64, 67),   // #c34043 autumn red
1142            stripe_even: PackedRgba::rgb(31, 31, 40),
1143            stripe_odd: PackedRgba::rgb(42, 42, 54),
1144        }
1145    }
1146
1147    pub fn ayu_mirage() -> Self {
1148        Self {
1149            accent: PackedRgba::rgb(115, 210, 222),     // #73d2de
1150            accent_alt: PackedRgba::rgb(217, 155, 243), // #d99bf3
1151            bg: PackedRgba::rgb(36, 42, 54),            // #242a36
1152            fg: PackedRgba::rgb(204, 204, 194),         // #cccac2
1153            surface: PackedRgba::rgb(44, 51, 64),       // #2c3340
1154            hint: PackedRgba::rgb(119, 126, 140), // lightened #6b7280 (WCAG AA-large on bg+surface)
1155            border: PackedRgba::rgb(60, 68, 82),  // #3c4452
1156            user: PackedRgba::rgb(135, 213, 134), // #87d586
1157            agent: PackedRgba::rgb(115, 210, 222), // #73d2de
1158            tool: PackedRgba::rgb(255, 213, 109), // #ffd56d
1159            system: PackedRgba::rgb(240, 113, 120), // #f07178
1160            stripe_even: PackedRgba::rgb(36, 42, 54),
1161            stripe_odd: PackedRgba::rgb(44, 51, 64),
1162        }
1163    }
1164
1165    pub fn nightfox() -> Self {
1166        Self {
1167            accent: PackedRgba::rgb(129, 180, 243),     // #81b4f3
1168            accent_alt: PackedRgba::rgb(174, 140, 211), // #ae8cd3
1169            bg: PackedRgba::rgb(18, 21, 31),            // #12151f
1170            fg: PackedRgba::rgb(205, 207, 216),         // #cdcfd8
1171            surface: PackedRgba::rgb(29, 33, 46),       // #1d212e
1172            hint: PackedRgba::rgb(106, 108, 122),       // #6a6c7a
1173            border: PackedRgba::rgb(48, 54, 71),        // #303647
1174            user: PackedRgba::rgb(129, 200, 152),       // #81c898
1175            agent: PackedRgba::rgb(129, 180, 243),      // #81b4f3
1176            tool: PackedRgba::rgb(218, 167, 89),        // #daa759
1177            system: PackedRgba::rgb(201, 101, 120),     // #c96578
1178            stripe_even: PackedRgba::rgb(18, 21, 31),
1179            stripe_odd: PackedRgba::rgb(29, 33, 46),
1180        }
1181    }
1182
1183    pub fn cyberpunk_aurora() -> Self {
1184        Self {
1185            accent: PackedRgba::rgb(255, 0, 128),     // #ff0080 neon pink
1186            accent_alt: PackedRgba::rgb(0, 255, 255), // #00ffff cyan
1187            bg: PackedRgba::rgb(13, 2, 33),           // #0d0221
1188            fg: PackedRgba::rgb(224, 210, 255),       // #e0d2ff
1189            surface: PackedRgba::rgb(22, 10, 48),     // #160a30
1190            hint: PackedRgba::rgb(120, 100, 160),     // #7864a0
1191            border: PackedRgba::rgb(60, 30, 100),     // #3c1e64
1192            user: PackedRgba::rgb(0, 255, 163),       // #00ffa3 neon green
1193            agent: PackedRgba::rgb(0, 200, 255),      // #00c8ff
1194            tool: PackedRgba::rgb(255, 213, 0),       // #ffd500
1195            system: PackedRgba::rgb(255, 51, 102),    // #ff3366
1196            stripe_even: PackedRgba::rgb(13, 2, 33),
1197            stripe_odd: PackedRgba::rgb(22, 10, 48),
1198        }
1199    }
1200
1201    pub fn synthwave_84() -> Self {
1202        Self {
1203            accent: PackedRgba::rgb(255, 123, 213),     // #ff7bd5 hot pink
1204            accent_alt: PackedRgba::rgb(114, 241, 223), // #72f1df mint
1205            bg: PackedRgba::rgb(34, 20, 54),            // #221436
1206            fg: PackedRgba::rgb(241, 233, 255),         // #f1e9ff
1207            surface: PackedRgba::rgb(44, 28, 68),       // #2c1c44
1208            hint: PackedRgba::rgb(130, 115, 165),       // #8273a5
1209            border: PackedRgba::rgb(70, 45, 100),       // #462d64
1210            user: PackedRgba::rgb(114, 241, 223),       // #72f1df mint
1211            agent: PackedRgba::rgb(54, 245, 253),       // #36f5fd
1212            tool: PackedRgba::rgb(254, 215, 102),       // #fed766
1213            system: PackedRgba::rgb(254, 73, 99),       // #fe4963
1214            stripe_even: PackedRgba::rgb(34, 20, 54),
1215            stripe_odd: PackedRgba::rgb(44, 28, 68),
1216        }
1217    }
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222    use super::*;
1223
1224    // ==================== TerminalWidth tests ====================
1225
1226    #[test]
1227    fn test_terminal_width_from_cols_narrow() {
1228        assert_eq!(TerminalWidth::from_cols(40), TerminalWidth::Narrow);
1229        assert_eq!(TerminalWidth::from_cols(79), TerminalWidth::Narrow);
1230    }
1231
1232    #[test]
1233    fn test_terminal_width_from_cols_normal() {
1234        assert_eq!(TerminalWidth::from_cols(80), TerminalWidth::Normal);
1235        assert_eq!(TerminalWidth::from_cols(100), TerminalWidth::Normal);
1236        assert_eq!(TerminalWidth::from_cols(120), TerminalWidth::Normal);
1237    }
1238
1239    #[test]
1240    fn test_terminal_width_from_cols_wide() {
1241        assert_eq!(TerminalWidth::from_cols(121), TerminalWidth::Wide);
1242        assert_eq!(TerminalWidth::from_cols(200), TerminalWidth::Wide);
1243    }
1244
1245    #[test]
1246    fn test_terminal_width_border_color() {
1247        assert_eq!(TerminalWidth::Narrow.border_color(), colors::BORDER_MINIMAL);
1248        assert_eq!(
1249            TerminalWidth::Normal.border_color(),
1250            colors::BORDER_STANDARD
1251        );
1252        assert_eq!(
1253            TerminalWidth::Wide.border_color(),
1254            colors::BORDER_EMPHASIZED
1255        );
1256    }
1257
1258    #[test]
1259    fn test_terminal_width_show_decorations() {
1260        assert!(!TerminalWidth::Narrow.show_decorations());
1261        assert!(TerminalWidth::Normal.show_decorations());
1262        assert!(TerminalWidth::Wide.show_decorations());
1263    }
1264
1265    #[test]
1266    fn test_terminal_width_show_extended_info() {
1267        assert!(!TerminalWidth::Narrow.show_extended_info());
1268        assert!(!TerminalWidth::Normal.show_extended_info());
1269        assert!(TerminalWidth::Wide.show_extended_info());
1270    }
1271
1272    // ==================== GradientShades tests ====================
1273
1274    #[test]
1275    fn test_gradient_shades_header() {
1276        let shades = GradientShades::header();
1277        assert_eq!(shades.dark, colors::GRADIENT_HEADER_TOP);
1278        assert_eq!(shades.mid, colors::GRADIENT_HEADER_MID);
1279        assert_eq!(shades.light, colors::GRADIENT_HEADER_BOT);
1280    }
1281
1282    #[test]
1283    fn test_gradient_shades_pill() {
1284        let shades = GradientShades::pill();
1285        assert_eq!(shades.dark, colors::GRADIENT_PILL_LEFT);
1286        assert_eq!(shades.mid, colors::GRADIENT_PILL_CENTER);
1287        assert_eq!(shades.light, colors::GRADIENT_PILL_RIGHT);
1288    }
1289
1290    #[test]
1291    fn test_gradient_shades_styles() {
1292        let shades = GradientShades::header();
1293        let (dark, mid, light) = shades.styles();
1294        assert_eq!(dark.bg, Some(shades.dark));
1295        assert_eq!(mid.bg, Some(shades.mid));
1296        assert_eq!(light.bg, Some(shades.light));
1297    }
1298
1299    // ==================== AdaptiveBorders tests ====================
1300
1301    #[test]
1302    fn test_adaptive_borders_for_width_narrow() {
1303        let borders = AdaptiveBorders::for_width(60);
1304        assert_eq!(borders.width_class, TerminalWidth::Narrow);
1305        assert!(!borders.use_double);
1306        assert!(!borders.show_corners);
1307    }
1308
1309    #[test]
1310    fn test_adaptive_borders_for_width_normal() {
1311        let borders = AdaptiveBorders::for_width(100);
1312        assert_eq!(borders.width_class, TerminalWidth::Normal);
1313        assert!(!borders.use_double);
1314        assert!(borders.show_corners);
1315    }
1316
1317    #[test]
1318    fn test_adaptive_borders_for_width_wide() {
1319        let borders = AdaptiveBorders::for_width(150);
1320        assert_eq!(borders.width_class, TerminalWidth::Wide);
1321        assert!(borders.use_double);
1322        assert!(borders.show_corners);
1323    }
1324
1325    #[test]
1326    fn test_adaptive_borders_focused() {
1327        let borders = AdaptiveBorders::focused(100);
1328        assert_eq!(borders.color, colors::BORDER_FOCUS);
1329    }
1330
1331    // ==================== ThemePalette tests ====================
1332
1333    #[test]
1334    fn test_theme_palette_light() {
1335        let palette = ThemePalette::light();
1336        // Light theme should have a light background
1337        assert_eq!(palette.bg, PackedRgba::rgb(250, 250, 252));
1338        // And dark foreground
1339        assert_eq!(palette.fg, PackedRgba::rgb(36, 41, 46));
1340    }
1341
1342    #[test]
1343    fn test_theme_palette_dark() {
1344        let palette = ThemePalette::dark();
1345        // Dark theme should have a dark background
1346        assert_eq!(palette.bg, colors::BG_DEEP);
1347        // And light foreground
1348        assert_eq!(palette.fg, colors::TEXT_PRIMARY);
1349    }
1350
1351    #[test]
1352    fn test_theme_palette_catppuccin() {
1353        let palette = ThemePalette::catppuccin();
1354        // Check specific Catppuccin colors
1355        assert_eq!(palette.bg, PackedRgba::rgb(30, 30, 46));
1356    }
1357
1358    #[test]
1359    fn test_theme_palette_dracula() {
1360        let palette = ThemePalette::dracula();
1361        assert_eq!(palette.bg, PackedRgba::rgb(40, 42, 54));
1362    }
1363
1364    #[test]
1365    fn test_theme_palette_nord() {
1366        let palette = ThemePalette::nord();
1367        assert_eq!(palette.bg, PackedRgba::rgb(46, 52, 64));
1368    }
1369
1370    #[test]
1371    fn test_theme_palette_high_contrast() {
1372        let palette = ThemePalette::high_contrast();
1373        // High contrast should use pure black and white
1374        assert_eq!(palette.bg, PackedRgba::rgb(0, 0, 0));
1375        assert_eq!(palette.fg, PackedRgba::rgb(255, 255, 255));
1376    }
1377
1378    #[test]
1379    fn test_theme_palette_agent_pane_known_agents() {
1380        // Test known agent color mappings
1381        let claude = ThemePalette::agent_pane("claude_code");
1382        assert_eq!(claude.bg, colors::AGENT_CLAUDE_BG);
1383
1384        let codex = ThemePalette::agent_pane("codex");
1385        assert_eq!(codex.bg, colors::AGENT_CODEX_BG);
1386
1387        let gemini = ThemePalette::agent_pane("gemini_cli");
1388        assert_eq!(gemini.bg, colors::AGENT_GEMINI_BG);
1389
1390        let chatgpt = ThemePalette::agent_pane("chatgpt");
1391        assert_eq!(chatgpt.bg, colors::AGENT_CHATGPT_BG);
1392    }
1393
1394    #[test]
1395    fn test_theme_palette_agent_pane_unknown_agent() {
1396        let unknown = ThemePalette::agent_pane("unknown_agent");
1397        assert_eq!(unknown.bg, colors::BG_DEEP);
1398    }
1399
1400    #[test]
1401    fn test_theme_palette_agent_icon() {
1402        assert_eq!(ThemePalette::agent_icon("codex"), "◆");
1403        assert_eq!(ThemePalette::agent_icon("claude_code"), "●");
1404        assert_eq!(ThemePalette::agent_icon("gemini"), "◇");
1405        assert_eq!(ThemePalette::agent_icon("chatgpt"), "○");
1406        assert_eq!(ThemePalette::agent_icon("unknown"), "•");
1407    }
1408
1409    #[test]
1410    fn test_theme_palette_role_theme() {
1411        let palette = ThemePalette::dark();
1412
1413        let user_theme = palette.role_theme("user");
1414        assert_eq!(user_theme.fg, palette.user);
1415
1416        let agent_theme = palette.role_theme("assistant");
1417        assert_eq!(agent_theme.fg, palette.agent);
1418
1419        let tool_theme = palette.role_theme("tool");
1420        assert_eq!(tool_theme.fg, palette.tool);
1421
1422        let system_theme = palette.role_theme("system");
1423        assert_eq!(system_theme.fg, palette.system);
1424    }
1425
1426    // ==================== ContrastLevel tests ====================
1427
1428    #[test]
1429    fn test_contrast_level_from_ratio() {
1430        assert_eq!(ContrastLevel::from_ratio(2.0), ContrastLevel::Fail);
1431        assert_eq!(ContrastLevel::from_ratio(3.5), ContrastLevel::AALarge);
1432        assert_eq!(ContrastLevel::from_ratio(5.0), ContrastLevel::AA);
1433        assert_eq!(ContrastLevel::from_ratio(8.0), ContrastLevel::AAA);
1434    }
1435
1436    #[test]
1437    fn test_contrast_level_meets() {
1438        assert!(ContrastLevel::AAA.meets(ContrastLevel::AA));
1439        assert!(ContrastLevel::AA.meets(ContrastLevel::AALarge));
1440        assert!(!ContrastLevel::Fail.meets(ContrastLevel::AA));
1441    }
1442
1443    #[test]
1444    fn test_contrast_level_name() {
1445        assert_eq!(ContrastLevel::AAA.name(), "AAA");
1446        assert_eq!(ContrastLevel::AA.name(), "AA");
1447        assert_eq!(ContrastLevel::Fail.name(), "Fail");
1448    }
1449
1450    // ==================== Luminance/Contrast tests ====================
1451
1452    #[test]
1453    fn test_relative_luminance_black() {
1454        let lum = relative_luminance(PackedRgba::rgb(0, 0, 0));
1455        assert!((lum - 0.0).abs() < 0.001);
1456    }
1457
1458    #[test]
1459    fn test_relative_luminance_white() {
1460        let lum = relative_luminance(PackedRgba::rgb(255, 255, 255));
1461        assert!((lum - 1.0).abs() < 0.001);
1462    }
1463
1464    #[test]
1465    fn test_relative_luminance_named_colors() {
1466        // Black should have low luminance
1467        let black_lum = relative_luminance(PackedRgba::BLACK);
1468        assert!(black_lum < 0.01);
1469
1470        // White should have high luminance
1471        let white_lum = relative_luminance(PackedRgba::WHITE);
1472        assert!(white_lum > 0.99);
1473    }
1474
1475    #[test]
1476    fn test_contrast_ratio_black_white() {
1477        let ratio = contrast_ratio(PackedRgba::rgb(255, 255, 255), PackedRgba::rgb(0, 0, 0));
1478        // Maximum contrast is 21:1
1479        assert!(ratio > 20.0);
1480    }
1481
1482    #[test]
1483    fn test_contrast_ratio_same_color() {
1484        let ratio = contrast_ratio(
1485            PackedRgba::rgb(128, 128, 128),
1486            PackedRgba::rgb(128, 128, 128),
1487        );
1488        // Same color = 1:1 contrast
1489        assert!((ratio - 1.0).abs() < 0.001);
1490    }
1491
1492    #[test]
1493    fn test_check_contrast() {
1494        // High contrast pair
1495        let level = check_contrast(PackedRgba::rgb(255, 255, 255), PackedRgba::rgb(0, 0, 0));
1496        assert_eq!(level, ContrastLevel::AAA);
1497
1498        // Low contrast pair (similar grays)
1499        let level = check_contrast(
1500            PackedRgba::rgb(100, 100, 100),
1501            PackedRgba::rgb(120, 120, 120),
1502        );
1503        assert_eq!(level, ContrastLevel::Fail);
1504    }
1505
1506    #[test]
1507    fn test_ensure_contrast_already_sufficient() {
1508        let bg = PackedRgba::rgb(0, 0, 0);
1509        let fg = PackedRgba::rgb(255, 255, 255);
1510        let result = ensure_contrast(fg, bg, ContrastLevel::AA);
1511        // Should return original since contrast is already good
1512        assert_eq!(result, fg);
1513    }
1514
1515    // ==================== ThemePreset tests ====================
1516
1517    #[test]
1518    fn test_theme_preset_default() {
1519        let preset = ThemePreset::default();
1520        assert_eq!(preset, ThemePreset::TokyoNight);
1521    }
1522
1523    #[test]
1524    fn test_theme_preset_name() {
1525        assert_eq!(ThemePreset::TokyoNight.name(), "Tokyo Night");
1526        assert_eq!(ThemePreset::Daylight.name(), "Daylight");
1527        assert_eq!(ThemePreset::Catppuccin.name(), "Catppuccin Mocha");
1528        assert_eq!(ThemePreset::Dracula.name(), "Dracula");
1529        assert_eq!(ThemePreset::Nord.name(), "Nord");
1530        assert_eq!(ThemePreset::HighContrast.name(), "High Contrast");
1531    }
1532
1533    #[test]
1534    fn test_theme_preset_next_cycles() {
1535        let mut preset = ThemePreset::TokyoNight;
1536        preset = preset.next();
1537        assert_eq!(preset, ThemePreset::Daylight);
1538        preset = preset.next();
1539        assert_eq!(preset, ThemePreset::Catppuccin);
1540        // Cycle through all 19 and verify wrap
1541        let mut p = ThemePreset::Colorblind;
1542        p = p.next();
1543        assert_eq!(p, ThemePreset::TokyoNight);
1544    }
1545
1546    #[test]
1547    fn test_theme_preset_prev_cycles() {
1548        let mut preset = ThemePreset::TokyoNight;
1549        preset = preset.prev();
1550        assert_eq!(preset, ThemePreset::Colorblind);
1551        preset = preset.prev();
1552        assert_eq!(preset, ThemePreset::HighContrast);
1553    }
1554
1555    #[test]
1556    fn test_theme_preset_to_palette() {
1557        let palette = ThemePreset::TokyoNight.to_palette();
1558        assert_eq!(palette.bg, ThemePalette::dark().bg);
1559
1560        let palette = ThemePreset::Daylight.to_palette();
1561        assert_eq!(palette.bg, ThemePalette::light().bg);
1562    }
1563
1564    #[test]
1565    fn test_theme_preset_all() {
1566        let all = ThemePreset::all();
1567        assert_eq!(all.len(), 19);
1568        assert!(all.contains(&ThemePreset::TokyoNight));
1569        assert!(all.contains(&ThemePreset::Daylight));
1570    }
1571
1572    // ==================== Style helper tests ====================
1573
1574    #[test]
1575    fn test_chip_style() {
1576        let palette = ThemePalette::dark();
1577        let style = chip_style(palette);
1578        assert_eq!(style.fg, Some(palette.accent_alt));
1579    }
1580
1581    #[test]
1582    fn test_kbd_style() {
1583        let palette = ThemePalette::dark();
1584        let style = kbd_style(palette);
1585        assert_eq!(style.fg, Some(palette.accent));
1586    }
1587
1588    #[test]
1589    fn test_score_style_high() {
1590        let palette = ThemePalette::dark();
1591        let style = score_style(9.0, palette);
1592        assert_eq!(style.fg, Some(colors::STATUS_SUCCESS));
1593    }
1594
1595    #[test]
1596    fn test_score_style_medium() {
1597        let palette = ThemePalette::dark();
1598        let style = score_style(6.0, palette);
1599        assert_eq!(style.fg, Some(palette.accent));
1600    }
1601
1602    #[test]
1603    fn test_score_style_low() {
1604        let palette = ThemePalette::dark();
1605        let style = score_style(3.0, palette);
1606        assert_eq!(style.fg, Some(palette.hint));
1607    }
1608
1609    // ==================== RoleTheme tests ====================
1610
1611    #[test]
1612    fn test_role_theme_has_all_fields() {
1613        let palette = ThemePalette::dark();
1614        let theme = palette.role_theme("user");
1615        // Verify all fields are set
1616        assert_ne!(theme.fg, PackedRgba::TRANSPARENT);
1617        assert_ne!(theme.bg, PackedRgba::TRANSPARENT);
1618        assert_ne!(theme.border, PackedRgba::TRANSPARENT);
1619        assert_ne!(theme.badge, PackedRgba::TRANSPARENT);
1620    }
1621
1622    // ==================== PaneTheme tests ====================
1623
1624    #[test]
1625    fn test_pane_theme_has_all_fields() {
1626        let pane = ThemePalette::agent_pane("claude");
1627        assert_ne!(pane.fg, PackedRgba::TRANSPARENT);
1628        assert_ne!(pane.bg, PackedRgba::TRANSPARENT);
1629        assert_ne!(pane.accent, PackedRgba::TRANSPARENT);
1630    }
1631
1632    // -- agent/role coherence tests (2dccg.10.2) --
1633
1634    const KNOWN_AGENTS: &[&str] = &[
1635        "claude_code",
1636        "codex",
1637        "cline",
1638        "gemini",
1639        "amp",
1640        "aider",
1641        "cursor",
1642        "chatgpt",
1643        "opencode",
1644        "pi_agent",
1645        "factory",
1646        "clawdbot",
1647        "vibe",
1648        "openclaw",
1649        "copilot",
1650        "copilot_cli",
1651        "crush",
1652        "hermes",
1653        "kimi",
1654        "qwen",
1655    ];
1656
1657    #[test]
1658    fn agent_accent_colors_are_pairwise_distinct() {
1659        let accents: Vec<(&str, PackedRgba)> = KNOWN_AGENTS
1660            .iter()
1661            .map(|a| (*a, ThemePalette::agent_pane(a).accent))
1662            .collect();
1663
1664        for i in 0..accents.len() {
1665            for j in (i + 1)..accents.len() {
1666                let (name_a, color_a) = accents[i];
1667                let (name_b, color_b) = accents[j];
1668                assert_ne!(
1669                    color_a, color_b,
1670                    "Agents {name_a} and {name_b} have identical accent colors — \
1671                     users cannot distinguish them"
1672                );
1673            }
1674        }
1675    }
1676
1677    #[test]
1678    fn known_agents_do_not_use_unknown_fallback_background() {
1679        for agent in KNOWN_AGENTS {
1680            let pane = ThemePalette::agent_pane(agent);
1681            assert_ne!(
1682                pane.bg,
1683                colors::BG_DEEP,
1684                "known agent {agent} should have a provider-specific background"
1685            );
1686        }
1687    }
1688
1689    #[test]
1690    fn agent_background_colors_are_pairwise_distinct() {
1691        let bgs: Vec<(&str, PackedRgba)> = KNOWN_AGENTS
1692            .iter()
1693            .map(|a| (*a, ThemePalette::agent_pane(a).bg))
1694            .collect();
1695
1696        for i in 0..bgs.len() {
1697            for j in (i + 1)..bgs.len() {
1698                let (name_a, bg_a) = bgs[i];
1699                let (name_b, bg_b) = bgs[j];
1700                // codex and pi_agent intentionally share AGENT_CODEX_BG
1701                if (name_a == "codex" && name_b == "pi_agent")
1702                    || (name_a == "pi_agent" && name_b == "codex")
1703                {
1704                    continue;
1705                }
1706                assert_ne!(
1707                    bg_a, bg_b,
1708                    "Agents {name_a} and {name_b} have identical background colors"
1709                );
1710            }
1711        }
1712    }
1713
1714    #[test]
1715    fn agent_icons_are_pairwise_distinct() {
1716        let icons: Vec<(&str, &str)> = KNOWN_AGENTS
1717            .iter()
1718            .map(|a| (*a, ThemePalette::agent_icon(a)))
1719            .collect();
1720
1721        for i in 0..icons.len() {
1722            for j in (i + 1)..icons.len() {
1723                let (name_a, icon_a) = icons[i];
1724                let (name_b, icon_b) = icons[j];
1725                assert_ne!(
1726                    icon_a, icon_b,
1727                    "Agents {name_a} and {name_b} have identical icons"
1728                );
1729            }
1730        }
1731    }
1732
1733    #[test]
1734    fn agent_icons_are_single_char_glyphs() {
1735        for agent in KNOWN_AGENTS {
1736            let icon = ThemePalette::agent_icon(agent);
1737            assert_eq!(
1738                icon.chars().count(),
1739                1,
1740                "Agent {agent} icon should be a single-width glyph for layout stability"
1741            );
1742        }
1743    }
1744
1745    #[test]
1746    fn unknown_agent_falls_back_gracefully() {
1747        let pane = ThemePalette::agent_pane("nonexistent_agent");
1748        // Should not panic and should produce a usable theme.
1749        assert_ne!(pane.fg, PackedRgba::TRANSPARENT);
1750        assert_ne!(pane.bg, PackedRgba::TRANSPARENT);
1751        assert_ne!(pane.accent, PackedRgba::TRANSPARENT);
1752
1753        let icon = ThemePalette::agent_icon("nonexistent_agent");
1754        assert!(!icon.is_empty(), "unknown agent should get a fallback icon");
1755    }
1756
1757    #[test]
1758    fn role_colors_are_pairwise_distinct_in_palette() {
1759        let palette = ThemePalette::dark();
1760        let roles = [
1761            ("user", palette.user),
1762            ("agent", palette.agent),
1763            ("tool", palette.tool),
1764            ("system", palette.system),
1765        ];
1766        for i in 0..roles.len() {
1767            for j in (i + 1)..roles.len() {
1768                let (name_a, color_a) = roles[i];
1769                let (name_b, color_b) = roles[j];
1770                assert_ne!(
1771                    color_a, color_b,
1772                    "ThemePalette::dark() role {name_a} and {name_b} have identical colors"
1773                );
1774            }
1775        }
1776    }
1777}