Skip to main content

zenith_cli/commands/
theme.rs

1//! Pure logic for `zenith theme new`.
2//!
3//! Synthesizes a complete theme `.zen` pack from brand seed colours, reusing
4//! [`zenith_core::theme`] for the palette (APCA-chosen `.content` foregrounds)
5//! and the canonical formatter for output. Operates on in-memory strings only.
6
7use std::fmt::Write as _;
8
9use zenith_core::color::{parse_rgb, rgb_to_hex};
10use zenith_core::theme::{PaletteSpec, Rgb, Scheme, synth_palette};
11
12/// Shape/typography inputs that are not derived (passed straight through).
13#[derive(Debug, Clone, Copy)]
14pub struct Shape {
15    /// Corner radius for boxes/cards (px).
16    pub radius_box: f64,
17    /// Corner radius for fields/buttons (px).
18    pub radius_field: f64,
19    /// Corner radius for small selectors/badges (px).
20    pub radius_selector: f64,
21    /// Default border width (px).
22    pub border: f64,
23    /// Emit a `shadow.depth` elevation token.
24    pub depth: bool,
25    /// Mark the theme as wanting a grain overlay (recorded in the header).
26    pub noise: bool,
27}
28
29impl Default for Shape {
30    fn default() -> Self {
31        Self {
32            radius_box: 16.0,
33            radius_field: 8.0,
34            radius_selector: 8.0,
35            border: 1.0,
36            depth: false,
37            noise: false,
38        }
39    }
40}
41
42/// All inputs for `theme new`. Colours are hex strings (`#rrggbb`); only
43/// `primary` is required.
44#[derive(Debug, Clone)]
45pub struct ThemeInput<'a> {
46    /// Theme name (used in ids and the preview title).
47    pub name: &'a str,
48    /// Light or dark base.
49    pub scheme: Scheme,
50    /// Required primary colour.
51    pub primary: &'a str,
52    /// Optional role overrides (hex).
53    pub secondary: Option<&'a str>,
54    /// See [`ThemeInput::secondary`].
55    pub accent: Option<&'a str>,
56    /// See [`ThemeInput::secondary`].
57    pub neutral: Option<&'a str>,
58    /// See [`ThemeInput::secondary`].
59    pub info: Option<&'a str>,
60    /// See [`ThemeInput::secondary`].
61    pub success: Option<&'a str>,
62    /// See [`ThemeInput::secondary`].
63    pub warning: Option<&'a str>,
64    /// See [`ThemeInput::secondary`].
65    pub error: Option<&'a str>,
66    /// Shape/typography knobs.
67    pub shape: Shape,
68}
69
70/// Error from `theme new`: a message plus a process exit code.
71#[derive(Debug)]
72pub struct ThemeErr {
73    /// Human-readable message.
74    pub message: String,
75    /// Exit code (2 for bad input).
76    pub exit_code: u8,
77}
78
79fn hex(label: &str, s: &str) -> Result<Rgb, ThemeErr> {
80    parse_rgb(s).ok_or_else(|| ThemeErr {
81        message: format!("invalid {label} colour '{s}': expected '#rrggbb' (lowercase hex)"),
82        exit_code: 2,
83    })
84}
85
86fn opt(label: &str, s: Option<&str>) -> Result<Option<Rgb>, ThemeErr> {
87    match s {
88        Some(v) => Ok(Some(hex(label, v)?)),
89        None => Ok(None),
90    }
91}
92
93/// Format a px scalar without a trailing `.0` (so `16.0` → `16`, `1.5` → `1.5`).
94fn px(v: f64) -> String {
95    if v.fract() == 0.0 {
96        format!("{}", v as i64)
97    } else {
98        format!("{v}")
99    }
100}
101
102/// Synthesize the theme and return canonical `.zen` source.
103pub fn new(input: &ThemeInput) -> Result<String, ThemeErr> {
104    let spec = PaletteSpec {
105        scheme: input.scheme,
106        primary: hex("primary", input.primary)?,
107        secondary: opt("secondary", input.secondary)?,
108        accent: opt("accent", input.accent)?,
109        neutral: opt("neutral", input.neutral)?,
110        info: opt("info", input.info)?,
111        success: opt("success", input.success)?,
112        warning: opt("warning", input.warning)?,
113        error: opt("error", input.error)?,
114    };
115    let palette = synth_palette(&spec);
116    let raw = emit(input, &palette);
117
118    // Canonicalize through the engine formatter — guarantees valid, canonical
119    // output and surfaces any emission bug as a real error.
120    crate::commands::fmt::run(&raw)
121        .map(|r| String::from_utf8_lossy(&r.formatted).into_owned())
122        .map_err(|e| ThemeErr {
123            message: format!(
124                "internal: synthesized theme failed to format: {}",
125                e.message
126            ),
127            exit_code: 2,
128        })
129}
130
131fn emit(input: &ThemeInput, palette: &[(&'static str, Rgb)]) -> String {
132    let name = input.name;
133    let scheme = match input.scheme {
134        Scheme::Light => "light",
135        Scheme::Dark => "dark",
136    };
137    let mut s = String::new();
138    let _ = writeln!(
139        s,
140        "// @zenith/theme.{name} — generated by `zenith theme new`"
141    );
142    let _ = writeln!(
143        s,
144        "// scheme: {scheme} | depth: {} | noise: {} (1 = apply grain-overlay recipe)",
145        input.shape.depth as u8, input.shape.noise as u8
146    );
147    let _ = writeln!(s, "zenith version=1 {{");
148    let _ = writeln!(
149        s,
150        "  project id=\"@zenith/theme.{name}\" name=\"Theme: {name}\""
151    );
152    let _ = writeln!(s, "  tokens format=\"zenith-token-v1\" {{");
153    for (id, rgb) in palette {
154        let _ = writeln!(
155            s,
156            "    token id=\"color.{id}\" type=\"color\" value=\"{}\"",
157            rgb_to_hex(*rgb)
158        );
159    }
160    let _ = writeln!(
161        s,
162        "    token id=\"radius.box\" type=\"dimension\" value=(px){}",
163        px(input.shape.radius_box)
164    );
165    let _ = writeln!(
166        s,
167        "    token id=\"radius.field\" type=\"dimension\" value=(px){}",
168        px(input.shape.radius_field)
169    );
170    let _ = writeln!(
171        s,
172        "    token id=\"radius.selector\" type=\"dimension\" value=(px){}",
173        px(input.shape.radius_selector)
174    );
175    let _ = writeln!(
176        s,
177        "    token id=\"border.width\" type=\"dimension\" value=(px){}",
178        px(input.shape.border)
179    );
180    let _ = writeln!(
181        s,
182        "    token id=\"space.unit\" type=\"dimension\" value=(px)4"
183    );
184    let _ = writeln!(
185        s,
186        "    token id=\"font.heading\" type=\"fontFamily\" value=\"Noto Sans\""
187    );
188    let _ = writeln!(
189        s,
190        "    token id=\"font.body\" type=\"fontFamily\" value=\"Noto Sans\""
191    );
192    let _ = writeln!(
193        s,
194        "    token id=\"size.h1\" type=\"dimension\" value=(px)64"
195    );
196    let _ = writeln!(
197        s,
198        "    token id=\"size.h2\" type=\"dimension\" value=(px)40"
199    );
200    let _ = writeln!(
201        s,
202        "    token id=\"size.body\" type=\"dimension\" value=(px)28"
203    );
204    let _ = writeln!(
205        s,
206        "    token id=\"size.caption\" type=\"dimension\" value=(px)18"
207    );
208    if input.shape.depth {
209        let _ = writeln!(
210            s,
211            "    token id=\"color.shadow\" type=\"color\" value=\"#0000002a\""
212        );
213        let _ = writeln!(
214            s,
215            "    token id=\"shadow.depth\" type=\"shadow\" {{ layer dx=(px)0 dy=(px)8 blur=(px)24 color=(token)\"color.shadow\" }}"
216        );
217    }
218    let _ = writeln!(s, "  }}");
219    let _ = writeln!(s, "  styles {{}}");
220    let _ = writeln!(
221        s,
222        "  document id=\"theme.{name}.preview\" title=\"Theme {name}\" {{"
223    );
224    let _ = writeln!(
225        s,
226        "    page id=\"pg\" w=(px)760 h=(px)220 background=(token)\"color.base.100\" {{"
227    );
228    let _ = writeln!(
229        s,
230        "      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}\" }}"
231    );
232    let sh = if input.shape.depth {
233        " shadow=(token)\"shadow.depth\""
234    } else {
235        ""
236    };
237    for (x, role) in [
238        (40, "primary"),
239        (220, "secondary"),
240        (400, "accent"),
241        (580, "neutral"),
242    ] {
243        let _ = writeln!(
244            s,
245            "      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}"
246        );
247    }
248    let _ = writeln!(s, "    }}");
249    let _ = writeln!(s, "  }}");
250    let _ = writeln!(s, "}}");
251    s
252}