Skip to main content

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}