Skip to main content

rio_theme/
derive.rs

1//! Case 2 — derive a full palette from one brand color.
2//!
3//! Neutrals (`muted`) carry a hint of the brand temperature so the
4//! whole UI reads as one family. Semantic state colors (success /
5//! warning / danger) are *not* derived here — they are universal
6//! conventions handled by `semantic.rs`.
7
8use crate::color::Color;
9
10/// The full set of brand-derived functional shades.
11#[derive(Debug, Clone, Copy)]
12pub struct DerivedPalette {
13    /// The brand itself, unchanged. Carried through so callers do not
14    /// need to thread two values.
15    pub brand: Color,
16    /// 10% brand into white — light fills, hover backgrounds.
17    pub brand_tint: Color,
18    /// Slightly darker brand — hover state on solid brand buttons.
19    pub brand_hover: Color,
20    /// Darker still — active / pressed state.
21    pub brand_active: Color,
22    /// Dark brand variant suitable for text on light surfaces.
23    pub brand_text: Color,
24    /// Page background tinted with a hint of brand.
25    pub bg: Color,
26    /// Hairline border in the brand family.
27    pub border: Color,
28    /// Brand-temperatured neutral gray. Built by mixing 35% brand into
29    /// `#6b7280` (Tailwind's gray-500) so muted text never looks
30    /// "unrelated" to the brand.
31    pub muted: Color,
32}
33
34/// Expand one brand color into the full derived palette (§5).
35pub fn derive_palette(brand: &Color) -> DerivedPalette {
36    let white = Color::from_hex("#ffffff").expect("constant");
37    let near_black = Color::from_hex("#111111").expect("constant");
38    let neutral_gray = Color::from_hex("#6b7280").expect("constant");
39
40    DerivedPalette {
41        brand: *brand,
42        // Per §5 the wording is "brand mixed N% into white": N% of the
43        // result is brand, the rest is white. With `a.mix(b, x)`
44        // returning (1-x)·a + x·b, "10% brand into white" is
45        // `brand.mix(white, 0.90)`.
46        brand_tint: brand.mix(&white, 0.90),
47        // "88% with black" → 88% brand, 12% black.
48        brand_hover: brand.mix(&near_black, 0.12),
49        // "75% with black" → 75% brand, 25% black.
50        brand_active: brand.mix(&near_black, 0.25),
51        // "72% with black" → 72% brand, 28% black. The final pass in
52        // engine.rs runs this through Case 1 against the surface, so
53        // if it still fails AA after darkening the fallback kicks in.
54        brand_text: brand.mix(&near_black, 0.28),
55        // "3% into white" → 97% white, 3% brand.
56        bg: brand.mix(&white, 0.97),
57        // "14% into white" → 86% white, 14% brand.
58        border: brand.mix(&white, 0.86),
59        // "35% brand into neutral gray" → 65% gray, 35% brand.
60        muted: neutral_gray.mix(brand, 0.35),
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    fn c(hex: &str) -> Color {
69        Color::from_hex(hex).unwrap()
70    }
71
72    #[test]
73    fn tint_is_lighter_than_brand() {
74        let p = derive_palette(&c("#0d9488"));
75        assert!(p.brand_tint.l > p.brand.l);
76    }
77
78    #[test]
79    fn hover_is_darker_than_brand_and_active_darker_still() {
80        let p = derive_palette(&c("#0d9488"));
81        assert!(p.brand_hover.l < p.brand.l);
82        assert!(p.brand_active.l < p.brand_hover.l);
83    }
84
85    #[test]
86    fn bg_is_almost_white_and_border_is_lighter_than_brand() {
87        let p = derive_palette(&c("#0d9488"));
88        assert!(p.bg.l > 0.95);
89        assert!(p.border.l > p.brand.l);
90    }
91
92    #[test]
93    fn muted_has_brand_temperature_not_pure_gray() {
94        let brand = c("#0d9488"); // teal hue ~190
95        let p = derive_palette(&brand);
96        // Muted should still have nonzero chroma — a pure gray would
97        // give c ≈ 0 and lose the brand family.
98        assert!(p.muted.c > 0.0);
99    }
100}