Adding a Component
Components are the building blocks inside a modality. A TextBoxComponent renders text, a CardComponent renders a styled card, a ShapeComponent renders geometric shapes. Each component declares its property schema via a typed struct and compiles itself into elements.
This guide walks through creating a new BadgeComponent for the whiteboard — a simple label with a background color.
The TypedComponent Trait
Section titled “The TypedComponent Trait”Every leaf component implements the TypedComponent trait, which provides typed properties and static identity metadata:
trait TypedComponent: Send + Sync + 'static { type Properties: ComponentProperties; type Metadata: Hash; type Output: Clone;
fn id() -> &'static str; fn name() -> &'static str; fn description() -> &'static str; fn icon() -> &'static str;
fn compile( &self, props: &Self::Properties, metadata: &Self::Metadata, ) -> Result<Self::Output, CompileError>;}A blanket impl bridges this to the base Component trait automatically — you never implement Component directly for leaf components. Similarly, modality-specific subtraits (WhiteboardComponent, WorksheetComponent) are blanket-implemented for any TypedComponent with matching associated types.
Step 1: Define the Properties Struct
Section titled “Step 1: Define the Properties Struct”Use #[derive(ComponentProperties)] to generate schema, extraction, and binding methods:
use modality_core::ComponentProperties;use crate::elements::Color;
#[derive(Clone, Copy, Default, modality_core::PropertyField)]pub enum BadgeColor { #[default] Blue, Green, Red, Yellow,}
#[derive(ComponentProperties)]pub struct BadgeProps { #[property(name = "Label", required, description = "Badge text")] pub label: String,
#[property(name = "Color", description = "Background color")] pub color: BadgeColor,
#[property(key = "fontSize", name = "Font Size", min = 8, max = 72, default = "16")] pub font_size: f64,}The #[property(...)] attribute supports:
key— override the property key string (defaults to field name). Use for camelCase backward compatibility.name— human-readable display namedefault— default value as a string literalmin,max— numeric constraintsmax_length— text length constraintdescription— shown in editors and AI promptsicon— icon identifierrequired,system,title— boolean flags
For enum fields, derive PropertyField to generate PropertyType::Select with lowerCamelCase option strings.
The macro generates:
BadgeProps::schema() -> Vec<PropertySchema>— property schemasBadgeProps::from_values(&HashMap<String, PropertyValue>) -> Self— typed extraction with defaultsBadgeProps::to_bindings(&self) -> HashMap<String, PropertyBinding>— for element creation in reducersBadgePropsUpdate— an all-Optionstruct withapply(self, &mut bindings)for partial updates
Step 2: Implement TypedComponent
Section titled “Step 2: Implement TypedComponent”use modality_core::TypedComponent;
pub struct BadgeComponent;
impl TypedComponent for BadgeComponent { type Properties = BadgeProps; type Metadata = WhiteboardChildMeta; type Output = Vec<WhiteboardElement>;
fn id() -> &'static str { "badge" } fn name() -> &'static str { "Badge" } fn description() -> &'static str { "Simple label with a colored background" } fn icon() -> &'static str { "label" }
fn compile( &self, props: &BadgeProps, meta: &WhiteboardChildMeta, ) -> Result<Vec<WhiteboardElement>, CompileError> { let bounds = &meta.bounds;
let background = WhiteboardElement::Shape(ShapeElement { id: next_id(), position: Offset { dx: bounds.x, dy: bounds.y }, size: Size { width: bounds.width, height: bounds.height }, color: badge_color_to_color(props.color), fill: ShapeFill::Solid, radius: 8.0, ..Default::default() });
let blocks = prose::render_prose_bytes( &prose::create_from_plain_text(&props.label), ); let text = WhiteboardElement::Text(TextElement { id: next_id(), position: Offset { dx: bounds.x + 12.0, dy: bounds.y + (bounds.height / 2.0) - (props.font_size / 2.0), }, size: Size { width: bounds.width - 24.0, height: props.font_size * 1.5, }, blocks, properties: TextBoxProperties::default(), });
Ok(vec![background, text]) }}
fn badge_color_to_color(c: BadgeColor) -> Color { match c { BadgeColor::Blue => Color(0xFF3B82F6), BadgeColor::Green => Color(0xFF22C55E), BadgeColor::Red => Color(0xFFEF4444), BadgeColor::Yellow => Color(0xFFEAB308), }}Note: compile() receives a typed &BadgeProps struct, not a HashMap. The blanket Component impl handles extraction automatically.
Step 3: Register in the Component Registry
Section titled “Step 3: Register in the Component Registry”Components use the inventory crate for static registration. Add a single line:
inventory::submit!(WhiteboardComponentEntry(&BadgeComponent));That’s it. The component is automatically discovered by resolve_component() and all_component_schemas() at runtime — no manual match arm needed.
Step 4: Use It
Section titled “Step 4: Use It”Create a placement that references your new component. In a reducer intent handler, use to_bindings():
let placement = ComponentPlacement { id: "badge-1".to_string(), component_key: "badge".to_string(), component_id: None, position: WhiteboardPosition { x: 100.0, y: 200.0, width: 150.0, height: 40.0, .. }, bindings: BadgeProps { label: "New".to_string(), color: BadgeColor::Green, font_size: 14.0, }.to_bindings(),};For partial updates in intent handlers, use the generated Update struct:
BadgePropsUpdate { color: Some(BadgeColor::Red), font_size: Some(18.0), ..Default::default()}.apply(&mut placement.bindings);How Compilation Works
Section titled “How Compilation Works”When the modality compiles, it will:
- Call
child_metadata(&placement)to get theWhiteboardChildMetafrom the position. - Resolve bindings to
HashMap<String, PropertyValue>. - Call
resolve_component("badge")to findBadgeComponentviainventory. - The blanket
Componentimpl callsBadgeProps::from_values(&values)to extract typed props. - Call
badge.compile(&props, &meta)to produceVec<WhiteboardElement>.
Testing
Section titled “Testing”Components are easy to test in isolation since compile() is a pure function:
#[test]fn test_badge_compile() { let meta = WhiteboardChildMeta { id: "b1".into(), bounds: Rect::new(0.0, 0.0, 150.0, 40.0), }; let values = HashMap::from([ ("label".into(), PropertyValue::String("Test".into())), ("color".into(), PropertyValue::String("red".into())), ]);
let result = Component::compile(&BadgeComponent, &meta, &values); assert!(result.is_ok());
let elements = result.unwrap(); assert_eq!(elements.len(), 2); // background + text}
#[test]fn test_badge_missing_label_uses_default() { let meta = WhiteboardChildMeta { id: "b2".into(), bounds: Rect::new(0.0, 0.0, 150.0, 40.0), }; // from_values always succeeds with defaults -- no MissingRequired panic let result = Component::compile(&BadgeComponent, &meta, &HashMap::new()); assert!(result.is_ok());}Next Steps
Section titled “Next Steps”- Adding an Intent — add intents that manipulate component placements
- Wiring AI — make your component AI-generatable with
Describe,Validate, andPending ComponentReference — full trait specification