Authoring from Rust
fuaran-rs is the dual-role host of the Fuaran UI wire format. The same
dependency-light crate serves a native backend, an edge worker, an embedded
target — and, compiled to wasm32, a browser-native client that renders and
drives a tree with no JavaScript frontend. Nothing in the authoring surface
changes between those roles; what changes is where the session lives.
This page is the authoring reference: the typed model, the round trip, and the mutation algebra. For the browser leg see The WASM browser client.
Names first, because the split catches people out: the crate publishes to
crates.io as fuaran-ui, this repository is fuaran-rs, and the import
path is fuaran_rs. So cargo add fuaran-ui, then use fuaran_rs::….
The headline: the wire's closed DUs are native enums
Every closed vocabulary in the wire format — NodeKind, the per-kind Spec
records, Binding, Action, TextSource, TreeOp — is a native Rust enum
with one variant per $type case. That is not a stylistic choice, and it is the
one structural advantage this host has over every other:
pub enum NodeKind {
// Layout
Box(BoxSpec),
SplitPanel(SplitPanelSpec),
Tabs(TabsSpec),
// … Display, Input, Visualisation, Structural
Mount(MountSpec),
}
A match over it with no _ arm is checked at compile time. When a kind is
added to the wire format, every such match stops compiling — the codec arm, the
renderer arm, the class-name arm, each one named by the compiler with its file
and line. In a language without sum types that same change is a runtime fallback
nobody notices until a fixture reds, and the exhaustiveness guard a host has to
build to compensate is a test rather than a proof.
The crate leans on this deliberately. NodeKind::type_name and
NodeKind::category are written as default-free matches precisely so that a
new kind cannot slip through them, and the same discipline runs through the
encoder, the decoder and the renderer.
So when you write your own walk over a decoded tree, do not add a _ => …
arm. You are giving up the guarantee the host is built around, and the cost
lands on whoever adopts the next wire version.
Building a tree
A node is a struct; its kind is an enum variant carrying that kind's spec:
use fuaran_rs::wire::{
encode_node, Accessibility, BoxLayout, BoxRole, BoxSpec, HeadingSpec,
HeadingVariant, MarkdownSpec, Node, NodeKind, TextSource,
};
let tree = Node {
id: "root".into(),
kind: NodeKind::Box(BoxSpec {
children: vec![
Node {
id: "title".into(),
kind: NodeKind::Heading(HeadingSpec {
level: 2,
text: TextSource::Literal("Channel performance".into()),
variant: HeadingVariant::Standard,
}),
state: Default::default(),
style: Default::default(),
accessibility: None,
tooltip: None,
},
Node {
id: "note".into(),
kind: NodeKind::Markdown(MarkdownSpec {
text: TextSource::Literal("**Hello** from a Fuaran tree.".into()),
}),
state: Default::default(),
style: Default::default(),
accessibility: None,
tooltip: None,
},
],
heading: None,
layout: BoxLayout::Auto,
role: BoxRole::Dashboard,
keep_together: false,
break_before: false,
}),
state: Default::default(),
style: Default::default(),
accessibility: Some(Accessibility { role: Some("main".into()), ..Default::default() }),
tooltip: None,
};
Two things in there are recent additions and are the reason a spec struct is worth
re-reading rather than remembering. BoxSpec's keep_together and break_before
are paged-medium declarations — this container stays on one page; this container
starts a fresh one — and both omit at false on the wire, so an ordinary screen
tree carries neither. There is deliberately no break-after member anywhere: a
break after this container is a break before the next one.
A spec struct is a plain struct with no Default, so the compiler names every
field you left out. That is the intended experience: when the wire format grows a
member, your construction sites stop compiling and you decide what each should
say, rather than silently inheriting someone else's zero value.
The Node envelope is:
pub struct Node {
pub id: NodeId,
pub kind: NodeKind,
pub state: StateBehaviour, // restored to empty when the wire omits it
pub style: SemanticStyle, // restored to all-default when the wire omits it
pub accessibility: Option<Accessibility>,
pub tooltip: Option<TextSource>, // a DESCRIPTION, never a name
}
state, style, accessibility and tooltip are envelope siblings of kind,
not fields inside a spec. Default::default() on the first two is the common
case and encodes to nothing — the canonical form omits what is default, so a
plainly-styled node produces the bare {"$type":"…", …} object.
tooltip is worth one sentence of its own: it is a hint, uniform across kinds,
which is why it sits on the envelope. A host projects it as aria-describedby
and never as aria-label — it describes, it does not name.
Text and bindings
TextSource has three cases:
pub enum TextSource {
Literal(String),
Bound(Box<Binding>),
I18n { key: String, args: Vec<(String, JVal)> },
}
A Literal is the canonical spelling for constant text — on the wire it is a
bare string, not an envelope. Anything live goes through Binding, whose cases
include Static, State { key, default_value }, Query { name, depends_on },
Filter { name, default_value }, Selection { node_id, default_value, field },
Format, Transform, I18n, Local, Computed, and the host-furnished
Now.
Now deserves a note because it is the one binding that looks like a clock read
and is not: it resolves once per render pass from a host-pinned ISO-8601 string,
never from a call into the system clock inside the renderer. That is what keeps
server-side output reproducible for a pinned instant.
Encoding and decoding
pub fn encode_node(n: &Node) -> String;
pub fn decode_node(json: &str) -> Result<Node, DecodeError>;
pub fn encode_op(op: &TreeOp) -> String;
pub fn decode_op(json: &str) -> Result<TreeOp, DecodeError>;
encode_node is infallible — a value of type Node is a tree that exists, so
there is nothing left to fail on. That is the type system paying for itself: the
structural hosts have to return a Result here because their model admits
documents the wire does not.
decode_node returns a DecodeError carrying the canonical code and a
$-rooted path, the same codes every conformant host surfaces for the same
input: INVALID_JSON, MISSING_FIELD, WRONG_TYPE, UNKNOWN_DU_CASE,
WRONG_NODE_KIND, EMPTY_NODE_ID, LIMIT_EXCEEDED, plus the out-of-band
FOREIGN_PROFILE for a versioning envelope this host cannot speak.
The corpus round-trip legs in tests/conformance.rs hold the byte-level
contract: every node and op fixture re-encodes byte-identically, and every
reject fixture surfaces the canonical code at the expected path.
Validating before you emit
Decoding checks input. validator checks a tree you built:
use fuaran_rs::validator::{validate, validate_with, Severity, ValidateOptions};
for f in validate(&tree) {
eprintln!("{:?} {} on {}: {}", f.severity, f.code, f.node_id, f.message);
}
pub struct Finding {
pub severity: Severity, // Warning | Error
pub code: &'static str, // FUARAN###, or EMPTY_NODE_ID for the §8 identity re-check
pub node_id: String,
pub message: String,
}
validate_op does the same for a single op. validate_with(&tree, ValidateOptions { orchestrated: true }) hardens the wire-survivability
advisories (FUARAN084) from Warning to Error — the mode to use when the tree
was emitted by a model rather than written by a person, where an escape that
merely usually survives is not good enough.
The codes are cross-host, so a finding here is the finding a reviewer on any other host would get.
Mutating a tree
use fuaran_rs::ops::{apply, can_apply};
let outcome = apply(&tree, &op)?; // ApplyOutcome { new_tree, emitted_telemetry }
let next = outcome.new_tree;
apply is total: a refusal is a structured ApplyError { code, message, batch_index }, never a panic, and the input tree is borrowed rather than
consumed so nothing is lost on a refusal. A Batch is atomic — it applies whole
or not at all, and batch_index names the inner op that refused.
can_apply is the dry run, and the apply-envelope law (can_apply ≡ apply
succeeds) is pinned by tests/apply.rs rather than assumed.
InsertChild and MoveNode append — there is no positional slot
Both ops put the node last, and a wire document carrying position or
newPosition is refused (WRONG_TYPE at $.position / $.newPosition),
checked before the required-field reads. The slot is retired vocabulary, not an
optional extra.
Placing anywhere else is two ops: Batch [InsertChild …, ReorderChildren …].
Rather than assemble that by hand, ops::placement derives it:
use fuaran_rs::ops::placement::{move_op, Placement, Target};
let target = Target::new("sidebar", Placement::Before("filters".into()));
let op = move_op(&tree, "legend", &target)?;
Placement is Last | First | Before(id) | After(id). Beside move_op sit
place_op, nudge_op, can_place (the pre-check, no dry-run apply) and the
clone verbs duplicate_op / paste_op, which mint fresh ids through a
FreshIds seam — DerivedIds (<id>-copy, probing) by default, or
SequentialIds::new(prefix) when you need deterministic replay.
These are helpers, not new wire: what comes out is an ordinary op any host
applies, and a PlaceError names the apply-time refusal it pre-states
(MoveIntoDescendant, UnknownAnchor, ChildlessKind, and the rest).
What this host renders in-host
Two postures worth knowing, because they differ from the other backend host and the difference is deliberate:
-
A
Chartis lowered in-host. This host takes the lower-in-host posture: aChartreaching the renderer is deterministically lowered to a canonicalDrawingand painted as inline SVG. The reason is the browser leg — a WASM client that could not paint a chart would not reach parity with the clients it sits beside, so the lowering earns its pixels here where in a purely headless host it would not. -
A
Sparklineis lowered in-host too, and through the same builder. The series is lowered to a canonicalDrawing(render::sparkline_lowering) and painted as inline SVG inside afuaran-sparklinecontainer — so the picture agrees with every other host's by construction rather than by a hand-written copy kept in step. An unresolved or empty series keeps the em-dash placeholder: that fallback is a host element rather than aShape, so the lowering reports it in the type (None) instead of drawing an empty canvas nobody can read.The
fuaran-sparklineclass is a CONTAINER, not the drawn element. That is where the 100×30 sizing and the inheritedcolorthe lowering'scurrentColorstroke reads have always lived, and it is what the byte-copied reference stylesheet targets —.fuaran-sparkline > .fuaran-drawing, a DIRECT-child selector, which is why the arm splices the bare svg rather than reusing theDrawingnode's own<div>wrapper.The contract is
wire-format-fixtures/sparkline-lowering/*, andtests/sparkline_lowering.rscertifies every committed vector byte-for-byte plus the wiring that reaches it. Before that suite this arm had no test at all — the corpus round-trip walk exercises the decode of aSparklineand says nothing about the picture.
The crate's shape
crate-type = ["lib", "cdylib", "staticlib"], and all three matter:
lib— native consumers and the test suite.cdylib— thewasm32browser module and native dynamic consumers.staticlib—libfuaran_rs.a, which the native Swift and Kotlin surfaces link over the C-ABI insrc/ffi/(declared ininclude/fuaran.h). Those surfaces decode and render; this crate owns truth and mutation for them.
Dependencies: none. Rust's standard library has no JSON, so the canonical layer is hand-written — which it would have to be anyway, since no general serialiser produces the byte-exact number and key layout the wire requires.
Edition 2024, rust-version = "1.85". The host is pre-1.0 and declares no
STABILITY.md; read the version off fuaran_rs::VERSION, which is derived from
Cargo.toml rather than restated (this paragraph named 0.0.2-alpha for two
releases after the crate had left it). 0.0.2-alpha carried a breaking change to
the DAG record surface, so pin deliberately.
Verifying
.\run.ps1 # cargo fmt --check -> clippy -> build -> test -> wasm build
.\run.ps1 -SkipTests # switches: -SkipFormat / -SkipBuild / -SkipTests
.\run.ps1 -DriverSemantics # adds the bounded-loop conformance legs
run.ps1 builds the wasm32 module automatically when the target is installed
(rustup target add wasm32-unknown-unknown). The conformance legs certify
against the shared corpus and skip cleanly when the repo is checked out alone.