#![allow(dead_code)] // Phase 0: types defined now, consumed in Phase 1+ //! Flexoki-based theme for the lore TUI. //! //! Uses FrankenTUI's `AdaptiveColor::adaptive(light, dark)` for automatic //! light/dark mode switching. The palette is [Flexoki](https://stephango.com/flexoki) //! by Steph Ango, designed in Oklab perceptual color space for balanced contrast. use ftui::{AdaptiveColor, Color, PackedRgba, Style, Theme}; // --------------------------------------------------------------------------- // Flexoki palette constants // --------------------------------------------------------------------------- // Base tones const PAPER: Color = Color::rgb(0xFF, 0xFC, 0xF0); const BASE_50: Color = Color::rgb(0xF2, 0xF0, 0xE5); const BASE_100: Color = Color::rgb(0xE6, 0xE4, 0xD9); const BASE_200: Color = Color::rgb(0xCE, 0xCD, 0xC3); const BASE_300: Color = Color::rgb(0xB7, 0xB5, 0xAC); const BASE_400: Color = Color::rgb(0x9F, 0x9D, 0x96); const BASE_500: Color = Color::rgb(0x87, 0x85, 0x80); const BASE_600: Color = Color::rgb(0x6F, 0x6E, 0x69); const BASE_700: Color = Color::rgb(0x57, 0x56, 0x53); const BASE_800: Color = Color::rgb(0x40, 0x3E, 0x3C); const BASE_850: Color = Color::rgb(0x34, 0x33, 0x31); const BASE_900: Color = Color::rgb(0x28, 0x27, 0x26); const BLACK: Color = Color::rgb(0x10, 0x0F, 0x0F); // Accent colors — light-600 (for light mode) const RED_600: Color = Color::rgb(0xAF, 0x30, 0x29); const ORANGE_600: Color = Color::rgb(0xBC, 0x52, 0x15); const YELLOW_600: Color = Color::rgb(0xAD, 0x83, 0x01); const GREEN_600: Color = Color::rgb(0x66, 0x80, 0x0B); const CYAN_600: Color = Color::rgb(0x24, 0x83, 0x7B); const BLUE_600: Color = Color::rgb(0x20, 0x5E, 0xA6); const PURPLE_600: Color = Color::rgb(0x5E, 0x40, 0x9D); // Accent colors — dark-400 (for dark mode) const RED_400: Color = Color::rgb(0xD1, 0x4D, 0x41); const ORANGE_400: Color = Color::rgb(0xDA, 0x70, 0x2C); const YELLOW_400: Color = Color::rgb(0xD0, 0xA2, 0x15); const GREEN_400: Color = Color::rgb(0x87, 0x9A, 0x39); const CYAN_400: Color = Color::rgb(0x3A, 0xA9, 0x9F); const BLUE_400: Color = Color::rgb(0x43, 0x85, 0xBE); const PURPLE_400: Color = Color::rgb(0x8B, 0x7E, 0xC8); const MAGENTA_400: Color = Color::rgb(0xCE, 0x5D, 0x97); // Muted fallback as PackedRgba (for Style::fg) const MUTED_PACKED: PackedRgba = PackedRgba::rgb(0x87, 0x85, 0x80); // --------------------------------------------------------------------------- // build_theme // --------------------------------------------------------------------------- /// Build the lore TUI theme with Flexoki adaptive colors. /// /// Each of the 19 semantic slots gets an `AdaptiveColor::adaptive(light, dark)` /// pair. FrankenTUI detects the terminal background and resolves accordingly. #[must_use] pub fn build_theme() -> Theme { Theme::builder() .primary(AdaptiveColor::adaptive(BLUE_600, BLUE_400)) .secondary(AdaptiveColor::adaptive(CYAN_600, CYAN_400)) .accent(AdaptiveColor::adaptive(PURPLE_600, PURPLE_400)) .background(AdaptiveColor::adaptive(PAPER, BLACK)) .surface(AdaptiveColor::adaptive(BASE_50, BASE_900)) .overlay(AdaptiveColor::adaptive(BASE_100, BASE_850)) .text(AdaptiveColor::adaptive(BASE_700, BASE_200)) .text_muted(AdaptiveColor::adaptive(BASE_500, BASE_500)) .text_subtle(AdaptiveColor::adaptive(BASE_400, BASE_600)) .success(AdaptiveColor::adaptive(GREEN_600, GREEN_400)) .warning(AdaptiveColor::adaptive(YELLOW_600, YELLOW_400)) .error(AdaptiveColor::adaptive(RED_600, RED_400)) .info(AdaptiveColor::adaptive(BLUE_600, BLUE_400)) .border(AdaptiveColor::adaptive(BASE_300, BASE_700)) .border_focused(AdaptiveColor::adaptive(BLUE_600, BLUE_400)) .selection_bg(AdaptiveColor::adaptive(BASE_100, BASE_800)) .selection_fg(AdaptiveColor::adaptive(BASE_700, BASE_100)) .scrollbar_track(AdaptiveColor::adaptive(BASE_50, BASE_900)) .scrollbar_thumb(AdaptiveColor::adaptive(BASE_300, BASE_700)) .build() } // --------------------------------------------------------------------------- // State colors // --------------------------------------------------------------------------- /// Map a GitLab entity state to a display color. /// /// Returns fixed (non-adaptive) colors — state indicators should be /// consistent regardless of light/dark mode. #[must_use] pub fn state_color(state: &str) -> Color { match state { "opened" => GREEN_400, "closed" => RED_400, "merged" => PURPLE_400, "locked" => YELLOW_400, _ => BASE_500, } } // --------------------------------------------------------------------------- // Event type colors // --------------------------------------------------------------------------- /// Map a timeline event type to a display color. #[must_use] pub fn event_color(event_type: &str) -> Color { match event_type { "created" => GREEN_400, "updated" => BLUE_400, "closed" => RED_400, "merged" => PURPLE_400, "commented" => CYAN_400, "labeled" => ORANGE_400, "milestoned" => YELLOW_400, _ => BASE_500, } } // --------------------------------------------------------------------------- // Label styling // --------------------------------------------------------------------------- /// Convert a GitLab label hex color (e.g., "#FF0000" or "FF0000") to a Style. /// /// Falls back to muted text color if the hex string is invalid. #[must_use] pub fn label_style(hex_color: &str) -> Style { let packed = parse_hex_to_packed(hex_color).unwrap_or(MUTED_PACKED); Style::default().fg(packed) } /// Parse a hex color string like "#RRGGBB" or "RRGGBB" into a `PackedRgba`. fn parse_hex_to_packed(s: &str) -> Option { let hex = s.strip_prefix('#').unwrap_or(s); if hex.len() != 6 { return None; } let r = u8::from_str_radix(&hex[0..2], 16).ok()?; let g = u8::from_str_radix(&hex[2..4], 16).ok()?; let b = u8::from_str_radix(&hex[4..6], 16).ok()?; Some(PackedRgba::rgb(r, g, b)) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn test_build_theme_compiles() { let theme = build_theme(); // Resolve for dark mode — primary should be Blue-400 let resolved = theme.resolve(true); assert_eq!(resolved.primary, BLUE_400); } #[test] fn test_build_theme_light_mode() { let theme = build_theme(); let resolved = theme.resolve(false); assert_eq!(resolved.primary, BLUE_600); } #[test] fn test_build_theme_all_slots_differ_between_modes() { let theme = build_theme(); let dark = theme.resolve(true); let light = theme.resolve(false); // Background should differ (Paper vs Black) assert_ne!(dark.background, light.background); // Text should differ assert_ne!(dark.text, light.text); } #[test] fn test_state_color_opened_is_green() { assert_eq!(state_color("opened"), GREEN_400); } #[test] fn test_state_color_closed_is_red() { assert_eq!(state_color("closed"), RED_400); } #[test] fn test_state_color_merged_is_purple() { assert_eq!(state_color("merged"), PURPLE_400); } #[test] fn test_state_color_unknown_returns_muted() { assert_eq!(state_color("unknown"), BASE_500); } #[test] fn test_event_color_created_is_green() { assert_eq!(event_color("created"), GREEN_400); } #[test] fn test_event_color_unknown_returns_muted() { assert_eq!(event_color("whatever"), BASE_500); } #[test] fn test_label_style_valid_hex_with_hash() { let style = label_style("#FF0000"); assert_eq!(style.fg, Some(PackedRgba::rgb(0xFF, 0x00, 0x00))); } #[test] fn test_label_style_valid_hex_without_hash() { let style = label_style("00FF00"); assert_eq!(style.fg, Some(PackedRgba::rgb(0x00, 0xFF, 0x00))); } #[test] fn test_label_style_lowercase_hex() { let style = label_style("#ff0000"); assert_eq!(style.fg, Some(PackedRgba::rgb(0xFF, 0x00, 0x00))); } #[test] fn test_label_style_invalid_hex_fallback() { let style = label_style("invalid"); assert_eq!(style.fg, Some(MUTED_PACKED)); } #[test] fn test_label_style_empty_fallback() { let style = label_style(""); assert_eq!(style.fg, Some(MUTED_PACKED)); } #[test] fn test_parse_hex_short_string() { assert!(parse_hex_to_packed("#FFF").is_none()); } #[test] fn test_parse_hex_non_hex_chars() { assert!(parse_hex_to_packed("#GGHHII").is_none()); } }