Skip to content

Modality

Modality is the top-level trait that ties everything together. It is a supertrait of both Reducer and Component, adding layout orchestration, child management, and snapshot emission. Each modality struct (Whiteboard, Worksheet, LessonPlan) implements all three traits.

crates/core/src/template/modality.rs
pub trait Modality: Reducer<State = State<Self::Synced, Self::Ephemeral>> + Component {
type Synced: Synced + HasResourceMeta + HasValues;
type Ephemeral: Default + Send + Sync + 'static
+ HasOutput<Output = <Self as Component>::Output>; // NO Clone
type Position: Clone + PartialEq + Hash + Send + Sync + 'static;
type ChildComponent: Component + ?Sized;
type Snapshot: Clone + Send + Sync + 'static;
fn new(synced: Self::Synced, services: Arc<Self::Services>, doc: &LoroDoc) -> Self;
fn placements(&self) -> &[ComponentPlacement<Self::Position>];
fn metadata(&self) -> <Self as Component>::Metadata;
fn child_metadata(
&self,
placement: &ComponentPlacement<Self::Position>,
) -> <Self::ChildComponent as Component>::Metadata;
fn compile_child_hash(
&self,
placement: &ComponentPlacement<Self::Position>,
meta: &<Self::ChildComponent as Component>::Metadata,
values: &HashMap<String, PropertyValue>,
) -> u64
where
<Self::ChildComponent as Component>::Metadata: Hash,
{
default_compile_child_hash(&placement.id, meta, values)
}
fn compile_child(
&self,
placement: &ComponentPlacement<Self::Position>,
meta: &<Self::ChildComponent as Component>::Metadata,
values: &HashMap<String, PropertyValue>,
) -> Result<<Self::ChildComponent as Component>::Output, CompileError>;
fn assemble(
&self,
pairs: Vec<(
ComponentPlacement<Self::Position>,
<Self::ChildComponent as Component>::Output,
)>,
) -> <Self as Component>::Output;
/// Called after synced state is restored from undo. Fix up local ephemeral state.
fn on_undo(&mut self) {}
/// Called after synced state is restored from redo. Fix up local ephemeral state.
fn on_redo(&mut self) {}
fn snapshot(
&self,
ai: &AiState,
export: &ExportState,
version: u64,
can_undo: bool,
can_redo: bool,
) -> Self::Snapshot;
fn export_bytes(output: &<Self as Component>::Output, format: OutputFormat)
-> Result<Vec<u8>, String> { Err("Export not supported".into()) }
fn system_prompt(&self) -> String { String::new() }
fn agent_config(&self) -> AgentConfig { AgentConfig::default() }
fn layout<F>(
&self,
compile_child: F,
values: &HashMap<String, PropertyValue>,
) -> Result<
Vec<(
ComponentPlacement<Self::Position>,
<Self::ChildComponent as Component>::Output,
)>,
CompileError,
>
where
F: FnMut(
&ComponentPlacement<Self::Position>,
&<Self::ChildComponent as Component>::Metadata,
&HashMap<String, PropertyValue>,
) -> Result<<Self::ChildComponent as Component>::Output, CompileError>;
}
TypeBoundPurpose
SyncedSynced + HasResourceMeta + HasValuesPersisted state, stored in LoroDoc. HasValues provides property value access for binding resolution.
EphemeralDefault + Send + Sync + 'static + HasOutputRuntime-only state. HasOutput stores latest compiled output. No Clone — allows non-clonable children like Session<Whiteboard>.
PositionClone + PartialEq + Hash + Send + Sync + 'staticModality-specific spatial info. Hash enables cache keys.
ChildComponentComponent + ?SizedThe child component type. ?Sized allows trait objects (dyn WhiteboardComponent).
SnapshotClone + Send + Sync + 'staticDTO pushed to sinks. Each modality defines its own concrete type.

Modality pins the Reducer state type to State<Self::Synced, Self::Ephemeral>. The Synced/Ephemeral split is a Modality concern, not a Reducer concern. state() and state_mut() accessors live on Reducer and are inherited through the supertrait.

Clone is not required on Ephemeral. This is deliberate — LessonPlanEphemeral holds HashMap<String, Slot<Session<Whiteboard, ...>, ...>>, and Session is not clonable. Snapshots are built via snapshot() which takes references, not clones.

compile() (from Component) drives a top-down pipeline through layout() and assemble():

