Layout Sync — DOM / Layout Tree Integration

The layout sync system bridges AssetDB with the DOM (browser) or a native layout tree. The layout tree is the source of truth at initialization — AssetDB observes and drives it.

Flow

Startup:
  DOM / Layout Tree ──hydrate()──→ AssetDB

Each tick:
  Layout ──poll_events()──→ :triggers components
                               ↓
                        Systems process (behavior, tween, state machine)
                               ↓
  AssetDB ──sync()────────→ Layout (DOM updates)

Inline queries:
  @layout(entity:property) ──→ resolved directly from layout backend
                                (never writes to AssetDB)

LayoutBackend Trait

Pluggable backend for different platforms:

#![allow(unused)]
fn main() {
pub trait LayoutBackend: Send + Sync {
    fn hydrate(&self, db: &Arc<AssetDB>) -> Result<()>;
    fn sync(&self, db: &Arc<AssetDB>) -> Result<()>;
    fn poll_events(&self, db: &Arc<AssetDB>) -> Result<()>;
    fn query(&self, entity: &str, property: &str) -> Option<f64>;
    fn query_string(&self, entity: &str, property: &str) -> Option<String>;
    fn hit_test(&self, x: f64, y: f64) -> Option<String>;
    fn parent_of(&self, entity: &str) -> Option<String>;
    fn children_of(&self, entity: &str) -> Option<Vec<String>>;
}
}
BackendTargetDescription
HeadlessLayoutBackendNative / TestingIn-memory layout nodes
DomLayoutBackendWasm / Browserweb-sys DOM access

Queryable Properties

PropertyTypeDescription
x, yf64Bounding box position
width, heightf64Bounding box dimensions
scrollX, scrollYf64Scroll offset
scrollProgressf64Normalized scroll 0..1
inViewportf641.0 if visible, 0.0 if not
opacityf64Computed opacity
parentWidth, parentHeightf64Parent dimensions
viewportWidth, viewportHeightf64Viewport dimensions
tagStringElement tag name
textStringText content

@layout() Variables

Behavior rules and expressions can reference layout-computed values inline without writing to AssetDB:

{
  "entity": "hero",
  "component": "behavior",
  "data": {
    "rules": [{
      "name": "parallax",
      "target": "transform.position.y",
      "expr": "scroll * -200",
      "vars": {
        "scroll": "@layout(page:scrollProgress)"
      }
    }]
  }
}

The @layout(entity:property) prefix tells the variable resolver to query the layout backend directly. The value is resolved at evaluation time — ephemeral, never stored.

Two-Way Binding

Entities with a :bind component get automatic bi-directional sync. No explicit DAG wiring needed for the data flow itself.

Full Bind

{ "entity": "slider", "component": "bind", "data": true }

Syncs all standard properties: transform, style, value, scroll.

Selective Bind

{
  "entity": "input_field",
  "component": "bind",
  "data": {
    "value": true,
    "scroll": true,
    "transform": false,
    "style": false
  }
}

Only syncs the properties you opt into.

What Happens Each Tick

For bound entities, the LayoutSyncSystem automatically:

DirectionWhatWhen
PullLayout position/scroll/value → AssetDBBefore systems run
PushAssetDB transform/style changes → LayoutAfter systems run

Traceability

All bind operations include metadata.source = "layout_pull" so you can trace them in Reflow's tracing system. The bound_changes outport emits every pull/push operation for the DAG inspector:

[
  {"entity": "slider", "direction": "pull", "property": "transform"},
  {"entity": "slider", "direction": "push"}
]

Bind vs Explicit Wiring

ApproachWhen to use
:bind componentStandard form inputs, draggable elements, scroll-driven animations. Set it and forget it.
Explicit DAG wiringComplex logic between layout and state. Custom pull/push conditions. Multi-step transformations.

Both are visible in the DAG inspector. Bind is convenience — it doesn't bypass the system, it just saves you from wiring the obvious.

DOM Component Schemas

:dom — Element definition

{
  "tag": "button",
  "text": "Click me",
  "parent": "nav",
  "width": 120,
  "height": 40
}

:style — Visual properties

{
  "opacity": 1.0,
  "backgroundColor": "#007bff",
  "borderRadius": "8px",
  "transform": "scale(1.0)"
}

:transform — Position/rotation/scale

{
  "position": [100, 200, 0],
  "rotation": [0, 0, 0],
  "scale": [1, 1, 1]
}

:triggers — Events (consumed each tick)

["pointerEnter", "pointerDown", "scroll"]

Written by poll_events() or external input systems. Read and cleared by StateMachineSystem / BehaviorSystem.

:bind — Two-way sync toggle

true

or selective:

{ "transform": true, "value": true, "scroll": false }

Example: Animated Button

Set up an interactive button with hover/press states and tween animations, all driven by data:

#![allow(unused)]
fn main() {
let db = get_or_create_db("./app.db")?;

// Element
db.set_component_json("btn", "dom", json!({
    "tag": "button", "text": "Submit"
}), json!({}))?;

db.set_component_json("btn", "transform", json!({
    "position": [0, 0, 0], "scale": [1, 1, 1]
}), json!({}))?;

db.set_component_json("btn", "style", json!({
    "opacity": 1.0, "backgroundColor": "#007bff"
}), json!({}))?;

// State machine
db.put_json("btn:state_machine", json!({
    "current": "idle",
    "states": {
        "idle": { "onEnter": { "tween": "btn_scale_normal" } },
        "hover": { "onEnter": { "tween": "btn_scale_up" } },
        "pressed": { "onEnter": { "tween": "btn_scale_down" } }
    },
    "transitions": [
        { "from": "idle", "to": "hover", "trigger": "pointerEnter" },
        { "from": "hover", "to": "idle", "trigger": "pointerLeave" },
        { "from": "hover", "to": "pressed", "trigger": "pointerDown" },
        { "from": "pressed", "to": "hover", "trigger": "pointerUp" }
    ]
}), json!({}))?;

// Tweens
db.put_json("btn_scale_up:tween", json!({
    "target": "btn:transform.scale",
    "from": [1, 1, 1], "to": [1.05, 1.05, 1.05],
    "duration": 0.15, "easing": "easeOutCubic",
    "state": "paused"
}), json!({}))?;

db.put_json("btn_scale_down:tween", json!({
    "target": "btn:transform.scale",
    "from": [1.05, 1.05, 1.05], "to": [0.95, 0.95, 0.95],
    "duration": 0.1, "easing": "easeOutCubic",
    "state": "paused"
}), json!({}))?;

db.put_json("btn_scale_normal:tween", json!({
    "target": "btn:transform.scale",
    "from": [0.95, 0.95, 0.95], "to": [1, 1, 1],
    "duration": 0.2, "easing": "easeOutCubic",
    "state": "paused"
}), json!({}))?;

// Bind for auto sync
db.set_component_json("btn", "bind", json!(true), json!({}))?;
}

DAG:

IntervalTrigger(16ms) → LayoutSync(phase: "both", entity: "btn")
                      → StateMachineSystem(entity: "btn")
                      → TweenSystem(entity: "btn")

The button responds to hover/press with smooth scale animations. No Rust code for the interaction logic — it's all data in the AssetDB.