use crate::color::{hue_distance, Color};
use crate::contrast::{contrast_ratio, AA_NON_TEXT, LIGHT_BG};
pub const MIN_HUE_GAP: f64 = 45.0;
pub const MAX_STATE_ROTATION: f64 = 30.0;
pub const ACHROMATIC_CHROMA: f64 = 0.02;
pub const SUCCESS_ANCHOR: &str = "#16a34a";
pub const WARNING_ANCHOR: &str = "#d97706";
pub const DANGER_ANCHOR: &str = "#dc2626";
#[derive(Debug, Clone, Copy)]
pub struct SemanticPalette {
pub success: Color,
pub warning: Color,
pub danger: Color,
}
fn signed_hue_diff(from: f64, to: f64) -> f64 {
let mut d = (to - from) % 360.0;
if d > 180.0 {
d -= 360.0;
} else if d <= -180.0 {
d += 360.0;
}
d
}
fn shift_away(anchor: Color, brand: &Color) -> Color {
if brand.c < ACHROMATIC_CHROMA {
return anchor;
}
let gap = hue_distance(anchor.h, brand.h);
if gap >= MIN_HUE_GAP {
return anchor;
}
if gap < MIN_HUE_GAP / 2.0 {
return anchor;
}
let signed = signed_hue_diff(brand.h, anchor.h);
let dir = if signed >= 0.0 { 1.0 } else { -1.0 };
let needed = (MIN_HUE_GAP - gap).min(MAX_STATE_ROTATION);
Color::from_oklch(anchor.l, anchor.c, anchor.h + dir * needed)
}
fn ensure_visible_on_white(color: Color) -> Color {
let bg = Color::from_hex(LIGHT_BG).expect("constant");
if contrast_ratio(&bg, &color) >= AA_NON_TEXT {
return color;
}
let mut c = color;
for _ in 0..10 {
let new_l = (c.l - 0.05).max(0.0);
if (new_l - c.l).abs() < f64::EPSILON {
break;
}
c = Color::from_oklch(new_l, c.c, c.h);
if contrast_ratio(&bg, &c) >= AA_NON_TEXT {
break;
}
}
c
}
pub fn resolve_semantics(brand: &Color) -> SemanticPalette {
let success = Color::from_hex(SUCCESS_ANCHOR).expect("constant");
let warning = Color::from_hex(WARNING_ANCHOR).expect("constant");
let danger = Color::from_hex(DANGER_ANCHOR).expect("constant");
SemanticPalette {
success: ensure_visible_on_white(shift_away(success, brand)),
warning: ensure_visible_on_white(shift_away(warning, brand)),
danger: ensure_visible_on_white(shift_away(danger, brand)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn c(hex: &str) -> Color {
Color::from_hex(hex).unwrap()
}
#[test]
fn red_brand_keeps_danger_at_anchor_for_family_coexistence() {
let red_brand = c("#cc2222");
let p = resolve_semantics(&red_brand);
assert_eq!(
p.danger.to_hex(),
c(DANGER_ANCHOR).to_hex(),
"danger should stay at anchor for an in-family brand"
);
}
#[test]
fn brand_at_gap_boundary_does_get_capped_rotation() {
let danger = c(DANGER_ANCHOR);
let off_brand_hue = danger.h + MIN_HUE_GAP / 2.0 + 5.0;
let brand = Color::from_oklch(0.55, 0.13, off_brand_hue);
let p = resolve_semantics(&brand);
let drift = hue_distance(p.danger.h, danger.h);
assert!(drift > 0.0 && drift <= MAX_STATE_ROTATION + 1.0);
}
#[test]
fn purple_brand_leaves_all_three_unshifted() {
let purple = c("#8a4cb4");
let p = resolve_semantics(&purple);
assert_eq!(p.success.to_hex(), c(SUCCESS_ANCHOR).to_hex());
assert_eq!(p.warning.to_hex(), c(WARNING_ANCHOR).to_hex());
assert_eq!(p.danger.to_hex(), c(DANGER_ANCHOR).to_hex());
}
#[test]
fn grey_brand_leaves_all_three_unshifted() {
let grey = c("#888888");
let p = resolve_semantics(&grey);
assert_eq!(p.success.to_hex(), c(SUCCESS_ANCHOR).to_hex());
assert_eq!(p.warning.to_hex(), c(WARNING_ANCHOR).to_hex());
assert_eq!(p.danger.to_hex(), c(DANGER_ANCHOR).to_hex());
}
#[test]
fn neon_green_brand_keeps_success_in_green_family() {
let lime = c("#39ff14");
let success_anchor_hue = c(SUCCESS_ANCHOR).h;
let shifted = resolve_semantics(&lime).success;
let drift = hue_distance(shifted.h, success_anchor_hue);
assert!(
drift <= MAX_STATE_ROTATION + 1.0,
"success drifted {drift}° from anchor — band cap broken"
);
}
#[test]
fn signed_hue_diff_handles_wraparound() {
assert!((signed_hue_diff(350.0, 10.0) - 20.0).abs() < 1e-9);
assert!((signed_hue_diff(10.0, 350.0) + 20.0).abs() < 1e-9);
assert!(signed_hue_diff(45.0, 45.0).abs() < 1e-9);
}
#[test]
fn rotation_direction_actually_moves_anchor_away() {
for brand_hex in ["#cc2222", "#22cc22", "#2222cc", "#cccc22"] {
let brand = c(brand_hex);
let p = resolve_semantics(&brand);
for state in [p.success, p.warning, p.danger] {
let gap = hue_distance(state.h, brand.h);
assert!((0.0..=180.0).contains(&gap));
}
}
}
}