Skip to main content

rio_theme/
hierarchy.rs

1//! Case 6 — multi-color role assignment.
2//!
3//! Given N colors, decide which carries the UI (primary), which
4//! ornaments it (secondary), and which fill data series (chart).
5//! No color is dropped — extras land in `chart` where multi-color
6//! variety is wanted.
7
8use crate::color::Color;
9use crate::contrast::{contrast_ratio, LIGHT_BG};
10
11/// Output of [`assign_roles`].
12#[derive(Debug, Clone)]
13pub struct RoleAssignment {
14    /// Leads the UI: topbar emblem, primary buttons.
15    pub primary: Color,
16    /// Accent only — badges, dots, focus rings.
17    pub secondary: Color,
18    /// Reserved for data-series fills (charts). May be empty.
19    pub chart: Vec<Color>,
20}
21
22/// Higher = better fit to carry large surfaces. Rewards readable
23/// contrast against the page background, penalises both excessive
24/// chroma (vivid colors are tiring at scale) and extreme lightness
25/// (too-light surfaces can't carry text, too-dark surfaces dominate).
26pub fn surface_fitness(color: &Color) -> f64 {
27    let bg = Color::from_hex(LIGHT_BG).expect("constant");
28    let cr = contrast_ratio(&bg, color);
29
30    // Contrast: more is better, up to a point. Log-shaped so a 7.0
31    // doesn't dwarf a 4.5.
32    let contrast_score = cr.ln().max(0.0);
33
34    // Lightness penalty: distance from 0.55 (a comfortable mid-tone
35    // that carries either text color).
36    let lightness_penalty = (color.l - 0.55).powi(2);
37
38    // Chroma penalty: above ~0.13 the color starts demanding too much
39    // visual attention for a large surface.
40    let chroma_penalty = (color.c - 0.10).max(0.0).powi(2) * 4.0;
41
42    contrast_score - lightness_penalty - chroma_penalty
43}
44
45/// Assign roles to the input list. Order on input is the client's
46/// stated priority, but the actual `primary` is chosen by fitness —
47/// the client's preferred ordering is a tiebreaker, not the sole
48/// signal (a client's first color is sometimes too vivid for the
49/// primary role).
50pub fn assign_roles(brand_colors: &[Color]) -> RoleAssignment {
51    // Edge: zero inputs. Caller (engine.rs) should have substituted
52    // the Case 7 default before reaching here, but guard anyway.
53    // Returns `secondary == primary` as a sentinel — the engine then
54    // substitutes its own (post-tame) hover as the secondary token,
55    // which is the right thing for "I have no real second colour".
56    if brand_colors.is_empty() {
57        let fallback = Color::from_hex("#3f6089").expect("constant");
58        return RoleAssignment {
59            primary: fallback,
60            secondary: fallback,
61            chart: Vec::new(),
62        };
63    }
64
65    // Edge: single input. Same sentinel — engine substitutes hover.
66    // This avoids deriving the secondary from the *raw* (possibly
67    // vivid) input; the engine's own hover is derived from the
68    // *tamed* surface and is the right value to surface.
69    if brand_colors.len() == 1 {
70        let only = brand_colors[0];
71        return RoleAssignment {
72            primary: only,
73            secondary: only,
74            chart: Vec::new(),
75        };
76    }
77
78    // Stable-sort by fitness, descending. Sort key preserves the
79    // client's input order on ties (Vec::sort_by is stable).
80    let mut ranked: Vec<(usize, Color)> = brand_colors.iter().copied().enumerate().collect();
81    ranked.sort_by(|a, b| {
82        surface_fitness(&b.1)
83            .partial_cmp(&surface_fitness(&a.1))
84            .unwrap_or(std::cmp::Ordering::Equal)
85    });
86
87    let primary = ranked[0].1;
88    let secondary = ranked[1].1;
89    let chart: Vec<Color> = ranked[2..].iter().map(|(_, c)| *c).collect();
90    RoleAssignment {
91        primary,
92        secondary,
93        chart,
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    fn c(hex: &str) -> Color {
102        Color::from_hex(hex).unwrap()
103    }
104
105    #[test]
106    fn five_inputs_yield_one_primary_one_secondary_three_chart() {
107        let inputs = vec![
108            c("#3f6089"),
109            c("#c9572e"),
110            c("#2e7d5b"),
111            c("#8a4cb4"),
112            c("#d4a017"),
113        ];
114        let r = assign_roles(&inputs);
115        assert_eq!(r.chart.len(), 3);
116        // primary should outrank secondary by fitness.
117        assert!(surface_fitness(&r.primary) >= surface_fitness(&r.secondary));
118    }
119
120    #[test]
121    fn one_input_uses_sentinel_secondary_equal_to_primary() {
122        // The engine reads `secondary == primary` as "no real second
123        // colour" and substitutes its own post-tame hover. Keeping
124        // the sentinel explicit avoids deriving from the raw input
125        // (which for vivid inputs would be unusable).
126        let r = assign_roles(&[c("#0d9488")]);
127        assert!(r.chart.is_empty());
128        assert_eq!(r.primary.to_hex(), r.secondary.to_hex());
129    }
130
131    #[test]
132    fn two_inputs_yield_empty_chart() {
133        let r = assign_roles(&[c("#3f6089"), c("#c9572e")]);
134        assert!(r.chart.is_empty());
135    }
136}