Skip to content

Typed Component Properties

Replace the stringly-typed property system — where components define schemas as Vec<PropertySchema>, receive values as HashMap<String, PropertyValue>, and construct bindings via bindings.insert("key".into(), bind_xxx(val)) — with a macro-driven, strongly typed system. A #[derive(ComponentProperties)] proc macro on a properties struct generates: (1) schema metadata from struct fields, (2) typed extraction from value maps, (3) typed binding construction for reducers, and (4) a partial-update struct for property mutations. A new TypedComponent trait provides a typed compile(&self, meta) method; a blanket impl wires it to the existing Component trait with zero changes to the compilation pipeline.

A component’s properties() method returns Vec<PropertySchema> with string keys. Its compile() method extracts values from HashMap<String, PropertyValue> using the same string keys via helper functions. Nothing connects the two — adding a property to properties() doesn’t produce a compile error if compile() forgets to read it, and a typo in a key string is a silent runtime bug.

// Schema says "stroke_width"
PropertySchema { key: "stroke_width".into(), ... }
// Compile reads "stroke_widht" — no error, just returns None
let stroke_width = get_number(values, "stroke_widht").unwrap_or(2.0);

2. Massive boilerplate in schema definitions

Section titled “2. Massive boilerplate in schema definitions”

Each property requires ~7 lines of PropertySchema construction. ShapeComponent has 8 properties = 60+ lines of schema boilerplate that could be a 10-line struct definition.

3. Reducer binding construction is stringly-typed

Section titled “3. Reducer binding construction is stringly-typed”

Whiteboard element creation and property updates use 60+ bindings.insert("key".into(), bind_xxx(val)) calls in whiteboard.rs. These duplicate the same string keys from the component’s schema with no compile-time connection. A key mismatch between the reducer and the component means the property silently doesn’t apply.

// Creating a shape: 10 lines of manual binding insertion
bindings.insert("shape_type".into(), bind_string(st_str));
bindings.insert("fill".into(), bind_string(fill_str));
bindings.insert("color".into(), bind_string(&color.to_hex()));
bindings.insert("stroke_color".into(), bind_string(&color.to_hex()));
bindings.insert("stroke_width".into(), bind_number(stroke_width));
bindings.insert("start_arrow".into(), bind_string(arrow_str(&start_arrow)));
bindings.insert("end_arrow".into(), bind_string(arrow_str(&end_arrow)));
bindings.insert("radius".into(), bind_number(0.0));
bindings.insert("flipped".into(), bind_bool(flipped));

Both whiteboard and worksheet define identical helper functions (get_string, get_number, get_bool, get_select_value, get_color, get_i32, get_json_string_array) in separate helpers.rs files. These exist only because the property system is untyped.

CategoryString-keyed sitesFiles
Schema definitions (properties())48 distinct keys across 17 components17
Value extraction in compile()85+ helper calls17
Binding construction in reducers60+ insert calls3
Binding reads in reducers5+ bindings.get("key")2
Total~200 string-keyed sites~20 files

A trait that bridges Rust types to the property system. Implemented for primitive types and user enums.

pub trait PropertyField: Sized {
/// The PropertyType this field maps to (used in schema generation).
fn property_type() -> PropertyType;
/// Convert from a PropertyValue. Returns None if the value doesn't match.
fn from_property_value(value: &PropertyValue) -> Option<Self>;
/// Convert to a PropertyValue for storage in bindings.
fn to_property_value(&self) -> PropertyValue;
}

Built-in implementations:

Rust TypePropertyTypePropertyValue representation
f64Number { min: None, max: None }Number(f64)
i32Number { min: None, max: None }Number(f64)
boolBooleanBool(bool)
StringText { max_length: None }String(String)
ColorColorString("#AARRGGBB")

For enums like ShapeType, a separate #[derive(PropertyField)] macro on the enum generates the PropertyField impl with string ↔ variant mapping (see below).

Derive macro: #[derive(PropertyField)] for enums

Section titled “Derive macro: #[derive(PropertyField)] for enums”

Applied to simple enums (no fields on variants) to generate the PropertyField impl. Each variant maps to a string value — by default the lowercased variant name, overridable with #[field(value = "...")].

