darq 0.1.0

darq CLI + TUI — autonomous issue → PR pipeline with SAT and a learning loop.
Documentation
//! SatPersonaDials — three persona sliders + score breakdown row.
//!
//! Layout:
//!   junior     ╟───●─────╢ 72
//!   senior     ╟──────●──╢ 91
//!   maintainer ╟──────●──╢ —
//!
//!   judging 2/3 · blended 73.7
//!   threshold 75.0 · variance 8.3
//!
//! Verdict states colored per design-system. Needle interpolation in Phase 4.5.

use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};

use crate::tui::app::{App, SatPersonaState};
use crate::tui::theme::Palette;

const PERSONAS: [&str; 3] = ["junior", "senior", "maintainer"];
/// Reserved width for the persona name label column (longest = "maintainer" = 10).
const LABEL_COL_WIDTH: usize = 11;
/// Reserved width for the trailing score column (`100` + space).
const SCORE_COL_WIDTH: usize = 5;
/// Padding before slider start and after end.
const SLIDER_PAD: usize = 2;
/// Minimum useful slider; below this we just print the score.
const MIN_SLIDER: usize = 6;

pub fn render(frame: &mut Frame, area: Rect, app: &App, palette: &Palette) {
    let title = match &app.sat_verdict {
        Some(v) if v.to_uppercase().contains("PASS") => Span::styled(
            " SAT PERSONA DIALS · PASS ✓ ",
            Style::new()
                .fg(palette.state_pass)
                .add_modifier(Modifier::BOLD),
        ),
        Some(v) if v.to_uppercase().contains("FAIL") => Span::styled(
            " SAT PERSONA DIALS · the forge cooled ✖ ",
            Style::new().fg(palette.red).add_modifier(Modifier::BOLD),
        ),
        Some(_) => Span::styled(
            " SAT PERSONA DIALS · judging… ",
            Style::new().fg(palette.violet).add_modifier(Modifier::BOLD),
        ),
        None => Span::styled(
            " SAT PERSONA DIALS ",
            Style::new().fg(palette.fg_2).add_modifier(Modifier::BOLD),
        ),
    };

    let block = Block::new()
        .borders(Borders::ALL)
        .border_style(Style::new().fg(palette.rule))
        .title(title);
    let inner = block.inner(area);
    frame.render_widget(block, area);

    // Compute slider width from available area: total - label - score - padding.
    // Falls back to MIN_SLIDER when the panel is too narrow.
    let slider_width = (inner.width as usize)
        .saturating_sub(LABEL_COL_WIDTH + SCORE_COL_WIDTH + SLIDER_PAD * 2)
        .max(MIN_SLIDER);

    let mut lines: Vec<Line> = Vec::new();
    let mut scores_present = 0;
    let mut scored_sum = 0.0_f32;

    for persona in PERSONAS {
        let state = app.sat_scores.get(persona);
        let line = persona_line(persona, state, palette, slider_width);
        lines.push(line);
        if let Some(s) = state
            && s.target > 0.0
        {
            scores_present += 1;
            scored_sum += s.target;
        }
    }

    // Empty spacer row.
    lines.push(Line::raw(""));

    // Breakdown row.
    let blended = if scores_present > 0 {
        scored_sum / scores_present as f32
    } else {
        0.0
    };
    let breakdown = Line::from(vec![
        Span::styled(
            format!("judging {scores_present}/3"),
            Style::new().fg(palette.fg_2),
        ),
        Span::styled(" · ", Style::new().fg(palette.fg_4)),
        Span::styled(
            format!("blended {blended:.1}"),
            Style::new().fg(palette.fg_0),
        ),
        Span::styled(" · ", Style::new().fg(palette.fg_4)),
        Span::styled("threshold 75.0", Style::new().fg(palette.fg_3)),
    ]);
    lines.push(breakdown);

    // ── Fill the empty space below: judge attribution + blueprint summary.
    // Both are static info, but they're contextual to the current SAT state
    // and avoid leaving the panel 70% void. Phase 6 toggles still let users
    // get the full Judges / Blueprints panels via [j] / [b].
    lines.push(Line::raw(""));
    lines.push(Line::from(vec![Span::styled(
        " JUDGES ",
        Style::new()
            .fg(palette.bg_0)
            .bg(palette.violet)
            .add_modifier(Modifier::BOLD),
    )]));
    let judge_rows = [
        ("J1", "ensemble · gpt-tier", app.sat_scores.get("junior")),
        ("J2", "ensemble · claude-tier", app.sat_scores.get("senior")),
        (
            "J3",
            "ensemble · arbitration",
            app.sat_scores.get("maintainer"),
        ),
    ];
    for (jid, attr, st) in judge_rows {
        let score = match st {
            Some(s) if s.target > 0.0 => format!("{:>3.0}", s.target),
            _ => "".into(),
        };
        lines.push(Line::from(vec![
            Span::styled(format!(" {jid} "), Style::new().fg(palette.fg_2)),
            Span::styled(attr, Style::new().fg(palette.violet)),
            Span::styled("  ", Style::new()),
            Span::styled(score, Style::new().fg(palette.fg_0)),
        ]));
    }

    if !app.blueprints.is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::from(vec![Span::styled(
            " BLUEPRINTS ",
            Style::new()
                .fg(palette.bg_0)
                .bg(palette.copper)
                .add_modifier(Modifier::BOLD),
        )]));
        // Compact preview: top 3 by similarity.
        for b in app.blueprints.iter().take(3) {
            use crate::tui::app::BlueprintKind;
            let (label, bg) = match b.kind {
                BlueprintKind::Reuse => ("REUSE", palette.copper),
                BlueprintKind::Avoid => ("AVOID", palette.amber),
            };
            let badge = Span::styled(
                format!(" {label} "),
                Style::new()
                    .fg(palette.bg_0)
                    .bg(bg)
                    .add_modifier(Modifier::BOLD),
            );
            let short = &b.run_id[..8.min(b.run_id.len())];
            lines.push(Line::from(vec![
                badge,
                Span::raw(" "),
                Span::styled(format!("#{short}"), Style::new().fg(palette.cyan)),
                Span::styled("  ", Style::new()),
                Span::styled(
                    format!("{:.2}", b.similarity),
                    Style::new().fg(palette.fg_2),
                ),
            ]));
        }
        if app.blueprints.len() > 3 {
            lines.push(Line::styled(
                format!(
                    " + {} more · press [b] for full list",
                    app.blueprints.len() - 3
                ),
                Style::new().fg(palette.fg_3),
            ));
        }
    }

    let para = Paragraph::new(lines).style(Style::new().bg(palette.bg_0));
    frame.render_widget(para, inner);
}

