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}