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}