flowchart TD
A["compile(metadata, values)"] --> B["layout(compile_child, values)"]
B --> C["for each placement"]
C --> D["child_metadata(placement)"]
D --> E["resolve_bindings(bindings, values)"]
E --> F["compile_child(placement, meta, resolved)"]
F --> G["collect pairs"]
G --> H["assemble(pairs)"]
H --> I["Output"]
style A fill:#f0f0f0,stroke:#333
style I fill:#f0f0f0,stroke:#333
  1. compile(metadata, values) — entry point from Component trait. Calls layout() with a closure, then assemble().
  2. layout(compile_child, values) — iterates placements(), resolves bindings, delegates to the compile_child closure. Has a default implementation that can be overridden.
  3. child_metadata(placement) — computes the metadata for a child based on its placement.
  4. compile_child(placement, meta, values) — compiles a single child component.
  5. assemble(pairs) — combines compiled child outputs into the modality’s final output.
crates/core/src/template/modality.rs [127:153]
fn layout<F>(&self, mut compile_child: F, values: &HashMap<String, PropertyValue>)
-> Result<Vec<(ComponentPlacement<Self::Position>, ChildOutput)>, CompileError>
where
F: FnMut(&ComponentPlacement<Self::Position>, &ChildMeta, &HashMap<String, PropertyValue>)
-> Result<ChildOutput, CompileError>,
{
self.placements().iter()
.map(|p| {
let meta = self.child_metadata(p);
let resolved = resolve_bindings(&p.bindings, values);
compile_child(p, &meta, &resolved).map(|out| (p.clone(), out))
})
.collect()
}

Modalities override layout() for custom behavior. For example, LessonPlan filters out disabled slides before compilation.

Compilation flows recursively through the modality tree:

flowchart LR
A["compile()"] --> B["layout()"] --> C["compile_child closure"]
C --> D["child compile()"] --> E["child layout()"] --> F["..."]

Session wraps the compile_child closure with hash-based caching for incremental recompilation. The hash is computed by compile_child_hash(), which modalities can override when compile output depends on state beyond metadata and property values (e.g. Whiteboard includes LoroText content for text placements). The default implementation hashes (placement_id, metadata, values) via the free function default_compile_child_hash().

MethodPurpose
new(synced, services, doc)Construct from synced state, shared services, and LoroDoc reference.
placements()Return all component placements in the synced state.
metadata()Return the modality’s own Component metadata.
child_metadata(placement)Compute the metadata for a specific child placement.
compile_child(placement, meta, values)Compile a single child.
assemble(pairs)Combine compiled children into final output.
on_undo()Hook called after synced state is restored from undo. Fix up ephemeral state. Default no-op.
on_redo()Hook called after synced state is restored from redo. Fix up ephemeral state. Default no-op.
snapshot(ai, export, version, can_undo, can_redo)Build a snapshot DTO from current state. Undo availability passed from Session.
export_bytes(output, format)Export compiled output to bytes. Default returns error.
system_prompt()AI system prompt built from current state. Default returns empty.
agent_config()Agent configuration defaults (temperature, model, preamble).
layout(compile_child, values)Orchestrate child compilation with a provided FnMut closure.
ModalityMetadataOutputChildComponentChild MetadataSession Type
WhiteboardWhiteboardMetadataVec<WhiteboardElement>dyn WhiteboardComponentWhiteboardChildMetaSession<Whiteboard, SessionIntent<WhiteboardIntent>>
WorksheetWorksheetMetadataVec<WorksheetElement>dyn WorksheetComponent()Session<Worksheet, SessionIntent<WorksheetIntent>>
LessonPlanLessonPlanMetadataVec<CompiledSlide>Slot<Session<Whiteboard, ...>, Vec<WhiteboardElement>>WhiteboardMetadataSession<LessonPlan, ParentSessionIntent<...>>
pub struct Whiteboard {
rt: Runtime<Self>,
}
pub struct Worksheet {
rt: Runtime<Self>,
}
pub struct LessonPlan {
rt: Runtime<Self>,
children: HashMap<String, Slot<Session<Whiteboard>, Vec<WhiteboardElement>>>,
}

All modality structs embed Runtime<Self>, which holds State<M::Synced, M::Ephemeral> inside rt.state. Session<Whiteboard> uses the default type parameter I = SessionIntent<WhiteboardIntent>.

Note that LessonPlan holds children directly. Its ChildComponent type is Slot, which implements Component by delegating to Hot sessions or returning cached Cold output.

  • Reducer is stateless (static reduce). Modality owns state. Session owns the modality + everything else.
  • layout() never loads state — storage is abstracted via the compile_child closure. Session wraps this closure with caching.
  • Elements are compiled output, never directly mutated. Modify placements, values, metadata — recompilation flows downward.
  • snapshot() builds from references, not clones. This removes the Clone bound on Ephemeral.
  • Reducer — state management supertrait
  • Component — compilation supertrait
  • Slot — hot/cold child container used by parent modalities
  • Effects — side effects available inside reduce
  • Command — the envelope dispatched through the reduce cycle