#[derive(PropertyField)]
enum ShapeType {
Rect, // → "rect"
Circle, // → "circle"
Triangle, // → "triangle"
Line, // → "line"
}
#[derive(PropertyField)]
enum ShapeFill {
Solid, // → "solid"
Transparent, // → "transparent"
#[field(value = "borderOnly")]
BorderOnly, // → "borderOnly" (override: not "borderonly")
}
#[derive(PropertyField)]
enum ArrowType {
None, // → "none"
Triangle, // → "triangle"
Line, // → "line"
Circle, // → "circle"
}

The macro generates for each enum:

impl PropertyField for ShapeType {
fn property_type() -> PropertyType {
PropertyType::Select {
options: vec![opt("rect"), opt("circle"), opt("triangle"), opt("line")],
multiple: false,
}
}
fn from_property_value(value: &PropertyValue) -> Option<Self> {
match value {
PropertyValue::String(s) => match s.as_str() {
"rect" => Some(Self::Rect),
"circle" => Some(Self::Circle),
"triangle" => Some(Self::Triangle),
"line" => Some(Self::Line),
_ => None,
},
_ => None,
}
}
fn to_property_value(&self) -> PropertyValue {
PropertyValue::String(match self {
Self::Rect => "rect",
Self::Circle => "circle",
Self::Triangle => "triangle",
Self::Line => "line",
}.into())
}
}

Each enum has exactly one PropertyField impl, derived on the enum itself. Multiple props structs can reference the same enum type with no conflict.

Derive macro: #[derive(ComponentProperties)]

Section titled “Derive macro: #[derive(ComponentProperties)]”

Applied to a properties struct. Generates schema, extraction, binding construction, and an update struct. The macro uses <T as PropertyField>::property_type() for each field to determine the PropertyType in the schema, and PropertyField::from_property_value() / to_property_value() for conversion. The #[property] attribute provides metadata overrides (display name, constraints, defaults).

#[derive(ComponentProperties)]
struct ShapeProps {
#[property(name = "Shape", default = "rect")]
shape_type: ShapeType,
#[property(name = "Fill", default = "solid")]
fill: ShapeFill,
#[property(name = "Color")]
color: Color,
#[property(name = "Stroke Color")]
stroke_color: Color,
#[property(name = "Stroke Width", min = 0.0, max = 50.0, default = 2.0)]
stroke_width: f64,
#[property(name = "Start Arrow", default = "none")]
start_arrow: ArrowType,
#[property(name = "End Arrow", default = "none")]
end_arrow: ArrowType,
#[property(name = "Rotation", min = 0.0, max = 360.0, default = 0.0)]
rotation: f64,
#[property(name = "Radius", default = 0.0)]
radius: f64,
#[property(name = "Flipped", default = false)]
flipped: bool,
}

The macro generates:

pub trait ComponentProperties: Sized {
/// Property schemas for this component.
fn schema() -> Vec<PropertySchema>;
/// Construct from resolved property values. Missing keys use defaults.
fn from_values(values: &HashMap<String, PropertyValue>) -> Self;
/// Convert all fields to a bindings map for element creation.
fn to_bindings(&self) -> HashMap<String, PropertyBinding>;
}

For ShapeProps, the generated schema() produces the same Vec<PropertySchema> that ShapeComponent::properties() currently returns — but derived from the struct fields and #[property] attributes.

The generated from_values() replaces all get_string/get_number/get_select_value helper calls:

// Generated — all fields use PropertyField trait for conversion
impl ComponentProperties for ShapeProps {
fn from_values(values: &HashMap<String, PropertyValue>) -> Self {
Self {
shape_type: values.get("shape_type")
.and_then(PropertyField::from_property_value)
.unwrap_or_else(|| PropertyField::from_property_value(
&PropertyValue::String("rect".into()) // from default = "rect"
).unwrap()),
color: values.get("color")
.and_then(PropertyField::from_property_value)
.unwrap_or_default(),
stroke_width: values.get("stroke_width")
.and_then(PropertyField::from_property_value)
.unwrap_or(2.0), // from default = 2.0
// ...
}
}
}

The generated to_bindings() replaces all manual bindings.insert(...) calls:

// Generated
fn to_bindings(&self) -> HashMap<String, PropertyBinding> {
let mut m = HashMap::new();
m.insert("shape_type".into(), PropertyBinding::Static {
value: self.shape_type.to_property_value()
});
m.insert("color".into(), PropertyBinding::Static {
value: self.color.to_property_value()
});
// ...
m
}

