Skip to main content

cvkg_cli/
token_export.rs

1//! Design Token Export -- Item 13
2//!
3//! Exports CVKG design tokens to various formats: Figma JSON, CSS variables,
4//! Swift constants, and plain JSON.
5//!
6//! # OS-agnostic
7//! Pure file I/O. No platform-specific APIs.
8
9use std::collections::HashMap;
10
11/// Token export engine.
12#[derive(Default)]
13pub struct TokenExport {
14    /// Color tokens: name → [r, g, b, a]
15    colors: HashMap<String, [f32; 4]>,
16    /// Spacing tokens: name → value in px
17    spacing: HashMap<String, f32>,
18    /// Border radius tokens: name → value in px
19    radius: HashMap<String, f32>,
20    /// Typography tokens: name → font size in px
21    typography: HashMap<String, f32>,
22}
23
24impl TokenExport {
25    /// Create a new token export with CVKG's default design tokens.
26    pub fn new() -> Self {
27        let mut colors = HashMap::new();
28        // Light theme colors
29        colors.insert("background".to_string(), [1.0, 1.0, 1.0, 1.0]);
30        colors.insert("surface".to_string(), [0.98, 0.98, 0.98, 1.0]);
31        colors.insert("surface_elevated".to_string(), [0.95, 0.95, 0.95, 1.0]);
32        colors.insert("surface_overlay".to_string(), [0.92, 0.92, 0.94, 1.0]);
33        colors.insert("text".to_string(), [0.1, 0.1, 0.12, 1.0]);
34        colors.insert("text_muted".to_string(), [0.4, 0.4, 0.45, 1.0]);
35        colors.insert("text_dim".to_string(), [0.55, 0.55, 0.6, 1.0]);
36        colors.insert("primary".to_string(), [0.2, 0.2, 0.25, 1.0]);
37        colors.insert("secondary".to_string(), [0.5, 0.5, 0.55, 1.0]);
38        colors.insert("accent".to_string(), [0.0, 0.8, 1.0, 1.0]);
39        colors.insert("accent_hover".to_string(), [0.2, 0.85, 1.0, 1.0]);
40        colors.insert("border".to_string(), [0.85, 0.85, 0.88, 1.0]);
41        colors.insert("border_strong".to_string(), [0.7, 0.7, 0.75, 1.0]);
42        colors.insert("hover".to_string(), [0.95, 0.95, 0.97, 1.0]);
43        colors.insert("active".to_string(), [0.9, 0.9, 0.93, 1.0]);
44        colors.insert("disabled".to_string(), [0.92, 0.92, 0.94, 1.0]);
45        colors.insert("disabled_text".to_string(), [0.65, 0.65, 0.7, 1.0]);
46        colors.insert("success".to_string(), [0.2, 0.8, 0.4, 1.0]);
47        colors.insert("warning".to_string(), [1.0, 0.7, 0.0, 1.0]);
48        colors.insert("error".to_string(), [0.95, 0.2, 0.3, 1.0]);
49        colors.insert("info".to_string(), [0.2, 0.6, 1.0, 1.0]);
50        colors.insert("focus_ring".to_string(), [0.0, 0.8, 1.0, 0.8]);
51        colors.insert("shadow".to_string(), [0.0, 0.0, 0.0, 0.15]);
52        colors.insert("code_bg".to_string(), [0.96, 0.96, 0.98, 1.0]);
53
54        // Dark theme overrides
55        let mut dark_colors = HashMap::new();
56        dark_colors.insert("background".to_string(), [0.05, 0.05, 0.08, 1.0]);
57        dark_colors.insert("surface".to_string(), [0.1, 0.1, 0.14, 1.0]);
58        dark_colors.insert("surface_elevated".to_string(), [0.15, 0.15, 0.2, 1.0]);
59        dark_colors.insert("surface_overlay".to_string(), [0.18, 0.18, 0.24, 1.0]);
60        dark_colors.insert("text".to_string(), [0.95, 0.95, 0.97, 1.0]);
61        dark_colors.insert("text_muted".to_string(), [0.6, 0.6, 0.65, 1.0]);
62        dark_colors.insert("text_dim".to_string(), [0.45, 0.45, 0.5, 1.0]);
63        dark_colors.insert("border".to_string(), [0.25, 0.25, 0.3, 1.0]);
64        dark_colors.insert("border_strong".to_string(), [0.35, 0.35, 0.4, 1.0]);
65        dark_colors.insert("hover".to_string(), [0.15, 0.15, 0.2, 1.0]);
66        dark_colors.insert("active".to_string(), [0.2, 0.2, 0.28, 1.0]);
67        dark_colors.insert("disabled".to_string(), [0.12, 0.12, 0.16, 1.0]);
68        dark_colors.insert("disabled_text".to_string(), [0.35, 0.35, 0.4, 1.0]);
69        dark_colors.insert("shadow".to_string(), [0.0, 0.0, 0.0, 0.4]);
70
71        let mut spacing = HashMap::new();
72        spacing.insert("xs".to_string(), 4.0);
73        spacing.insert("sm".to_string(), 8.0);
74        spacing.insert("md".to_string(), 16.0);
75        spacing.insert("lg".to_string(), 24.0);
76        spacing.insert("xl".to_string(), 32.0);
77        spacing.insert("2xl".to_string(), 48.0);
78        spacing.insert("3xl".to_string(), 64.0);
79
80        let mut radius = HashMap::new();
81        radius.insert("xs".to_string(), 2.0);
82        radius.insert("sm".to_string(), 4.0);
83        radius.insert("md".to_string(), 6.0);
84        radius.insert("lg".to_string(), 8.0);
85        radius.insert("xl".to_string(), 12.0);
86        radius.insert("2xl".to_string(), 16.0);
87        radius.insert("full".to_string(), 9999.0);
88
89        let mut typography = HashMap::new();
90        typography.insert("footnote".to_string(), 10.0);
91        typography.insert("caption".to_string(), 12.0);
92        typography.insert("body".to_string(), 14.0);
93        typography.insert("body_large".to_string(), 16.0);
94        typography.insert("heading3".to_string(), 20.0);
95        typography.insert("heading2".to_string(), 24.0);
96        typography.insert("heading1".to_string(), 32.0);
97        typography.insert("display".to_string(), 48.0);
98
99        Self {
100            colors,
101            spacing,
102            radius,
103            typography,
104        }
105    }
106
107    /// Generate token output in the specified format.
108    pub fn generate(&self, format: &str) -> Result<String, String> {
109        match format {
110            "figma" => Ok(self.generate_figma()),
111            "css" => Ok(self.generate_css()),
112            "swift" => Ok(self.generate_swift()),
113            "json" => Ok(self.generate_json()),
114            other => Err(format!("Unknown format: {}", other)),
115        }
116    }
117
118    /// Generate Figma Tokens JSON format.
119    fn generate_figma(&self) -> String {
120        let mut parts = vec!["{".to_string()];
121
122        // Colors
123        parts.push("  \"colors\": {".to_string());
124        let color_entries: Vec<String> = self
125            .colors
126            .iter()
127            .map(|(name, rgba)| {
128                let r = (rgba[0] * 255.0).round() as u32;
129                let g = (rgba[1] * 255.0).round() as u32;
130                let b = (rgba[2] * 255.0).round() as u32;
131                format!(
132                    "    \"{}\": {{\"r\": {}, \"g\": {}, \"b\": {}, \"a\": {:.2}}}",
133                    name, r, g, b, rgba[3]
134                )
135            })
136            .collect();
137        parts.push(color_entries.join(",\n"));
138        parts.push("  },".to_string());
139
140        // Spacing
141        parts.push("  \"spacing\": {".to_string());
142        let spacing_entries: Vec<String> = self
143            .spacing
144            .iter()
145            .map(|(name, val)| format!("    \"{}\": {}", name, val))
146            .collect();
147        parts.push(spacing_entries.join(",\n"));
148        parts.push("  },".to_string());
149
150        // Radius
151        parts.push("  \"radius\": {".to_string());
152        let radius_entries: Vec<String> = self
153            .radius
154            .iter()
155            .map(|(name, val)| format!("    \"{}\": {}", name, val))
156            .collect();
157        parts.push(radius_entries.join(",\n"));
158        parts.push("  },".to_string());
159
160        // Typography
161        parts.push("  \"typography\": {".to_string());
162        let type_entries: Vec<String> = self
163            .typography
164            .iter()
165            .map(|(name, val)| format!("    \"{}\": {}", name, val))
166            .collect();
167        parts.push(type_entries.join(",\n"));
168        parts.push("  }".to_string());
169
170        parts.push("}".to_string());
171        parts.join("\n")
172    }
173
174    /// Generate CSS custom properties.
175    fn generate_css(&self) -> String {
176        let mut lines = vec![":root {".to_string()];
177
178        // Colors
179        for (name, rgba) in &self.colors {
180            let css_name = format!("--color-{}", name.replace('_', "-"));
181            let r = (rgba[0] * 255.0).round() as u32;
182            let g = (rgba[1] * 255.0).round() as u32;
183            let b = (rgba[2] * 255.0).round() as u32;
184            if rgba[3] < 1.0 {
185                lines.push(format!(
186                    "  {}: rgba({}, {}, {}, {:.2});",
187                    css_name, r, g, b, rgba[3]
188                ));
189            } else {
190                lines.push(format!("  {}: rgb({}, {}, {});", css_name, r, g, b));
191            }
192        }
193
194        // Spacing
195        for (name, val) in &self.spacing {
196            lines.push(format!(
197                "  --spacing-{}: {}px;",
198                name.replace('_', "-"),
199                val
200            ));
201        }
202
203        // Radius
204        for (name, val) in &self.radius {
205            lines.push(format!("  --radius-{}: {}px;", name.replace('_', "-"), val));
206        }
207
208        // Typography
209        for (name, val) in &self.typography {
210            lines.push(format!(
211                "  --font-size-{}: {}px;",
212                name.replace('_', "-"),
213                val
214            ));
215        }
216
217        lines.push("}".to_string());
218        lines.join("\n")
219    }
220
221    /// Generate SwiftUI-compatible constants.
222    fn generate_swift(&self) -> String {
223        let mut lines = vec![
224            "// CVKG Design Tokens — SwiftUI".to_string(),
225            "// Auto-generated by cvkg tokens export --format swift".to_string(),
226            "".to_string(),
227            "import SwiftUI".to_string(),
228            "".to_string(),
229            "struct CVKGTheme {".to_string(),
230        ];
231
232        // Colors
233        lines.append(&mut vec![
234            "    // MARK: - Colors".to_string(),
235            "    struct Colors {".to_string(),
236        ]);
237        for (name, rgba) in &self.colors {
238            let swift_name = Self::to_camel_case(name);
239            lines.push(format!(
240                "        static let {} = Color(red: {:.3}, green: {:.3}, blue: {:.3}, opacity: {:.2})",
241                swift_name, rgba[0], rgba[1], rgba[2], rgba[3]
242            ));
243        }
244        lines.push("    }".to_string());
245        lines.push("".to_string());
246
247        // Spacing
248        lines.append(&mut vec![
249            "    // MARK: - Spacing".to_string(),
250            "    struct Spacing {".to_string(),
251        ]);
252        for (name, val) in &self.spacing {
253            let swift_name = Self::to_camel_case(name);
254            lines.push(format!(
255                "        static let {}: CGFloat = {}",
256                swift_name, val
257            ));
258        }
259        lines.push("    }".to_string());
260        lines.push("".to_string());
261
262        // Radius
263        lines.append(&mut vec![
264            "    // MARK: - Corner Radius".to_string(),
265            "    struct Radius {".to_string(),
266        ]);
267        for (name, val) in &self.radius {
268            let swift_name = Self::to_camel_case(name);
269            lines.push(format!(
270                "        static let {}: CGFloat = {}",
271                swift_name, val
272            ));
273        }
274        lines.push("    }".to_string());
275        lines.push("".to_string());
276
277        // Typography
278        lines.append(&mut vec![
279            "    // MARK: - Typography".to_string(),
280            "    struct Typography {".to_string(),
281        ]);
282        for (name, val) in &self.typography {
283            let swift_name = Self::to_camel_case(name);
284            lines.push(format!(
285                "        static let {}: CGFloat = {}",
286                swift_name, val
287            ));
288        }
289        lines.push("    }".to_string());
290
291        lines.push("}".to_string());
292        lines.join("\n")
293    }
294
295    /// Generate plain JSON format.
296    fn generate_json(&self) -> String {
297        let mut parts = vec!["{".to_string()];
298
299        parts.push("  \"colors\": {".to_string());
300        let color_entries: Vec<String> = self
301            .colors
302            .iter()
303            .map(|(name, rgba)| {
304                format!(
305                    "    \"{}\": [{:.3}, {:.3}, {:.3}, {:.2}]",
306                    name, rgba[0], rgba[1], rgba[2], rgba[3]
307                )
308            })
309            .collect();
310        parts.push(color_entries.join(",\n"));
311        parts.push("  },".to_string());
312
313        parts.push("  \"spacing\": {".to_string());
314        let spacing_entries: Vec<String> = self
315            .spacing
316            .iter()
317            .map(|(name, val)| format!("    \"{}\": {}", name, val))
318            .collect();
319        parts.push(spacing_entries.join(",\n"));
320        parts.push("  },".to_string());
321
322        parts.push("  \"radius\": {".to_string());
323        let radius_entries: Vec<String> = self
324            .radius
325            .iter()
326            .map(|(name, val)| format!("    \"{}\": {}", name, val))
327            .collect();
328        parts.push(radius_entries.join(",\n"));
329        parts.push("  },".to_string());
330
331        parts.push("  \"typography\": {".to_string());
332        let type_entries: Vec<String> = self
333            .typography
334            .iter()
335            .map(|(name, val)| format!("    \"{}\": {}", name, val))
336            .collect();
337        parts.push(type_entries.join(",\n"));
338        parts.push("  }".to_string());
339
340        parts.push("}".to_string());
341        parts.join("\n")
342    }
343
344    /// Convert snake_case to camelCase for Swift.
345    fn to_camel_case(s: &str) -> String {
346        let mut result = String::new();
347        let mut capitalize = false;
348        for c in s.chars() {
349            if c == '_' {
350                capitalize = true;
351            } else if capitalize {
352                result.push(c.to_ascii_uppercase());
353                capitalize = false;
354            } else {
355                result.push(c);
356            }
357        }
358        // Capitalize first letter
359        if let Some(first) = result.chars().next() {
360            result = first.to_ascii_uppercase().to_string() + &result[1..];
361        }
362        result
363    }
364}