Skip to main content

rio_theme/
adaptive.rs

1//! Case 5 — mode-adaptive brand.
2//!
3//! A single brand color faces two backgrounds (light and dark) and
4//! the two checks pull in opposite directions. The engine measures
5//! each independently and only edits the failing mode.
6//!
7//! `rustio-admin` is currently light-only, so today both outputs
8//! often equal the input. The token structure still ships both so a
9//! future dark-mode return is a configuration flip, not a refactor.
10
11use crate::color::Color;
12use crate::contrast::{contrast_ratio, AA_NON_TEXT, DARK_BG, LIGHT_BG};
13
14/// The light- and dark-mode variants of one brand color, plus a flag
15/// for each side indicating whether the engine had to adjust it and
16/// whether that adjustment actually cleared AA. `*_adjusted` says
17/// "we tried"; `*_clears_aa` says "we succeeded".
18#[derive(Debug, Clone, Copy)]
19pub struct AdaptiveBrand {
20    /// Variant to use on light backgrounds.
21    pub light: Color,
22    /// Variant to use on dark backgrounds.
23    pub dark: Color,
24    /// True when the light variant was nudged from the input.
25    pub light_adjusted: bool,
26    /// True when the dark variant was nudged from the input.
27    pub dark_adjusted: bool,
28    /// True when the (possibly adjusted) light variant clears
29    /// `AA_NON_TEXT` against `LIGHT_BG`. False means the loop ran
30    /// out of headroom — the caller should know the token is still
31    /// failing rather than silently trust the report.
32    pub light_clears_aa: bool,
33    /// Same as [`Self::light_clears_aa`] for the dark background.
34    pub dark_clears_aa: bool,
35}
36
37/// Produce the light/dark pair for one brand color.
38pub fn adaptive_brand(brand: &Color) -> AdaptiveBrand {
39    let light_bg = Color::from_hex(LIGHT_BG).expect("constant");
40    let dark_bg = Color::from_hex(DARK_BG).expect("constant");
41
42    let (light, light_adjusted, light_clears_aa) =
43        adjust_for_bg(brand, &light_bg, /*lighten=*/ false);
44    let (dark, dark_adjusted, dark_clears_aa) =
45        adjust_for_bg(brand, &dark_bg, /*lighten=*/ true);
46
47    AdaptiveBrand {
48        light,
49        dark,
50        light_adjusted,
51        dark_adjusted,
52        light_clears_aa,
53        dark_clears_aa,
54    }
55}
56
57/// Run the (color, bg) check; if it passes, pass the color through.
58/// If it fails, step toward more contrast until it passes or the loop
59/// runs out of headroom. Returns `(color, adjusted, cleared_aa)`.
60fn adjust_for_bg(color: &Color, bg: &Color, lighten: bool) -> (Color, bool, bool) {
61    if contrast_ratio(bg, color) >= AA_NON_TEXT {
62        return (*color, false, true);
63    }
64    let mut c = *color;
65    let mut cleared = false;
66    for _ in 0..20 {
67        let new_l = if lighten {
68            (c.l + 0.05).min(1.0)
69        } else {
70            (c.l - 0.05).max(0.0)
71        };
72        if (new_l - c.l).abs() < f64::EPSILON {
73            break;
74        }
75        c = Color::from_oklch(new_l, c.c, c.h);
76        if contrast_ratio(bg, &c) >= AA_NON_TEXT {
77            cleared = true;
78            break;
79        }
80    }
81    (c, true, cleared)
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    fn c(hex: &str) -> Color {
89        Color::from_hex(hex).unwrap()
90    }
91
92    #[test]
93    fn very_dark_navy_lightens_for_dark_mode_only() {
94        // #0a1a2e is near-invisible on a near-black dark background.
95        let navy = c("#0a1a2e");
96        let a = adaptive_brand(&navy);
97        assert!(!a.light_adjusted, "should pass against white");
98        assert!(a.dark_adjusted, "should fail against dark bg");
99        assert!(a.dark_clears_aa, "should clear AA after lightening");
100        assert!(a.dark.l > navy.l, "dark variant should be lighter");
101    }
102
103    #[test]
104    fn mid_tone_passes_both_modes_unchanged() {
105        let mid = c("#0d9488");
106        let a = adaptive_brand(&mid);
107        assert!(!a.light_adjusted);
108        assert!(!a.dark_adjusted);
109        assert!(a.light_clears_aa);
110        assert!(a.dark_clears_aa);
111        assert_eq!(a.light.to_hex(), mid.to_hex());
112        assert_eq!(a.dark.to_hex(), mid.to_hex());
113    }
114
115    #[test]
116    fn adjustment_flag_separates_attempted_from_succeeded() {
117        // For #0a1a2e against the dark bg, the loop attempts and
118        // succeeds — the two flags are both true and consistent.
119        let navy = c("#0a1a2e");
120        let a = adaptive_brand(&navy);
121        assert!(a.dark_adjusted && a.dark_clears_aa);
122    }
123}