Component Coercion
Summary
Section titled “Summary”Component coercion lets any component be swapped for another compatible component. Compatibility is determined by property schema overlap: two components are coercible if they share at least one property key with matching type, and have zero property keys with conflicting types (same name, different type).
Coercion is lossless — all property values are preserved in the binding bag. Properties that the target component doesn’t use simply remain dormant, ready for a future coercion back. No properties are ever dropped, renamed, or transformed.
A default Coercible implementation computes targets from the component registry via schema comparison. Components (including Layout) can override this to constrain the target set.
This RFC also redesigns LayoutType as a shape-only definition (slots + grid options, no component references). Both homogeneous and heterogeneous layouts reference a LayoutType by ID. Homogeneous coercion swaps the component (layout shape stays); heterogeneous layouts coerce individual children.
Depends on Typed Element Components for per-component property schemas. Depends on Typed Component Properties for the typed schema definitions. Extends Layout Abstraction with layout-specific coercion semantics.
Motivation
Section titled “Motivation”-
No component substitution — A user placing a PieGraph can’t switch to a BarGraph without deleting and recreating the element, losing all property values. Components that represent similar data should be interchangeable.
-
Layout type changes are destructive — Changing a homogeneous layout from “card” to “question” requires removing all children and re-adding them. There’s no way to express “same arrangement, different component.”
-
Property naming is unconstrained — Without a coercion system that relies on schema overlap, there’s no incentive for consistent property naming across components. Coercion makes naming conventions load-bearing, which drives alignment.
-
AI can’t explore alternatives — If AI generates a layout with cards, it can’t suggest “try questions instead” without understanding which components are interchangeable. Coercion gives AI a vocabulary for substitution.
Design
Section titled “Design”Coercion rule
Section titled “Coercion rule”Two components A and B are coercible if and only if:
- Non-zero overlap — at least one property key exists in both A’s schema and B’s schema with the same
PropertyType - Zero conflicts — no property key exists in both schemas with different
PropertyTypes
If any shared key has a type mismatch, the components are not coercible regardless of how many keys match. A single conflict poisons the relationship.
Property transfer
Section titled “Property transfer”When coercing component A to component B:
- All property values in the binding bag are preserved as-is
- Properties that B reads will take effect immediately
- Properties that B doesn’t read remain in the bag, unused but not deleted
- If the user coerces back to A, those properties are still there
This means the binding bag is a universal accumulator — it grows over a placement’s lifetime and never shrinks from coercion. The HashMap<String, PropertyValue> already supports this naturally.
Coercible trait
Section titled “Coercible trait”/// Describes what a component can be coerced to.pub struct CoercionTarget { /// Target component ID. pub component_id: String, /// Property keys that overlap (same name + same type). pub shared_keys: Vec<String>,}
/// A registered component's identity + schema for coercion lookup.pub struct ComponentEntry { pub component_id: String, pub schema: Vec<PropertySchema>,}
/// Trait for components that support coercion.pub trait Coercible { /// Return the list of components this can be coerced to. /// /// Default implementation computes targets from schema overlap /// against all registered components. fn coercion_targets(&self, registry: &[ComponentEntry]) -> Vec<CoercionTarget>;}Component has associated types (Metadata, Output) so it’s not object-safe. Instead of &[&dyn Component], coercion takes &[ComponentEntry] — a pre-built list of (id, schema) pairs from inventory::collect!. Each modality builds this list once at startup from its registered components’ properties().
The default implementation iterates entries, calls compute_overlap() on each, and returns those satisfying the coercion rule. Components override to narrow the list — for example, a homogeneous Layout constrains targets to components coercible from its component_id.
Snapshot-driven coercion targets
Section titled “Snapshot-driven coercion targets”Coercion targets are pre-computed in the snapshot, not queried by the UI at runtime. The flow:
- Selection/focus changes (via
Selectable/Focusablefrom Selection Trait RFC) - Snapshot builder identifies the focused element(s) and calls
coercion_targets()for them - Snapshot includes
coercion_targets: Vec<CoercionTarget>alongside the element data - Dart UI reads the snapshot and renders available swap options in the toolbar
- User picks a target → dispatches
CoercionIntent
This matches the existing pattern: the snapshot is the single source of truth for the UI. Dart never queries Rust for derived state — everything is pre-computed at snapshot time.
TODO: Consider how coercion targets interact with the toolbar trait (Schema-Driven Toolbar). The toolbar already auto-generates controls from property schemas; coercion targets could be a toolbar section generated from the
Coercibletrait in the same way. The toolbar trait may need acoercion_section()method or similar integration point.
Schema source
Section titled “Schema source”Each modality builds a Vec<ComponentEntry> at startup from its inventory::collect! entries, calling properties() on each to capture the schema. This list is passed to coercion_targets(). No separate registry trait — just a flat list of (id, schema) pairs.
Schema overlap computation
Section titled “Schema overlap computation”fn compute_overlap( source: &[PropertySchema], target: &[PropertySchema],) -> Option<Vec<String>> { let mut shared = Vec::new(); for s in source { if let Some(t) = target.iter().find(|t| t.key == s.key) { if s.property_type != t.property_type { // Type conflict — not coercible at all return None; } shared.push(s.key.clone()); } } if shared.is_empty() { None // No overlap } else { Some(shared) }}Returns None if not coercible (zero overlap or any conflict), Some(keys) if coercible.
Property naming conventions
Section titled “Property naming conventions”Coercion makes property naming load-bearing. If Card uses title and Question uses title, they must mean the same thing. Conventions:
| Key | Type | Semantics |
|---|---|---|
title | String | Primary display heading |
content | String | Body text / markdown |
data | List | Structured data (charts, tables) |
color | String | Primary color (ARGB hex) |
image_url | String | Image source URL |
label | String | Short label / caption |
This table is not exhaustive — it evolves as components are added. The convention is: if two components use the same key, they agree on its semantics and type. Coercion enforces this at the type level; naming discipline is a development convention.
LayoutType redesign
Section titled “LayoutType redesign”LayoutType becomes shape-only — it defines the grid structure without referencing specific components:
/// A named layout type definition — shape only, no component references.pub struct LayoutType { /// Unique identifier (e.g. "single", "two_column", "grid_3x2"). pub id: String, /// Human-readable name. pub name: String, /// Slot definitions (shape of each position). pub slots: Vec<LayoutSlot>, /// Grid configuration for this layout type. pub grid_options: GridOptions,}
/// A slot defines the shape of a position — no component reference.pub struct LayoutSlot { /// Column span for this slot. pub col_span: i32, /// Row span for this slot. pub row_span: i32,}Both homogeneous and heterogeneous layouts reference a LayoutType:
/// Layout configuration — always references a LayoutType.pub struct LayoutConfig { /// The layout type that defines the grid shape. pub layout_type_id: String, /// For homogeneous layouts: the component all children use. /// None means heterogeneous (each child specifies its own). pub component_id: Option<String>,}LayoutKind (the old enum) is replaced by LayoutConfig. Homogeneous vs heterogeneous is determined by component_id: Some = homogeneous, None = heterogeneous.
Layout coercion semantics
Section titled “Layout coercion semantics”Layout components override Coercible to constrain coercion:
Homogeneous layout coercion — changing the component:
- All N children swap from component A to component B
- Layout shape (grid options, slot spans) stays identical
- Targets constrained to components coercible from the current
component_id - Implementation: update
LayoutConfig.component_idand each child’scomponent_id
Heterogeneous layouts don’t support component coercion (each child has its own component — coerce individual children instead).
Grid shape changes (slot count, column layout, spans) are handled by LayoutIntent, not coercion.
LayoutType inventory
Section titled “LayoutType inventory”Layout types can be registered via inventory::submit! like whiteboard components:
pub struct LayoutTypeEntry(pub &'static LayoutType);inventory::collect!(LayoutTypeEntry);
inventory::submit!(LayoutTypeEntry(&LayoutType { id: "two_column", name: "Two Column", slots: &[ LayoutSlot { col_span: 1, row_span: 1 }, LayoutSlot { col_span: 1, row_span: 1 }, ], grid_options: GridOptions { columns: Some(2), ..GridOptions::DEFAULT },}));Or they can be loaded dynamically from a configuration source.
Coercion intent
Section titled “Coercion intent”/// Coerce a component placement to a different component.pub struct CoercionIntent { /// The placement to coerce. pub placement_id: String, /// The target component to swap to. pub target_component_id: String,}The reducer validates coercibility before applying. If the target isn’t coercible (conflict or no overlap), it returns an error.
Grid shape changes (e.g. 2-column → 3-column) are not coercion — they’re layout configuration handled by LayoutIntent::SetGridOptions or similar. Coercion is strictly about schema-overlap component swaps.
Alternatives
Section titled “Alternatives”Manual coercion mappings
Section titled “Manual coercion mappings”Instead of schema-overlap discovery, explicitly declare which components can coerce to which. Rejected because it doesn’t scale — every new component would need manual mapping entries.
Property migration on coercion
Section titled “Property migration on coercion”Transform or rename properties during coercion (e.g. label → title). Rejected because it introduces complexity and makes coercion lossy in spirit. If two components mean the same thing by different names, the fix is renaming the property to align, not adding migration logic.
Drop unused properties on coercion
Section titled “Drop unused properties on coercion”Delete property values that the target doesn’t use. Rejected because it makes coercion destructive — you can’t coerce back without losing data.
Keep LayoutKind as an enum
Section titled “Keep LayoutKind as an enum”Keep Homogeneous { component_id } and Heterogeneous { layout_type_id } as separate variants. Rejected because both should reference a LayoutType — homogeneous layouts also have a grid shape. The enum creates an artificial distinction where a config struct is cleaner.
Unresolved Questions
Section titled “Unresolved Questions”-
Should coercion targets be cached? Schema comparison against the full registry on every query could be expensive. A pre-computed coercion graph (built at startup) would be O(1) lookup.
-
LayoutType registration — should layout types always use
inventory(compile-time), or should there be a runtime registration API for dynamically loaded types? -
Cross-modality coercion — if a whiteboard component and a worksheet component share the same property schema, should they be coercible? This matters for the whiteboard-to-worksheet bridge mentioned in the Layout Abstraction RFC.
-
Minimum overlap threshold — the current rule is “any non-zero overlap with zero conflicts.” Should there be a higher bar (e.g. majority of source properties must overlap) to avoid spurious coercion targets?