Skip to main content

rio_theme/
vivid.rs

1//! Case 3 — vivid color taming and role split.
2//!
3//! A neon brand is fine as a small accent but painful as a large
4//! surface and hostile to text contrast. Above a chroma threshold the
5//! engine produces two siblings: the original (for small touches) and
6//! a tamed surface (for large fills). Hue never moves — only chroma
7//! and lightness adjust.
8
9use crate::color::Color;
10
11/// Chroma above this (in OKLCH) is "too vivid for large surfaces".
12/// 0.16 sits comfortably below electric primaries (red ≈ 0.25, lime
13/// ≈ 0.24, cyan ≈ 0.18) while leaving room for healthy brand teals
14/// (~0.13–0.14) to pass through untamed.
15pub const VIVID_CHROMA_THRESHOLD: f64 = 0.16;
16
17/// Output of [`split_vivid_roles`].
18#[derive(Debug, Clone, Copy)]
19pub struct VividSplit {
20    /// Original brand, intended for small elements: dots, icons,
21    /// borders, links.
22    pub accent: Color,
23    /// Tamed sibling, intended for large fills: topbar background,
24    /// primary-button background.
25    pub surface: Color,
26    /// True when `surface` was modified from `accent`.
27    pub was_tamed: bool,
28}
29
30/// Split a brand into accent (small) and surface (large) roles.
31pub fn split_vivid_roles(brand: &Color) -> VividSplit {
32    if brand.c <= VIVID_CHROMA_THRESHOLD {
33        return VividSplit {
34            accent: *brand,
35            surface: *brand,
36            was_tamed: false,
37        };
38    }
39
40    // Target a calm chroma well under the threshold so the surface
41    // truly reads as "dialed back", not "barely tamed".
42    let target_c = (VIVID_CHROMA_THRESHOLD * 0.75).min(brand.c);
43
44    // Clamp lightness into a mid range so the surface can carry both
45    // light and dark text. Outside [0.35, 0.65] the surface starts to
46    // bias contrast strongly toward one text color or the other.
47    let target_l = brand.l.clamp(0.35, 0.65);
48
49    // Hue is held fixed — a tamed lime must still read as lime.
50    let surface = Color::from_oklch(target_l, target_c, brand.h);
51
52    VividSplit {
53        accent: *brand,
54        surface,
55        was_tamed: true,
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use crate::color::hue_distance;
63
64    fn c(hex: &str) -> Color {
65        Color::from_hex(hex).unwrap()
66    }
67
68    #[test]
69    fn neon_lime_is_tamed_and_hue_is_preserved() {
70        let lime = c("#39ff14");
71        let split = split_vivid_roles(&lime);
72        assert!(split.was_tamed);
73        assert!(split.surface.c < split.accent.c);
74        // Hue must drift less than ~5° after a full sRGB roundtrip —
75        // the internal hue value would compare equal to itself
76        // trivially, so re-parse the emitted hex to measure what a
77        // downstream consumer will actually see.
78        let surface_roundtrip = Color::from_hex(&split.surface.to_hex()).unwrap();
79        let accent_roundtrip = Color::from_hex(&split.accent.to_hex()).unwrap();
80        let drift = hue_distance(surface_roundtrip.h, accent_roundtrip.h);
81        assert!(
82            drift < 5.0,
83            "hue drifted {drift}° after roundtrip ({} -> {})",
84            split.accent.to_hex(),
85            split.surface.to_hex()
86        );
87    }
88
89    #[test]
90    fn calm_blue_passes_through_untamed() {
91        let blue = c("#3f6089");
92        let split = split_vivid_roles(&blue);
93        assert!(!split.was_tamed);
94        assert_eq!(split.accent.to_hex(), split.surface.to_hex());
95    }
96}