darq 0.1.0

darq CLI + TUI — autonomous issue → PR pipeline with SAT and a learning loop.
Documentation
#![allow(dead_code)]
//! Color and motion tokens, mirroring `docs/design/design-system/colors_and_type.css`.
//!
//! Single source of truth for ratatui rendering. Detects truecolor vs 256-color
//! at startup; tokens resolve to the matching `ratatui::style::Color` variant.
//! Palette/Motion/Glyphs constants are the full token set — subset used today,
//! rest reserved for widgets under active development.
//!
//! ## Token vocabulary
//!
//! - **Identity:** `brand_green` (logo, selection — never a status color)
//! - **Surfaces:** `bg_0`/`bg_1`/`bg_2`, `rule_dim`/`rule`/`rule_strong`
//! - **Foreground:** `fg_0`..`fg_4` (decreasing brightness)
//! - **Factory states:** `ember` (active), `copper` (blueprints), `cyan` (workflow),
//!   `magenta` (tool_use), `violet` (judgement), `amber` (temper/warn),
//!   `red` (the forge cooled), `heartbeat` (live pulse)
//!
//! ## Note vs the checklist
//!
//! The checklist (§3.1) suggested `darq_core::theme`. We put it in the `darq` binary crate instead:
//! ratatui types belong with the TUI crate; pulling ratatui into `darq-core` would
//! force the dep on every consumer. Same single-source-of-truth behavior, narrower deps.

use ratatui::style::Color;
use std::time::Duration;

/// Color palette — one variant per CSS token. Resolves to truecolor RGB when
/// `$COLORTERM == "truecolor"`, else falls back to 256-color indices.
#[derive(Debug, Clone, Copy)]
pub struct Palette {
    pub brand_green: Color,
    pub brand_green_dim: Color,
    pub brand_green_glow: Color,

    pub bg_0: Color,
    pub bg_1: Color,
    pub bg_2: Color,
    pub bg_3: Color,
    pub bg_4: Color,

    pub fg_0: Color,
    pub fg_1: Color,
    pub fg_2: Color,
    pub fg_3: Color,
    pub fg_4: Color,

    pub rule_dim: Color,
    pub rule: Color,
    pub rule_strong: Color,

    pub ember: Color,
    pub ember_dim: Color,
    pub copper: Color,
    pub cyan: Color,
    pub cyan_dim: Color,
    pub magenta: Color,
    pub heartbeat: Color,
    pub amber: Color,
    pub red: Color,
    pub violet: Color,

    /// State-pass — design-system has an internal contradiction (NOTES-DARQ-FLAGS.md §1).
    /// We use brand_green_dim until that is resolved.
    pub state_pass: Color,
}

impl Palette {
    /// Truecolor palette — RGB values from `colors_and_type.css`.
    pub const fn truecolor() -> Self {
        Self {
            brand_green: Color::Rgb(0xa0, 0xce, 0x29),
            brand_green_dim: Color::Rgb(0x7e, 0xa3, 0x21),
            brand_green_glow: Color::Rgb(0xc4, 0xec, 0x4d),

            bg_0: Color::Rgb(0x07, 0x09, 0x0b),
            bg_1: Color::Rgb(0x0d, 0x10, 0x14),
            bg_2: Color::Rgb(0x14, 0x18, 0x20),
            bg_3: Color::Rgb(0x1c, 0x21, 0x2c),
            bg_4: Color::Rgb(0x26, 0x2c, 0x39),

            fg_0: Color::Rgb(0xf2, 0xf5, 0xf2),
            fg_1: Color::Rgb(0xc9, 0xd0, 0xca),
            fg_2: Color::Rgb(0x8a, 0x93, 0x88),
            fg_3: Color::Rgb(0x5a, 0x63, 0x60),
            fg_4: Color::Rgb(0x34, 0x3b, 0x3a),

            rule_dim: Color::Rgb(0x22, 0x28, 0x34),
            rule: Color::Rgb(0x2e, 0x36, 0x44),
            rule_strong: Color::Rgb(0x3b, 0x45, 0x55),

            ember: Color::Rgb(0xff, 0x7b, 0x3e),
            ember_dim: Color::Rgb(0xc5, 0x59, 0x24),
            copper: Color::Rgb(0xd6, 0x8e, 0x5a),
            cyan: Color::Rgb(0x4c, 0xc9, 0xf0),
            cyan_dim: Color::Rgb(0x2a, 0x8a, 0xaa),
            magenta: Color::Rgb(0xd9, 0x46, 0xef),
            heartbeat: Color::Rgb(0xff, 0x2e, 0x7e),
            amber: Color::Rgb(0xff, 0xb0, 0x00),
            red: Color::Rgb(0xef, 0x44, 0x44),
            violet: Color::Rgb(0x7c, 0x3a, 0xed),

            state_pass: Color::Rgb(0x7e, 0xa3, 0x21), // brand_green_dim per NOTES-DARQ-FLAGS §1
        }
    }

