Skip to main content

adk_ui/kit/
generator.rs

1use std::collections::BTreeMap;
2
3use serde_json::{Map, Value, json};
4
5use super::spec::{
6    BRAND_KIT_SCHEMA_VERSION, BrandKitManifest, KitComponentRecipe, KitDensity, KitRadius,
7    KitSemanticColors, KitSemanticTokens, KitSemanticTypography, KitSource, KitSpec, KitStatus,
8    KitTemplate, is_css_color_token,
9};
10
11#[derive(Debug, Clone)]
12pub struct KitArtifacts {
13    pub manifest: BrandKitManifest,
14    pub catalog: Value,
15    pub tokens: Value,
16    pub templates: Value,
17    pub agent_context: Value,
18    pub theme_css: String,
19}
20
21#[derive(Debug, Default)]
22pub struct KitGenerator;
23
24impl KitGenerator {
25    pub fn new() -> Self {
26        Self
27    }
28
29    /// Generate a draft kit. Approval remains an explicit host-side operation.
30    pub fn generate(&self, spec: &KitSpec) -> KitArtifacts {
31        let kit_name = non_empty(&spec.name, "Untitled Kit");
32        let kit_version = non_empty(&spec.version, "0.1.0");
33        let slug = {
34            let value = slugify(kit_name);
35            if value.is_empty() {
36                "untitled-kit".to_string()
37            } else {
38                value
39            }
40        };
41        let catalog_id = format!("zavora.ai:adk-ui/kit/{slug}@{kit_version}");
42        let components = catalog_components();
43        let recipes = component_recipes(&components, spec);
44        let semantic_tokens = semantic_tokens(spec);
45        let templates = spec
46            .templates
47            .iter()
48            .filter(|template| !template.trim().is_empty())
49            .map(|template| KitTemplate {
50                id: template.clone(),
51                description: None,
52                required_components: Vec::new(),
53            })
54            .collect::<Vec<_>>();
55        let mut provenance = spec.provenance.clone();
56        if provenance.generated_by.is_none()
57            && matches!(provenance.source, KitSource::Generated | KitSource::Hybrid)
58        {
59            provenance.generated_by = Some("adk-ui/render_kit".to_string());
60        }
61
62        let manifest = BrandKitManifest {
63            schema_version: BRAND_KIT_SCHEMA_VERSION.to_string(),
64            id: catalog_id.clone(),
65            name: kit_name.to_string(),
66            version: kit_version.to_string(),
67            status: KitStatus::Draft,
68            provenance,
69            brand: spec.brand.clone(),
70            assets: spec.assets.clone(),
71            tokens: semantic_tokens.clone(),
72            components: recipes,
73            templates: templates.clone(),
74        };
75
76        let catalog = json!({
77            "$schema": "https://json-schema.org/draft/2020-12/schema",
78            "$id": catalog_id,
79            "title": format!("ADK-UI Brand Kit: {kit_name}"),
80            "description": format!("Generated component contract for {kit_name}. Runtime use requires host approval."),
81            "catalogId": catalog_id,
82            "components": components,
83            "theme": {
84                "primaryColor": semantic_tokens.colors.primary,
85                "agentDisplayName": kit_name,
86                "kitStatus": "draft"
87            },
88            "x-adkUiBrandKit": {
89                "schemaVersion": BRAND_KIT_SCHEMA_VERSION,
90                "manifestId": catalog_id,
91                "status": "draft"
92            }
93        });
94
95        let tokens = serde_json::to_value(&semantic_tokens).unwrap_or_else(|_| json!({}));
96        let templates_value = json!({ "templates": templates });
97        let allowed_components = catalog
98            .get("components")
99            .and_then(Value::as_object)
100            .map(|value| value.keys().cloned().collect::<Vec<_>>())
101            .unwrap_or_default();
102        let agent_context = json!({
103            "kitId": catalog_id,
104            "status": "draft",
105            "allowedComponents": allowed_components,
106            "assetPolicy": "Reference declared assets with kit://<asset-id>. Do not invent logos, marks, fonts, or brand colors.",
107            "designPolicy": "Use semantic tokens and declared component recipes. Treat the kit as the design source of truth.",
108            "templates": spec.templates,
109        });
110        let theme_css = theme_css(&catalog_id, &semantic_tokens);
111
112        KitArtifacts {
113            manifest,
114            catalog,
115            tokens,
116            templates: templates_value,
117            agent_context,
118            theme_css,
119        }
120    }
121}
122
123fn catalog_components() -> Map<String, Value> {
124    serde_json::from_str::<Value>(include_str!("../../catalog/extended_catalog.json"))
125        .ok()
126        .and_then(|catalog| {
127            catalog
128                .get("components")
129                .and_then(Value::as_object)
130                .cloned()
131        })
132        .unwrap_or_default()
133}
134
135fn component_recipes(
136    catalog_components: &Map<String, Value>,
137    spec: &KitSpec,
138) -> BTreeMap<String, KitComponentRecipe> {
139    catalog_components
140        .keys()
141        .map(|component| {
142            let variant = match component.as_str() {
143                "Button" => spec
144                    .components
145                    .button
146                    .as_ref()
147                    .and_then(|button| button.variants.first().cloned()),
148                "Card" => spec
149                    .components
150                    .card
151                    .as_ref()
152                    .and_then(|card| card.elevation.clone()),
153                "TextInput" => spec
154                    .components
155                    .input
156                    .as_ref()
157                    .and_then(|input| input.style.clone()),
158                "Table" => spec.components.table.as_ref().and_then(|table| {
159                    table
160                        .striped
161                        .map(|striped| if striped { "striped" } else { "plain" }.to_string())
162                }),
163                _ => None,
164            };
165            (
166                component.clone(),
167                KitComponentRecipe {
168                    component: component.clone(),
169                    variant,
170                    asset_id: None,
171                    slots: BTreeMap::new(),
172                },
173            )
174        })
175        .collect()
176}
177
178fn semantic_tokens(spec: &KitSpec) -> KitSemanticTokens {
179    let primary = color_or(&spec.colors.primary, "#0f766e");
180    let accent = spec
181        .colors
182        .accent
183        .as_deref()
184        .map(|value| color_or(value, &primary))
185        .unwrap_or_else(|| primary.clone());
186    let background = spec
187        .colors
188        .background
189        .as_deref()
190        .map(|value| color_or(value, "#f7f9f8"))
191        .unwrap_or_else(|| "#f7f9f8".to_string());
192    let surface = spec
193        .colors
194        .surface
195        .as_deref()
196        .map(|value| color_or(value, "#ffffff"))
197        .unwrap_or_else(|| "#ffffff".to_string());
198    let foreground = spec
199        .colors
200        .text
201        .as_deref()
202        .map(|value| color_or(value, "#17211f"))
203        .unwrap_or_else(|| "#17211f".to_string());
204    let body_family = non_empty(&spec.typography.family, "Avenir Next, sans-serif").to_string();
205
206    KitSemanticTokens {
207        colors: KitSemanticColors {
208            primary_foreground: contrast_foreground(&primary),
209            primary,
210            secondary_foreground: contrast_foreground(&accent),
211            secondary: accent.clone(),
212            background,
213            foreground: foreground.clone(),
214            surface,
215            surface_foreground: foreground,
216            muted: "#edf2f1".to_string(),
217            muted_foreground: "#5b6b68".to_string(),
218            border: "#d8e1df".to_string(),
219            input: "#c7d3d0".to_string(),
220            ring: accent,
221            destructive: "#b42318".to_string(),
222            destructive_foreground: "#ffffff".to_string(),
223            success: "#18794e".to_string(),
224            warning: "#a15c00".to_string(),
225            info: "#175cd3".to_string(),
226            chart: vec![
227                "#0f766e".to_string(),
228                "#175cd3".to_string(),
229                "#a15c00".to_string(),
230                "#7a5af8".to_string(),
231                "#c4320a".to_string(),
232            ],
233        },
234        typography: KitSemanticTypography {
235            body_family: body_family.clone(),
236            heading_family: body_family,
237            mono_family: "ui-monospace, SFMono-Regular, Menlo, monospace".to_string(),
238            scale: spec.typography.scale.clone(),
239        },
240        density: spec.density.clone(),
241        radius: spec.radius.clone(),
242        spacing: density_spacing(&spec.density),
243        motion_duration: 160,
244    }
245}
246
247fn theme_css(kit_id: &str, tokens: &KitSemanticTokens) -> String {
248    let colors = &tokens.colors;
249    let selector_id = css_string(kit_id);
250    let body_family = css_string(&tokens.typography.body_family);
251    let heading_family = css_string(&tokens.typography.heading_family);
252    let mono_family = css_string(&tokens.typography.mono_family);
253    format!(
254        "[data-adk-ui-kit=\"{selector_id}\"] {{\n  --adk-ui-primary: {};\n  --adk-ui-primary-foreground: {};\n  --adk-ui-secondary: {};\n  --adk-ui-secondary-foreground: {};\n  --adk-ui-background: {};\n  --adk-ui-foreground: {};\n  --adk-ui-surface: {};\n  --adk-ui-surface-foreground: {};\n  --adk-ui-muted: {};\n  --adk-ui-muted-foreground: {};\n  --adk-ui-border: {};\n  --adk-ui-input: {};\n  --adk-ui-ring: {};\n  --adk-ui-destructive: {};\n  --adk-ui-destructive-foreground: {};\n  --adk-ui-success: {};\n  --adk-ui-warning: {};\n  --adk-ui-info: {};\n  --adk-ui-font-body: \"{body_family}\";\n  --adk-ui-font-heading: \"{heading_family}\";\n  --adk-ui-font-mono: \"{mono_family}\";\n  --adk-ui-radius: {};\n  --adk-ui-space: {}px;\n  --adk-ui-motion-duration: {}ms;\n}}\n",
255        colors.primary,
256        colors.primary_foreground,
257        colors.secondary,
258        colors.secondary_foreground,
259        colors.background,
260        colors.foreground,
261        colors.surface,
262        colors.surface_foreground,
263        colors.muted,
264        colors.muted_foreground,
265        colors.border,
266        colors.input,
267        colors.ring,
268        colors.destructive,
269        colors.destructive_foreground,
270        colors.success,
271        colors.warning,
272        colors.info,
273        radius_value(&tokens.radius),
274        tokens.spacing,
275        tokens.motion_duration,
276    )
277}
278
279fn density_spacing(density: &KitDensity) -> u8 {
280    match density {
281        KitDensity::Compact => 3,
282        KitDensity::Comfortable => 4,
283        KitDensity::Spacious => 6,
284    }
285}
286
287fn radius_value(radius: &KitRadius) -> &'static str {
288    match radius {
289        KitRadius::None => "0px",
290        KitRadius::Sm => "4px",
291        KitRadius::Md => "8px",
292        KitRadius::Lg => "12px",
293        KitRadius::Xl => "18px",
294    }
295}
296
297fn contrast_foreground(color: &str) -> String {
298    let Some(hex) = color.strip_prefix('#').filter(|value| value.len() == 6) else {
299        return "#ffffff".to_string();
300    };
301    let Ok(red) = u8::from_str_radix(&hex[0..2], 16) else {
302        return "#ffffff".to_string();
303    };
304    let Ok(green) = u8::from_str_radix(&hex[2..4], 16) else {
305        return "#ffffff".to_string();
306    };
307    let Ok(blue) = u8::from_str_radix(&hex[4..6], 16) else {
308        return "#ffffff".to_string();
309    };
310    let luminance = (u32::from(red) * 299 + u32::from(green) * 587 + u32::from(blue) * 114) / 1000;
311    if luminance > 155 {
312        "#17211f".to_string()
313    } else {
314        "#ffffff".to_string()
315    }
316}
317
318fn color_or(value: &str, fallback: &str) -> String {
319    let value = value.trim();
320    if is_css_color_token(value) {
321        value.to_string()
322    } else {
323        fallback.to_string()
324    }
325}
326
327fn css_string(value: &str) -> String {
328    value
329        .replace('\\', "\\\\")
330        .replace('"', "\\\"")
331        .replace(['\n', '\r'], " ")
332}
333
334fn non_empty<'a>(value: &'a str, fallback: &'a str) -> &'a str {
335    if value.trim().is_empty() {
336        fallback
337    } else {
338        value.trim()
339    }
340}
341
342fn slugify(input: &str) -> String {
343    let mut out = String::new();
344    for ch in input.chars() {
345        if ch.is_ascii_alphanumeric() {
346            out.push(ch.to_ascii_lowercase());
347        } else if (ch.is_whitespace() || ch == '-' || ch == '_') && !out.ends_with('-') {
348            out.push('-');
349        }
350    }
351    out.trim_matches('-').to_string()
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::kit::spec::{KitBrand, KitColors, KitProvenance, KitSpec, KitTypography};
358
359    fn spec() -> KitSpec {
360        KitSpec {
361            name: "Fintech Pro".to_string(),
362            version: "0.1.0".to_string(),
363            brand: KitBrand {
364                vibe: "trustworthy".to_string(),
365                industry: None,
366            },
367            colors: KitColors {
368                primary: "#2F6BFF".to_string(),
369                accent: None,
370                surface: None,
371                background: None,
372                text: None,
373            },
374            typography: KitTypography {
375                family: "Source Sans 3".to_string(),
376                scale: None,
377            },
378            density: Default::default(),
379            radius: Default::default(),
380            components: Default::default(),
381            assets: Vec::new(),
382            provenance: KitProvenance::default(),
383            templates: vec!["auth_login".to_string()],
384        }
385    }
386
387    #[test]
388    fn generates_complete_draft_manifest_and_catalog() {
389        let artifacts = KitGenerator::new().generate(&spec());
390        assert_eq!(
391            artifacts.catalog["catalogId"],
392            "zavora.ai:adk-ui/kit/fintech-pro@0.1.0"
393        );
394        assert_eq!(artifacts.manifest.status, KitStatus::Draft);
395        assert!(artifacts.manifest.validate().is_ok());
396        assert!(artifacts.catalog["components"]["Card"].is_object());
397        assert!(artifacts.tokens["colors"]["primary"].is_string());
398        assert_eq!(artifacts.agent_context["status"], "draft");
399        assert!(artifacts.theme_css.contains("--adk-ui-primary"));
400    }
401
402    #[test]
403    fn sanitizes_invalid_legacy_css_values() {
404        let mut unsafe_spec = spec();
405        unsafe_spec.colors.primary = "rgb(0, 0, 0); } body { display:none".to_string();
406        unsafe_spec.typography.family = "Bad\"; color:red".to_string();
407
408        let artifacts = KitGenerator::new().generate(&unsafe_spec);
409        assert_eq!(artifacts.tokens["colors"]["primary"], "#0f766e");
410        assert!(!artifacts.theme_css.contains("} body"));
411        assert!(artifacts.theme_css.contains("Bad\\\"; color:red"));
412    }
413}