The macro generates a companion ShapePropsUpdate struct with Option<T> fields:

// Generated
#[derive(Default)]
pub struct ShapePropsUpdate {
pub shape_type: Option<ShapeType>,
pub fill: Option<ShapeFill>,
pub color: Option<Color>,
pub stroke_color: Option<Color>,
pub stroke_width: Option<f64>,
pub start_arrow: Option<ArrowType>,
pub end_arrow: Option<ArrowType>,
pub rotation: Option<f64>,
pub radius: Option<f64>,
pub flipped: Option<bool>,
}
impl ShapePropsUpdate {
pub fn apply(self, bindings: &mut HashMap<String, PropertyBinding>) {
if let Some(v) = self.shape_type {
bindings.insert("shape_type".into(), PropertyBinding::Static {
value: v.to_property_value()
});
}
if let Some(v) = self.color {
bindings.insert("color".into(), PropertyBinding::Static {
value: v.to_property_value()
});
}
// ... one arm per field
}
}

TypedComponent trait + blanket Component impl

Section titled “TypedComponent trait + blanket Component impl”

A new trait that components implement instead of Component directly:

pub trait TypedComponent: Send + Sync + 'static {
type Properties: ComponentProperties;
type Metadata: Hash;
type Output: Clone;
/// Component identity metadata.
fn id() -> &'static str;
fn name() -> &'static str;
fn description() -> &'static str;
fn icon() -> &'static str;
/// Typed compile — receives the extracted properties struct, not a HashMap.
fn compile(&self, props: &Self::Properties, metadata: &Self::Metadata) -> Result<Self::Output, CompileError>;
}

Blanket impl wires it to the existing Component trait:

impl<T: TypedComponent> Component for T {
type Metadata = T::Metadata;
type Output = T::Output;
fn properties(&self) -> Vec<PropertySchema> {
T::Properties::schema()
}
fn compile(
&self,
metadata: &Self::Metadata,
values: &HashMap<String, PropertyValue>,
) -> Result<Self::Output, CompileError> {
let props = T::Properties::from_values(values);
TypedComponent::compile(self, &props, metadata)
}
}

The Component trait, Modality trait, compilation pipeline, caching, Slot, and all generic machinery are completely untouched. The blanket impl bridges the typed world to the existing untyped pipeline.

Before (current):

pub struct ShapeComponent;
impl Component for ShapeComponent {
type Metadata = WhiteboardChildMeta;
type Output = Vec<WhiteboardElement>;
fn properties(&self) -> Vec<PropertySchema> {
vec![
PropertySchema {
key: "shape_type".into(), name: "Shape".into(),
property_type: PropertyType::Select {
options: vec![opt("rect"), opt("circle"), opt("triangle"), opt("line")],
multiple: false,
},
default_value: Some(PropertyValue::String("rect".into())),
..Default::default()
},
// ... 7 more PropertySchema blocks (~60 lines)
]
}
fn compile(
&self,
meta: &WhiteboardChildMeta,
values: &HashMap<String, PropertyValue>,
) -> Result<Vec<WhiteboardElement>, CompileError> {
let shape_type = match get_select_value(values, "shape_type").as_deref() {
Some("circle") => ShapeType::Circle,
Some("triangle") => ShapeType::Triangle,
Some("line") => ShapeType::Line,
_ => ShapeType::Rect,
};
let fill = match get_select_value(values, "fill").as_deref() { ... };
let color = get_color(values, "color").unwrap_or_default();
let stroke_color = get_color(values, "stroke_color").unwrap_or_default();
let stroke_width = get_number(values, "stroke_width").unwrap_or(2.0);
let start_arrow = match get_select_value(values, "start_arrow").as_deref() { ... };
let end_arrow = match get_select_value(values, "end_arrow").as_deref() { ... };
let rotation = get_number(values, "rotation").unwrap_or(0.0);
let radius = get_number(values, "radius").unwrap_or(0.0);
let flipped = get_bool(values, "flipped").unwrap_or(false);
Ok(vec![WhiteboardElement::Shape(ShapeElement {
id: meta.id.clone(),
position: Offset { dx: meta.bounds.x, dy: meta.bounds.y },
size: Size { width: meta.bounds.width, height: meta.bounds.height },
shape_type, fill, color, stroke_color, stroke_width,
start_arrow, end_arrow, rotation, radius, flipped,
})])
}
}
impl WhiteboardComponent for ShapeComponent {
fn id(&self) -> &'static str { "shape" }
fn name(&self) -> &'static str { "Shape" }
fn description(&self) -> &'static str { "Geometric shape..." }
fn icon(&self) -> &'static str { "shape_line" }
}

