Modality Theme
Summary
Section titled “Summary”Add a typed Theme struct to modality_core that provides default visual properties (colors, typography, spacing) for all modalities. Components reference theme tokens via a new PropertyBinding::Theme { token } variant — when a placement binding points to a theme token, resolve_bindings() resolves it from the modality’s persisted theme before the component ever sees it. The #[derive(ComponentProperties)] macro (the Typed Component Properties RFC) gains a #[property(theme = "token")] attribute that generates Theme bindings as default bindings. A SetTheme(ThemeUpdate) intent allows AI and users to change the theme, triggering recompilation of all components that reference theme tokens.
This means AI doesn’t need to choose colors when creating content — all visual properties have sensible defaults from the theme. AI only overrides a binding when it explicitly wants a non-default value.
Depends on Typed Component Properties for the #[property] macro and ComponentProperties trait.
Motivation
Section titled “Motivation”1. AI has to pick colors for every element
Section titled “1. AI has to pick colors for every element”When AI creates a text box, shape, or card, it must specify colors explicitly via static bindings. There’s no concept of “use the document’s foreground color” — every element gets hardcoded color values. This leads to inconsistent styling and forces the AI system prompt to include color guidance that should be implicit.
2. No global style consistency
Section titled “2. No global style consistency”Each component defines its own defaults independently. TextBoxComponent defaults backgroundColor to one value, ShapeComponent defaults color to another. There’s no shared palette — changing “the brand color” means updating every element individually.
3. No dark mode or theme switching
Section titled “3. No dark mode or theme switching”Switching a whiteboard from light to dark requires touching every element’s color bindings. A theme system lets users change background, foreground, and surface in one operation, and all components that reference those tokens recompile with the new values.
4. Metadata only carries geometry
Section titled “4. Metadata only carries geometry”WhiteboardMetadata currently holds width and height. There’s no mechanism for passing design context (palette, typography) from the modality level to components during compilation.
5. LessonContext already has ad-hoc theming
Section titled “5. LessonContext already has ad-hoc theming”LessonPlanMetadata carries a LessonContext with color and icon fields — a crude per-modality design context. This RFC generalizes that pattern into a typed, shared system.
Design
Section titled “Design”Theme struct
Section titled “Theme struct”Defined in modality_core. Typed, with known fields:
/// Visual design tokens shared across all components in a modality.////// flutter_rust_bridge:non_opaque#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ComponentProperties)]pub struct Theme { // ── Palette ────────────────────────────────────────────── /// Primary brand/accent color. #[property(name = "Primary")] pub primary: Color,
/// Secondary accent color. #[property(name = "Secondary")] pub secondary: Color,
/// Tertiary accent color. #[property(name = "Tertiary")] pub tertiary: Color,
/// Canvas/page background. #[property(name = "Background")] pub background: Color,
/// Card/container surface color. #[property(name = "Surface")] pub surface: Color,
/// Default text/foreground color. #[property(name = "Foreground")] pub foreground: Color,
/// Text on primary-colored surfaces. #[property(name = "On Primary")] pub on_primary: Color,
/// Text on secondary-colored surfaces. #[property(name = "On Secondary")] pub on_secondary: Color,
/// Subdued/caption text color. #[property(name = "Caption")] pub caption: Color,
/// Default border/stroke color. #[property(name = "Border")] pub border: Color,
/// Success semantic color. #[property(name = "Success")] pub success: Color,
/// Warning semantic color. #[property(name = "Warning")] pub warning: Color,
/// Error semantic color. #[property(name = "Error")] pub error: Color,
// ── Typography ─────────────────────────────────────────── /// Default body font family. #[property(name = "Font Family", select = ["poppins", "inter", "roboto", ...], default = "inter")] pub font_family: TextFontFamily,
/// Heading font family (falls back to font_family if same). #[property(name = "Heading Font Family", select = ["poppins", "inter", "roboto", ...], default = "poppins")] pub heading_font_family: TextFontFamily,
/// Base font size in logical pixels. #[property(name = "Base Font Size", min = 8.0, max = 72.0, default = 16.0)] pub base_font_size: f64,
// ── Spacing ────────────────────────────────────────────── /// Default inner padding for containers. #[property(name = "Padding", min = 0.0, default = 12.0)] pub padding: f64,
/// Default border radius for containers. #[property(name = "Border Radius", min = 0.0, default = 8.0)] pub border_radius: f64,
/// Default border/stroke width. #[property(name = "Border Width", min = 0.0, max = 20.0, default = 2.0)] pub border_width: f64,}#[derive(ComponentProperties)] (the Typed Component Properties RFC) generates:
Theme::schema() -> Vec<PropertySchema>— for toolbar/UI introspectionTheme::from_values(values) -> Self— deserialization from property mapTheme::to_bindings() -> HashMap<String, PropertyBinding>— not used directly, but availableThemeUpdate— partial-update struct withOption<T>fields +apply(&mut Theme)method
Default theme
Section titled “Default theme”impl Default for Theme { fn default() -> Self { Self { primary: Color::from_hex("#4F46E5"), // Indigo secondary: Color::from_hex("#7C3AED"), // Violet tertiary: Color::from_hex("#2563EB"), // Blue background: Color::from_hex("#FFFFFF"), surface: Color::from_hex("#F8FAFC"), // Slate-50 foreground: Color::from_hex("#1E293B"), // Slate-800 on_primary: Color::from_hex("#FFFFFF"), on_secondary: Color::from_hex("#FFFFFF"), caption: Color::from_hex("#64748B"), // Slate-500 border: Color::from_hex("#CBD5E1"), // Slate-300 success: Color::from_hex("#16A34A"), warning: Color::from_hex("#D97706"), error: Color::from_hex("#DC2626"), font_family: TextFontFamily::Inter, heading_font_family: TextFontFamily::Poppins, base_font_size: 16.0, padding: 12.0, border_radius: 8.0, border_width: 2.0, } }}PropertyBinding::Theme variant
Section titled “PropertyBinding::Theme variant”Add to the existing PropertyBinding enum in modality_core:
pub enum PropertyBinding { Reference { property_id: String }, Static { value: PropertyValue }, Theme { token: String }, // NEW}token is the snake_case field name from the Theme struct (e.g., "foreground", "surface", "font_family").
Theme resolution in resolve_bindings
Section titled “Theme resolution in resolve_bindings”resolve_bindings() gains a theme: &Theme parameter:
pub fn resolve_bindings( bindings: &HashMap<String, PropertyBinding>, parent_values: &HashMap<String, PropertyValue>, theme: &Theme,) -> HashMap<String, PropertyValue> { bindings .iter() .map(|(key, binding)| { let value = match binding { PropertyBinding::Static { value } => value.clone(), PropertyBinding::Reference { property_id } => parent_values .get(property_id) .cloned() .unwrap_or(PropertyValue::Null), PropertyBinding::Theme { token } => theme .get_token(token) .unwrap_or(PropertyValue::Null), }; (key.clone(), value) }) .collect()}Theme::get_token(&self, token: &str) -> Option<PropertyValue> is a generated or hand-written lookup that maps token names to property values:
impl Theme { pub fn get_token(&self, token: &str) -> Option<PropertyValue> { match token { "primary" => Some(self.primary.to_property_value()), "secondary" => Some(self.secondary.to_property_value()), "background" => Some(self.background.to_property_value()), "surface" => Some(self.surface.to_property_value()), "foreground" => Some(self.foreground.to_property_value()), "caption" => Some(self.caption.to_property_value()), "border" => Some(self.border.to_property_value()), "font_family" => Some(self.font_family.to_property_value()), "base_font_size" => Some(self.base_font_size.to_property_value()), "padding" => Some(self.padding.to_property_value()), "border_radius" => Some(self.border_radius.to_property_value()), "border_width" => Some(self.border_width.to_property_value()), // ... all fields _ => None, } }}This could also be macro-generated by #[derive(ComponentProperties)].
Where theme lives
Section titled “Where theme lives”On each modality’s synced state, persisted in Loro:
// modality_core — trait requirementpub trait Modality { // ... existing associated types and methods ...
/// The modality's current theme. Read from synced state. fn theme(&self) -> &Theme;}
// whiteboardpub struct WhiteboardSynced { pub schema: Vec<PropertySchema>, pub placements: Vec<ComponentPlacement<WhiteboardPosition>>, pub theme: Theme, // NEW — persisted in Loro // ...}
// worksheetpub struct WorksheetSynced { pub schema: Vec<PropertySchema>, pub slides: Vec<WorksheetSlide>, pub theme: Theme, // NEW // ...}Session compilation reads modality.theme() and passes it to resolve_bindings():
// In Session::recompile()let theme = self.state.synced.modality.theme();let values = self.state.synced.values();
for placement in &placements { let meta = modality.child_metadata(placement); let resolved = resolve_bindings(&placement.bindings, &values, theme); let output = component.compile(&meta, &resolved)?; // ...}Macro integration: #[property(theme = "token")]
Section titled “Macro integration: #[property(theme = "token")]”the Typed Component Properties RFC’s #[property] attribute gains a theme option:
#[derive(ComponentProperties)]struct TextBoxProps { #[property(name = "Background", theme = "surface")] background_color: Color,
#[property(name = "Text Color", theme = "foreground")] color: Color,
#[property(name = "Border Color", theme = "border")] border_color: Color,
#[property(name = "Font Family", theme = "font_family")] font_family: TextFontFamily,
#[property(name = "Padding", theme = "padding")] padding: f64,
#[property(name = "Border Radius", theme = "border_radius")] border_radius: f64,
#[property(name = "Text", default = "")] text: String, // No theme token — just a static default}Effect on generated code:
default_bindings()— for fields withtheme = "...", generatesPropertyBinding::Theme { token }instead ofPropertyBinding::Static:
// Generatedimpl TextBoxProps { pub fn default_bindings() -> HashMap<String, PropertyBinding> { let mut m = HashMap::new(); m.insert("background_color".into(), PropertyBinding::Theme { token: "surface".into(), }); m.insert("color".into(), PropertyBinding::Theme { token: "foreground".into(), }); m.insert("text".into(), PropertyBinding::Static { value: PropertyValue::String("".into()), }); // ... m }}-
from_values()— unchanged. By the time the component sees values, theme tokens are already resolved to concretePropertyValues byresolve_bindings(). -
to_bindings()— generatesStaticbindings (converts current concrete values). Used when creating elements with explicit overrides.
Default bindings in ComponentSchema
Section titled “Default bindings in ComponentSchema”ComponentSchema (the catalog entry exposed to AI) gains a default_bindings field:
pub struct ComponentSchema { pub id: String, pub name: String, pub description: String, pub icon: String, pub schema: Vec<PropertySchema>, pub default_bindings: HashMap<String, PropertyBinding>, // NEW}When AI creates a placement without specifying bindings for a property, the placement inherits default_bindings from the component catalog. AI only needs to specify bindings it wants to override.
// AI creates a text box — no color specifiedWhiteboardIntent::AddComponent { component_key: "text_box", position: ..., bindings: { "text": Static { value: "Hello world" }, // background_color, color, border_color, font_family, padding, border_radius // all inherited from TextBoxProps::default_bindings() → Theme tokens },}The reducer merges explicit bindings over defaults:
let mut bindings = TextBoxProps::default_bindings();bindings.extend(explicit_bindings); // AI overrides winSetTheme intent
Section titled “SetTheme intent”A session-level intent, since theme is a modality-level concern:
pub enum SessionIntent<I> { Modality(I), Undo, Redo, SetTheme(ThemeUpdate), // NEW // ...}ThemeUpdate is auto-generated by the ComponentProperties derive:
// Generated by #[derive(ComponentProperties)] on Theme#[derive(Default, Clone, Debug)]pub struct ThemeUpdate { pub primary: Option<Color>, pub secondary: Option<Color>, pub tertiary: Option<Color>, pub background: Option<Color>, pub surface: Option<Color>, pub foreground: Option<Color>, pub on_primary: Option<Color>, pub on_secondary: Option<Color>, pub caption: Option<Color>, pub border: Option<Color>, pub success: Option<Color>, pub warning: Option<Color>, pub error: Option<Color>, pub font_family: Option<TextFontFamily>, pub heading_font_family: Option<TextFontFamily>, pub base_font_size: Option<f64>, pub padding: Option<f64>, pub border_radius: Option<f64>, pub border_width: Option<f64>,}Session reduction:
SessionIntent::SetTheme(update) => { update.apply(&mut state.synced.modality.theme); // Theme change invalidates all compile caches for components // that use Theme bindings → triggers recompile Fx::recompile()}AI interaction
Section titled “AI interaction”The AI system prompt includes the current theme. AI tools:
set_theme— takes aThemeUpdate(partial). AI says “dark mode” → sets background, surface, foreground, caption, border.- No color tools needed for element creation — theme defaults handle it. AI only specifies colors when explicitly requested (“make this box red”).
Example AI flow:
User: "Create a presentation about ocean life"
AI dispatches:1. SetTheme({ primary: "#0077B6", secondary: "#00B4D8", background: "#CAF0F8" })2. AddComponent("text_box", { text: "Ocean Life" }) → background_color defaults to theme.surface → color defaults to theme.foreground → No colors specified by AIPreset themes
Section titled “Preset themes”Optional convenience — a set of named presets:
impl Theme { pub fn light() -> Self { Self::default() }
pub fn dark() -> Self { Self { background: Color::from_hex("#1E1E2E"), surface: Color::from_hex("#313244"), foreground: Color::from_hex("#CDD6F4"), caption: Color::from_hex("#A6ADC8"), border: Color::from_hex("#585B70"), ..Self::default() } }}Presets are convenience constructors. AI and users can also build arbitrary themes via ThemeUpdate.
Loro persistence
Section titled “Loro persistence”Theme is stored as a LoroMap inside the modality’s synced container:
// In WhiteboardSynced Loro roundtripimpl LoroMapItem for Theme { fn to_loro_map(&self, map: &LoroMap) { map.insert("primary", self.primary.to_hex()); map.insert("secondary", self.secondary.to_hex()); // ... }
fn from_loro_map(map: &LoroMap) -> Self { Self { primary: Color::from_hex(map.get_string("primary").unwrap_or_default()), // ... } }}This could also be macro-generated. Existing documents without a theme key deserialize to Theme::default().
Resolution chain summary
Section titled “Resolution chain summary”For any component property during compilation:
1. Placement has explicit binding? ├─ Static { value } → use value ├─ Reference { prop } → lookup parent property value └─ Theme { token } → lookup theme.get_token(token)
2. No binding for this property? └─ Use default_bindings from ComponentSchema └─ (which may itself be Theme { token })
3. Resolved value is Null? └─ from_values() uses PropertySchema.default_value or compile-time fallbackImplementation Plan
Section titled “Implementation Plan”-
Add
Themestruct tomodality_core— fields,Default,get_token(),LoroMapItem. AddPropertyBinding::Theme { token }variant. Updateresolve_bindings()signature. Verify:cargo check --workspace(fix all match exhaustiveness). -
Add
theme: Themeto each modality’s synced state —WhiteboardSynced,WorksheetSynced,LessonPlanSynced. ImplementModality::theme(). Wire into Loro persistence withTheme::default()fallback for existing docs. Verify:cargo test --workspace. -
Add
default_bindingstoComponentSchema— populate from component implementations. Update reducer to merge defaults when creating placements. Verify: existing tests pass. -
Add
SetTheme(ThemeUpdate)toSessionIntent— reduce handler applies update, invalidates compile cache, triggers recompile. Verify: unit test round-tripping theme changes. -
Migrate component properties to use theme tokens — update
TextBoxComponent,ShapeComponent,TextComponent,CardComponent,ListComponentdefault bindings to reference theme tokens for colors, fonts, spacing. Verify: compiled output unchanged with default theme. -
Add
#[property(theme = "token")]to the Typed Component Properties RFC macro — generatedefault_bindings()withPropertyBinding::Themefor annotated fields. Generateget_token()on theThemestruct. Verify: macro expansion tests. -
Expose theme in AI system prompt and tools — include current theme in system prompt, add
set_themetool, remove color guidance from prompts. Verify: AI can create elements without specifying colors. -
FRB bindings — expose
Theme,ThemeUpdate,SetThemeintent through FRB. Wire Dart toolbar for theme editing. Verify:flutter_rust_bridge_codegen generate.
Alternatives Considered
Section titled “Alternatives Considered”Theme on metadata instead of synced state
Section titled “Theme on metadata instead of synced state”Theme as part of WhiteboardMetadata (immutable compile-time context). Rejected because theme needs to be editable at runtime (AI changing colors, user switching dark mode) and persisted with the document. Metadata is immutable and not stored in Loro.
DefaultSource enum on PropertySchema instead of PropertyBinding::Theme
Section titled “DefaultSource enum on PropertySchema instead of PropertyBinding::Theme”enum DefaultSource { Value(PropertyValue), ThemeToken(String),}Replace PropertySchema.default_value with default_source: Option<DefaultSource>. Components declare “my color defaults to theme.foreground” in their schema, and resolution happens in from_values(). Rejected because it mixes two concerns: the schema (what properties exist) and the binding (where values come from). PropertyBinding::Theme keeps resolution in the binding layer where Reference and Static already live. It’s also more flexible — a placement can override a theme binding with a static value without touching the schema.
Convention-based resolution in compile()
Section titled “Convention-based resolution in compile()”fn compile(&self, meta: &M, values: &HashMap<String, PropertyValue>, theme: &Theme) { let bg = get_color(values, "backgroundColor").unwrap_or_else(|| theme.surface());}Pass theme directly to components. Each component hard-codes which theme token it falls back to. Rejected because the mapping is implicit, duplicated across components, and invisible to AI and tooling. PropertyBinding::Theme makes the mapping declarative and inspectable.
HashMap-based open-ended theme tokens
Section titled “HashMap-based open-ended theme tokens”Theme as HashMap<String, PropertyValue> instead of a typed struct. Allows arbitrary tokens without struct changes. Rejected because it loses type safety, has no compile-time validation of token names, and can’t generate ThemeUpdate or derive PropertyField impls. A typed struct with the Typed Component Properties RFC macro gives us schema generation, partial updates, and token validation for free.
Unresolved Questions
Section titled “Unresolved Questions”Should theme tokens support expressions or transformations?
Section titled “Should theme tokens support expressions or transformations?”E.g., “caption color is foreground at 60% opacity”. Currently tokens are direct lookups. Derived tokens (opacity, lighten/darken) would require an expression system. Likely out of scope — can be added later if needed.
Should theme apply to freehand drawing (PathElement)?
Section titled “Should theme apply to freehand drawing (PathElement)?”Freehand paths use tool_color from ephemeral state at draw time. Should tool_color default to theme.foreground? Probably yes for initial tool state, but drawn paths are permanently baked — they don’t recompile when theme changes. This is acceptable (matching real pen behavior).
Interaction with LessonContext
Section titled “Interaction with LessonContext”LessonPlanMetadata has LessonContext { color, icon }. Should LessonContext.color map to theme.primary? Or should LessonContext be subsumed by Theme entirely? Probably keep LessonContext for lesson-specific domain metadata (topic name, icon) and let Theme handle visual styling. The lesson modality could initialize its theme’s primary from LessonContext.color.
Theme presets — where do they live?
Section titled “Theme presets — where do they live?”Named presets (Theme::light(), Theme::dark()) are compile-time Rust constructors. Should there be a runtime preset registry? User-defined presets? This can be a follow-up — start with constructors and SetTheme(ThemeUpdate).
Cache invalidation granularity
Section titled “Cache invalidation granularity”SetTheme currently invalidates all compile caches. Could we track which placements use PropertyBinding::Theme and only recompile those? Optimization for later — full recompile is correct and simple.