zenith-tool 0.0.7

The Zenith command-line interface (the `zenith` binary) for the design-document toolchain.
Documentation
//! Pure logic for `zenith theme new`.
//!
//! Synthesizes a complete theme `.zen` pack from brand seed colours, reusing
//! [`zenith_core::theme`] for the palette (APCA-chosen `.content` foregrounds)
//! and the canonical formatter for output. Operates on in-memory strings only.

use std::fmt::Write as _;

use zenith_core::color::{parse_rgb, rgb_to_hex};
use zenith_core::theme::{PaletteSpec, Rgb, Scheme, synth_palette};

/// Shape/typography inputs that are not derived (passed straight through).
#[derive(Debug, Clone, Copy)]
pub struct Shape {
    /// Corner radius for boxes/cards (px).
    pub radius_box: f64,
    /// Corner radius for fields/buttons (px).
    pub radius_field: f64,
    /// Corner radius for small selectors/badges (px).
    pub radius_selector: f64,
    /// Default border width (px).
    pub border: f64,
    /// Emit a `shadow.depth` elevation token.
    pub depth: bool,
    /// Mark the theme as wanting a grain overlay (recorded in the header).
    pub noise: bool,
}

impl Default for Shape {
    fn default() -> Self {
        Self {
            radius_box: 16.0,
            radius_field: 8.0,
            radius_selector: 8.0,
            border: 1.0,
            depth: false,
            noise: false,
        }
    }
}

/// All inputs for `theme new`. Colours are hex strings (`#rrggbb`); only
/// `primary` is required.
#[derive(Debug, Clone)]
pub struct ThemeInput<'a> {
    /// Theme name (used in ids and the preview title).
    pub name: &'a str,
    /// Light or dark base.
    pub scheme: Scheme,
    /// Required primary colour.
    pub primary: &'a str,
    /// Optional role overrides (hex).
    pub secondary: Option<&'a str>,
    /// See [`ThemeInput::secondary`].
    pub accent: Option<&'a str>,
    /// See [`ThemeInput::secondary`].
    pub neutral: Option<&'a str>,
    /// See [`ThemeInput::secondary`].
    pub info: Option<&'a str>,
    /// See [`ThemeInput::secondary`].
    pub success: Option<&'a str>,
    /// See [`ThemeInput::secondary`].
    pub warning: Option<&'a str>,
    /// See [`ThemeInput::secondary`].
    pub error: Option<&'a str>,
    /// Shape/typography knobs.
    pub shape: Shape,
}

/// Error from `theme new`: a message plus a process exit code.
#[derive(Debug)]
pub struct ThemeErr {
    /// Human-readable message.
    pub message: String,
    /// Exit code (2 for bad input).
    pub exit_code: u8,
}

fn hex(label: &str, s: &str) -> Result<Rgb, ThemeErr> {
    parse_rgb(s).ok_or_else(|| ThemeErr {
        message: format!("invalid {label} colour '{s}': expected '#rrggbb' (lowercase hex)"),
        exit_code: 2,
    })
}

fn opt(label: &str, s: Option<&str>) -> Result<Option<Rgb>, ThemeErr> {
    match s {
        Some(v) => Ok(Some(hex(label, v)?)),
        None => Ok(None),
    }
}

/// Format a px scalar without a trailing `.0` (so `16.0` → `16`, `1.5` → `1.5`).
fn px(v: f64) -> String {
    if v.fract() == 0.0 {
        format!("{}", v as i64)
    } else {
        format!("{v}")
    }
}

/// Synthesize the theme and return canonical `.zen` source.
pub fn new(input: &ThemeInput) -> Result<String, ThemeErr> {
    let spec = PaletteSpec {
        scheme: input.scheme,
        primary: hex("primary", input.primary)?,
        secondary: opt("secondary", input.secondary)?,
        accent: opt("accent", input.accent)?,
        neutral: opt("neutral", input.neutral)?,
        info: opt("info", input.info)?,
        success: opt("success", input.success)?,
        warning: opt("warning", input.warning)?,
        error: opt("error", input.error)?,
    };
    let palette = synth_palette(&spec);
    let raw = emit(input, &palette);

    // Canonicalize through the engine formatter — guarantees valid, canonical
    // output and surfaces any emission bug as a real error.
    crate::commands::fmt::run(&raw)
        .map(|r| String::from_utf8_lossy(&r.formatted).into_owned())
        .map_err(|e| ThemeErr {
            message: format!(
                "internal: synthesized theme failed to format: {}",
                e.message
            ),
            exit_code: 2,
        })
}

