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}