After:

#[derive(ComponentProperties)]
struct ShapeProps {
#[property(name = "Shape", default = "rect")]
shape_type: ShapeType,
#[property(name = "Fill", default = "solid")]
fill: ShapeFill,
#[property(name = "Color")]
color: Color,
#[property(name = "Stroke Color")]
stroke_color: Color,
#[property(name = "Stroke Width", min = 0.0, max = 50.0, default = 2.0)]
stroke_width: f64,
#[property(name = "Start Arrow", default = "none")]
start_arrow: ArrowType,
#[property(name = "End Arrow", default = "none")]
end_arrow: ArrowType,
#[property(name = "Rotation", min = 0.0, max = 360.0, default = 0.0)]
rotation: f64,
#[property(name = "Radius", default = 0.0)]
radius: f64,
#[property(name = "Flipped", default = false)]
flipped: bool,
}
pub struct ShapeComponent;
inventory::submit!(WhiteboardComponentEntry(&ShapeComponent));
impl TypedComponent for ShapeComponent {
type Properties = ShapeProps;
type Metadata = WhiteboardChildMeta;
type Output = Vec<WhiteboardElement>;
fn id() -> &'static str { "shape" }
fn name() -> &'static str { "Shape" }
fn description() -> &'static str { "Geometric shape: rectangle, circle, triangle, or line." }
fn icon() -> &'static str { "shape_line" }
fn compile(&self, props: &ShapeProps, meta: &WhiteboardChildMeta) -> Result<Vec<WhiteboardElement>, CompileError> {
Ok(vec![WhiteboardElement::Shape(ShapeElement {
id: meta.id.clone(),
position: Offset { dx: meta.bounds.x, dy: meta.bounds.y },
size: Size { width: meta.bounds.width, height: meta.bounds.height },
shape_type: props.shape_type,
fill: props.fill,
color: props.color,
stroke_color: props.stroke_color,
stroke_width: props.stroke_width,
start_arrow: props.start_arrow,
end_arrow: props.end_arrow,
rotation: props.rotation,
radius: props.radius,
flipped: props.flipped,
})])
}
}

Before/After: Reducer binding construction

Section titled “Before/After: Reducer binding construction”

Before (whiteboard.rs, AddShape handler):

let mut bindings = HashMap::new();
bindings.insert("shape_type".into(), bind_string(st_str));
bindings.insert("fill".into(), bind_string(fill_str));
bindings.insert("color".into(), bind_string(&color.to_hex()));
bindings.insert("stroke_color".into(), bind_string(&color.to_hex()));
bindings.insert("stroke_width".into(), bind_number(stroke_width));
bindings.insert("start_arrow".into(), bind_string(arrow_str(&start_arrow)));
bindings.insert("end_arrow".into(), bind_string(arrow_str(&end_arrow)));
bindings.insert("radius".into(), bind_number(0.0));
bindings.insert("flipped".into(), bind_bool(flipped));

After:

let bindings = ShapeProps {
shape_type, fill, color,
stroke_color: color,
stroke_width,
start_arrow, end_arrow,
rotation: 0.0, radius: 0.0, flipped,
}.to_bindings();

Before (whiteboard.rs, UpdateShapeProperties handler):

if let Some(v) = shape_type {
let s = serde_json::to_value(&v).unwrap().as_str().unwrap().to_string();
p.bindings.insert("shape_type".into(), bind_string(s));
}
if let Some(v) = fill {
let s = serde_json::to_value(&v).unwrap().as_str().unwrap().to_string();
p.bindings.insert("fill".into(), bind_string(s));
}
if let Some(v) = color { p.bindings.insert("color".into(), bind_string(&v.to_hex())); }
if let Some(v) = stroke_color { p.bindings.insert("stroke_color".into(), bind_string(&v.to_hex())); }
if let Some(v) = stroke_width { p.bindings.insert("stroke_width".into(), bind_number(v)); }
if let Some(v) = start_arrow { ... }
if let Some(v) = end_arrow { ... }
if let Some(v) = rotation { p.bindings.insert("rotation".into(), bind_number(v)); }
if let Some(v) = radius { p.bindings.insert("radius".into(), bind_number(v)); }
if let Some(v) = flipped { p.bindings.insert("flipped".into(), bind_bool(v)); }

