facett-core 0.1.3

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **OKLCH colour** — the authoring space for the facett palette (§7 of the
//! look-and-feel work order). Colours are authored as `Oklch { l, c, h }`
//! (lightness 0..1, chroma 0..~0.37, hue degrees) and converted to sRGB
//! [`egui::Color32`] for painting. Perceptual lightness lets us **derive** the
//! five egui widget states by lightness steps (no per-state literals), and lets
//! the WCAG contrast gate reason about relative luminance honestly.
//!
//! Refs: OKLab (<https://bottosson.github.io/posts/oklab/>), oklch.com.

use egui::Color32;
use serde::{Deserialize, Serialize};

/// A colour in the OKLCH perceptual space. `l` ∈ [0,1], `c` ≥ 0 (chroma),
/// `h` in degrees. Authoring happens here; painting happens in sRGB.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Oklch {
    pub l: f32,
    pub c: f32,
    pub h: f32,
}

impl Oklch {
    pub const fn new(l: f32, c: f32, h: f32) -> Self {
        Self { l, c, h }
    }

    /// A neutral grey at lightness `l` (zero chroma).
    pub const fn grey(l: f32) -> Self {
        Self { l, c: 0.0, h: 0.0 }
    }

    /// Step the lightness by `dl` (clamped to [0,1]) — the primitive that derives
    /// hover/active/disabled widget states without per-state colour literals.
    pub fn lighten(self, dl: f32) -> Self {
        Self { l: (self.l + dl).clamp(0.0, 1.0), ..self }
    }

    /// Scale chroma (e.g. desaturate a disabled state).
    pub fn with_chroma_scale(self, k: f32) -> Self {
        Self { c: (self.c * k).max(0.0), ..self }
    }

    pub fn with_l(self, l: f32) -> Self {
        Self { l: l.clamp(0.0, 1.0), ..self }
    }

    /// Convert to a straight (unmultiplied) sRGB [`Color32`], fully opaque.
    pub fn to_color32(self) -> Color32 {
        let (r, g, b) = self.to_srgb8();
        Color32::from_rgb(r, g, b)
    }

    /// Convert to sRGB with an explicit alpha.
    pub fn to_color32_alpha(self, a: u8) -> Color32 {
        let (r, g, b) = self.to_srgb8();
        Color32::from_rgba_unmultiplied(r, g, b, a)
    }

    /// OKLCH → linear sRGB (the standard Björn Ottosson matrices), then encode.
    pub fn to_srgb8(self) -> (u8, u8, u8) {
        let (lr, lg, lb) = self.to_linear_srgb();
        (encode(lr), encode(lg), encode(lb))
    }

    /// OKLCH → **linear** sRGB in [0,1] (no gamma), used by the luminance gate.
    pub fn to_linear_srgb(self) -> (f32, f32, f32) {
        // OKLCH → OKLab
        let hr = self.h.to_radians();
        let a = self.c * hr.cos();
        let b = self.c * hr.sin();
        let l = self.l;
        // OKLab → LMS' (cube roots) → LMS
        let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
        let m_ = l - 0.105_561_346 * a - 0.063_854_17 * b;
        let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
        let l3 = l_ * l_ * l_;
        let m3 = m_ * m_ * m_;
        let s3 = s_ * s_ * s_;
        // LMS → linear sRGB
        let r = 4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_94 * s3;
        let g = -1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_38 * s3;
        let b = -0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3;
        (r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0))
    }
}

/// Linear sRGB component → 8-bit gamma-encoded sRGB.
fn encode(c: f32) -> u8 {
    let c = c.clamp(0.0, 1.0);
    let v = if c <= 0.003_130_8 { 12.92 * c } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 };
    (v * 255.0).round().clamp(0.0, 255.0) as u8
}

/// 8-bit sRGB component → linear (for luminance of an existing [`Color32`]).
fn srgb8_to_linear(c: u8) -> f32 {
    let c = c as f32 / 255.0;
    if c <= 0.040_45 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
}

/// **WCAG relative luminance** of a straight-sRGB colour (Y in [0,1]).
pub fn relative_luminance(c: Color32) -> f32 {
    let r = srgb8_to_linear(c.r());
    let g = srgb8_to_linear(c.g());
    let b = srgb8_to_linear(c.b());
    0.2126 * r + 0.7152 * g + 0.0722 * b
}

/// **WCAG 2.2 contrast ratio** between two opaque colours (1.0 ..= 21.0). The gate
/// (§7) requires ≥ 4.5:1 for body text, ≥ 3:1 for large text / non-text UI.
pub fn contrast_ratio(a: Color32, b: Color32) -> f32 {
    let la = relative_luminance(a);
    let lb = relative_luminance(b);
    let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
    (hi + 0.05) / (lo + 0.05)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn white_and_black_round_trip_to_extremes() {
        // L=1, c=0 → white; L=0 → black.
        let white = Oklch::grey(1.0).to_color32();
        let black = Oklch::grey(0.0).to_color32();
        assert_eq!(white, Color32::from_rgb(255, 255, 255));
        assert_eq!(black, Color32::from_rgb(0, 0, 0));
    }

    #[test]
    fn contrast_black_on_white_is_21() {
        let r = contrast_ratio(Color32::BLACK, Color32::WHITE);
        assert!((r - 21.0).abs() < 0.1, "black/white contrast ~21, got {r}");
    }

    #[test]
    fn contrast_is_symmetric_and_self_is_one() {
        let a = Oklch::new(0.6, 0.1, 250.0).to_color32();
        let b = Oklch::new(0.2, 0.05, 30.0).to_color32();
        assert!((contrast_ratio(a, b) - contrast_ratio(b, a)).abs() < 1e-4);
        assert!((contrast_ratio(a, a) - 1.0).abs() < 1e-4);
    }

    #[test]
    fn lighten_increases_luminance() {
        let base = Oklch::new(0.4, 0.08, 250.0);
        let lighter = base.lighten(0.2);
        assert!(relative_luminance(lighter.to_color32()) > relative_luminance(base.to_color32()));
    }

    #[test]
    fn a_known_blue_lands_in_blue_octant() {
        // A mid blue: hue ~250°, moderate chroma — blue channel should dominate.
        let (r, g, b) = Oklch::new(0.6, 0.15, 255.0).to_srgb8();
        assert!(b > r && b > g, "expected blue-dominant, got ({r},{g},{b})");
    }
}