Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

.nornir/saga-idiot-guide.md

nornir-saga UI-movement — idiot guide (HOW-TO)

Capture UI movements from your Elm app in dev, then watch/replay them. The capture is compiled out of your release build — it costs nothing shipped.

A. Capture a saga from your app (dev only)

  1. Add nornir-saga as an OPTIONAL dep behind your own saga feature (so a release build without the feature pulls nothing):
# your app's Cargo.toml
[dependencies]
nornir-saga = { version = "0.1", optional = true, features = ["saga"] }

[features]
saga = ["dep:nornir-saga"]
  1. Tell nornir-saga how your Elm Msg maps to a UiMsg — implement SagaMsg once on your app's message type. Return None for messages that aren't UI movements (ticks, data loads):
use nornir_saga::ui::{SagaMsg, UiMsg};

impl SagaMsg for AppMsg {
    fn to_ui_msg(&self) -> Option<UiMsg> {
        match self {
            AppMsg::Pointer(x, y)   => Some(UiMsg::pointer_move(*x, *y)),
            AppMsg::Click(x, y, el) => Some(UiMsg::click(*x, *y, el)),
            AppMsg::Navigate(route) => Some(UiMsg::nav(route)),
            AppMsg::Focus(el)       => Some(UiMsg::focus(el)),
            AppMsg::Edit(el, v)     => Some(UiMsg::field_edit(el, v)),
            _ => None,
        }
    }
}
  1. Add ONE tap line at your Elm update choke point — the single place every Msg flows through:
fn update(&mut self, msg: AppMsg) -> Vec<Effect> {
    nornir_saga::ui::tap(&msg);   // no-op unless built with `--features saga`
    // … your real update …
}

One tap covers a lone facett widget AND a whole app — same hook, because both share the Elm update pipeline.

  1. Run with capture, then save the saga:
cargo run --features saga        # dev: capture is live
// at session end (or on a cadence):
let saga = nornir_saga::ui::drain_saga("my checkout session");
std::fs::write("session.json", saga.to_json())?;
  1. Ship clean — capture is gone:
cargo build --release            # no `saga` feature → tap() is a no-op,
                                 # nothing captured, no extra deps compiled

A′. Stream LIVE (or capture explicitly) via the emit seam

The global tap() above is the zero-config path (one process-global buffer, drained with drain_saga). When you want to stream movements live to a viewer, or hold your own capture buffer, use the pluggable emit seam — Emitter<S> over a UiSink:

Sink Use
VecSink in-process capture; .into_saga(title) to persist/replay
ChannelSink LIVE — sends each movement down an mpsc; pair the receiver with a ChannelSource feeding a viewer on another thread
NullSink drop everything (a runtime "capture off")
use nornir_saga::ui::{Emitter, ChannelSink, ChannelSource, Viewer};

// Host side: tap the SAME app Msg through an Emitter into a live channel.
let (tx, rx) = std::sync::mpsc::channel();
let mut emit = Emitter::new(ChannelSink::new(tx));

fn update(&mut self, msg: AppMsg, emit: &mut Emitter<ChannelSink>) {
    emit.tap(&msg);            // project (SagaMsg) + stamp t_ms + emit; no-op-shaped
    // … real update …
}
// emit.close() ends the stream when the session finishes.

// Viewer side (another thread): fold the live stream as it arrives.
let mut src = ChannelSource::new(rx);
let mut v = Viewer::new();
while let Some(ev) = src.next() { v.apply(ev.t_ms, &ev.msg); }  // renders live

Emitter also has emit(UiMsg), emit_at(t_ms, UiMsg) (deterministic, no clock) and emit_at_with_state(t_ms, UiMsg, state_json) (carry the model snapshot you observed, for inject-assert replay).

Prove it with the built-in wire-against fixture (no Njord, no browser)

nornir_saga::ui ships a synthetic saga-enabled ELM app so you can see the whole tap/emit → capture → persist → replay loop end to end in a test:

use nornir_saga::ui::{wire_synthetic, wired_saga, VecSink, ChannelSink, Player};

// capture: drive the scripted app through the emit seam into a VecSink → Saga
let saga = wired_saga("demo session");           // = wire_synthetic(VecSink::new()).into_saga(..)
let last = Player::new(saga).frames().pop().unwrap();
// each event carries the app's observed state_json → replay asserts it matches:
assert_eq!(last.state_json, last.expect_state.unwrap());

// LIVE: same app, streamed through a ChannelSink to a viewer thread
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || { wire_synthetic(ChannelSink::new(tx)); });
// … fold rx via ChannelSource on this thread …

See crates/nornir-saga/tests/wire_against.rs for the full end-to-end assertions.

B. Watch / replay a saga (nornir CLI)

No app of your own? Use the built-in synthetic saga with --demo.

# Final folded model (the whole observable ViewerModel as state_json):
nornir saga view session.json
nornir saga view --demo

# The full deterministic frame sequence (one state_json per movement):
nornir saga view session.json --frames

# Replay: print the deterministic frames (JSONL), time-scaled:
nornir saga replay session.json --speed 2

# Replay in the browser — a self-contained page that actually MOVES
# (cursor + trail + route + log, live over SSE). Needs the web feature:
cargo build --features saga-web
nornir saga replay --demo --web            # serves http://127.0.0.1:8971

# Drive the movements at a REAL page via the WebReplayDriver seam.
# Without the drive feature this prints the gestures it WOULD dispatch (mock):
nornir saga replay --demo --drive http://localhost:8080

# With a real browser (needs a running geckodriver/chromedriver):
cargo build --features saga-drive
nornir saga replay session.json --drive http://localhost:8080 \
      --webdriver http://localhost:4444

C. Cheat sheet

Want Do
Capture in dev app dep nornir-saga optional + saga feature; tap(&msg) at update; cargo run --features saga
Ship without capture cargo build --release (no saga feature) — tap is a no-op, zero deps
See the model nornir saga view <saga.json> (or --demo)
Replay frames nornir saga replay <saga.json> [--speed N]
Replay in a browser build --features saga-web; nornir saga replay … --web
Move a real page --drive <url> (mock), or build --features saga-drive + --webdriver <endpoint> for a real browser

The contract, the ELM-dispatch tap, and the design rationale are in .nornir/saga-design.md; the original feature request is in .nornir/nornir-saga.md.