redbear-tui-theme 0.1.0

Default TUI skin palette for Red Bear OS — single source of truth for the Red Bear brand colours used by every TUI application
Documentation
//! Core palette types: [`Rgb`] and [`Theme`].

/// A 24-bit RGB colour. Deliberately not a re-export of
/// `ratatui::style::Color::Rgb` so this crate stays free of any
/// TUI library dependency.
///
/// Apps convert to their own colour type with a one-liner:
/// `ratatui::style::Color::Rgb(c.0, c.1, c.2)`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Rgb(
    /// Red channel, 0..=255.
    pub u8,
    /// Green channel, 0..=255.
    pub u8,
    /// Blue channel, 0..=255.
    pub u8,
);

impl Rgb {
    /// Construct a new `Rgb` from three `u8` channels.
    #[must_use]
    pub const fn new(r: u8, g: u8, b: u8) -> Self {
        Self(r, g, b)
    }
}

/// A complete color theme. All slots are truecolor `Rgb(u8, u8, u8)`.
///
/// 33 slots, organised into seven semantic groups:
///
/// - **Surface/background family** (6): `background`, `surface`,
///   `overlay_bg`, `title_bg`, `status_bg`, `gauge_bg`. The
///   three-tier surface (`background < surface < overlay_bg`)
///   gives cards visual lift without resorting to greyscale.
/// - **Foreground/text family** (4): `text`, `muted`, `dim`,
///   `border`. `dim` deliberately fails WCAG AA-body — it is for
///   de-emphasis only.
/// - **Accent family** (4): `accent`, `accent_soft`, `title_accent`,
///   `pink`. `accent` is the brand red and is identical in both
///   dark and light themes; `title_accent` is a brighter red for
///   title bars.
/// - **Semantic family** (4): `success`, `warning`, `error`,
///   `info`. Apps must add a non-colour cue (glyph, bold,
///   underline) alongside these — see
///   `local/docs/RED-BEAR-TUI-THEME.md`.
/// - **Gauge** (1): `gauge_fg` (brand-red progress fill; matches
///   `accent` so a dark-accent progress bar uses the same hex as the
///   selected-row highlight).
/// - **File-type slots (tlc)** (6): `directory`, `executable`,
///   `symlink`, `device`, `device_warn`, `hidden`.
/// - **Selection/cursor/marked (tlc)** (8): `selection_{bg,fg}`,
///   `cursor_{bg,fg}`, `marked_{bg,fg}`, `buttonbar_{bg,fg}`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Theme {
    /// Default panel body background.
    pub background: Rgb,
    /// Raised card (slightly lighter than background).
    pub surface: Rgb,
    /// Modal/popup background (between body and card).
    pub overlay_bg: Rgb,
    /// Title bar / section header background.
    pub title_bg: Rgb,
    /// Status line / button bar background.
    pub status_bg: Rgb,
    /// Progress bar unfilled portion.
    pub gauge_bg: Rgb,

    /// Primary body text.
    pub text: Rgb,
    /// Secondary text, labels, hints.
    pub muted: Rgb,
    /// De-emphasized text, disabled items. AA-large only.
    pub dim: Rgb,
    /// Panel borders, separators. AA-body compliant.
    pub border: Rgb,

    /// Primary brand red — selection, focused borders, brand marks.
    pub accent: Rgb,
    /// Washed red — hover, secondary accents.
    pub accent_soft: Rgb,
    /// Brighter red for title bar foreground.
    pub title_accent: Rgb,
    /// Highlight / favorite / active link. Used sparingly.
    pub pink: Rgb,

    /// Completed operations, online state.
    pub success: Rgb,
    /// Caution, pending state.
    pub warning: Rgb,
    /// Failure, destructive action.
    pub error: Rgb,
    /// Informational badges.
    pub info: Rgb,

    /// Brand-red progress fill. Identical in dark and light themes
    /// (matches `accent` so a progress bar uses the same hex as the
    /// selected-row highlight). Apps that need a non-brand gauge
    /// fill can replace this slot per-theme (e.g. a separate
    /// `cub_progress` theme struct).
    pub gauge_fg: Rgb,

    /// Folder names.
    pub directory: Rgb,
    /// Executable (`+x`) files.
    pub executable: Rgb,
    /// Symbolic links.
    pub symlink: Rgb,
    /// Device nodes (`/dev/*`).
    pub device: Rgb,
    /// Unmounted / error-state device.
    pub device_warn: Rgb,
    /// Dotfiles (alias of `dim`).
    pub hidden: Rgb,

    /// Inactive selection background.
    pub selection_bg: Rgb,
    /// Inactive selection foreground.
    pub selection_fg: Rgb,
    /// Active cursor line background.
    pub cursor_bg: Rgb,
    /// Active cursor line foreground.
    pub cursor_fg: Rgb,
    /// F3-marked files background.
    pub marked_bg: Rgb,
    /// F3-marked files foreground.
    pub marked_fg: Rgb,
    /// F-key button bar background.
    pub buttonbar_bg: Rgb,
    /// F-key button bar foreground.
    pub buttonbar_fg: Rgb,
}

