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 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}