Spec Construction
JSON-UI specs are flat element maps. There are four ways to construct one. Pick by the shape of the data, not by precedent.
The four strategies are not interchangeable. Each one matches a specific data shape, and choosing the wrong one tends to produce code that is either more verbose than necessary or that cannot express the case at hand. The decision rubric below maps data shape to strategy so the choice can be made by inspection.
Decision rubric
| Your data shape | Use | Where it lives |
|---|---|---|
| Static — the page does not depend on per-request data | JSON file + JsonUi::render_file | src/views/{module}/{view}.json |
| Homogeneous iteration — N elements with uniform shape, data-driven count | JSON file + $each directive | src/views/{module}/{view}.json |
| Conditional emission — single template, branches on a runtime flag | JSON file + $if directive | src/views/{module}/{view}.json |
| Heterogeneous runtime construction — element graph cannot be expressed declaratively | Rust + SpecBuilder::element_nested | controller handler |
Read the rubric top-down. The first row whose "data shape" description matches is the one to use. The four sections below show one worked example per quadrant.
Static spec
The page structure does not depend on per-request data. Props may still bind to data via $data, but the set of elements and the parent/child graph is fixed.
JSON file (src/views/dashboard/payments.json):
{
"$schema": "ferro-json-ui/v2",
"title": "Payments",
"layout": "dashboard",
"root": "page_header",
"elements": {
"page_header": {
"type": "PageHeader",
"props": { "title": "Payments" }
}
}
}
Rust handler:
#![allow(unused)] fn main() { use ferro::{handler, JsonUi, Response}; #[handler] pub async fn index() -> Response { JsonUi::render_file("src/views/dashboard/payments.json", serde_json::json!({})) } }
When the page has no per-request structure (the elements and their relationships are fixed), the static form is the default. Adding directives or moving to SpecBuilder for a static page introduces machinery without changing the output.
Homogeneous iteration — $each
N elements share one structure; the count and the per-element data come from a runtime array. The $each directive instantiates one element per row in a data array, with auto-suffixed IDs ({id}-0, {id}-1, ...) that prevent collisions.
JSON file (orders kanban):
{
"$schema": "ferro-json-ui/v2",
"title": "Orders",
"layout": "dashboard",
"root": "kanban_board",
"elements": {
"kanban_board": {
"type": "KanbanBoard",
"props": { "columns": { "$data": "/columns" } },
"children": ["order_card"]
},
"order_card": {
"type": "Card",
"$each": { "path": "/orders", "as": "order" },
"props": {
"title": { "$template": "#{/order/order_number} — {/order/total_display}" },
"description": { "$data": "/order/customer_name" }
}
}
}
}
At resolve time, ferro replaces order_card with order_card-0, order_card-1, ..., one per row in data.orders. The parent's children list is rewritten to reference the new clone IDs.
See ./expressions.md#each for the full directive reference (fields, validation, correlated children, limitations).
Conditional emission — $if
A single element template branches on a runtime flag. The $if directive removes the element from the spec entirely when the predicate is false — no DOM is emitted at all.
JSON file fragment:
"btn_advance": {
"type": "Button",
"$if": { "path": "/can_advance", "operator": "eq", "value": true },
"props": { "label": "Advance" }
}
$if is distinct from the visible prop on elements. The difference matters:
$ifis evaluated at resolve time. A falsy predicate removes the element fromSpec.elements. No HTML is emitted.visibleis evaluated at render time. A falsy condition renders the element with hidden semantics. The HTML is present but suppressed visually.
Use $if when the element should not exist in the response (security-sensitive actions, structural omission, eliminating wasted bytes). Use visible when client-side toggling needs the DOM available.
See ./expressions.md#if for the full predicate syntax, validation rules, and composition with $each.
Heterogeneous runtime construction — SpecBuilder
The element graph is computed from complex domain state and cannot be expressed declaratively. The graph shape itself depends on runtime data, not just per-element props or counts.
The SpecBuilder nested-element API constructs the flat element map from nested Rust types. Child IDs are auto-generated by structural position (root-0, root-1, ...).
#![allow(unused)] fn main() { use ferro::json_ui::{Spec, SpecBuilder, Element}; let spec: Spec = SpecBuilder::new() .title("Order detail") .layout("dashboard") .element_nested("root", Element::new("Card") .prop("title", "Order #1042") .child_nested(Element::new("Text").prop("content", "Status: confirmed")) .child_nested(Element::new("Button").prop("label", "Advance"))) .build()?; }
The existing flat SpecBuilder::element(id, builder) API remains available for cases where explicit IDs are preferred. The nested form is sugar over construction; the runtime Spec type is the same flat element map either way.
Reach for SpecBuilder only after confirming the case cannot be expressed with $each and $if. Most controller code that hand-builds heterogeneous shapes is doing one of two things that the directives already cover: iterating a list (use $each) or branching on a flag (use $if). Truly heterogeneous runtime construction — a graph whose nesting structure depends on runtime values, not just per-element data — is the only case where Rust construction is the right tool.
Namespace: element-level vs prop-level directives
Directives split into two namespaces, distinguished by where they appear in the JSON.
Element-level directives appear as keys on Element JSON objects, alongside type, props, children. They are resolved by expand_directives before render.
$each— iterate, instantiating one element per row.$if— conditional emission, removing the element when falsy.
Prop-level directives appear inside props values. They are resolved by resolve_expressions after expansion.
$data— type-preserving extraction fromspec.data.$template— string interpolation with{/path}placeholders.
There is no $template element. Element-level templating is covered by $each; the $template keyword is reserved for prop-level string interpolation. A separate $template element would be a parallel mechanism without additional expressive power, and it would collide semantically with the existing prop-level $template directive.
Composition rules
$eachand$ifcan co-occur on the same element.$ifis evaluated first; if false, the element is removed and$eachproduces no clones.- Sibling templates with the same
{path, as}pair are correlated by index. The i-th clone of one sibling references the i-th clone of the other. Different{path, as}pairs at the same level are rejected asMismatchedEachat validation time. - Nested
$eachdeeper than direct-children siblings is rejected asNestedEachat validation time. If a nested-iteration case appears, file an issue with the data shape. - Reserved
asnames:data,root,_root,_each,this,self. Using any of these as the loop variable is rejected asEachAsReservedName.
Nesting depth limit
The maximum allowed nesting depth from the root element is controlled by MAX_NESTING_DEPTH, currently set to 5. Specs that exceed depth 5 fail validation with SpecError::DepthExceeded.
The limit accommodates dashboard layouts at depth 4 (root → grid → card → badge) with one level of headroom. A typical deep layout — root → grid → card → row → atom — sits exactly at depth 5.
If a layout exceeds depth 5, flatten with Element.children ID references instead of physical nesting. Most layouts that appear to require deep nesting can be restructured by promoting inner containers to named top-level elements and wiring them via explicit children IDs.
Spec.title binding
The title field accepts either a literal string or a {"$data": "/path"} binding. Bindings are resolved against spec.data at render time via JSON Pointer. When the path is missing or the resolved value is not a string, the title falls back to "Ferro".
Literal title:
{
"$schema": "ferro-json-ui/v2",
"title": "Orders",
"layout": "dashboard",
"root": "page_header",
"elements": { ... }
}
Binding title (resolved from handler data):
{
"$schema": "ferro-json-ui/v2",
"title": { "$data": "/page_title" },
"layout": "dashboard",
"root": "page_header",
"elements": { ... }
}
With handler data { "page_title": "Order #1042" }, the rendered <title> is Order #1042. The $data path follows JSON Pointer syntax (/key/nested).
Note: title is the only top-level spec field that accepts a binding expression. layout, root, and $schema are always literal strings.
Decision examples
The following concrete cases illustrate how the rubric maps to real controllers.
Case 1 — Settings page with a fixed list of switches.
The set of switches is hardcoded; their state comes from the database. Static spec — the elements are fixed; $data binds the switch values.
Case 2 — Order kanban with N order cards.
The card structure is uniform; the count comes from the orders query. Homogeneous iteration — $each over /orders.
Case 3 — Order detail with an "Advance" button that only appears when the order is advanceable.
A single button template; one runtime flag controls emission. Conditional emission — $if with { path: "/can_advance", operator: "eq", value: true }.
Case 4 — Form whose field set depends on the chosen product type.
The field set varies in nesting structure per product type, not just per-field props. The selected type determines which subgroups exist, in what order, and at what depth. Heterogeneous runtime construction — SpecBuilder::element_nested in the handler.