After:

ShapePropsUpdate {
shape_type, fill, color, stroke_color,
stroke_width, start_arrow, end_arrow,
rotation, radius, flipped,
}.apply(&mut p.bindings);
AttributeTypeEffect
name = "Display Name"StringSets PropertySchema.name
default = valueliteralSets PropertySchema.default_value and from_values() fallback
min = 0.0f64Sets PropertyType::Number { min: Some(v), .. }
max = 100.0f64Sets PropertyType::Number { .., max: Some(v) }
max_length = 500usizeSets PropertyType::Text { max_length: Some(v) }
description = "..."StringSets PropertySchema.description
icon = "..."StringSets PropertySchema.icon
systemflagSets PropertySchema.is_system = true
titleflagSets PropertySchema.is_title = true
toolbar = "hidden"StringSets PropertySchema.toolbar = ToolbarHint::Hidden (the Schema-Driven Toolbar RFC)
requiredflagSets PropertySchema.required = true

Interaction with per-modality component traits

Section titled “Interaction with per-modality component traits”

Currently, whiteboard and worksheet have modality-specific traits:

pub trait WhiteboardComponent: Component<Metadata = WhiteboardChildMeta, Output = Vec<WhiteboardElement>> {
fn id(&self) -> &'static str;
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn icon(&self) -> &'static str;
}

WhiteboardComponent is used as a trait object (&'static dyn WhiteboardComponent) in the inventory registry. TypedComponent has static methods (fn id() -> &'static str) which are not object-safe, so WhiteboardComponent cannot simply become a supertrait bound on TypedComponent.

Instead, WhiteboardComponent keeps its &self methods and gets a blanket impl that delegates from TypedComponent’s static methods:

// WhiteboardComponent keeps its &self methods for trait-object compatibility
pub trait WhiteboardComponent: Component<Metadata = WhiteboardChildMeta, Output = Vec<WhiteboardElement>> {
fn id(&self) -> &'static str;
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn icon(&self) -> &'static str;
}
// Blanket impl: any TypedComponent with matching types is automatically a WhiteboardComponent
impl<T> WhiteboardComponent for T
where T: TypedComponent<Metadata = WhiteboardChildMeta, Output = Vec<WhiteboardElement>>
{
fn id(&self) -> &'static str { T::id() }
fn name(&self) -> &'static str { T::name() }
fn description(&self) -> &'static str { T::description() }
fn icon(&self) -> &'static str { T::icon() }
}

The inventory registration pattern is unchanged — ShapeComponent is still a unit struct submitted via inventory::submit!. The &'static dyn WhiteboardComponent trait object works because WhiteboardComponent only has &self methods.

the Typed Element Components RFC (Typed Element Components) — this is the direct successor. the Typed Element Components RFC established per-type components with individual properties. This RFC makes those properties compile-time typed.

the Schema-Driven Toolbar RFC (Schema-Driven Toolbar) — complementary. The #[property] attributes can include toolbar hints (toolbar = "hidden"). The macro generates the schemas that the toolbar consumes. No conflict.

  • Modality impls (Whiteboard, Worksheet, LessonPlan) — these orchestrate child compilation, they don’t have property structs. They continue implementing Component directly.
  • Slot<C, O> — generic container, delegates to inner session. No properties.
  • ComponentPlacement.bindings — remains HashMap<String, PropertyBinding>. The typed layer converts to/from this at the boundary. Bindings must stay dynamic because PropertyBinding::Reference resolves at compile time from parent template values.
  • resolve_bindings() — unchanged. Still produces HashMap<String, PropertyValue> from placement bindings + parent values. The typed from_values() consumes this output.
  • Incremental cache — unchanged. Still hashes the resolved HashMap<String, PropertyValue>.