fn emit(input: &ThemeInput, palette: &[(&'static str, Rgb)]) -> String {
    let name = input.name;
    let scheme = match input.scheme {
        Scheme::Light => "light",
        Scheme::Dark => "dark",
    };
    let mut s = String::new();
    let _ = writeln!(
        s,
        "// @zenith/theme.{name} — generated by `zenith theme new`"
    );
    let _ = writeln!(
        s,
        "// scheme: {scheme} | depth: {} | noise: {} (1 = apply grain-overlay recipe)",
        input.shape.depth as u8, input.shape.noise as u8
    );
    let _ = writeln!(s, "zenith version=1 {{");
    let _ = writeln!(
        s,
        "  project id=\"@zenith/theme.{name}\" name=\"Theme: {name}\""
    );
    let _ = writeln!(s, "  tokens format=\"zenith-token-v1\" {{");
    for (id, rgb) in palette {
        let _ = writeln!(
            s,
            "    token id=\"color.{id}\" type=\"color\" value=\"{}\"",
            rgb_to_hex(*rgb)
        );
    }
    let _ = writeln!(
        s,
        "    token id=\"radius.box\" type=\"dimension\" value=(px){}",
        px(input.shape.radius_box)
    );
    let _ = writeln!(
        s,
        "    token id=\"radius.field\" type=\"dimension\" value=(px){}",
        px(input.shape.radius_field)
    );
    let _ = writeln!(
        s,
        "    token id=\"radius.selector\" type=\"dimension\" value=(px){}",
        px(input.shape.radius_selector)
    );
    let _ = writeln!(
        s,
        "    token id=\"border.width\" type=\"dimension\" value=(px){}",
        px(input.shape.border)
    );
    let _ = writeln!(
        s,
        "    token id=\"space.unit\" type=\"dimension\" value=(px)4"
    );
    let _ = writeln!(
        s,
        "    token id=\"font.heading\" type=\"fontFamily\" value=\"Noto Sans\""
    );
    let _ = writeln!(
        s,
        "    token id=\"font.body\" type=\"fontFamily\" value=\"Noto Sans\""
    );
    let _ = writeln!(
        s,
        "    token id=\"size.h1\" type=\"dimension\" value=(px)64"
    );
    let _ = writeln!(
        s,
        "    token id=\"size.h2\" type=\"dimension\" value=(px)40"
    );
    let _ = writeln!(
        s,
        "    token id=\"size.body\" type=\"dimension\" value=(px)28"
    );
    let _ = writeln!(
        s,
        "    token id=\"size.caption\" type=\"dimension\" value=(px)18"
    );
    if input.shape.depth {
        let _ = writeln!(
            s,
            "    token id=\"color.shadow\" type=\"color\" value=\"#0000002a\""
        );
        let _ = writeln!(
            s,
            "    token id=\"shadow.depth\" type=\"shadow\" {{ layer dx=(px)0 dy=(px)8 blur=(px)24 color=(token)\"color.shadow\" }}"
        );
    }
    let _ = writeln!(s, "  }}");
    let _ = writeln!(s, "  styles {{}}");
    let _ = writeln!(
        s,
        "  document id=\"theme.{name}.preview\" title=\"Theme {name}\" {{"
    );
    let _ = writeln!(
        s,
        "    page id=\"pg\" w=(px)760 h=(px)220 background=(token)\"color.base.100\" {{"
    );
    let _ = writeln!(
        s,
        "      text id=\"t.title\" x=(px)40 y=(px)28 w=(px)680 h=(px)52 fill=(token)\"color.base.content\" font-family=(token)\"font.heading\" font-size=(token)\"size.h2\" {{ span \"{name}\" }}"
    );
    let sh = if input.shape.depth {
        " shadow=(token)\"shadow.depth\""
    } else {
        ""
    };
    for (x, role) in [
        (40, "primary"),
        (220, "secondary"),
        (400, "accent"),
        (580, "neutral"),
    ] {
        let _ = writeln!(
            s,
            "      rect id=\"sw.{role}\" x=(px){x} y=(px)104 w=(px)160 h=(px)84 fill=(token)\"color.{role}\" radius=(token)\"radius.box\"{sh}"
        );
    }
    let _ = writeln!(s, "    }}");
    let _ = writeln!(s, "  }}");
    let _ = writeln!(s, "}}");
    s
}