Skip to main content

slt/style/
color.rs

1/// Terminal color.
2///
3/// Covers the standard 16 named colors, 256-color palette indices, and
4/// 24-bit RGB true color. Use [`Color::Reset`] to restore the terminal's
5/// default foreground or background.
6#[non_exhaustive]
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum Color {
9    /// Reset to the terminal's default color.
10    Reset,
11    /// Standard black (color index 0).
12    Black,
13    /// Standard red (color index 1).
14    Red,
15    /// Standard green (color index 2).
16    Green,
17    /// Standard yellow (color index 3).
18    Yellow,
19    /// Standard blue (color index 4).
20    Blue,
21    /// Standard magenta (color index 5).
22    Magenta,
23    /// Standard cyan (color index 6).
24    Cyan,
25    /// Standard white (color index 7).
26    White,
27    /// Bright black / dark gray (color index 8).
28    DarkGray,
29    /// Bright red (color index 9).
30    LightRed,
31    /// Bright green (color index 10).
32    LightGreen,
33    /// Bright yellow (color index 11).
34    LightYellow,
35    /// Bright blue (color index 12).
36    LightBlue,
37    /// Bright magenta (color index 13).
38    LightMagenta,
39    /// Bright cyan (color index 14).
40    LightCyan,
41    /// Bright white (color index 15).
42    LightWhite,
43    /// 24-bit true color.
44    Rgb(u8, u8, u8),
45    /// 256-color palette index.
46    Indexed(u8),
47}
48
49#[inline]
50fn to_linear(c: f32) -> f32 {
51    if c <= 0.04045 {
52        c / 12.92
53    } else {
54        ((c + 0.055) / 1.055).powf(2.4)
55    }
56}
57
58impl Color {
59    /// Resolve to `(r, g, b)` for luminance and blending operations.
60    ///
61    /// Named colors map to their typical terminal palette values.
62    /// [`Color::Reset`] maps to black; [`Color::Indexed`] maps to the xterm-256 palette.
63    pub(crate) fn to_rgb(self) -> (u8, u8, u8) {
64        match self {
65            Color::Rgb(r, g, b) => (r, g, b),
66            Color::Black => (0, 0, 0),
67            Color::Red => (205, 49, 49),
68            Color::Green => (13, 188, 121),
69            Color::Yellow => (229, 229, 16),
70            Color::Blue => (36, 114, 200),
71            Color::Magenta => (188, 63, 188),
72            Color::Cyan => (17, 168, 205),
73            Color::White => (229, 229, 229),
74            Color::DarkGray => (128, 128, 128),
75            Color::LightRed => (255, 0, 0),
76            Color::LightGreen => (0, 255, 0),
77            Color::LightYellow => (255, 255, 0),
78            Color::LightBlue => (0, 0, 255),
79            Color::LightMagenta => (255, 0, 255),
80            Color::LightCyan => (0, 255, 255),
81            Color::LightWhite => (255, 255, 255),
82            Color::Reset => (0, 0, 0),
83            Color::Indexed(idx) => xterm256_to_rgb(idx),
84        }
85    }
86
87    /// Compute relative luminance using ITU-R BT.709 coefficients.
88    ///
89    /// Returns a value in `[0.0, 1.0]` where 0 is darkest and 1 is brightest.
90    /// Use this to determine whether text on a given background should be
91    /// light or dark.
92    ///
93    /// # Example
94    ///
95    /// ```
96    /// use slt::Color;
97    ///
98    /// let dark = Color::Rgb(30, 30, 46);
99    /// assert!(dark.luminance() < 0.15);
100    ///
101    /// let light = Color::Rgb(205, 214, 244);
102    /// assert!(light.luminance() > 0.6);
103    /// ```
104    pub fn luminance(self) -> f32 {
105        let (r, g, b) = self.to_rgb();
106        let rf = to_linear(r as f32 / 255.0);
107        let gf = to_linear(g as f32 / 255.0);
108        let bf = to_linear(b as f32 / 255.0);
109        0.2126 * rf + 0.7152 * gf + 0.0722 * bf
110    }
111
112    /// Return a contrasting foreground color for the given background.
113    ///
114    /// Uses the WCAG 2.1 relative luminance threshold (0.179) to decide
115    /// between white and black text. For theme-aware contrast, prefer using
116    /// this over hardcoding `theme.bg` as the foreground.
117    ///
118    /// # Example
119    ///
120    /// ```
121    /// use slt::Color;
122    ///
123    /// let bg = Color::Rgb(189, 147, 249); // Dracula purple
124    /// let fg = Color::contrast_fg(bg);
125    /// // Dracula purple → white (WCAG luminance 0.385 < 0.179 threshold)
126    /// ```
127    pub fn contrast_fg(bg: Color) -> Color {
128        if bg.luminance() > 0.179 {
129            Color::Rgb(0, 0, 0)
130        } else {
131            Color::Rgb(255, 255, 255)
132        }
133    }
134
135    /// Blend this color over another with the given alpha.
136    ///
137    /// `alpha` is in `[0.0, 1.0]` where 0.0 returns `other` unchanged and
138    /// 1.0 returns `self` unchanged. Both colors are resolved to RGB.
139    ///
140    /// # Example
141    ///
142    /// ```
143    /// use slt::Color;
144    ///
145    /// let white = Color::Rgb(255, 255, 255);
146    /// let black = Color::Rgb(0, 0, 0);
147    /// let gray = white.blend(black, 0.5);
148    /// // ≈ Rgb(128, 128, 128)
149    /// ```
150    pub fn blend(self, other: Color, alpha: f32) -> Color {
151        let alpha = alpha.clamp(0.0, 1.0);
152        let (r1, g1, b1) = self.to_rgb();
153        let (r2, g2, b2) = other.to_rgb();
154        let r = (r1 as f32 * alpha + r2 as f32 * (1.0 - alpha)).round() as u8;
155        let g = (g1 as f32 * alpha + g2 as f32 * (1.0 - alpha)).round() as u8;
156        let b = (b1 as f32 * alpha + b2 as f32 * (1.0 - alpha)).round() as u8;
157        Color::Rgb(r, g, b)
158    }
159
160    /// Lighten this color by the given amount (0.0–1.0).
161    ///
162    /// Blends toward white. `amount = 0.0` returns the original color;
163    /// `amount = 1.0` returns white.
164    pub fn lighten(self, amount: f32) -> Color {
165        Color::Rgb(255, 255, 255).blend(self, 1.0 - amount.clamp(0.0, 1.0))
166    }
167
168    /// Darken this color by the given amount (0.0–1.0).
169    ///
170    /// Blends toward black. `amount = 0.0` returns the original color;
171    /// `amount = 1.0` returns black.
172    pub fn darken(self, amount: f32) -> Color {
173        Color::Rgb(0, 0, 0).blend(self, 1.0 - amount.clamp(0.0, 1.0))
174    }
175
176    /// Compute the WCAG 2.1 contrast ratio between two colors.
177    ///
178    /// Returns a value >= 1.0. A ratio >= 4.5 meets WCAG AA for normal text;
179    /// >= 3.0 meets AA for large text.
180    ///
181    /// # Example
182    ///
183    /// ```
184    /// use slt::Color;
185    ///
186    /// let ratio = Color::contrast_ratio(Color::White, Color::Black);
187    /// assert!(ratio > 15.0);
188    /// ```
189    pub fn contrast_ratio(a: Color, b: Color) -> f32 {
190        let la = a.luminance() + 0.05;
191        let lb = b.luminance() + 0.05;
192        if la > lb {
193            la / lb
194        } else {
195            lb / la
196        }
197    }
198
199    /// Returns `true` if the contrast ratio between two colors meets WCAG AA
200    /// for normal text (ratio >= 4.5).
201    pub fn meets_contrast_aa(fg: Color, bg: Color) -> bool {
202        Self::contrast_ratio(fg, bg) >= 4.5
203    }
204
205    /// Downsample this color to fit the given color depth.
206    ///
207    /// - `TrueColor`: returns self unchanged.
208    /// - `EightBit`: converts `Rgb` to the nearest `Indexed` color.
209    /// - `Basic`: converts `Rgb` and `Indexed` to the nearest named color.
210    /// - `NoColor`: returns [`Color::Reset`] — emit no ANSI color at all.
211    ///
212    /// Named colors (`Red`, `Green`, etc.) and `Reset` pass through at
213    /// depths other than `NoColor`.
214    pub fn downsampled(self, depth: ColorDepth) -> Color {
215        match depth {
216            ColorDepth::TrueColor => self,
217            ColorDepth::EightBit => match self {
218                Color::Rgb(r, g, b) => Color::Indexed(rgb_to_ansi256(r, g, b)),
219                other => other,
220            },
221            ColorDepth::Basic => match self {
222                Color::Rgb(r, g, b) => rgb_to_ansi16(r, g, b),
223                Color::Indexed(i) => {
224                    let (r, g, b) = xterm256_to_rgb(i);
225                    rgb_to_ansi16(r, g, b)
226                }
227                other => other,
228            },
229            ColorDepth::NoColor => Color::Reset,
230        }
231    }
232
233    /// Parse a hex string (`#rgb` or `#rrggbb`) into [`Color::Rgb`].
234    ///
235    /// The leading `#` is required. Short form `#rgb` expands each nibble
236    /// (`#abc` → `Rgb(0xaa, 0xbb, 0xcc)`). Returns `None` for any malformed
237    /// input (wrong length, non-hex digits, missing `#`).
238    ///
239    /// # Example
240    ///
241    /// ```
242    /// use slt::Color;
243    ///
244    /// assert_eq!(Color::from_hex("#ff6b6b"), Some(Color::Rgb(255, 107, 107)));
245    /// assert_eq!(Color::from_hex("#abc"), Some(Color::Rgb(170, 187, 204)));
246    /// assert_eq!(Color::from_hex("ff6b6b"), None); // missing '#'
247    /// assert_eq!(Color::from_hex("#xyz"), None); // non-hex
248    /// ```
249    #[doc(alias = "parse")]
250    pub fn from_hex(s: &str) -> Option<Color> {
251        let hex = s.strip_prefix('#')?;
252        match hex.len() {
253            3 => {
254                let mut it = hex.chars().map(|c| c.to_digit(16));
255                let r = it.next()??;
256                let g = it.next()??;
257                let b = it.next()??;
258                // Expand each nibble: 0xa -> 0xaa.
259                Some(Color::Rgb((r * 17) as u8, (g * 17) as u8, (b * 17) as u8))
260            }
261            6 => {
262                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
263                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
264                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
265                Some(Color::Rgb(r, g, b))
266            }
267            _ => None,
268        }
269    }
270
271    /// Format an `Rgb` color as a `#rrggbb` hex string.
272    ///
273    /// Non-`Rgb` variants are first resolved to their RGB equivalent via the
274    /// internal palette, so the result is always a valid `#rrggbb` token.
275    ///
276    /// # Example
277    ///
278    /// ```
279    /// use slt::Color;
280    ///
281    /// assert_eq!(Color::Rgb(255, 107, 107).to_hex(), "#ff6b6b");
282    /// ```
283    pub fn to_hex(self) -> String {
284        let (r, g, b) = self.to_rgb();
285        format!("#{r:02x}{g:02x}{b:02x}")
286    }
287}
288
289#[cfg(feature = "serde")]
290impl Color {
291    /// Serialized token for a named color, or `None` for non-named variants.
292    fn named_token(self) -> Option<&'static str> {
293        Some(match self {
294            Color::Reset => "reset",
295            Color::Black => "black",
296            Color::Red => "red",
297            Color::Green => "green",
298            Color::Yellow => "yellow",
299            Color::Blue => "blue",
300            Color::Magenta => "magenta",
301            Color::Cyan => "cyan",
302            Color::White => "white",
303            Color::DarkGray => "darkgray",
304            Color::LightRed => "lightred",
305            Color::LightGreen => "lightgreen",
306            Color::LightYellow => "lightyellow",
307            Color::LightBlue => "lightblue",
308            Color::LightMagenta => "lightmagenta",
309            Color::LightCyan => "lightcyan",
310            Color::LightWhite => "lightwhite",
311            Color::Rgb(..) | Color::Indexed(_) => return None,
312        })
313    }
314
315    /// Parse a color from a human-friendly token used in theme files.
316    ///
317    /// Accepts `#rgb` / `#rrggbb` hex, named colors (case-insensitive, e.g.
318    /// `"cyan"`, `"lightblue"`, `"darkgray"`, `"reset"`), and `indexed:N`
319    /// palette indices (`0..=255`).
320    fn from_token(s: &str) -> Option<Color> {
321        if let Some(c) = Color::from_hex(s) {
322            return Some(c);
323        }
324        let lower = s.trim().to_ascii_lowercase();
325        if let Some(rest) = lower.strip_prefix("indexed:") {
326            return rest.trim().parse::<u8>().ok().map(Color::Indexed);
327        }
328        Some(match lower.as_str() {
329            "reset" | "default" => Color::Reset,
330            "black" => Color::Black,
331            "red" => Color::Red,
332            "green" => Color::Green,
333            "yellow" => Color::Yellow,
334            "blue" => Color::Blue,
335            "magenta" => Color::Magenta,
336            "cyan" => Color::Cyan,
337            "white" => Color::White,
338            "darkgray" | "darkgrey" | "gray" | "grey" => Color::DarkGray,
339            "lightred" => Color::LightRed,
340            "lightgreen" => Color::LightGreen,
341            "lightyellow" => Color::LightYellow,
342            "lightblue" => Color::LightBlue,
343            "lightmagenta" => Color::LightMagenta,
344            "lightcyan" => Color::LightCyan,
345            "lightwhite" => Color::LightWhite,
346            _ => return None,
347        })
348    }
349
350    /// The canonical serialized token for this color.
351    ///
352    /// Named colors emit their lowercase name, `Rgb` emits `#rrggbb`,
353    /// `Indexed(n)` emits `indexed:n`. This is the inverse of [`Color::from_token`].
354    fn to_token(self) -> String {
355        if let Some(name) = self.named_token() {
356            return name.to_string();
357        }
358        match self {
359            Color::Indexed(n) => format!("indexed:{n}"),
360            // `Rgb` and any other true-color variant.
361            other => other.to_hex(),
362        }
363    }
364}
365
366#[cfg(feature = "serde")]
367impl serde::Serialize for Color {
368    /// Serialize as a human-friendly string token (`#rrggbb`, a named color,
369    /// or `indexed:N`) so theme files stay hand-editable and round-trip.
370    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
371    where
372        S: serde::Serializer,
373    {
374        serializer.serialize_str(&self.to_token())
375    }
376}
377
378#[cfg(feature = "serde")]
379impl<'de> serde::Deserialize<'de> for Color {
380    /// Deserialize from a token string: `#rgb`/`#rrggbb`, a named color
381    /// (case-insensitive), or `indexed:N`.
382    fn deserialize<D>(deserializer: D) -> Result<Color, D::Error>
383    where
384        D: serde::Deserializer<'de>,
385    {
386        struct ColorVisitor;
387
388        impl serde::de::Visitor<'_> for ColorVisitor {
389            type Value = Color;
390
391            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
392                f.write_str("a color token like \"#ff6b6b\", \"cyan\", or \"indexed:245\"")
393            }
394
395            fn visit_str<E>(self, value: &str) -> Result<Color, E>
396            where
397                E: serde::de::Error,
398            {
399                Color::from_token(value).ok_or_else(|| {
400                    E::custom(format!(
401                        "invalid color token {value:?}: expected #rgb/#rrggbb, a named color, or indexed:N"
402                    ))
403                })
404            }
405        }
406
407        deserializer.deserialize_str(ColorVisitor)
408    }
409}
410
411/// Terminal color depth capability.
412///
413/// Determines the maximum number of colors a terminal can display.
414/// Use [`ColorDepth::detect`] for automatic detection via environment
415/// variables, or specify explicitly in [`crate::RunConfig`].
416#[non_exhaustive]
417#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
418#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
419pub enum ColorDepth {
420    /// 24-bit true color (16 million colors).
421    TrueColor,
422    /// 256-color palette (xterm-256color).
423    EightBit,
424    /// 16 basic ANSI colors.
425    Basic,
426    /// No color output — every color is downsampled to [`Color::Reset`] and
427    /// the terminal emits no SGR color codes. Selected automatically by
428    /// [`ColorDepth::detect`] when the `NO_COLOR` environment variable is
429    /// set to any non-empty value, per <https://no-color.org>.
430    NoColor,
431}
432
433#[cfg(test)]
434mod color_depth_tests {
435    use super::{Color, ColorDepth};
436
437    #[test]
438    fn no_color_downsamples_everything_to_reset() {
439        assert_eq!(Color::Red.downsampled(ColorDepth::NoColor), Color::Reset);
440        assert_eq!(
441            Color::Rgb(10, 20, 30).downsampled(ColorDepth::NoColor),
442            Color::Reset
443        );
444        assert_eq!(
445            Color::Indexed(44).downsampled(ColorDepth::NoColor),
446            Color::Reset
447        );
448    }
449}
450
451impl ColorDepth {
452    /// Detect the terminal's color depth from environment variables.
453    ///
454    /// Order of precedence:
455    /// 1. `NO_COLOR` (any non-empty value) → [`ColorDepth::NoColor`]
456    /// 2. `COLORTERM=truecolor|24bit` → [`ColorDepth::TrueColor`]
457    /// 3. `TERM` contains `256color` → [`ColorDepth::EightBit`]
458    /// 4. Fallback → [`ColorDepth::Basic`] (16 colors)
459    pub fn detect() -> Self {
460        // https://no-color.org — ANY non-empty value disables color.
461        if std::env::var("NO_COLOR")
462            .ok()
463            .is_some_and(|v| !v.is_empty())
464        {
465            return Self::NoColor;
466        }
467        if let Ok(ct) = std::env::var("COLORTERM") {
468            let ct = ct.to_lowercase();
469            if ct == "truecolor" || ct == "24bit" {
470                return Self::TrueColor;
471            }
472        }
473        if let Ok(term) = std::env::var("TERM") {
474            if term.contains("256color") {
475                return Self::EightBit;
476            }
477        }
478        Self::Basic
479    }
480}
481
482fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
483    if r == g && g == b {
484        if r < 8 {
485            return 16;
486        }
487        if r >= 248 {
488            return 231;
489        }
490        return 232 + (((r as u16 - 8) * 24 / 240) as u8);
491    }
492
493    let ri = if r < 48 {
494        0
495    } else {
496        ((r as u16 - 35) / 40) as u8
497    };
498    let gi = if g < 48 {
499        0
500    } else {
501        ((g as u16 - 35) / 40) as u8
502    };
503    let bi = if b < 48 {
504        0
505    } else {
506        ((b as u16 - 35) / 40) as u8
507    };
508    16 + 36 * ri.min(5) + 6 * gi.min(5) + bi.min(5)
509}
510
511fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
512    let lum = 0.2126 * to_linear(r as f32 / 255.0)
513        + 0.7152 * to_linear(g as f32 / 255.0)
514        + 0.0722 * to_linear(b as f32 / 255.0);
515
516    let max = r.max(g).max(b);
517    let min = r.min(g).min(b);
518    let saturation = if max == 0 {
519        0.0
520    } else {
521        (max - min) as f32 / max as f32
522    };
523
524    if saturation < 0.2 {
525        // Grayscale: classify purely by luminance.
526        return match lum {
527            l if l < 0.05 => Color::Black,
528            l if l < 0.25 => Color::DarkGray,
529            l if l < 0.7 => Color::White,
530            _ => Color::White, // LightWhite is not available in Color enum
531        };
532    }
533
534    // For chromatic colors the "bright" variant of each ANSI hue (e.g. xterm
535    // LightRed = `(255, 85, 85)`) is distinguished by the minimum channel
536    // being lifted off zero — it's a brighter, partially desaturated version
537    // of the same hue. A perfectly saturated primary (`min == 0`) like pure
538    // red `(255, 0, 0)` should map to the standard color. We pick the bright
539    // variant only when both the value is high and the color is desaturated
540    // enough to look "lifted".
541    let bright = max >= 200 && min >= 64;
542
543    let rf = r as f32;
544    let gf = g as f32;
545    let bf = b as f32;
546
547    if rf >= gf && rf >= bf {
548        if gf > bf * 1.5 {
549            if bright {
550                Color::LightYellow
551            } else {
552                Color::Yellow
553            }
554        } else if bf > gf * 1.5 {
555            if bright {
556                Color::LightMagenta
557            } else {
558                Color::Magenta
559            }
560        } else if bright {
561            Color::LightRed
562        } else {
563            Color::Red
564        }
565    } else if gf >= rf && gf >= bf {
566        if bf > rf * 1.5 {
567            if bright {
568                Color::LightCyan
569            } else {
570                Color::Cyan
571            }
572        } else if bright {
573            Color::LightGreen
574        } else {
575            Color::Green
576        }
577    } else if rf > gf * 1.5 {
578        if bright {
579            Color::LightMagenta
580        } else {
581            Color::Magenta
582        }
583    } else if gf > rf * 1.5 {
584        if bright {
585            Color::LightCyan
586        } else {
587            Color::Cyan
588        }
589    } else if bright {
590        Color::LightBlue
591    } else {
592        Color::Blue
593    }
594}
595
596fn xterm256_to_rgb(idx: u8) -> (u8, u8, u8) {
597    match idx {
598        0 => (0, 0, 0),
599        1 => (128, 0, 0),
600        2 => (0, 128, 0),
601        3 => (128, 128, 0),
602        4 => (0, 0, 128),
603        5 => (128, 0, 128),
604        6 => (0, 128, 128),
605        7 => (192, 192, 192),
606        8 => (128, 128, 128),
607        9 => (255, 0, 0),
608        10 => (0, 255, 0),
609        11 => (255, 255, 0),
610        12 => (0, 0, 255),
611        13 => (255, 0, 255),
612        14 => (0, 255, 255),
613        15 => (255, 255, 255),
614        16..=231 => {
615            let n = idx - 16;
616            let b_idx = n % 6;
617            let g_idx = (n / 6) % 6;
618            let r_idx = n / 36;
619            let to_val = |i: u8| if i == 0 { 0u8 } else { 55 + 40 * i };
620            (to_val(r_idx), to_val(g_idx), to_val(b_idx))
621        }
622        232..=255 => {
623            let v = 8 + 10 * (idx - 232);
624            (v, v, v)
625        }
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    #![allow(clippy::unwrap_used)]
632    use super::*;
633
634    #[test]
635    fn blend_halfway_rounds_to_128() {
636        assert_eq!(
637            Color::Rgb(255, 255, 255).blend(Color::Rgb(0, 0, 0), 0.5),
638            Color::Rgb(128, 128, 128)
639        );
640    }
641
642    #[test]
643    fn contrast_ratio_white_on_black_is_high() {
644        let ratio = Color::contrast_ratio(Color::White, Color::Black);
645        assert!(ratio > 15.0);
646    }
647
648    #[test]
649    fn contrast_ratio_same_color_is_one() {
650        let ratio = Color::contrast_ratio(Color::Rgb(100, 100, 100), Color::Rgb(100, 100, 100));
651        assert!((ratio - 1.0).abs() < 0.01);
652    }
653
654    #[test]
655    fn meets_contrast_aa_white_on_black() {
656        assert!(Color::meets_contrast_aa(Color::White, Color::Black));
657    }
658
659    #[test]
660    fn meets_contrast_aa_low_contrast_fails() {
661        assert!(!Color::meets_contrast_aa(
662            Color::Rgb(180, 180, 180),
663            Color::Rgb(200, 200, 200)
664        ));
665    }
666
667    // --- regression: issue #104 rgb_to_ansi256 overflow at r=g=b=248 ---
668
669    #[test]
670    fn rgb_to_ansi256_no_overflow_full_range() {
671        // 256^3 exhaustive — guarantees no panic in debug or release builds
672        for r in 0u8..=255 {
673            for g in 0u8..=255 {
674                for b in 0u8..=255 {
675                    let _ = Color::Rgb(r, g, b).downsampled(ColorDepth::EightBit);
676                }
677            }
678        }
679    }
680
681    #[test]
682    fn rgb_248_maps_to_231() {
683        assert_eq!(
684            Color::Rgb(248, 248, 248).downsampled(ColorDepth::EightBit),
685            Color::Indexed(231)
686        );
687    }
688
689    // --- regression: issue #105 WCAG luminance sRGB gamma ---
690
691    #[test]
692    fn luminance_dracula_purple_wcag() {
693        let l = Color::Rgb(189, 147, 249).luminance();
694        assert!((l - 0.385).abs() < 0.01, "expected ~0.385, got {l}");
695    }
696
697    #[test]
698    fn contrast_aa_dracula_pair() {
699        let p = Color::Rgb(189, 147, 249);
700        let bg = Color::Rgb(40, 42, 54);
701        assert!(Color::meets_contrast_aa(p, bg));
702        let r = Color::contrast_ratio(p, bg);
703        assert!((r - 5.90).abs() < 0.1, "expected ~5.90, got {r}");
704    }
705
706    #[test]
707    fn contrast_white_on_black_is_21() {
708        let r = Color::contrast_ratio(Color::Rgb(255, 255, 255), Color::Rgb(0, 0, 0));
709        assert!((r - 21.0).abs() < 0.5, "expected ~21.0, got {r}");
710    }
711
712    // --- regression: issue #107 rgb_to_ansi16 includes bright (8-15) colors ---
713
714    #[test]
715    fn rgb_to_ansi16_bright_variants() {
716        // bright red → LightRed
717        assert_eq!(
718            Color::Rgb(255, 80, 80).downsampled(ColorDepth::Basic),
719            Color::LightRed
720        );
721        // dark red → Red
722        assert_eq!(
723            Color::Rgb(128, 20, 20).downsampled(ColorDepth::Basic),
724            Color::Red
725        );
726        // bright gray → White
727        assert_eq!(
728            Color::Rgb(200, 200, 200).downsampled(ColorDepth::Basic),
729            Color::White
730        );
731        // dark gray → DarkGray
732        assert_eq!(
733            Color::Rgb(80, 80, 80).downsampled(ColorDepth::Basic),
734            Color::DarkGray
735        );
736    }
737}