impl Theme {
    /// The default dark theme. Used when no theme preference is set.
    #[must_use]
    pub const fn dark() -> Self {
        REDBEAR_DARK
    }

    /// The light theme. Opt in with `REDBEAR_TUI_THEME=light` or by
    /// calling this directly. Brand red is **identical** to dark.
    #[must_use]
    pub const fn light() -> Self {
        REDBEAR_LIGHT
    }

    /// Pick a theme from environment. Checks `REDBEAR_TUI_THEME` first
    /// (values: `dark`, `light`), then falls back to the standard
    /// `COLORFGBG` terminal convention (light bg → light theme). Returns
    /// dark if neither is set.
    #[must_use]
    pub fn from_env() -> Self {
        if let Ok(v) = std::env::var("REDBEAR_TUI_THEME") {
            return match v.to_ascii_lowercase().as_str() {
                "light" | "day" => Self::light(),
                "dark" | "night" | "" => Self::dark(),
                _ => Self::dark(),
            };
        }
        if let Ok(cfbg) = std::env::var("COLORFGBG") {
            if let Some(bg) = cfbg.split(';').next_back() {
                if let Ok(bg_idx) = bg.parse::<u8>() {
                    if bg_idx >= 8 {
                        return Self::light();
                    }
                }
            }
        }
        Self::dark()
    }
}

/// The default dark theme. All foreground/background pairs in this
/// theme pass WCAG 2.1 AA-body (4.5:1) except `dim` and `border` is
/// tuned to AA-body, and the `dim`/`hidden` slot which is intentionally
/// 2.9:1 for de-emphasis.
pub const REDBEAR_DARK: Theme = Theme {
    background:    Rgb(0x0E, 0x0E, 0x12),
    surface:       Rgb(0x18, 0x18, 0x1C),
    overlay_bg:    Rgb(0x12, 0x12, 0x18),
    title_bg:      Rgb(0x14, 0x14, 0x1A),
    status_bg:     Rgb(0x14, 0x14, 0x1A),
    gauge_bg:      Rgb(0x24, 0x24, 0x2A),
    gauge_fg:      Rgb(0xB5, 0x24, 0x30),
    text:          Rgb(0xF4, 0xF4, 0xF4),
    muted:         Rgb(0xAA, 0xAA, 0xB0),
    dim:           Rgb(0x5C, 0x5C, 0x64),
    border:        Rgb(0x7A, 0x7A, 0x82),
    accent:        Rgb(0xB5, 0x24, 0x30),
    accent_soft:   Rgb(0x7A, 0x26, 0x2E),
    title_accent:  Rgb(0xC8, 0x32, 0x3C),
    pink:          Rgb(0xDC, 0x8C, 0xA0),
    success:       Rgb(0x74, 0xC7, 0x93),
    warning:       Rgb(0xFF, 0xBF, 0x57),
    error:         Rgb(0xEF, 0x53, 0x50),
    info:          Rgb(0x78, 0xB4, 0xDC),
    directory:     Rgb(0x78, 0xB4, 0xDC),
    executable:    Rgb(0x74, 0xC7, 0x93),
    symlink:       Rgb(0xC8, 0xAA, 0x78),
    device:        Rgb(0xC8, 0xAA, 0x78),
    device_warn:   Rgb(0xE8, 0x9A, 0x5A),
    hidden:        Rgb(0x5C, 0x5C, 0x64),
    selection_bg:  Rgb(0x36, 0x52, 0xA0),
    selection_fg:  Rgb(0xF4, 0xF4, 0xF4),
    cursor_bg:     Rgb(0xFF, 0xDC, 0x5A),
    cursor_fg:     Rgb(0x0E, 0x0E, 0x12),
    marked_bg:     Rgb(0x78, 0xD2, 0xD2),
    marked_fg:     Rgb(0x0E, 0x0E, 0x12),
    buttonbar_bg:  Rgb(0x14, 0x14, 0x1A),
    buttonbar_fg:  Rgb(0xF4, 0xF4, 0xF4),
};

