Skip to main content

adk_ui/kit/
spec.rs

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    /// Existing company assets to include in the draft. Generated assets should be reviewed
22    /// and then supplied here with provenance before approval.
23    #[serde(default)]
24    pub assets: Vec<KitAsset>,
25    /// Records where the supplied brand inputs came from.
26    #[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/// Publication state for a brand kit. Runtime hosts should only auto-select approved kits.
116#[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/// How the kit was assembled. This is provenance, not a trust decision.
126#[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/// An approved, addressable brand asset. Components reference these as `kit://<id>`.
162#[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    /// Base spacing unit in pixels.
225    pub spacing: u8,
226    /// Default transition duration in milliseconds.
227    pub motion_duration: u16,
228}
229
230/// Maps an agent-facing component to a renderer recipe and optional branded asset.
231#[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/// Versioned company design contract shared by agents, protocols, and renderers.
254#[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}