1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, HashSet};
4use std::fmt;
5
6pub const BRAND_KIT_SCHEMA_VERSION: &str = "1.0";
7
8#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
9pub struct KitSpec {
10 pub name: String,
11 pub version: String,
12 pub brand: KitBrand,
13 pub colors: KitColors,
14 pub typography: KitTypography,
15 #[serde(default)]
16 pub density: KitDensity,
17 #[serde(default)]
18 pub radius: KitRadius,
19 #[serde(default)]
20 pub components: KitComponents,
21 #[serde(default)]
24 pub assets: Vec<KitAsset>,
25 #[serde(default)]
27 pub provenance: KitProvenance,
28 #[serde(default)]
29 pub templates: Vec<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
33pub struct KitBrand {
34 pub vibe: String,
35 #[serde(default)]
36 pub industry: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
40pub struct KitColors {
41 pub primary: String,
42 #[serde(default)]
43 pub accent: Option<String>,
44 #[serde(default)]
45 pub surface: Option<String>,
46 #[serde(default)]
47 pub background: Option<String>,
48 #[serde(default)]
49 pub text: Option<String>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
53pub struct KitTypography {
54 pub family: String,
55 #[serde(default)]
56 pub scale: Option<String>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
60#[serde(rename_all = "snake_case")]
61pub enum KitDensity {
62 Compact,
63 #[default]
64 Comfortable,
65 Spacious,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
69#[serde(rename_all = "snake_case")]
70pub enum KitRadius {
71 None,
72 Sm,
73 #[default]
74 Md,
75 Lg,
76 Xl,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
80pub struct KitComponents {
81 #[serde(default)]
82 pub button: Option<KitComponentButton>,
83 #[serde(default)]
84 pub card: Option<KitComponentCard>,
85 #[serde(default)]
86 pub input: Option<KitComponentInput>,
87 #[serde(default)]
88 pub table: Option<KitComponentTable>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
92pub struct KitComponentButton {
93 #[serde(default)]
94 pub variants: Vec<String>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
98pub struct KitComponentCard {
99 #[serde(default)]
100 pub elevation: Option<String>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
104pub struct KitComponentInput {
105 #[serde(default)]
106 pub style: Option<String>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
110pub struct KitComponentTable {
111 #[serde(default)]
112 pub striped: Option<bool>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
117#[serde(rename_all = "snake_case")]
118pub enum KitStatus {
119 #[default]
120 Draft,
121 Approved,
122 Deprecated,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
127#[serde(rename_all = "snake_case")]
128pub enum KitSource {
129 Imported,
130 #[default]
131 Generated,
132 Hybrid,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
136#[serde(rename_all = "camelCase")]
137pub struct KitProvenance {
138 #[serde(default)]
139 pub source: KitSource,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub source_uri: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub generated_by: Option<String>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub reviewed_by: Option<String>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub approved_at: Option<String>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
151#[serde(rename_all = "snake_case")]
152pub enum KitAssetKind {
153 Logo,
154 Icon,
155 Illustration,
156 Image,
157 Font,
158 Model3d,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
163#[serde(rename_all = "camelCase")]
164pub struct KitAsset {
165 pub id: String,
166 pub kind: KitAssetKind,
167 pub uri: String,
168 pub mime_type: String,
169 pub alt: String,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub sha256: Option<String>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub license: Option<String>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub width: Option<u32>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub height: Option<u32>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub byte_size: Option<u64>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
183#[serde(rename_all = "camelCase")]
184pub struct KitSemanticColors {
185 pub primary: String,
186 pub primary_foreground: String,
187 pub secondary: String,
188 pub secondary_foreground: String,
189 pub background: String,
190 pub foreground: String,
191 pub surface: String,
192 pub surface_foreground: String,
193 pub muted: String,
194 pub muted_foreground: String,
195 pub border: String,
196 pub input: String,
197 pub ring: String,
198 pub destructive: String,
199 pub destructive_foreground: String,
200 pub success: String,
201 pub warning: String,
202 pub info: String,
203 #[serde(default)]
204 pub chart: Vec<String>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
208#[serde(rename_all = "camelCase")]
209pub struct KitSemanticTypography {
210 pub body_family: String,
211 pub heading_family: String,
212 pub mono_family: String,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub scale: Option<String>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
218#[serde(rename_all = "camelCase")]
219pub struct KitSemanticTokens {
220 pub colors: KitSemanticColors,
221 pub typography: KitSemanticTypography,
222 pub density: KitDensity,
223 pub radius: KitRadius,
224 pub spacing: u8,
226 pub motion_duration: u16,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
232#[serde(rename_all = "camelCase")]
233pub struct KitComponentRecipe {
234 pub component: String,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub variant: Option<String>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub asset_id: Option<String>,
239 #[serde(default)]
240 pub slots: BTreeMap<String, String>,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
244#[serde(rename_all = "camelCase")]
245pub struct KitTemplate {
246 pub id: String,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub description: Option<String>,
249 #[serde(default)]
250 pub required_components: Vec<String>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
255#[serde(rename_all = "camelCase")]
256pub struct BrandKitManifest {
257 pub schema_version: String,
258 pub id: String,
259 pub name: String,
260 pub version: String,
261 #[serde(default)]
262 pub status: KitStatus,
263 #[serde(default)]
264 pub provenance: KitProvenance,
265 pub brand: KitBrand,
266 #[serde(default)]
267 pub assets: Vec<KitAsset>,
268 pub tokens: KitSemanticTokens,
269 #[serde(default)]
270 pub components: BTreeMap<String, KitComponentRecipe>,
271 #[serde(default)]
272 pub templates: Vec<KitTemplate>,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct KitValidationError {
277 pub field: String,
278 pub message: String,
279}
280
281impl KitValidationError {
282 fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
283 Self {
284 field: field.into(),
285 message: message.into(),
286 }
287 }
288}
289
290impl fmt::Display for KitValidationError {
291 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
292 write!(formatter, "{}: {}", self.field, self.message)
293 }
294}
295
296impl BrandKitManifest {
297 pub fn validate(&self) -> Result<(), Vec<KitValidationError>> {
298 let mut errors = Vec::new();
299
300 if self.schema_version != BRAND_KIT_SCHEMA_VERSION {
301 errors.push(KitValidationError::new(
302 "schemaVersion",
303 format!("expected {BRAND_KIT_SCHEMA_VERSION}"),
304 ));
305 }
306 if self.id.trim().is_empty() {
307 errors.push(KitValidationError::new("id", "must not be empty"));
308 }
309 if self.name.trim().is_empty() {
310 errors.push(KitValidationError::new("name", "must not be empty"));
311 }
312 if self.version.trim().is_empty() {
313 errors.push(KitValidationError::new("version", "must not be empty"));
314 }
315 if self.status == KitStatus::Approved
316 && (self.provenance.reviewed_by.is_none() || self.provenance.approved_at.is_none())
317 {
318 errors.push(KitValidationError::new(
319 "provenance",
320 "approved kits require reviewedBy and approvedAt",
321 ));
322 }
323 if let Some(source_uri) = &self.provenance.source_uri
324 && !source_uri.trim().starts_with("https://")
325 {
326 errors.push(KitValidationError::new(
327 "provenance.sourceUri",
328 "must use HTTPS",
329 ));
330 }
331
332 validate_colors(&self.tokens.colors, &mut errors);
333
334 let mut asset_ids = HashSet::new();
335 for (index, asset) in self.assets.iter().enumerate() {
336 let field = format!("assets[{index}]");
337 if asset.id.trim().is_empty() {
338 errors.push(KitValidationError::new(
339 format!("{field}.id"),
340 "must not be empty",
341 ));
342 } else if !asset_ids.insert(asset.id.as_str()) {
343 errors.push(KitValidationError::new(
344 format!("{field}.id"),
345 "must be unique",
346 ));
347 }
348 if !is_safe_asset_uri(&asset.uri) {
349 errors.push(KitValidationError::new(
350 format!("{field}.uri"),
351 "must be an HTTPS URL or a root-relative application asset",
352 ));
353 }
354 if !is_supported_asset_mime(&asset.mime_type) {
355 errors.push(KitValidationError::new(
356 format!("{field}.mimeType"),
357 "unsupported brand asset media type",
358 ));
359 }
360 if asset.alt.trim().is_empty() && asset.kind != KitAssetKind::Font {
361 errors.push(KitValidationError::new(
362 format!("{field}.alt"),
363 "non-font assets require alternative text",
364 ));
365 }
366 if asset.kind == KitAssetKind::Model3d
367 && asset.byte_size.unwrap_or(0) > 25 * 1024 * 1024
368 {
369 errors.push(KitValidationError::new(
370 format!("{field}.byteSize"),
371 "3D model assets must not exceed 25 MiB",
372 ));
373 }
374 if let Some(sha256) = &asset.sha256
375 && (sha256.len() != 64
376 || !sha256
377 .chars()
378 .all(|character| character.is_ascii_hexdigit()))
379 {
380 errors.push(KitValidationError::new(
381 format!("{field}.sha256"),
382 "must be a 64-character hexadecimal digest",
383 ));
384 }
385 }
386
387 for (name, recipe) in &self.components {
388 if recipe.component.trim().is_empty() {
389 errors.push(KitValidationError::new(
390 format!("components.{name}.component"),
391 "must not be empty",
392 ));
393 }
394 if let Some(asset_id) = &recipe.asset_id
395 && !asset_ids.contains(asset_id.as_str())
396 {
397 errors.push(KitValidationError::new(
398 format!("components.{name}.assetId"),
399 "must reference an asset declared by this kit",
400 ));
401 }
402 }
403
404 if errors.is_empty() {
405 Ok(())
406 } else {
407 Err(errors)
408 }
409 }
410}
411
412fn validate_colors(colors: &KitSemanticColors, errors: &mut Vec<KitValidationError>) {
413 let values = [
414 ("primary", &colors.primary),
415 ("primaryForeground", &colors.primary_foreground),
416 ("secondary", &colors.secondary),
417 ("secondaryForeground", &colors.secondary_foreground),
418 ("background", &colors.background),
419 ("foreground", &colors.foreground),
420 ("surface", &colors.surface),
421 ("surfaceForeground", &colors.surface_foreground),
422 ("muted", &colors.muted),
423 ("mutedForeground", &colors.muted_foreground),
424 ("border", &colors.border),
425 ("input", &colors.input),
426 ("ring", &colors.ring),
427 ("destructive", &colors.destructive),
428 ("destructiveForeground", &colors.destructive_foreground),
429 ("success", &colors.success),
430 ("warning", &colors.warning),
431 ("info", &colors.info),
432 ];
433
434 for (name, value) in values {
435 if !is_css_color_token(value) {
436 errors.push(KitValidationError::new(
437 format!("tokens.colors.{name}"),
438 "must be a hex, rgb(), rgba(), hsl(), hsla(), or CSS variable color",
439 ));
440 }
441 }
442 for (index, value) in colors.chart.iter().enumerate() {
443 if !is_css_color_token(value) {
444 errors.push(KitValidationError::new(
445 format!("tokens.colors.chart[{index}]"),
446 "must be a supported CSS color",
447 ));
448 }
449 }
450}
451
452pub(crate) fn is_css_color_token(value: &str) -> bool {
453 let value = value.trim();
454 let valid_hex = value.strip_prefix('#').is_some_and(|hex| {
455 matches!(hex.len(), 3 | 4 | 6 | 8) && hex.chars().all(|ch| ch.is_ascii_hexdigit())
456 });
457 if valid_hex {
458 return true;
459 }
460
461 if let Some(variable) = value
462 .strip_prefix("var(")
463 .and_then(|token| token.strip_suffix(')'))
464 {
465 return variable.starts_with("--")
466 && variable.len() > 2
467 && variable.chars().all(|character| {
468 character.is_ascii_alphanumeric() || matches!(character, '-' | '_')
469 });
470 }
471
472 ["rgb(", "rgba(", "hsl(", "hsla("].iter().any(|prefix| {
473 value
474 .strip_prefix(prefix)
475 .and_then(|token| token.strip_suffix(')'))
476 .is_some_and(|arguments| {
477 !arguments.is_empty()
478 && arguments.chars().all(|character| {
479 character.is_ascii_digit()
480 || character.is_ascii_whitespace()
481 || matches!(character, '.' | ',' | '%' | '/' | '+' | '-')
482 })
483 })
484 })
485}
486
487fn is_safe_asset_uri(uri: &str) -> bool {
488 let uri = uri.trim();
489 uri.starts_with("https://") || (uri.starts_with('/') && !uri.starts_with("//"))
490}
491
492fn is_supported_asset_mime(mime: &str) -> bool {
493 matches!(
494 mime.trim().to_ascii_lowercase().as_str(),
495 "image/png"
496 | "image/jpeg"
497 | "image/webp"
498 | "image/avif"
499 | "image/svg+xml"
500 | "font/woff"
501 | "font/woff2"
502 | "model/gltf-binary"
503 )
504}
505
506#[cfg(test)]
507mod manifest_tests {
508 use super::*;
509
510 fn valid_manifest() -> BrandKitManifest {
511 BrandKitManifest {
512 schema_version: BRAND_KIT_SCHEMA_VERSION.to_string(),
513 id: "zavora.ai:adk-ui/kit/acme@1.0.0".to_string(),
514 name: "Acme".to_string(),
515 version: "1.0.0".to_string(),
516 status: KitStatus::Draft,
517 provenance: KitProvenance::default(),
518 brand: KitBrand {
519 vibe: "clear".to_string(),
520 industry: None,
521 },
522 assets: vec![KitAsset {
523 id: "logo".to_string(),
524 kind: KitAssetKind::Logo,
525 uri: "/assets/logo.svg".to_string(),
526 mime_type: "image/svg+xml".to_string(),
527 alt: "Acme".to_string(),
528 sha256: None,
529 license: None,
530 width: None,
531 height: None,
532 byte_size: None,
533 }],
534 tokens: KitSemanticTokens {
535 colors: KitSemanticColors {
536 primary: "#00695c".to_string(),
537 primary_foreground: "#ffffff".to_string(),
538 secondary: "#e7f4f1".to_string(),
539 secondary_foreground: "#102a27".to_string(),
540 background: "#f8faf9".to_string(),
541 foreground: "#102a27".to_string(),
542 surface: "#ffffff".to_string(),
543 surface_foreground: "#102a27".to_string(),
544 muted: "#eef2f1".to_string(),
545 muted_foreground: "#5b6b68".to_string(),
546 border: "#d9e2e0".to_string(),
547 input: "#d9e2e0".to_string(),
548 ring: "#00695c".to_string(),
549 destructive: "#b42318".to_string(),
550 destructive_foreground: "#ffffff".to_string(),
551 success: "#18794e".to_string(),
552 warning: "#a15c00".to_string(),
553 info: "#175cd3".to_string(),
554 chart: vec!["#00695c".to_string()],
555 },
556 typography: KitSemanticTypography {
557 body_family: "Source Sans 3".to_string(),
558 heading_family: "Source Sans 3".to_string(),
559 mono_family: "ui-monospace".to_string(),
560 scale: None,
561 },
562 density: KitDensity::Comfortable,
563 radius: KitRadius::Md,
564 spacing: 4,
565 motion_duration: 160,
566 },
567 components: BTreeMap::new(),
568 templates: Vec::new(),
569 }
570 }
571
572 #[test]
573 fn validates_safe_manifest() {
574 assert!(valid_manifest().validate().is_ok());
575 }
576
577 #[test]
578 fn rejects_unsafe_assets_and_unreviewed_approval() {
579 let mut manifest = valid_manifest();
580 manifest.status = KitStatus::Approved;
581 manifest.assets[0].uri = "javascript:alert(1)".to_string();
582
583 let errors = manifest.validate().expect_err("invalid manifest");
584 assert!(errors.iter().any(|error| error.field == "provenance"));
585 assert!(errors.iter().any(|error| error.field == "assets[0].uri"));
586 }
587
588 #[test]
589 fn rejects_css_function_injection() {
590 let mut manifest = valid_manifest();
591 manifest.tokens.colors.primary = "rgb(0, 0, 0); } body { display:none".to_string();
592
593 let errors = manifest.validate().expect_err("unsafe color");
594 assert!(
595 errors
596 .iter()
597 .any(|error| error.field == "tokens.colors.primary")
598 );
599 }
600}