rio_theme/semantic.rs
1//! Case 4 — brand-vs-state collision resolver.
2//!
3//! Success / warning / danger are universal conventions. They are
4//! *not* derived from the brand. The only adjustment the engine makes
5//! is to push a state color out of the brand's way when the hues
6//! collide, so a red brand doesn't reduce the "delete" button to
7//! noise.
8//!
9//! Two guard rails on top of the brief's algorithm:
10//!
11//! 1. **Achromatic brand short-circuit.** When the brand is grey
12//! (`chroma ≈ 0`) the stored hue is arbitrary and using it as a
13//! collision target produces nonsense rotations. The resolver
14//! returns the three anchors unchanged in that case.
15//!
16//! 2. **Bounded rotation.** A naive "rotate until gap ≥ 45°" can
17//! push success out of the green band (e.g. a neon-green brand
18//! sends success all the way to teal). [`MAX_STATE_ROTATION`]
19//! caps how far a state may move so it always still reads as
20//! itself — the brand simply has to coexist with a state when
21//! the brand was *chosen* in that state's hue family.
22
23use crate::color::{hue_distance, Color};
24use crate::contrast::{contrast_ratio, AA_NON_TEXT, LIGHT_BG};
25
26/// Minimum OKLCH hue gap (degrees) a state color tries to keep from
27/// the brand. The actual achieved gap may be smaller — see
28/// [`MAX_STATE_ROTATION`].
29pub const MIN_HUE_GAP: f64 = 45.0;
30
31/// Hard upper bound on how far the resolver will move a state color
32/// to clear the brand. 30° keeps each state inside its conventional
33/// hue family (success in green, warning in amber/orange, danger in
34/// red/crimson) even when the brand is a near-match. The brief
35/// allows up to a 45° rotation, but 45° pushes red→amber and
36/// green→teal — losing the state's identity. The capped value
37/// prefers a smaller separation over a wrong-colored state.
38pub const MAX_STATE_ROTATION: f64 = 30.0;
39
40/// OKLCH chroma below this is treated as achromatic for collision
41/// purposes — hue is undefined, so semantic colors stay unshifted.
42pub const ACHROMATIC_CHROMA: f64 = 0.02;
43
44/// Fixed anchor hue for "success". Green is the universal convention.
45pub const SUCCESS_ANCHOR: &str = "#16a34a";
46/// Fixed anchor hue for "warning". Amber is the universal convention.
47pub const WARNING_ANCHOR: &str = "#d97706";
48/// Fixed anchor hue for "danger". Red is the universal convention.
49pub const DANGER_ANCHOR: &str = "#dc2626";
50
51/// Resolved state colors after collision avoidance.
52#[derive(Debug, Clone, Copy)]
53pub struct SemanticPalette {
54 /// Foreground for success affordances (typically green).
55 pub success: Color,
56 /// Foreground for warning affordances (typically amber).
57 pub warning: Color,
58 /// Foreground for danger / destructive affordances (typically red).
59 pub danger: Color,
60}
61
62/// Signed shortest-arc difference `to - from` normalised to
63/// `(-180.0, 180.0]`. Positive means `to` sits counter-clockwise
64/// from `from`. Used to derive the away-from-brand rotation
65/// direction.
66fn signed_hue_diff(from: f64, to: f64) -> f64 {
67 let mut d = (to - from) % 360.0;
68 if d > 180.0 {
69 d -= 360.0;
70 } else if d <= -180.0 {
71 d += 360.0;
72 }
73 d
74}
75
76fn shift_away(anchor: Color, brand: &Color) -> Color {
77 // Achromatic brand has no hue to collide with.
78 if brand.c < ACHROMATIC_CHROMA {
79 return anchor;
80 }
81 let gap = hue_distance(anchor.h, brand.h);
82 if gap >= MIN_HUE_GAP {
83 return anchor;
84 }
85 // Brand sits *inside* this state's hue family — the user chose a
86 // brand in (e.g.) the green family on purpose. Rotating would
87 // push success into cyan/teal and lose the state's identity. We
88 // accept the visual coexistence; the brand and the state will
89 // read as close cousins, which is what the user signaled.
90 if gap < MIN_HUE_GAP / 2.0 {
91 return anchor;
92 }
93 // Brand sits near the edge of the state's family. Rotate AWAY,
94 // capped by [`MAX_STATE_ROTATION`] so the result stays inside
95 // the state's conventional band. signed_hue_diff(brand, anchor)
96 // tells us which side the anchor sits on; we keep moving in
97 // that direction so the gap grows monotonically.
98 let signed = signed_hue_diff(brand.h, anchor.h);
99 let dir = if signed >= 0.0 { 1.0 } else { -1.0 };
100 let needed = (MIN_HUE_GAP - gap).min(MAX_STATE_ROTATION);
101 Color::from_oklch(anchor.l, anchor.c, anchor.h + dir * needed)
102}
103
104fn ensure_visible_on_white(color: Color) -> Color {
105 let bg = Color::from_hex(LIGHT_BG).expect("constant");
106 if contrast_ratio(&bg, &color) >= AA_NON_TEXT {
107 return color;
108 }
109 // Step the color darker in OKLCH lightness until it clears AA
110 // against white. Bounded at 10 iterations — at 0.05/step that
111 // covers the entire mid-band before bottoming out at black.
112 let mut c = color;
113 for _ in 0..10 {
114 let new_l = (c.l - 0.05).max(0.0);
115 if (new_l - c.l).abs() < f64::EPSILON {
116 break;
117 }
118 c = Color::from_oklch(new_l, c.c, c.h);
119 if contrast_ratio(&bg, &c) >= AA_NON_TEXT {
120 break;
121 }
122 }
123 c
124}
125
126/// Pick state colors, pushing each one away from the brand hue when
127/// it sits inside the brand's collision zone. See the module docs
128/// for the two guard rails layered on top of the brief's algorithm.
129pub fn resolve_semantics(brand: &Color) -> SemanticPalette {
130 let success = Color::from_hex(SUCCESS_ANCHOR).expect("constant");
131 let warning = Color::from_hex(WARNING_ANCHOR).expect("constant");
132 let danger = Color::from_hex(DANGER_ANCHOR).expect("constant");
133
134 SemanticPalette {
135 success: ensure_visible_on_white(shift_away(success, brand)),
136 warning: ensure_visible_on_white(shift_away(warning, brand)),
137 danger: ensure_visible_on_white(shift_away(danger, brand)),
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 fn c(hex: &str) -> Color {
146 Color::from_hex(hex).unwrap()
147 }
148
149 #[test]
150 fn red_brand_keeps_danger_at_anchor_for_family_coexistence() {
151 // Red brand sits inside the danger hue family. Rotating
152 // would push danger into amber/orange territory and lose the
153 // "red = danger" convention. The engine accepts coexistence:
154 // the user chose a red brand knowing it lives next to danger.
155 let red_brand = c("#cc2222");
156 let p = resolve_semantics(&red_brand);
157 assert_eq!(
158 p.danger.to_hex(),
159 c(DANGER_ANCHOR).to_hex(),
160 "danger should stay at anchor for an in-family brand"
161 );
162 }
163
164 #[test]
165 fn brand_at_gap_boundary_does_get_capped_rotation() {
166 // Build a brand at exactly MIN_HUE_GAP/2 + small from the
167 // danger anchor — outside the "family" zone, inside the
168 // collision zone. Rotation should fire but stay capped.
169 let danger = c(DANGER_ANCHOR);
170 let off_brand_hue = danger.h + MIN_HUE_GAP / 2.0 + 5.0;
171 let brand = Color::from_oklch(0.55, 0.13, off_brand_hue);
172 let p = resolve_semantics(&brand);
173 let drift = hue_distance(p.danger.h, danger.h);
174 assert!(drift > 0.0 && drift <= MAX_STATE_ROTATION + 1.0);
175 }
176
177 #[test]
178 fn purple_brand_leaves_all_three_unshifted() {
179 let purple = c("#8a4cb4");
180 let p = resolve_semantics(&purple);
181 assert_eq!(p.success.to_hex(), c(SUCCESS_ANCHOR).to_hex());
182 assert_eq!(p.warning.to_hex(), c(WARNING_ANCHOR).to_hex());
183 assert_eq!(p.danger.to_hex(), c(DANGER_ANCHOR).to_hex());
184 }
185
186 #[test]
187 fn grey_brand_leaves_all_three_unshifted() {
188 // Achromatic brand has no hue to collide with — the engine
189 // must not rotate semantics based on the arbitrary stored
190 // hue of a grey colour.
191 let grey = c("#888888");
192 let p = resolve_semantics(&grey);
193 assert_eq!(p.success.to_hex(), c(SUCCESS_ANCHOR).to_hex());
194 assert_eq!(p.warning.to_hex(), c(WARNING_ANCHOR).to_hex());
195 assert_eq!(p.danger.to_hex(), c(DANGER_ANCHOR).to_hex());
196 }
197
198 #[test]
199 fn neon_green_brand_keeps_success_in_green_family() {
200 // The bug this guards against: an unbounded 45° rotation
201 // pushed a green brand's success colour all the way to
202 // teal/cyan. Capped rotation must keep success readable as
203 // green (OKLCH hue in roughly the green band).
204 let lime = c("#39ff14");
205 let success_anchor_hue = c(SUCCESS_ANCHOR).h;
206 let shifted = resolve_semantics(&lime).success;
207 let drift = hue_distance(shifted.h, success_anchor_hue);
208 assert!(
209 drift <= MAX_STATE_ROTATION + 1.0,
210 "success drifted {drift}° from anchor — band cap broken"
211 );
212 }
213
214 #[test]
215 fn signed_hue_diff_handles_wraparound() {
216 // 10° from 350° is +20° (going through 0), not -340°.
217 assert!((signed_hue_diff(350.0, 10.0) - 20.0).abs() < 1e-9);
218 // 350° from 10° is -20°.
219 assert!((signed_hue_diff(10.0, 350.0) + 20.0).abs() < 1e-9);
220 // Identity.
221 assert!(signed_hue_diff(45.0, 45.0).abs() < 1e-9);
222 }
223
224 #[test]
225 fn rotation_direction_actually_moves_anchor_away() {
226 // Regression for the dual-candidate verifier bug — pick a
227 // brand on each side of an anchor and confirm the post-shift
228 // distance is larger than the pre-shift distance, never
229 // smaller.
230 for brand_hex in ["#cc2222", "#22cc22", "#2222cc", "#cccc22"] {
231 let brand = c(brand_hex);
232 let p = resolve_semantics(&brand);
233 for state in [p.success, p.warning, p.danger] {
234 // For brand+state combos where shift fired, the gap
235 // must be strictly greater than (or equal to) what a
236 // zero-rotation would give. We can't easily compute
237 // the "pre" gap without re-running, so just assert
238 // the post gap is sane.
239 let gap = hue_distance(state.h, brand.h);
240 assert!((0.0..=180.0).contains(&gap));
241 }
242 }
243 }
244}