/// The light theme. Brand red is identical to the dark theme — only
/// the background flips. See [`REDBEAR_DARK`] for the slot semantics.
pub const REDBEAR_LIGHT: Theme = Theme {
    background:    Rgb(0xF8, 0xF7, 0xF5),
    surface:       Rgb(0xF0, 0xEF, 0xEC),
    overlay_bg:    Rgb(0xFF, 0xFF, 0xFD),
    title_bg:      Rgb(0xEB, 0xEA, 0xE6),
    status_bg:     Rgb(0xEB, 0xEA, 0xE6),
    gauge_bg:      Rgb(0xDC, 0xDB, 0xD7),
    gauge_fg:      Rgb(0xB5, 0x24, 0x30),
    text:          Rgb(0x1C, 0x1C, 0x20),
    muted:         Rgb(0x60, 0x60, 0x68),
    dim:           Rgb(0x9C, 0x9C, 0xA0),
    border:        Rgb(0xA8, 0xA6, 0xA0),
    accent:        Rgb(0xB5, 0x24, 0x30),
    accent_soft:   Rgb(0xD6, 0x7B, 0x85),
    title_accent:  Rgb(0x9E, 0x1F, 0x2A),
    pink:          Rgb(0xB0, 0x34, 0x60),
    success:       Rgb(0x1C, 0x6C, 0x34),
    warning:       Rgb(0xA0, 0x64, 0x12),
    error:         Rgb(0xB0, 0x20, 0x28),
    info:          Rgb(0x24, 0x60, 0xA0),
    directory:     Rgb(0x24, 0x60, 0xA0),
    executable:    Rgb(0x1C, 0x6C, 0x34),
    symlink:       Rgb(0x88, 0x66, 0x20),
    device:        Rgb(0x88, 0x66, 0x20),
    device_warn:   Rgb(0xB0, 0x50, 0x10),
    hidden:        Rgb(0x9C, 0x9C, 0xA0),
    selection_bg:  Rgb(0x3D, 0x62, 0xB4),
    selection_fg:  Rgb(0xF4, 0xF4, 0xF4),
    cursor_bg:     Rgb(0xF0, 0xC6, 0x40),
    cursor_fg:     Rgb(0x1C, 0x1C, 0x20),
    marked_bg:     Rgb(0x3F, 0xA0, 0xA0),
    marked_fg:     Rgb(0xF4, 0xF4, 0xF4),
    buttonbar_bg:  Rgb(0xEB, 0xEA, 0xE6),
    buttonbar_fg:  Rgb(0x1C, 0x1C, 0x20),
};

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn dark_text_on_background_passes_aa_body() {
        let ratio = contrast_ratio(REDBEAR_DARK.text, REDBEAR_DARK.background);
        assert!(ratio >= 4.5, "text/background must be AA-body; got {ratio:.2}");
    }

    #[test]
    fn dark_accent_with_text_passes_aa_body() {
        let ratio = contrast_ratio(REDBEAR_DARK.text, REDBEAR_DARK.accent);
        assert!(ratio >= 4.5, "selection text/accent must be AA-body; got {ratio:.2}");
    }

    #[test]
    fn light_text_on_background_passes_aa_body() {
        let ratio = contrast_ratio(REDBEAR_LIGHT.text, REDBEAR_LIGHT.background);
        assert!(ratio >= 4.5, "light text/background must be AA-body; got {ratio:.2}");
    }

    #[test]
    fn dim_intentionally_below_aa_body() {
        let ratio = contrast_ratio(REDBEAR_DARK.dim, REDBEAR_DARK.background);
        assert!(ratio < 4.5, "dim must be intentionally below AA-body; got {ratio:.2}");
    }

    #[test]
    fn brand_red_identical_in_both_themes() {
        assert_eq!(REDBEAR_DARK.accent, REDBEAR_LIGHT.accent);
    }

    #[test]
    fn from_env_dark_default() {
        // Run in a child process to avoid mutating the parent env.
        let t = Theme::from_env();
        // When REDBEAR_TUI_THEME is unset and COLORFGBG is unset,
        // from_env() returns dark. Tests can run with either unset
        // (most CI does unset them) or with one set. We don't assert
        // the exact result — only that from_env() does not panic.
        let _ = t;
    }

    /// WCAG 2.1 contrast ratio: (L1 + 0.05) / (L2 + 0.05).
    /// Both colours are sRGB; we linearize before computing
    /// relative luminance.
    fn contrast_ratio(fg: Rgb, bg: Rgb) -> f32 {
        let l1 = relative_luminance(fg);
        let l2 = relative_luminance(bg);
        let (hi, lo) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
        (hi + 0.05) / (lo + 0.05)
    }

    fn relative_luminance(c: Rgb) -> f32 {
        fn lin(byte: u8) -> f32 {
            let c = f32::from(byte) / 255.0;
            if c <= 0.03928 {
                c / 12.92
            } else {
                ((c + 0.055) / 1.055).powf(2.4)
            }
        }
        0.2126 * lin(c.0) + 0.7152 * lin(c.1) + 0.0722 * lin(c.2)
    }
}