Skip to main content

rch_common/ui/
theme.rs

1//! RCH brand colors and semantic styles.
2//!
3//! Provides a unified theme for consistent visual branding across all RCH commands.
4//! All color constants are valid hex colors that work with `rich_rust`.
5
6#[cfg(all(feature = "rich-ui", unix))]
7use rich_rust::prelude::{Color, Style};
8
9use crate::types::WorkerStatus;
10
11/// RCH brand colors and semantic styles.
12///
13/// # Design Philosophy
14///
15/// - **Purple**: Compilation/build theme - sophisticated, technical
16/// - **Cyan**: Data transfer theme - clean, digital
17/// - **Amber**: Attention/highlights - warm, visible
18///
19/// # Example (with rich-ui feature)
20///
21/// ```ignore
22/// use rch_common::ui::RchTheme;
23///
24/// let style = RchTheme::success();
25/// // Use style with rich_rust Console
26/// ```
27pub struct RchTheme;
28
29impl RchTheme {
30    // ═══════════════════════════════════════════════════════════════════════
31    // BRAND COLORS
32    // ═══════════════════════════════════════════════════════════════════════
33
34    /// Primary brand color - Purple (compilation/build theme).
35    pub const PRIMARY: &'static str = "#8B5CF6";
36
37    /// Secondary brand color - Cyan (data/transfer theme).
38    pub const SECONDARY: &'static str = "#06B6D4";
39
40    /// Accent color - Amber (highlights, attention).
41    pub const ACCENT: &'static str = "#F59E0B";
42
43    // ═══════════════════════════════════════════════════════════════════════
44    // SEMANTIC COLORS
45    // ═══════════════════════════════════════════════════════════════════════
46
47    /// Success state - Green.
48    pub const SUCCESS: &'static str = "#10B981";
49
50    /// Warning state - Amber.
51    pub const WARNING: &'static str = "#F59E0B";
52
53    /// Error state - Red.
54    pub const ERROR: &'static str = "#EF4444";
55
56    /// Informational - Blue.
57    pub const INFO: &'static str = "#3B82F6";
58
59    // ═══════════════════════════════════════════════════════════════════════
60    // WORKER STATUS COLORS
61    // These match the WorkerStatus enum from rch-common/types
62    // ═══════════════════════════════════════════════════════════════════════
63
64    /// Worker is healthy and accepting work - Green.
65    pub const STATUS_HEALTHY: &'static str = "#10B981";
66
67    /// Worker is degraded (slow, high error rate) - Amber.
68    pub const STATUS_DEGRADED: &'static str = "#F59E0B";
69
70    /// Worker is unreachable (connection failed) - Red.
71    pub const STATUS_UNREACHABLE: &'static str = "#EF4444";
72
73    /// Worker is draining (no new work) - Purple.
74    pub const STATUS_DRAINING: &'static str = "#8B5CF6";
75
76    /// Worker is drained (drain complete, idle) - Indigo.
77    pub const STATUS_DRAINED: &'static str = "#6366F1";
78
79    /// Worker is disabled (admin disabled) - Gray.
80    pub const STATUS_DISABLED: &'static str = "#737B8A";
81
82    // ═══════════════════════════════════════════════════════════════════════
83    // TEXT COLORS
84    // ═══════════════════════════════════════════════════════════════════════
85
86    /// Muted text (secondary information) - Gray-400.
87    pub const MUTED: &'static str = "#9CA3AF";
88
89    /// Dim text (tertiary information) - Gray-500.
90    pub const DIM: &'static str = "#737B8A";
91
92    /// Bright text (emphasis) - Gray-50.
93    pub const BRIGHT: &'static str = "#F9FAFB";
94
95    // ═══════════════════════════════════════════════════════════════════════
96    // STYLE GENERATORS (require rich-ui feature)
97    // ═══════════════════════════════════════════════════════════════════════
98
99    /// Create style for success messages.
100    #[cfg(all(feature = "rich-ui", unix))]
101    #[must_use]
102    pub fn success() -> Style {
103        Style::new().color(Color::parse(Self::SUCCESS).unwrap_or_default())
104    }
105
106    /// Create style for error messages (bold for emphasis).
107    #[cfg(all(feature = "rich-ui", unix))]
108    #[must_use]
109    pub fn error() -> Style {
110        Style::new()
111            .bold()
112            .color(Color::parse(Self::ERROR).unwrap_or_default())
113    }
114
115    /// Create style for warning messages.
116    #[cfg(all(feature = "rich-ui", unix))]
117    #[must_use]
118    pub fn warning() -> Style {
119        Style::new().color(Color::parse(Self::WARNING).unwrap_or_default())
120    }
121
122    /// Create style for informational messages.
123    #[cfg(all(feature = "rich-ui", unix))]
124    #[must_use]
125    pub fn info() -> Style {
126        Style::new().color(Color::parse(Self::INFO).unwrap_or_default())
127    }
128
129    /// Create style for muted/secondary text.
130    #[cfg(all(feature = "rich-ui", unix))]
131    #[must_use]
132    pub fn muted() -> Style {
133        Style::new().color(Color::parse(Self::MUTED).unwrap_or_default())
134    }
135
136    /// Create style for dim/tertiary text.
137    #[cfg(all(feature = "rich-ui", unix))]
138    #[must_use]
139    pub fn dim() -> Style {
140        Style::new()
141            .dim()
142            .color(Color::parse(Self::DIM).unwrap_or_default())
143    }
144
145    /// Create style for primary brand elements.
146    #[cfg(all(feature = "rich-ui", unix))]
147    #[must_use]
148    pub fn primary() -> Style {
149        Style::new().color(Color::parse(Self::PRIMARY).unwrap_or_default())
150    }
151
152    /// Create style for secondary brand elements.
153    #[cfg(all(feature = "rich-ui", unix))]
154    #[must_use]
155    pub fn secondary() -> Style {
156        Style::new().color(Color::parse(Self::SECONDARY).unwrap_or_default())
157    }
158
159    /// Create style for accent/highlight elements.
160    #[cfg(all(feature = "rich-ui", unix))]
161    #[must_use]
162    pub fn accent() -> Style {
163        Style::new().color(Color::parse(Self::ACCENT).unwrap_or_default())
164    }
165
166    /// Create style for worker status based on string.
167    #[cfg(all(feature = "rich-ui", unix))]
168    #[must_use]
169    pub fn worker_status_str(status: &str) -> Style {
170        let color = match status.to_lowercase().as_str() {
171            "healthy" => Self::STATUS_HEALTHY,
172            "degraded" => Self::STATUS_DEGRADED,
173            "unreachable" => Self::STATUS_UNREACHABLE,
174            "draining" => Self::STATUS_DRAINING,
175            "disabled" => Self::STATUS_DISABLED,
176            _ => Self::MUTED,
177        };
178        Style::new().color(Color::parse(color).unwrap_or_default())
179    }
180
181    /// Create style for worker status enum.
182    #[cfg(all(feature = "rich-ui", unix))]
183    #[must_use]
184    pub fn for_worker_status(status: WorkerStatus) -> Style {
185        let color = match status {
186            WorkerStatus::Healthy => Self::STATUS_HEALTHY,
187            WorkerStatus::Degraded => Self::STATUS_DEGRADED,
188            WorkerStatus::Unreachable => Self::STATUS_UNREACHABLE,
189            WorkerStatus::Draining => Self::STATUS_DRAINING,
190            WorkerStatus::Drained => Self::STATUS_DRAINED,
191            WorkerStatus::Disabled => Self::STATUS_DISABLED,
192        };
193        Style::new().color(Color::parse(color).unwrap_or_default())
194    }
195
196    // ═══════════════════════════════════════════════════════════════════════
197    // COMPOSITE STYLES (require rich-ui feature)
198    // ═══════════════════════════════════════════════════════════════════════
199
200    /// Style for table headers.
201    #[cfg(all(feature = "rich-ui", unix))]
202    #[must_use]
203    pub fn table_header() -> Style {
204        Style::new()
205            .bold()
206            .color(Color::parse(Self::BRIGHT).unwrap_or_default())
207    }
208
209    /// Style for table borders.
210    #[cfg(all(feature = "rich-ui", unix))]
211    #[must_use]
212    pub fn table_border() -> Style {
213        Style::new().color(Color::parse(Self::PRIMARY).unwrap_or_default())
214    }
215
216    /// Style for panel titles.
217    #[cfg(all(feature = "rich-ui", unix))]
218    #[must_use]
219    pub fn panel_title() -> Style {
220        Style::new()
221            .bold()
222            .color(Color::parse(Self::SECONDARY).unwrap_or_default())
223    }
224
225    /// Style for command/code text.
226    #[cfg(all(feature = "rich-ui", unix))]
227    #[must_use]
228    pub fn code() -> Style {
229        Style::new().color(Color::parse(Self::SECONDARY).unwrap_or_default())
230    }
231
232    /// Style for paths/filenames.
233    #[cfg(all(feature = "rich-ui", unix))]
234    #[must_use]
235    pub fn path() -> Style {
236        Style::new()
237            .italic()
238            .color(Color::parse(Self::INFO).unwrap_or_default())
239    }
240
241    /// Style for numbers/metrics.
242    #[cfg(all(feature = "rich-ui", unix))]
243    #[must_use]
244    pub fn number() -> Style {
245        Style::new().color(Color::parse(Self::ACCENT).unwrap_or_default())
246    }
247
248    // ═══════════════════════════════════════════════════════════════════════
249    // COLOR LOOKUP (always available)
250    // ═══════════════════════════════════════════════════════════════════════
251
252    /// Get color hex code for a worker status.
253    #[must_use]
254    pub const fn color_for_worker_status(status: WorkerStatus) -> &'static str {
255        match status {
256            WorkerStatus::Healthy => Self::STATUS_HEALTHY,
257            WorkerStatus::Degraded => Self::STATUS_DEGRADED,
258            WorkerStatus::Unreachable => Self::STATUS_UNREACHABLE,
259            WorkerStatus::Draining => Self::STATUS_DRAINING,
260            WorkerStatus::Drained => Self::STATUS_DRAINED,
261            WorkerStatus::Disabled => Self::STATUS_DISABLED,
262        }
263    }
264
265    /// Get color hex code for a status string (case-insensitive).
266    #[must_use]
267    pub fn color_for_status_str(status: &str) -> &'static str {
268        match status.to_lowercase().as_str() {
269            "healthy" => Self::STATUS_HEALTHY,
270            "degraded" => Self::STATUS_DEGRADED,
271            "unreachable" => Self::STATUS_UNREACHABLE,
272            "draining" => Self::STATUS_DRAINING,
273            "drained" => Self::STATUS_DRAINED,
274            "disabled" => Self::STATUS_DISABLED,
275            "success" | "ok" => Self::SUCCESS,
276            "warning" | "warn" => Self::WARNING,
277            "error" | "fail" | "failed" => Self::ERROR,
278            "info" => Self::INFO,
279            _ => Self::MUTED,
280        }
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    fn parse_hex_rgb(color: &str) -> (u8, u8, u8) {
289        let color = color.strip_prefix('#').unwrap_or(color);
290        assert_eq!(color.len(), 6, "Expected RRGGBB hex string, got: {color}");
291
292        let r = u8::from_str_radix(&color[0..2], 16).expect("Invalid hex for R");
293        let g = u8::from_str_radix(&color[2..4], 16).expect("Invalid hex for G");
294        let b = u8::from_str_radix(&color[4..6], 16).expect("Invalid hex for B");
295
296        (r, g, b)
297    }
298
299    fn srgb_channel_to_linear(channel: u8) -> f64 {
300        let c = f64::from(channel) / 255.0;
301        if c <= 0.04045 {
302            c / 12.92
303        } else {
304            ((c + 0.055) / 1.055).powf(2.4)
305        }
306    }
307
308    fn relative_luminance(color: &str) -> f64 {
309        let (r, g, b) = parse_hex_rgb(color);
310        let r = srgb_channel_to_linear(r);
311        let g = srgb_channel_to_linear(g);
312        let b = srgb_channel_to_linear(b);
313
314        0.2126 * r + 0.7152 * g + 0.0722 * b
315    }
316
317    fn contrast_ratio(foreground: &str, background: &str) -> f64 {
318        let l1 = relative_luminance(foreground);
319        let l2 = relative_luminance(background);
320        let (lighter, darker) = if l1 >= l2 { (l1, l2) } else { (l2, l1) };
321        (lighter + 0.05) / (darker + 0.05)
322    }
323
324    #[test]
325    fn test_all_color_constants_are_valid_hex() {
326        // All color constants should be 7-character hex strings (#RRGGBB)
327        let colors = [
328            RchTheme::PRIMARY,
329            RchTheme::SECONDARY,
330            RchTheme::ACCENT,
331            RchTheme::SUCCESS,
332            RchTheme::WARNING,
333            RchTheme::ERROR,
334            RchTheme::INFO,
335            RchTheme::STATUS_HEALTHY,
336            RchTheme::STATUS_DEGRADED,
337            RchTheme::STATUS_UNREACHABLE,
338            RchTheme::STATUS_DRAINING,
339            RchTheme::STATUS_DISABLED,
340            RchTheme::MUTED,
341            RchTheme::DIM,
342            RchTheme::BRIGHT,
343        ];
344
345        for color in colors {
346            assert!(color.starts_with('#'), "Color should start with #: {color}");
347            assert_eq!(color.len(), 7, "Color should be 7 chars: {color}");
348            assert!(
349                color[1..].chars().all(|c| c.is_ascii_hexdigit()),
350                "Color should be valid hex: {color}"
351            );
352        }
353    }
354
355    #[test]
356    fn test_semantic_colors_meet_contrast_on_dark_background() {
357        // WCAG 2.1 AA target for normal text is 4.5:1.
358        //
359        // Terminals vary widely, but a dark background is the most common
360        // interactive default. For light terminals (or accessibility tooling),
361        // RCH supports `NO_COLOR=1` to disable color entirely.
362        const BACKGROUND: &str = "#000000";
363        const MIN_RATIO: f64 = 4.5;
364
365        let colors = [
366            ("PRIMARY", RchTheme::PRIMARY),
367            ("SECONDARY", RchTheme::SECONDARY),
368            ("ACCENT", RchTheme::ACCENT),
369            ("SUCCESS", RchTheme::SUCCESS),
370            ("WARNING", RchTheme::WARNING),
371            ("ERROR", RchTheme::ERROR),
372            ("INFO", RchTheme::INFO),
373            ("STATUS_HEALTHY", RchTheme::STATUS_HEALTHY),
374            ("STATUS_DEGRADED", RchTheme::STATUS_DEGRADED),
375            ("STATUS_UNREACHABLE", RchTheme::STATUS_UNREACHABLE),
376            ("STATUS_DRAINING", RchTheme::STATUS_DRAINING),
377            ("STATUS_DISABLED", RchTheme::STATUS_DISABLED),
378            ("MUTED", RchTheme::MUTED),
379            ("DIM", RchTheme::DIM),
380            ("BRIGHT", RchTheme::BRIGHT),
381        ];
382
383        for (name, color) in colors {
384            let ratio = contrast_ratio(color, BACKGROUND);
385            assert!(
386                ratio >= MIN_RATIO,
387                "{name} ({color}) contrast vs {BACKGROUND} too low: {ratio:.2}"
388            );
389        }
390    }
391
392    #[test]
393    fn test_color_for_worker_status() {
394        assert_eq!(
395            RchTheme::color_for_worker_status(WorkerStatus::Healthy),
396            RchTheme::STATUS_HEALTHY
397        );
398        assert_eq!(
399            RchTheme::color_for_worker_status(WorkerStatus::Degraded),
400            RchTheme::STATUS_DEGRADED
401        );
402        assert_eq!(
403            RchTheme::color_for_worker_status(WorkerStatus::Unreachable),
404            RchTheme::STATUS_UNREACHABLE
405        );
406        assert_eq!(
407            RchTheme::color_for_worker_status(WorkerStatus::Draining),
408            RchTheme::STATUS_DRAINING
409        );
410        assert_eq!(
411            RchTheme::color_for_worker_status(WorkerStatus::Drained),
412            RchTheme::STATUS_DRAINED
413        );
414        assert_eq!(
415            RchTheme::color_for_worker_status(WorkerStatus::Disabled),
416            RchTheme::STATUS_DISABLED
417        );
418    }
419
420    #[test]
421    fn test_color_for_status_str() {
422        // Case insensitive
423        assert_eq!(
424            RchTheme::color_for_status_str("HEALTHY"),
425            RchTheme::STATUS_HEALTHY
426        );
427        assert_eq!(
428            RchTheme::color_for_status_str("healthy"),
429            RchTheme::STATUS_HEALTHY
430        );
431        assert_eq!(
432            RchTheme::color_for_status_str("Healthy"),
433            RchTheme::STATUS_HEALTHY
434        );
435
436        // Aliases
437        assert_eq!(RchTheme::color_for_status_str("success"), RchTheme::SUCCESS);
438        assert_eq!(RchTheme::color_for_status_str("ok"), RchTheme::SUCCESS);
439        assert_eq!(RchTheme::color_for_status_str("error"), RchTheme::ERROR);
440        assert_eq!(RchTheme::color_for_status_str("fail"), RchTheme::ERROR);
441        assert_eq!(RchTheme::color_for_status_str("failed"), RchTheme::ERROR);
442
443        // Unknown -> muted
444        assert_eq!(RchTheme::color_for_status_str("unknown"), RchTheme::MUTED);
445        assert_eq!(RchTheme::color_for_status_str(""), RchTheme::MUTED);
446    }
447
448    #[test]
449    fn test_all_status_colors_are_distinct() {
450        let colors = [
451            RchTheme::STATUS_HEALTHY,
452            RchTheme::STATUS_DEGRADED,
453            RchTheme::STATUS_UNREACHABLE,
454            RchTheme::STATUS_DRAINING,
455            RchTheme::STATUS_DRAINED,
456            RchTheme::STATUS_DISABLED,
457        ];
458
459        // Check all pairs are distinct
460        for (i, &c1) in colors.iter().enumerate() {
461            for &c2 in &colors[i + 1..] {
462                assert_ne!(c1, c2, "Status colors should be distinct");
463            }
464        }
465    }
466
467    // =========================================================================
468    // ADDITIONAL WCAG 2.1 AA ACCESSIBILITY TESTS
469    // =========================================================================
470
471    /// Verify contrast ratio calculation is correct.
472    #[test]
473    fn test_contrast_ratio_calculation_accuracy() {
474        // Black on white should be 21:1 (maximum)
475        let ratio = contrast_ratio("#FFFFFF", "#000000");
476        assert!(
477            (ratio - 21.0).abs() < 0.1,
478            "White/black contrast should be ~21:1, got {ratio:.2}:1"
479        );
480
481        // Same color should be 1:1
482        let same = contrast_ratio("#FF0000", "#FF0000");
483        assert!(
484            (same - 1.0).abs() < 0.01,
485            "Same color contrast should be 1:1"
486        );
487    }
488
489    /// Verify colors work on slightly darker terminal backgrounds.
490    #[test]
491    fn test_colors_on_dark_gray_background() {
492        const DARK_GRAY: &str = "#1a1a1a";
493        const MIN_RATIO: f64 = 4.5;
494
495        let critical_colors = [
496            ("ERROR", RchTheme::ERROR),
497            ("SUCCESS", RchTheme::SUCCESS),
498            ("WARNING", RchTheme::WARNING),
499        ];
500
501        for (name, color) in critical_colors {
502            let ratio = contrast_ratio(color, DARK_GRAY);
503            assert!(
504                ratio >= MIN_RATIO,
505                "{name} ({color}) must be readable on dark gray: {ratio:.2}:1 < {MIN_RATIO}:1"
506            );
507        }
508    }
509
510    #[cfg(all(feature = "rich-ui", unix))]
511    mod rich_ui_tests {
512        use super::*;
513        use rich_rust::prelude::Color;
514
515        #[test]
516        fn test_all_colors_parse_with_rich_rust() {
517            // Ensure no color constant is malformed
518            assert!(Color::parse(RchTheme::PRIMARY).is_ok());
519            assert!(Color::parse(RchTheme::SECONDARY).is_ok());
520            assert!(Color::parse(RchTheme::ACCENT).is_ok());
521            assert!(Color::parse(RchTheme::SUCCESS).is_ok());
522            assert!(Color::parse(RchTheme::WARNING).is_ok());
523            assert!(Color::parse(RchTheme::ERROR).is_ok());
524            assert!(Color::parse(RchTheme::INFO).is_ok());
525            assert!(Color::parse(RchTheme::STATUS_HEALTHY).is_ok());
526            assert!(Color::parse(RchTheme::STATUS_DEGRADED).is_ok());
527            assert!(Color::parse(RchTheme::STATUS_UNREACHABLE).is_ok());
528            assert!(Color::parse(RchTheme::STATUS_DRAINING).is_ok());
529            assert!(Color::parse(RchTheme::STATUS_DRAINED).is_ok());
530            assert!(Color::parse(RchTheme::STATUS_DISABLED).is_ok());
531            assert!(Color::parse(RchTheme::MUTED).is_ok());
532            assert!(Color::parse(RchTheme::DIM).is_ok());
533            assert!(Color::parse(RchTheme::BRIGHT).is_ok());
534        }
535
536        #[test]
537        fn test_styles_dont_panic() {
538            // Ensure style generators don't panic
539            let _ = RchTheme::success();
540            let _ = RchTheme::error();
541            let _ = RchTheme::warning();
542            let _ = RchTheme::info();
543            let _ = RchTheme::muted();
544            let _ = RchTheme::dim();
545            let _ = RchTheme::primary();
546            let _ = RchTheme::secondary();
547            let _ = RchTheme::accent();
548            let _ = RchTheme::table_header();
549            let _ = RchTheme::table_border();
550            let _ = RchTheme::panel_title();
551            let _ = RchTheme::code();
552            let _ = RchTheme::path();
553            let _ = RchTheme::number();
554        }
555
556        #[test]
557        fn test_worker_status_styles() {
558            // String-based
559            let _ = RchTheme::worker_status_str("healthy");
560            let _ = RchTheme::worker_status_str("unknown"); // Should not panic
561
562            // Enum-based
563            let _ = RchTheme::for_worker_status(WorkerStatus::Healthy);
564            let _ = RchTheme::for_worker_status(WorkerStatus::Degraded);
565            let _ = RchTheme::for_worker_status(WorkerStatus::Unreachable);
566            let _ = RchTheme::for_worker_status(WorkerStatus::Draining);
567            let _ = RchTheme::for_worker_status(WorkerStatus::Drained);
568            let _ = RchTheme::for_worker_status(WorkerStatus::Disabled);
569        }
570    }
571}