    /// 256-color palette — index mappings from `design-system/README.md` §Iconography.
    pub const fn indexed_256() -> Self {
        Self {
            brand_green: Color::Indexed(149),
            brand_green_dim: Color::Indexed(106),
            brand_green_glow: Color::Indexed(155),

            bg_0: Color::Indexed(232),
            bg_1: Color::Indexed(233),
            bg_2: Color::Indexed(234),
            bg_3: Color::Indexed(235),
            bg_4: Color::Indexed(236),

            fg_0: Color::Indexed(255),
            fg_1: Color::Indexed(252),
            fg_2: Color::Indexed(245),
            fg_3: Color::Indexed(241),
            fg_4: Color::Indexed(238),

            rule_dim: Color::Indexed(236),
            rule: Color::Indexed(239),
            rule_strong: Color::Indexed(243),

            ember: Color::Indexed(208),
            ember_dim: Color::Indexed(166),
            copper: Color::Indexed(173),
            cyan: Color::Indexed(45),
            cyan_dim: Color::Indexed(31),
            magenta: Color::Indexed(200),
            heartbeat: Color::Indexed(198),
            amber: Color::Indexed(214),
            red: Color::Indexed(203),
            violet: Color::Indexed(99),

            state_pass: Color::Indexed(106), // brand_green_dim equivalent
        }
    }

    /// Detect terminal capability and return the appropriate palette.
    pub fn detect() -> Self {
        match std::env::var("COLORTERM").as_deref() {
            Ok("truecolor") | Ok("24bit") => Self::truecolor(),
            _ => Self::indexed_256(),
        }
    }
}

/// Motion timing tokens — durations and easing keys.
/// Mirrors `--dur-fast / --dur / --dur-slow / --dur-beat` from the design system.
pub struct Motion;

impl Motion {
    pub const FAST: Duration = Duration::from_millis(120);
    pub const NORMAL: Duration = Duration::from_millis(200);
    pub const SLOW: Duration = Duration::from_millis(420);
    pub const BEAT: Duration = Duration::from_millis(1200);
}

/// Linearly interpolate between two ratatui colors. If either side is not RGB
/// (e.g. Indexed-256), returns `a` unchanged when `t < 0.5`, else `b`. truecolor
/// callers get a true gradient; 256c callers get a stepped fade.
pub fn color_lerp(a: Color, b: Color, t: f32) -> Color {
    let t = t.clamp(0.0, 1.0);
    match (a, b) {
        (Color::Rgb(ar, ag, ab), Color::Rgb(br, bg, bb)) => {
            let lerp = |x: u8, y: u8| -> u8 {
                let xf = x as f32;
                let yf = y as f32;
                (xf + (yf - xf) * t).round().clamp(0.0, 255.0) as u8
            };
            Color::Rgb(lerp(ar, br), lerp(ag, bg), lerp(ab, bb))
        }
        _ => {
            if t < 0.5 {
                a
            } else {
                b
            }
        }
    }
}

/// Breathing brightness curve: `0.86 + 0.14 * (1 + sin(elapsed * 2π / period_ms)) / 2`.
/// Returns a value in `[0.86, 1.0]` for use as a color-lerp `t` against a dimmer baseline.
pub fn breathing_brightness(elapsed_ms: u64, period_ms: u64) -> f32 {
    use std::f32::consts::PI;
    let phase = (elapsed_ms as f32 / period_ms as f32) * 2.0 * PI;
    0.86 + 0.14 * (1.0 + phase.sin()) / 2.0
}

/// Waterfall fade-out: `lerp(1.0, 0.6, age_s / 8)`, never below 0.6.
pub fn fade_factor(age_secs: f32) -> f32 {
    let t = (age_secs / 8.0).clamp(0.0, 1.0);
    1.0 - 0.4 * t
}

/// Glyph constants — ASCII fallback substitution table per design-system §Iconography.
pub struct Glyphs;

impl Glyphs {
    /// Braille spinner frames (heartbeat animation).
    pub const SPINNER: &'static [&'static str] =
        &["", "", "", "", "", "", "", "", "", ""];

    /// Sparkline cells, low → high.
    pub const SPARKLINE: &'static [&'static str] = &["", "", "", "", "", "", "", ""];

    pub const HEARTBEAT_DOT: &'static str = "";
    pub const ARROW_RIGHT: &'static str = "─▶";
    pub const ARROW_THICK: &'static str = "═▶";
    pub const ARROW_BIDI: &'static str = "";
}

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

    #[test]
    fn truecolor_palette_brand_matches_logo() {
        let p = Palette::truecolor();
        assert_eq!(p.brand_green, Color::Rgb(0xa0, 0xce, 0x29));
    }

    #[test]
    fn indexed_palette_ember_is_208() {
        let p = Palette::indexed_256();
        assert_eq!(p.ember, Color::Indexed(208));
    }

    #[test]
    fn detect_returns_a_palette() {
        let _ = Palette::detect();
    }

    #[test]
    fn motion_durations_match_css() {
        assert_eq!(Motion::FAST, Duration::from_millis(120));
        assert_eq!(Motion::BEAT, Duration::from_millis(1200));
    }

    #[test]
    fn spinner_has_10_frames() {
        assert_eq!(Glyphs::SPINNER.len(), 10);
    }
}