fn persona_line<'a>(
    name: &'a str,
    state: Option<&SatPersonaState>,
    palette: &Palette,
    slider_width: usize,
) -> Line<'a> {
    let (slider, score_text, score_color) = match state {
        Some(s) if s.target > 0.0 => {
            let pos = ((s.current / 100.0).clamp(0.0, 1.0) * (slider_width as f32)) as usize;
            let pos = pos.min(slider_width.saturating_sub(1));
            let mut s_str = String::with_capacity(slider_width + 4);
            s_str.push('');
            for i in 0..slider_width {
                if i == pos {
                    s_str.push('');
                } else {
                    s_str.push('');
                }
            }
            s_str.push('');
            (s_str, format!("{:>3.0}", s.target), palette.fg_0)
        }
        _ => {
            let mut s_str = String::with_capacity(slider_width + 4);
            s_str.push('');
            for _ in 0..slider_width {
                s_str.push('');
            }
            s_str.push('');
            (s_str, "".to_string(), palette.fg_4)
        }
    };

    Line::from(vec![
        Span::styled(
            format!("{name:<width$} ", width = LABEL_COL_WIDTH - 1),
            Style::new().fg(palette.fg_2),
        ),
        Span::styled(slider, Style::new().fg(palette.violet)),
        Span::raw(" "),
        Span::styled(score_text, Style::new().fg(score_color)),
    ])
}

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

    #[test]
    fn label_col_width_fits_longest_persona() {
        // "maintainer" is 10 chars, plus 1 space gutter = 11.
        assert!(LABEL_COL_WIDTH > "maintainer".len());
    }

    #[test]
    fn three_personas_defined() {
        assert_eq!(PERSONAS.len(), 3);
        assert!(PERSONAS.contains(&"junior"));
        assert!(PERSONAS.contains(&"senior"));
        assert!(PERSONAS.contains(&"maintainer"));
    }
}