The ComponentProperties derive macro lives in the existing crates/macros/ crate alongside CommandTool. It depends on modality_core types (PropertySchema, PropertyType, PropertyValue, PropertyBinding). The PropertyField trait and ComponentProperties trait live in modality_core (no macro dependency).

  1. Define core traits — add PropertyField, ComponentProperties, and TypedComponent to modality_core in a new template/typed_component.rs module. Add built-in PropertyField impls for f64, i32, bool, String. Add blanket impl<T: TypedComponent> Component for T. Verify: cargo check --workspace

  2. Implement #[derive(PropertyField)] — add to crates/macros/. Generates PropertyField impl for simple enums with #[field(value = "...")] attribute for string overrides. Verify: unit tests on the macro output

  3. Implement #[derive(ComponentProperties)] — add to crates/macros/. Generates ComponentProperties impl (schema(), from_values(), to_bindings()) and update struct. Uses PropertyField trait bounds on all fields. Verify: unit tests on the macro output

  4. Add PropertyField impls for whiteboard enums — derive PropertyField on ShapeType, ShapeFill, ArrowType, TextFontFamily, TextAlign, FileElementType, UrlType, GeogebraType. Add manual PropertyField impl for Color. Verify: cargo check -p modality-whiteboard

  5. Migrate ShapeComponent — first test case. Convert to ShapeProps + TypedComponent. Verify: existing tests pass unchanged (cargo test -p modality-whiteboard)

  6. Migrate remaining whiteboard componentsPathComponent, TextComponent, TextBoxComponent, CardComponent, ListComponent, FileComponent, UrlComponent, GeogebraComponent, MaskComponent. Verify: cargo test -p modality-whiteboard

  7. Update WhiteboardComponent trait — add blanket impl from TypedComponent, remove manual impl WhiteboardComponent blocks. Delete whiteboard helpers.rs. Verify: cargo test -p modality-whiteboard

  8. Migrate whiteboard reducer — replace bindings.insert(...) calls with to_bindings() and PropsUpdate::apply(). Delete bind_string, bind_number, bind_bool, bind_json helpers. Verify: cargo test -p modality-whiteboard

  9. Migrate worksheet components — derive PropertyField on worksheet enums. Convert all 6 components. Update WorksheetComponent trait. Delete worksheet helpers.rs. Verify: cargo test -p modality-worksheet

  10. Migrate assessment components — convert MultiChoiceComponent, ShortAnswerComponent. Verify: cargo test -p modality-assessment

  11. Clean up — remove dead code, unused imports. Final: cargo test --workspace

Replace &HashMap<String, PropertyValue> with a generic &Self::Properties in the Component trait itself. Rejected because the HashMap signature is threaded through 24 implementations, the Modality trait (3 methods), Session recompilation, Slot<C,O>, FRB APIs, and 50+ test calls — ~40-50 files, 200+ change points. The blanket impl achieves the same result with zero changes to the pipeline.

Put fields directly on ShapeComponent instead of a separate ShapeProps. Rejected because components are registered as &'static unit structs via inventory::submit! — adding fields would require constructing them at static scope. A separate props struct is constructed at compile time from resolved values.

Specify enum options in #[property(select = [...])] instead of #[derive(PropertyField)]

Section titled “Specify enum options in #[property(select = [...])] instead of #[derive(PropertyField)]”

Keep the string ↔ variant mapping on the props struct field rather than on the enum itself (e.g., #[property(select = ["rect", "circle"])]). Rejected because the macro cannot inspect the enum type’s variants from the struct derive — it only sees the type name. This would require either convention-based variant-to-string mapping (fragile) or explicit "string" => Variant syntax in the attribute (verbose). More importantly, if the same enum type appears in multiple props structs, the macro would try to generate duplicate PropertyField impls. #[derive(PropertyField)] on the enum gives exactly one impl per type, and the #[field(value = "...")] attribute handles cases where the string differs from the lowercased variant name.

Have components implement both the HashMap-based compile() and a typed compile_typed(), with a default impl of compile() that calls compile_typed(). Rejected because there’s no reason to implement both — the blanket impl handles the conversion automatically. Components only implement TypedComponent::compile().

Naming: ShapePropsUpdate vs ShapePropsPartial vs ShapePropsOverride

Section titled “Naming: ShapePropsUpdate vs ShapePropsPartial vs ShapePropsOverride”

The auto-generated Option<T> struct for partial updates. Update is most descriptive of its purpose. Open to alternatives.

Interaction with PropertyBinding::Reference

Section titled “Interaction with PropertyBinding::Reference”

The typed system handles Static bindings. Reference bindings are resolved by resolve_bindings() before reaching the component. The props struct only ever sees resolved values. However, could to_bindings() support generating Reference bindings for template authoring? This can be a follow-up — current scope is Static only.