Nested Toolbar Context
Summary
Section titled “Summary”Introduce a nested toolbar context system where multiple toolbar layers are stacked based on the current focus/selection depth, and the UI always renders the most deeply nested context. Each layer provides tupled (PropertySchema, PropertyValue) pairs — the same data contract established by the Schema-Driven Toolbar RFC, but paired rather than split. The toolbar stack lives in ephemeral state and is mutated in reduce() in response to select/focus intents, following the same architectural rules as all other state.
Toolbar intents (property changes from the UI) enter at the session level via SessionIntent::Toolbar(ToolbarIntent) and route down through the hierarchy: Session → Modality → ChildComponent → recurse. This is the same dispatch pattern as Undo/Redo and Export. Routing and reduction are governed by a Toolbar trait with blanket implementations — no ad-hoc impl Whiteboard methods with potential for undefined behavior outside the architecture.
The key distinction: the context toolbar (top bar, floating, or side peek — modality decides) is schema-driven and changes with focus/selection state. The tool palette (pen, eraser, shape, select, undo/redo) is modality-specific, static, and completely separate. This RFC only concerns the context toolbar.
Motivation
Section titled “Motivation”1. the Schema-Driven Toolbar RFC is flat — one component, one toolbar
Section titled “1. the Schema-Driven Toolbar RFC is flat — one component, one toolbar”the Schema-Driven Toolbar RFC solved the problem of generating toolbars from component schemas. But it assumes a single toolbar surface driven by a single selected component. In practice, toolbar context is layered:
- A whiteboard slide with nothing selected should show slide-level properties (background color, grid settings).
- Selecting a shape element should show that element’s component schema (fill, stroke, border radius).
- Focusing a text element and selecting text within it should show prose formatting options (bold, italic, link, font size).
the Schema-Driven Toolbar RFC’s SchemaToolbar renders one set of controls. It has no concept of “which controls should I show right now?” or “what happens when the user clicks outside the text but keeps the element selected?“
2. Selection-level toolbars are not components
Section titled “2. Selection-level toolbars are not components”A text selection (bold/italic/link controls) is not a Component in the Typed Element Components RFC sense — it does not have a ComponentProperties struct. The text selection toolbar operates on a buffer range within an element, not on the element’s own property bindings. Yet it needs to present the same paired (schema, value) data contract so that PropertyEditor widgets can render it uniformly.
Similarly, the slide-level toolbar operates on the modality itself (or its metadata), not on any specific element. These are additional toolbar layers that don’t fit the Schema-Driven Toolbar RFC’s component-only model.
3. No nesting model exists
Section titled “3. No nesting model exists”The current system has no way to express “I am three layers deep — show the innermost one.” When a user focuses a text element inside a slide inside a lesson plan, there are potentially four toolbar contexts stacked:
- Lesson plan — lesson-level properties
- Slide — slide-level properties (the whiteboard modality)
- Element — the text element’s component schema
- Prose selection — bold, italic, link, font size
The UI should show layer 4 when text is selected, layer 3 when the element is selected but no text range is active, layer 2 when nothing is selected on the slide, and layer 1 when no slide is focused. There is no mechanism for this today.
4. Different modalities render toolbars differently
Section titled “4. Different modalities render toolbars differently”A whiteboard might show the context toolbar as a persistent top bar. A document modality might show it as a floating toolbar near the selection. A worksheet might show it in a side peek panel. The rendering strategy is modality-specific, but the underlying data — a stack of toolbar contexts — should be uniform so that PropertyEditor widgets are reusable everywhere.
Design
Section titled “Design”ToolbarEntry — paired schema and value
Section titled “ToolbarEntry — paired schema and value”A toolbar entry tuples a property schema with its current value. This replaces the previous pattern of separate Vec<PropertySchema> + HashMap<String, PropertyValue> collections:
/// A single property control in a toolbar, pairing its schema with its current value.pub struct ToolbarEntry { pub schema: PropertySchema, pub value: PropertyValue,}This pairing is important: the schema and value are always consumed together. Separating them into parallel collections invites mismatches and forces consumers to do key-based lookups. Tupling them makes the data self-contained.
ToolbarContext — the unit of toolbar data
Section titled “ToolbarContext — the unit of toolbar data”A ToolbarContext is the data a single toolbar layer provides:
/// A single layer of toolbar context.pub struct ToolbarContext { /// Human-readable label for this layer (e.g. "Slide", "Shape", "Text Formatting"). pub label: String,
/// Paired schema + value entries for this layer. pub entries: Vec<ToolbarEntry>,
/// Identifies which layer this context belongs to, /// so the session can route toolbar intents to the correct reducer. pub layer: ToolbarLayer,}
pub enum ToolbarLayer { /// Modality-level properties (e.g. slide background). Modality,
/// Component-level properties for a specific element. Component { element_id: String },
/// Multi-select operations (group, align, distribute) + shared properties. MultiSelect { element_ids: Vec<String> },
/// Selection-level properties (e.g. prose formatting). Selection { element_id: String },}On the Dart side, each ToolbarContext maps directly to a SchemaToolbar widget — entries drive the controls and ToolbarLayer determines how onChange dispatches back.
Toolbar stack lives in ephemeral state
Section titled “Toolbar stack lives in ephemeral state”The toolbar stack is ephemeral state, stored alongside other non-persisted runtime state. It is mutated in reduce() in response to select/focus intents, following the same architectural rules as everything else.
/// Ordered from outermost (modality) to innermost (selection).pub type ToolbarStack = Vec<ToolbarContext>;The toolbar stack is stored on the modality’s ephemeral state:
pub struct WhiteboardEphemeral { // ... existing ephemeral fields ... pub toolbar_stack: ToolbarStack,}Why ephemeral state, not computed at snapshot time
Section titled “Why ephemeral state, not computed at snapshot time”- Consistency — all state that drives the UI goes through the same reduce->snapshot pipeline. No special-case “compute on read” paths.
- Controllability — the toolbar stack is explicitly mutated in response to known intents. You can reason about when it changes by reading the reduce function.
- Performance — building the stack once on state change is cheaper than recomputing it on every snapshot read.
Toolbar trait — who can provide toolbar context and reduce toolbar intents
Section titled “Toolbar trait — who can provide toolbar context and reduce toolbar intents”The Toolbar trait covers both sides: building the toolbar context (what to show) and reducing toolbar intents (what happens when the user changes a value). This is a single trait because the type that knows its schema also knows how to apply changes to itself.
/// Trait for types that can provide toolbar context and handle toolbar property changes.pub trait Toolbar { /// Return the toolbar context for this type, if active. /// Returns `None` if this type has no toolbar to show. fn toolbar_context(&self) -> Option<ToolbarContext>;
/// Reduce a toolbar property change against this type's state. fn reduce_toolbar(&mut self, key: &str, value: PropertyValue);}No blanket default impl. A type must explicitly implement Toolbar. The one exception: a conditional blanket impl exists for types whose Selectable associated type also implements Toolbar — because then the behavior is obvious (selected instance’s toolbar goes on the stack, property changes route to the selected instance). But Selectable itself doesn’t and shouldn’t abstract over multi-select vs. single-select, so a generic “if selectable, then toolbar” default would be wrong.
Blanket impl for TypedComponent
Section titled “Blanket impl for TypedComponent”Any component that derives ComponentProperties (the Typed Component Properties RFC) gets a free Toolbar implementation. The schema comes from the derive macro; the reduction updates the binding bag:
impl<T: TypedComponent> Toolbar for T { fn toolbar_context(&self) -> Option<ToolbarContext> { let schemas = <T::Properties as ComponentProperties>::schemas(); let values = self.current_bindings();
let entries = schemas .into_iter() .filter(|s| s.toolbar != ToolbarHint::Hidden) .map(|schema| { let value = values .get(&schema.key) .cloned() .or(schema.default_value.clone()) .unwrap_or(PropertyValue::Null); ToolbarEntry { schema, value } }) .collect();
Some(ToolbarContext { label: T::COMPONENT_ID.into(), entries, layer: ToolbarLayer::Component { element_id: self.placement_id().into(), }, }) }
fn reduce_toolbar(&mut self, key: &str, value: PropertyValue) { self.update_binding(key, value); }}This means every registered component automatically participates in the toolbar system. No per-component toolbar wiring needed.
Blanket impl for Modality when Selectable::Item: Toolbar
Section titled “Blanket impl for Modality when Selectable::Item: Toolbar”When a modality’s selectable item type implements Toolbar, routing is well-defined — the selected item’s toolbar context goes on the stack, and toolbar intents route to the selected item:
/// Conditional: if the thing you select implements Toolbar,/// the single-selection toolbar case is automatic.impl<M> ToolbarRouting for Mwhere M: Modality + Selectable, <M as Selectable>::Item: Toolbar,{ fn selected_toolbar_context(&self) -> Option<ToolbarContext> { let selected = self.selected_item()?; selected.toolbar_context() }
fn reduce_selected_toolbar(&mut self, key: &str, value: PropertyValue) { if let Some(item) = self.selected_item_mut() { item.reduce_toolbar(key, value); } }}This does NOT cover multi-select (the modality must implement that itself) or modality-level toolbar (also modality-specific). It only covers the single-selection → component toolbar case.
Session-level dispatch — SessionIntent::Toolbar
Section titled “Session-level dispatch — SessionIntent::Toolbar”Toolbar intents enter at the session level, just like Undo/Redo and Export. The session routes based on ToolbarLayer:
pub enum SessionIntent<I> { Modality(I), Ai(AiIntent), Export(OutputFormat), SetTheme(ThemeUpdate), Undo, Redo, /// Toolbar property change — session routes to the correct reducer. Toolbar(ToolbarIntent),}
/// A property change from the toolbar UI.pub struct ToolbarIntent { /// Which layer the change targets. pub layer: ToolbarLayer, /// The property key being changed. pub key: String, /// The new value. pub value: PropertyValue,}Routing in SessionIntent::reduce
Section titled “Routing in SessionIntent::reduce”The session reduces Toolbar intents by routing through the hierarchy based on ToolbarLayer. This follows the same pattern as Undo/Redo — the session knows the structure and delegates:
SessionIntent::Toolbar(intent) => { match &intent.layer { ToolbarLayer::Modality => { // Route to the modality's own toolbar reducer state.modality.reduce_toolbar(&intent.key, intent.value); } ToolbarLayer::Component { element_id } => { // Route to the specific element's component state.modality.reduce_element_toolbar( element_id, &intent.key, intent.value, ); } ToolbarLayer::MultiSelect { element_ids } => { // Route to the modality's multi-select handler state.modality.reduce_multi_select_toolbar( element_ids, &intent.key, intent.value, ); } ToolbarLayer::Selection { element_id } => { // Route to the selection-level reducer (e.g. prose formatting) state.modality.reduce_selection_toolbar( element_id, &intent.key, intent.value, ); } } // Rebuild the toolbar stack after any property change state.modality.rebuild_toolbar_stack(); Effect::none()}ParentSessionIntent routing
Section titled “ParentSessionIntent routing”For parent sessions (lesson plan), toolbar routing follows the same pattern as child dispatch and undo. If the toolbar targets a child session’s layer, the parent forwards:
ParentSessionIntent::Base(SessionIntent::Toolbar(intent)) => { // If the modality has a focused child and the intent targets // a layer deeper than the parent modality, forward to child. if let Some(child_id) = state.modality.selected_child_id() { if intent.layer.depth() > 0 { // Shift the layer depth and forward to child session if let Some(slot) = state.modality.children().get(child_id) { if let Some(arc) = slot.as_hot() { let mut child = arc.lock(); child.dispatch(Command::Intent( SessionIntent::Toolbar(intent.shift_depth()), )); } } state.invalidated_children.push(child_id.to_string()); return Effect::none(); } } // Otherwise handle at parent level (same as leaf session) // ...}ToolbarReducer trait — the modality-level contract
Section titled “ToolbarReducer trait — the modality-level contract”Each modality implements this trait. It defines the toolbar-related methods that the session routes to. The blanket impls above provide defaults for the common cases; modalities override where their selection model differs.
/// Modality-level toolbar contract./// Session routes ToolbarIntent to these methods based on ToolbarLayer.pub trait ToolbarReducer { /// Modality-level toolbar context (always the base layer). fn modality_toolbar_context(&self) -> Option<ToolbarContext>;
/// Reduce a modality-level property change. fn reduce_toolbar(&mut self, key: &str, value: PropertyValue);
/// Reduce a component-level property change for a specific element. fn reduce_element_toolbar(&mut self, element_id: &str, key: &str, value: PropertyValue);
/// Reduce a multi-select property change across elements. /// Default: no-op (modalities without multi-select don't need this). fn reduce_multi_select_toolbar( &mut self, _element_ids: &[String], _key: &str, _value: PropertyValue, ) {}
/// Reduce a selection-level property change (e.g. prose formatting). /// Default: no-op (modalities without sub-element selection don't need this). fn reduce_selection_toolbar( &mut self, _element_id: &str, _key: &str, _value: PropertyValue, ) {}
/// Rebuild the full toolbar stack from current state. /// Called after any selection/focus change or toolbar property update. fn rebuild_toolbar_stack(&mut self);}Stack rebuild in reduce()
Section titled “Stack rebuild in reduce()”The toolbar stack is rebuilt in reduce() whenever selection or focus state changes. This is driven by the modality’s own intent handling — the modality calls rebuild_toolbar_stack() at the end of any intent that changes selection or focus:
// In Whiteboard's reduce():WhiteboardIntent::Select(ids) => { state.synced.selected = ids.clone(); self.rebuild_toolbar_stack(); // trait method, not ad-hoc impl Effect::none()}
WhiteboardIntent::Focus(id) => { state.ephemeral.focused = Some(id.clone()); self.rebuild_toolbar_stack(); Effect::none()}
WhiteboardIntent::Deselect => { state.synced.selected.clear(); state.ephemeral.focused = None; self.rebuild_toolbar_stack(); Effect::none()}Whiteboard’s ToolbarReducer impl
Section titled “Whiteboard’s ToolbarReducer impl”The whiteboard implements ToolbarReducer with its own selection model. Multi-select vs. single-select logic is whiteboard-specific — not abstracted into a generic trait:
impl ToolbarReducer for Whiteboard { fn modality_toolbar_context(&self) -> Option<ToolbarContext> { // Slide-level properties: background color, grid settings, etc. Some(ToolbarContext { label: "Slide".into(), entries: vec![/* ... */], layer: ToolbarLayer::Modality, }) }
fn reduce_toolbar(&mut self, key: &str, value: PropertyValue) { // Update slide-level property }
fn reduce_element_toolbar(&mut self, element_id: &str, key: &str, value: PropertyValue) { // Delegates to the element's component via the Toolbar blanket impl. // The component's reduce_toolbar() is called. if let Some(placement) = self.state_mut().synced.placements.get_mut(element_id) { placement.update_binding(key, value); } }
fn reduce_multi_select_toolbar( &mut self, element_ids: &[String], key: &str, value: PropertyValue, ) { // Whiteboard-specific: group/ungroup/align/distribute actions, // or shared property update across all selected elements. match key { "group" => { /* group selected */ } "align_left" | "align_center" | "align_right" => { /* align */ } "distribute_h" | "distribute_v" => { /* distribute */ } _ => { // Shared property — apply to all selected elements for id in element_ids { self.reduce_element_toolbar(id, key, value.clone()); } } } }
fn reduce_selection_toolbar(&mut self, element_id: &str, key: &str, value: PropertyValue) { // Route to prose formatting if let Some(prose) = self.state_mut().ephemeral.prose.get_mut(element_id) { match key { "bold" => prose.toggle_bold(), "italic" => prose.toggle_italic(), "underline" => prose.toggle_underline(), "link" => { /* set/remove link */ } _ => {} } } }
fn rebuild_toolbar_stack(&mut self) { let state = self.state(); let mut stack = Vec::new();
// Layer 1: Modality-level (slide properties) — always present if let Some(ctx) = self.modality_toolbar_context() { stack.push(ctx); }
// Layer 2+: Selection-dependent let selected = &state.synced.selected; if selected.len() == 1 { let id = &selected[0];
// Single selection → use the component's Toolbar impl (blanket) if let Some(component) = self.get_component(id) { if let Some(ctx) = component.toolbar_context() { stack.push(ctx); } }
// Layer 3: Focus-level (prose formatting) if let Some(ref focused_id) = state.ephemeral.focused { if let Some(ctx) = self.focus_toolbar_context(focused_id) { stack.push(ctx); } } } else if selected.len() > 1 { // Multi-selection → whiteboard builds this itself (not abstractable) stack.push(self.multi_select_toolbar_context(selected)); }
self.state_mut().ephemeral.toolbar_stack = stack; }}The condition for whether to build a multi-select toolbar vs. an element toolbar is whiteboard-specific logic. Other modalities may have entirely different selection models — a worksheet might not have multi-select at all.
Prose selection toolbar
Section titled “Prose selection toolbar”The prose selection toolbar is a non-component toolbar layer. When a user selects text inside a focused text element, the toolbar should show formatting controls. This is built by the modality (not by the Toolbar trait) because prose selections aren’t components:
impl Whiteboard { fn focus_toolbar_context(&self, element_id: &str) -> Option<ToolbarContext> { let prose = self.state().ephemeral.prose.get(element_id)?; let selection = self.state().ephemeral.text_selection.as_ref()?;
if selection.is_collapsed() { return None; }
let attrs = prose.attributes_at(selection);
let entries = vec![ ToolbarEntry { schema: PropertySchema::boolean("bold", "Bold"), value: PropertyValue::Boolean(attrs.bold), }, ToolbarEntry { schema: PropertySchema::boolean("italic", "Italic"), value: PropertyValue::Boolean(attrs.italic), }, ToolbarEntry { schema: PropertySchema::boolean("underline", "Underline"), value: PropertyValue::Boolean(attrs.underline), }, ToolbarEntry { schema: PropertySchema::text("link", "Link URL"), value: attrs.link.map(PropertyValue::String).unwrap_or(PropertyValue::Null), }, ];
Some(ToolbarContext { label: "Text Formatting".into(), entries, layer: ToolbarLayer::Selection { element_id: element_id.to_string(), }, }) }}This is one of the cases where a concrete impl Whiteboard method is appropriate — prose selection toolbars don’t generalize across modalities, and Selectable doesn’t abstract over sub-element selection.
Snapshot integration
Section titled “Snapshot integration”The toolbar stack is included in the snapshot because Dart needs it to render. Since it lives in ephemeral state, the snapshot just reads it out:
pub struct WhiteboardSnapshot { pub elements: Vec<WhiteboardElement>, pub selected: HashSet<ElementId>, pub focused: Option<ElementId>, pub toolbar_stack: ToolbarStack, // read from ephemeral // ...}
impl Whiteboard { fn snapshot(&self, ...) -> WhiteboardSnapshot { WhiteboardSnapshot { // ... toolbar_stack: self.state().ephemeral.toolbar_stack.clone(), } }}Dart dispatch — single entry point
Section titled “Dart dispatch — single entry point”Dart dispatches all toolbar changes through SessionIntent::Toolbar. The ToolbarLayer on the context tells Dart which layer the change targets — no switch/case routing on the Dart side:
void onToolbarChange(ToolbarContext ctx, String key, PropertyValue value) { dispatch(SessionIntent.toolbar( ToolbarIntent(layer: ctx.layer, key: key, value: value), ));}This is simpler than the previous design where Dart had to construct different modality-specific intents per layer. The session handles all routing in Rust.
Lesson plan nesting
Section titled “Lesson plan nesting”For a lesson plan, the stack can be four layers deep:
LessonPlan— lesson-level properties- The active slide’s
Whiteboard— slide-level properties - The selected element’s component toolbar — element properties
- The focused element’s prose toolbar — text formatting
The lesson plan builds layer 1 itself. Layers 2-4 come from the child whiteboard session’s ephemeral toolbar stack. The parent session concatenates them in its rebuild_toolbar_stack:
impl ToolbarReducer for LessonPlan { fn rebuild_toolbar_stack(&mut self) { let mut stack = Vec::new();
// Layer 1: Lesson plan level if let Some(ctx) = self.modality_toolbar_context() { stack.push(ctx); }
// Layers 2+: Delegate to the focused child (whiteboard slide) if let Some(slide_id) = self.state().synced.selected_slide.as_ref() { if let Some(Slot::Hot(session)) = self.state().synced.children.get(slide_id) { let child_stack = &session.lock().state().ephemeral.toolbar_stack; stack.extend(child_stack.iter().cloned()); } }
self.state_mut().ephemeral.toolbar_stack = stack; }
// ... other ToolbarReducer methods}Toolbar intents targeting layers 2+ are forwarded to the child session by ParentSessionIntent routing (see above).
Tool palette vs. context toolbar
Section titled “Tool palette vs. context toolbar”These are entirely separate concerns:
| Aspect | Context Toolbar | Tool Palette |
|---|---|---|
| Content | Schema-driven, changes with focus/selection | Static set of tools |
| Driven by | ToolbarStack from ephemeral state | Modality type (hardcoded) |
| Position | Top bar / floating / side peek (modality decides) | Right side / bottom (fixed) |
| Examples | Fill color, stroke width, bold, italic | Pen, eraser, shape tool, select, undo/redo |
| Scope | This RFC + the Schema-Driven Toolbar RFC | Not in scope |
Rendering flexibility
Section titled “Rendering flexibility”Different modalities render the context toolbar differently, but consume the same ToolbarStack data:
| Modality | Rendering | Notes |
|---|---|---|
| Whiteboard | Persistent top bar | Always visible, swaps content based on active context |
| Document | Floating toolbar | Appears near text selection, disappears on blur |
| Worksheet | Side peek panel | Opens when a question is selected |
| Lesson Plan | Persistent top bar | Same as whiteboard (the toolbar stack includes lesson + slide + element layers) |
The rendering decision is purely a Dart-side concern. The Rust side provides the ToolbarStack in the snapshot; Dart chooses the widget tree.
Implementation Plan
Section titled “Implementation Plan”- Define
ToolbarEntry,ToolbarContext,ToolbarLayer,ToolbarStackinmodality_core. Pure data types. Verify:cargo check --package modality_core - Define
Toolbartrait inmodality_core. No default impl. Verify:cargo check --package modality_core - Define
ToolbarReducertrait inmodality_core. Default no-ops for multi-select and selection layers. Verify:cargo check --package modality_core - Blanket
Toolbarimpl forTypedComponent— schema from derive, reduce updates bindings. Verify:cargo check --package modality_core - Add
ToolbarIntenttoSessionIntent— session-level dispatch with layer-based routing. Verify:cargo check --package modality_session - Add
ToolbarIntentrouting toParentSessionIntent— forward to child session for deeper layers. Verify:cargo check --package modality_session - Add
toolbar_stack: ToolbarStacktoWhiteboardEphemeral— ephemeral state, not persisted. Verify:cargo check --package modality_whiteboard - Implement
ToolbarReducerforWhiteboard—rebuild_toolbar_stack, single/multi-select logic, prose toolbar, modality-level toolbar. Verify:cargo test --package modality_whiteboard - Add
toolbar_stacktoWhiteboardSnapshot— read from ephemeral. Verify: snapshot tests pass - Expose
ToolbarEntry,ToolbarContext,ToolbarLayer,ToolbarIntentvia FRB —#[frb(non_opaque)]on all types. Runflutter_rust_bridge_codegen generate - Update Dart toolbar consumer — single
onToolbarChangedispatchesSessionIntent.toolbar(...), readtoolbarStackfrom snapshot, render deepest layer - Implement
ToolbarReducerforLessonPlan— concatenate lesson-level context with child whiteboard stack, forward intents to child - Extend to other modalities —
Worksheet,Document(when the Document Modality RFC lands)
Alternatives Considered
Section titled “Alternatives Considered”Toolbar stack computed at snapshot time
Section titled “Toolbar stack computed at snapshot time”Build the toolbar stack during snapshot() rather than storing it in ephemeral state and mutating in reduce().
Rejected because it creates a special-case “compute on read” path that doesn’t follow the standard reduce->state->snapshot pipeline. Storing the stack in ephemeral state means it’s governed by the same rules as everything else — mutated in response to intents, predictable, debuggable.
Ad-hoc impl Whiteboard methods (previous version of this RFC)
Section titled “Ad-hoc impl Whiteboard methods (previous version of this RFC)”The previous revision had all toolbar logic as private impl Whiteboard methods (build_toolbar_stack, component_toolbar, multi_select_toolbar, focus_toolbar). Dart routed toolbar changes by constructing modality-specific intents per layer.
Rejected because it places logic outside the trait system — methods on a concrete type have no architectural contract, no blanket impls, and no guarantee that other modalities follow the same pattern. The trait-based approach makes routing well-defined and enables blanket impls for common cases (component toolbar from TypedComponent, single-select routing when Selectable::Item: Toolbar).
Modality-level dispatch (no session routing)
Section titled “Modality-level dispatch (no session routing)”Have Dart construct the correct modality-specific intent per toolbar layer (e.g. WhiteboardIntent.updateProperty, WhiteboardIntent.formatText).
Rejected because it pushes routing logic to Dart, which shouldn’t know about the Rust-side hierarchy. Session-level dispatch keeps routing in Rust where the type system can enforce it. This also matches the established pattern — Undo/Redo, Export, and AI all enter at session level.
Generic build_toolbar_stack function
Section titled “Generic build_toolbar_stack function”A single generic function that takes M: Modality + Selectable + Focusable + Toolbar and automatically builds the stack from trait queries.
Rejected because the selection model is modality-specific. Selectable doesn’t abstract over multi-select vs. single-select — those are different behaviors with different toolbar implications. The whiteboard decides when to show a multi-select toolbar; the worksheet may not have multi-select at all. Each modality implements its own rebuild_toolbar_stack with its own logic.
Blanket default impl on Toolbar for all Selectable types
Section titled “Blanket default impl on Toolbar for all Selectable types”Provide a blanket Toolbar for anything that implements Selectable.
Rejected because Selectable’s associated type doesn’t necessarily implement Toolbar. A default impl would need to assume that selected items can provide toolbar context, which isn’t always true. A conditional impl is only safe if the selectable’s associated type also implements Toolbar — this is expressed as the ToolbarRouting conditional impl above.
Separate schemas and values collections
Section titled “Separate schemas and values collections”Use Vec<PropertySchema> + HashMap<String, PropertyValue> instead of Vec<ToolbarEntry>.
Rejected because schema and value are always consumed together. Parallel collections invite mismatches (a key present in schemas but missing from values, or vice versa). Tupling them makes each entry self-contained and eliminates key-based lookup overhead on the consumer side.
Single merged toolbar instead of a stack
Section titled “Single merged toolbar instead of a stack”Flatten all active layers into one combined toolbar — show slide properties and element properties and prose formatting simultaneously.
Rejected because it creates an overwhelming toolbar for deeply nested contexts (lesson plan + slide + element + prose = potentially 20+ controls). The “most-nested wins” model keeps the toolbar focused on the user’s current editing context. The full stack is still available if a modality wants breadcrumbs.
Unresolved Questions
Section titled “Unresolved Questions”Mixed values in multi-select
Section titled “Mixed values in multi-select”When multiple elements share a property but have different values (e.g. three shapes with different fill colors), the toolbar should show a “mixed” state. The ToolbarEntry value could be PropertyValue::Null as a sentinel, or a new PropertyValue::Mixed variant. TBD during implementation.
PropertyType::Action
Section titled “PropertyType::Action”The multi-select toolbar introduces action entries for buttons that trigger commands (group, ungroup, align, distribute). This is a new PropertyType variant that renders as a button rather than a value editor. The exact semantics (one-shot vs. toggle) need to be resolved.
Toolbar stack rebuild granularity
Section titled “Toolbar stack rebuild granularity”Currently the entire stack is rebuilt on any selection/focus change. For modalities with expensive toolbar construction (many elements, deep nesting), incremental updates might be needed. Deferred — unlikely to be a bottleneck in practice.
Breadcrumb navigation
Section titled “Breadcrumb navigation”Should the UI show the full toolbar stack as a breadcrumb trail with the ability to click a level to jump to that toolbar? Useful for deep nesting but adds UI complexity. Deferred to implementation.
Prose toolbar content
Section titled “Prose toolbar content”The exact set of prose formatting properties depends on the prose model (the Embedded Prose RFC) and what formatting the LoroText-based prose system supports. The mechanism is defined here; the specific entries are an implementation detail.
Dependencies
Section titled “Dependencies”- the Typed Component Properties RFC (Typed Component Properties) —
#[derive(ComponentProperties)]generatesschemas()and typed value extraction, which theToolbarblanket impl uses - the Schema-Driven Toolbar RFC (Schema-Driven Toolbar) — this RFC extends the schema-driven approach to multiple stacked layers;
SchemaToolbarandPropertyEditorrender eachToolbarContext - the Selection Trait RFC (Selection Trait) —
SelectableandFocusabletraits inform which toolbar layers are active; conditionalToolbarRoutingblanket impl usesSelectable::Item - the Embedded Prose RFC (Embedded Prose Reducer) — prose selection state is needed for the focus-level toolbar layer
- the Typed Element Components RFC (Typed Element Components) — per-element-type components provide the element-level toolbar layer via the
Toolbarblanket impl onTypedComponent