#Widget Catalogue
Widgets are the building blocks of an app page. Each is one entry in a page's
widgets record — widgetComponent selects the widget, config is its options
(config values may carry {{ … }} smart values, see
Smart Values):
"widgets": {
"tasks": { "widgetComponent": "data_grid", "config": { "header": "Open tasks", "columns": [ … ] } }
}
A page places its widgets with its layout; the layout's own building blocks —
sections, toolbars, tabs, headers, footers — are in
App Layouts. For the structure that holds pages
(dashboards → pages → widgets) see
App spec structure.
#A real app, rendered
Everything below renders with the actual app widgets — this is a live Tasks dashboard (a heading, a task table, and a create-task form), not a screenshot:
{
"title": "Tasks · main dashboard",
"widgets": [
{
"widgetName": "headline",
"config": {
"text": "Tasks",
"headerLevel": 1
}
},
{
"widgetName": "table",
"config": {
"header": "Open tasks",
"columns": [
{
"field": "title",
"header": "Task",
"type": "string"
},
{
"field": "state",
"header": "State",
"type": "string"
},
{
"field": "priority",
"header": "Priority",
"type": "number"
}
],
"rows": [
{
"id": "t1",
"title": "Draft kickoff brief",
"state": "doing",
"priority": 1
},
{
"id": "t2",
"title": "Set up CI",
"state": "todo",
"priority": 2
},
{
"id": "t3",
"title": "Write API smoke test",
"state": "done",
"priority": 3
}
]
}
},
{
"widgetName": "form",
"config": {
"header": "New task",
"fields": [
{
"id": "title",
"label": "Task title",
"type": "string",
"required": true,
"placeholder": "What needs doing?"
},
{
"id": "state",
"label": "State",
"type": "string",
"options": [
{
"label": "To do",
"value": "todo"
},
{
"label": "Doing",
"value": "doing"
},
{
"label": "Done",
"value": "done"
}
]
},
{
"id": "priority",
"label": "Priority",
"type": "number",
"min": 1,
"max": 5
}
],
"submit": {
"label": "Add task"
}
}
}
]
}
This page uses the table widget (its rows are inline). In a production app the
server-resolved data_grid does the same job at scale — reading
rows from a named dataSource, paging, and carrying row actions. The form
submits a command and buttons navigate or run actions; previews here are visual,
so interactions are inert.
| Widget | What it is |
|---|---|
headline |
A heading. |
data_grid |
A paged, sortable, filterable grid over a named page source. |
table |
A table over rows handed in as a value, optionally grouped. |
cards |
A list of rows drawn as cards — a comment thread, an activity feed. |
actions |
A row of buttons: authored actions and decided controls. |
form |
An input form, plain or bound to entities. |
filter_bar |
Free-input filters that publish their values as state. |
chips |
One row of preset filter chips. |
page_tabs |
Tab buttons that navigate between pages, with params. |
section_switcher |
Buttons that switch sections within one page. |
breadcrumbs |
A breadcrumb trail. |
entity_info |
A labelled field list for one entity. |
custom_markup |
Authored markup with named handlers. |
#Content & data widgets
#headline
A static (or smart-valued) heading.
| Field | Type | Req | Notes |
|---|---|---|---|
text |
string | ✓ | Heading text (may be {{ }}). |
headerLevel |
int 1–3 | Visual level; defaults to 1. |
{
"widgetName": "headline",
"config": {
"text": "Vessels",
"headerLevel": 2
}
}
#data_grid
The workhorse: a sortable grid over one named page source, with a text filter
per column, row actions, menus of decided controls,
status cells, selection and expandable rows, paged by a page-number pager, a
cursor, or a Load more button. Filtering by a picker composes from OUTSIDE the
grid: a filter_bar or chips widget publishes state that the grid's SOURCE
reads through optional filter particles. Data Grids is
the full guide — sources (including rows read from an action with $job),
paging, server-paged $http, row actions, exports and the wire format.
| Field | Type | Req | Notes |
|---|---|---|---|
dataSource |
string | ✓ | The NAME of an entry in the page's dataSources map — a $query, $rows, $http or $job source ($typeSchema cannot feed a grid). |
columns |
array | ✓ | { type, field, header?, display?, lookup?, statusMap?, sortable?, filterable?, minWidth?, maxWidth? }. type is string / number / date / dateTime / status. field is an attribute, Type.attr, a system column ($name, $created_at, …) or a dotted path, depending on the source. |
actions / leftActions |
rowAction[] | Row controls, all in one actions column: an icon action { icon, command, accessor?, hrefAccessor? } or a menu { menu: [{ label, command, … }] }. The command sees the row's id as itemId. |
|
fromRow / fromRowMenu |
string / object | The path to each row's decided controls, and how their row menu lists them — see Data Grids → Menus of decided controls. | |
selectable |
boolean | Checkbox column; checked ids are thisWidget.selectedRows / page.widgets.<id>.selectedRows. |
|
expand |
boolean | An expand chevron per row; the open row's id is page.widgets.<id>.state.expand, read by a widget you place. |
|
pageSize |
int 1–200 | Rows per page (default 20); a paged grid reads at most 100 a page. | |
paginate |
true | false | "cursor" | "more" |
The pager (default), previous / next by cursor, every row on one page, or a Load more button — see Data Grids → Paging. | |
surfaceKey |
string | The surface the grid's sort and filters belong to, for one grid that lists different things in place. | |
header |
string | Wraps the grid in a titled section. | |
noResultsMessage |
string | Text shown in place of the grid when the source has no rows. |
The preview supplies one page of rows the way the engine would (data keyed by
the source name; total drives the pager):
{
"widgetName": "data_grid",
"config": {
"header": "Open tasks",
"dataSource": "tasks",
"pageSize": 3,
"columns": [
{
"type": "string",
"field": "title",
"header": "Task"
},
{
"type": "string",
"field": "state",
"header": "State",
"lookup": {
"todo": "To do",
"doing": "Doing"
}
},
{
"type": "date",
"field": "due",
"header": "Due"
}
],
"actions": [
{
"icon": "openLink",
"command": []
}
]
},
"data": {
"tasks": {
"rows": [
{
"id": "t1",
"attributes": {
"title": "Draft kickoff brief",
"state": "doing",
"due": "2026-10-01"
}
},
{
"id": "t2",
"attributes": {
"title": "Set up CI",
"state": "todo",
"due": "2026-10-03"
}
},
{
"id": "t3",
"attributes": {
"title": "Write API smoke test",
"state": "todo",
"due": "2026-10-08"
}
}
],
"total": 5
}
}
}
#table
A table over rows handed in as a value — a page-data value, an action's output,
a config slice — rather than a named source: no server fetch, no pager. Columns
read each row flat (row[field], or a dotted path for a status column). A
row with no id is keyed by its position.
| Field | Type | Req | Notes |
|---|---|---|---|
columns |
array | ✓ | { field, header?, type?, statusMap? } — type is string (default), number or status. A status column shows the status view at its field path, worded by its statusMap (Status cells). |
rows |
array | ✓ | The rows — usually { "$jsonata": "page.data.<source>" } (a {{ }} template would turn the array into text). null renders an empty table. |
actions / leftActions |
rowAction[] | Per-row controls, as on a data_grid. |
|
fromRow |
string | Path on each row to its list of decided controls: the table adds one row menu of them, each item enabled only on the rows that offer it enabled. | |
fromRowMenu |
object | { items?, match?, merge? } — list the menu's items yourself, or merge them into the first actions menu; see Data Grids → fromRowMenu. |
|
groupBy |
object | { field, labelField, command? } — one heading and one table per distinct field value, in first-row order. With command, each heading is a button whose command reads {{ thisWidget.state.groupKey }} / groupLabel. |
|
header, noResultsMessage |
string | Copy. |
{
"widgetName": "table",
"config": {
"header": "Recent",
"columns": [
{
"field": "name",
"header": "Name",
"type": "string"
},
{
"field": "status",
"header": "Status",
"type": "string"
}
],
"rows": [
{
"id": "1",
"name": "Aurora",
"status": "active"
},
{
"id": "2",
"name": "Borealis",
"status": "docked"
}
]
}
}
A checklist of steps. A process read hands a page one item's steps — each
with its words, a status view and its controls — and a table draws them with a
status column and a row menu:
"dataSources": {
"steps": {
"$job": {
"action": "describeProcessActions",
"config": { "processes": ["inspection"], "anchorIds": ["{{ page.params.id }}"] },
"rowsPath": "anchors.0.steps"
}
}
},
"widgets": {
"checklist": {
"widgetComponent": "table",
"config": {
"rows": { "$jsonata": "page.data.steps" },
"groupBy": { "field": "category", "labelField": "category" },
"columns": [
{ "field": "label", "header": "Step" },
{ "field": "status", "header": "Status", "type": "status",
"statusMap": [ { "state": "DONE", "glyph": "✅", "label": "{label} on {date}" } ] }
],
"fromRow": "actions"
}
}
}
{
"widgetName": "table",
"config": {
"columns": [
{
"field": "emit",
"header": "Step"
},
{
"field": "status",
"header": "Status",
"type": "status",
"statusMap": [
{
"state": "DONE",
"glyph": "✅",
"label": "{label} on {date}"
}
]
}
],
"rows": [
{
"emit": "Survey",
"status": {
"state": "IN_PROGRESS",
"label": "Surveying",
"tone": "active",
"badges": [],
"stateAt": "2026-10-05T16:40:00Z"
},
"actions": [
{
"actionId": "inspection:survey:v1:complete",
"transition": "complete",
"label": "Pass",
"icon": null,
"variant": null,
"cta": true,
"surfaces": null,
"enabled": true,
"reason": null,
"details": null,
"confirm": null,
"intent": {
"kind": "process.perform",
"target": {
"process": "inspection",
"emit": "survey",
"anchorId": "b7fd4216-8dda-455c-a60d-ceb88dafe8b0"
},
"transition": "complete",
"inputs": {},
"expectedUpdatedAt": "2026-10-05T16:40:00Z"
}
},
{
"actionId": "inspection:survey:v1:undo",
"transition": "undo",
"label": "Undo",
"icon": null,
"variant": null,
"cta": false,
"surfaces": null,
"enabled": true,
"reason": null,
"details": null,
"confirm": null,
"intent": {
"kind": "process.undo",
"target": {
"row": {
"type": "Check",
"id": "eff63216-7b7e-4e22-a8aa-ea45cc441394"
}
},
"expectedUpdatedAt": "2026-10-05T16:40:00Z"
}
}
]
},
{
"emit": "Paperwork",
"status": {
"state": "DONE",
"label": "Filed",
"tone": "done",
"badges": [],
"stateAt": "2026-10-02T09:00:00Z"
},
"actions": []
}
],
"fromRow": "actions"
}
}
A press on a row's item reads the rows fresh and does what the clicked row's descriptor says — see Decided controls.
#cards
A list of rows drawn as cards, in the order given: each row its own card with a
heading line, a body and a right-aligned caption, a divider between cards. It is
what a comment thread or an activity feed needs. It writes nothing — a page adds
to the list with a form beside it.
| Field | Type | Req | Notes |
|---|---|---|---|
rows |
array | ✓ | The rows, usually { "$jsonata": "page.data.<source>" }. null is an empty list. |
body |
string | ✓ | Path on each row to the card's body. |
title |
string | Path on each row to the card's heading line. | |
caption |
string | Path on each row to the card's caption. | |
captionType |
enum | text (default) shows the caption as written; date shows a timestamp as its day. |
|
noResultsMessage |
string | Shown when there are no rows; without it an empty list draws nothing. |
Text, numbers and booleans show as written; a path with nothing at it shows nothing.
{
"widgetName": "cards",
"config": {
"rows": [
{
"id": "c1",
"author": "Ana",
"text": "Hull inspected, no findings.",
"at": "2026-09-30T08:15:00Z"
},
{
"id": "c2",
"author": "Ben",
"text": "Certificates uploaded.",
"at": "2026-10-02T14:40:00Z"
}
],
"title": "author",
"body": "text",
"caption": "at",
"captionType": "date",
"noResultsMessage": "No comments yet"
}
}
A thread over entity rows reads their attributes by path:
"dataSources": {
"comments": { "$query": { "typeName": "Comment", "sort": [{ "path": ["created_at"], "direction": "asc" }] } }
},
"widgets": {
"thread": { "widgetComponent": "cards", "config": {
"rows": { "$jsonata": "page.data.comments" },
"title": "attributes.author", "body": "attributes.text",
"caption": "created_at", "captionType": "date",
"noResultsMessage": "No comments yet"
} }
}
#actions
A free-standing row of buttons. Each entry is either an authored action — a
label and what it does — or a decided control, a descriptor a process read
hands the page (see Decided controls); one list may mix
both. To pin the row to the bottom of a modal, wrap it in a footer
(App Layouts → footer).
| Field | Type | Req | Notes |
|---|---|---|---|
actions |
array | ✓ | Authored actions and descriptors, in order. Must be an array — wrap a computed list in $append([], …) (below). |
overflow |
boolean | Draw at most one button — the first call to action on offer (a descriptor's cta, or an authored primary), else the first action — and put every other enabled action in one ⋮ dropdown. |
|
hideDisabled |
boolean | Leave every disabled action off the row instead of saying why. |
An authored action:
| Field | Type | Notes |
|---|---|---|
label |
string | Required. |
command |
step sequence | What a press runs. Exactly one of command and href. |
href |
string | Renders a link instead of a button; a press opens it in the client and calls nothing. Build host addresses with $link (Links). |
variant |
enum | primary, secondary (default) or ghost. |
icon |
string | A leading icon (the row-action icon names, e.g. starOutline). |
enabled |
boolean | Whether the action can be used; a smart value, resolved before the widget draws. Absent means enabled. |
reason |
string | Why it is disabled, in the viewer's words. |
A disabled action — an authored one with enabled: false, or a descriptor
with "enabled": false — is drawn as disabled text: its reason (a
descriptor's reason.label), else its label. It has no button, and a press on
its old id runs nothing.
{
"widgetName": "actions",
"config": {
"actions": [
{
"label": "Edit",
"variant": "primary",
"command": []
},
{
"label": "Export CSV",
"command": []
},
{
"label": "Publish",
"command": [],
"enabled": false,
"reason": "Only an admin can publish"
}
]
}
}
Gating an action on a permission. enabled is resolved before the widget
draws, so it can read any page value — typically a
checkPermissions
answer:
"dataSources": {
"access": { "$job": { "action": "checkPermissions",
"config": { "checks": [ { "resource": "entity", "resourceName": "Vessel", "action": "update" } ] },
"rowsPath": "allowed" } }
},
"widgets": {
"edit": { "widgetComponent": "actions", "config": { "actions": [ {
"label": "Edit", "variant": "primary",
"command": [ { "kind": "navigate", "page": "edit", "modal": true } ],
"enabled": { "$jsonata": "page.data.access[0]" },
"reason": "You cannot edit vessels"
} ] } }
}
The answer is for display: what the command writes is judged again when it runs.
One primary action per bar. With overflow: true a bar shows one button —
the first call to action — and every other enabled action in a ⋮ dropdown; a
disabled action stays beside them as disabled text unless hideDisabled leaves
it off. This is the shape for an item's step controls:
"bar": {
"widgetComponent": "actions",
"config": {
"overflow": true,
"actions": { "$jsonata": "$append($append([], page.data.steps.actions), [{ 'label': 'Refresh', 'command': [{ 'kind': 'refresh' }] }])" }
}
}
{
"widgetName": "actions",
"config": {
"overflow": true,
"actions": [
{
"actionId": "inspection:paperwork:v1:complete",
"transition": "complete",
"label": "File papers",
"icon": null,
"variant": null,
"cta": false,
"surfaces": null,
"enabled": true,
"reason": null,
"details": null,
"confirm": null,
"intent": {
"kind": "process.perform",
"target": {
"process": "inspection",
"emit": "paperwork",
"anchorId": "b7fd4216-8dda-455c-a60d-ceb88dafe8b0"
},
"transition": "complete",
"inputs": {},
"expectedUpdatedAt": null
}
},
{
"actionId": "inspection:survey:v1:complete",
"transition": "complete",
"label": "Pass",
"icon": null,
"variant": null,
"cta": true,
"surfaces": null,
"enabled": true,
"reason": null,
"details": null,
"confirm": null,
"intent": {
"kind": "process.perform",
"target": {
"process": "inspection",
"emit": "survey",
"anchorId": "b7fd4216-8dda-455c-a60d-ceb88dafe8b0"
},
"transition": "complete",
"inputs": {},
"expectedUpdatedAt": "2026-10-05T16:40:00Z"
}
},
{
"actionId": "inspection:report:v1:complete",
"transition": "complete",
"label": "Send report",
"icon": null,
"variant": null,
"cta": false,
"surfaces": null,
"enabled": false,
"reason": {
"code": "PERMISSION_DENIED",
"label": "Not permitted"
},
"details": null,
"confirm": null,
"intent": {
"kind": "process.perform",
"target": {
"process": "inspection",
"emit": "report",
"anchorId": "b7fd4216-8dda-455c-a60d-ceb88dafe8b0"
},
"transition": "complete",
"inputs": {},
"expectedUpdatedAt": null
}
},
{
"label": "Refresh",
"command": []
}
]
}
}
Here Pass (the call to action) is the button, File papers and Refresh are
in the dropdown, and Not permitted is the disabled Send report.
$append([], …). JSONata collapses a single match to the object itself and
gives undefined when nothing matches; the actions config refuses both.
$append([], …) keeps the result an array in every case — the bar then draws
nothing for an empty list.
#form
An input form. In its plain shape it collects values and submits a command; with bindings it edits entities or a principal config directly and generates its own submit.
| Field | Type | Req | Notes |
|---|---|---|---|
fields |
array | ✓ | The inputs — see the field vocabulary below. May be generated ({ "$jsonata": … }). |
submit |
object | { label, command? } — the primary button. With command, the form's values reach the steps as outputs.form.<id>; without it, a bound form generates its own writes. |
|
buttons |
array | Additional buttons, each with a command. |
|
initialValues |
record | Seed values per field id (wins over a field's default). |
|
resetKey |
string | While it stays the same, the user's in-progress values survive every re-render. When it changes, the form rebuilds its values from default / bound data / initialValues. |
|
inline |
boolean | Inline (vs stacked) layout. | |
emptyFallback |
object | { message, variant? } shown when every field is gated away. |
|
entities |
record | Bound form — entity slots (below). | |
config |
object | Bound form — { principalType }: fields derive from that principal-config schema and submit writes the config. |
|
links |
array | Bound form — relationships the submit maintains (below). | |
onSuccess / onError |
steps | Run after a bound submit succeeds / fails. |
Resetting a form. A form keeps what the user has typed across re-renders.
Give it a resetKey to start over on a trigger of your choosing — typically a
smart value over the widget that drives the form's defaults:
{
"widgetComponent": "form",
"config": {
"resetKey": "{{ page.widgets.template_picker.state.template }}",
"fields": [
{
"id": "subject",
"type": "string",
"label": "Subject",
"default": "{{ page.data.template.attributes.subject }}"
}
],
"submit": { "label": "Send", "command": [/* … */] }
}
}
Picking another template changes the key, so the form's values (and a submit
made in the same request) fall back to the new defaults; edits made while the
key stays the same are kept. A null key behaves like no key. The form records
its key under the reserved field id __reset_key__ — don't use that id for a
field.
Fields. Every unbound field has id, type, and optionally label,
placeholder, required, default; a field entry may be $if-gated. The
type selects the input:
type |
Extra keys | Renders |
|---|---|---|
string |
options?, showAsButtons?, showAsDropdown?, numLines?, maxLength?, format? (date) |
A text input; with options, a select (showAsButtons → a button row, showAsDropdown → an auto-applying dropdown). |
number |
min?, max? |
A numeric input. |
boolean |
A toggle. | |
array |
options (required) |
A multi-select. |
dateRange |
Two date pickers, published as <id>.from / <id>.to. |
|
secretInput |
target (required) — { scope, scopeId?, name, env? } |
A write-only secret input; the value may only flow into a setSecret step. See Secrets. |
file |
previewPage?, accept?, maxFileSize?, buttonLabel?, uploadType? |
The host's upload control and an × that clears it, on one row. See below. |
maxLength caps the characters a text input or text area accepts in the client.
It does not apply to a string field rendered as a select, buttons or dropdown,
and a submit is not checked against it — validate in the command if the limit
matters.
options is either a static [{ value, label }] array or
{ "dataSource": "<name>", "limit"?: n } binding a page source whose rows carry
value and label — a literal $rows array carries them itself; a $query or
$http source shapes them with a $pipe map step.
The file field is a composite, not a single input: the host's upload
control, labelled with the field's label, and an × that clears only that field,
on one row. Every render mints a fresh signed upload link for the control, so it
is ready when the form opens, and clearing the field also hands out a new link.
The serving provider must declare an upload type
(Apps → Declare the provider's upload type);
without one the field renders a warning that file upload is unavailable there.
The value the field ends up holding is the uploaded
temp file's id, which is then ordinary
form state — outputs.form.<id> on submit, page.widgets.<form>.state.<id>
elsewhere. previewPage names the modal a Preview button opens (no
previewPage, no Preview button); accept takes dotted extensions or MIME
types; maxFileSize is a per-file byte ceiling capped at 25 MiB. A file field
is never bound — its value is a file id that no attribute schema derives —
so in is refused where it is authored. The full lifecycle is in
Apps → File uploads.
{
"widgetName": "form",
"config": {
"header": "New task",
"fields": [
{
"id": "title",
"label": "Title",
"type": "string",
"required": true
},
{
"id": "priority",
"label": "Priority",
"type": "number"
},
{
"id": "urgent",
"label": "Urgent",
"type": "boolean"
}
],
"submit": {
"label": "Create"
}
}
}
Bound forms. Declaring entities binds the form to one or more entity
slots; each slot is { type, id?, matchOn?, seed? } and its mode follows
from what it carries — id present ⇒ edit that entity; matchOn present ⇒
upsert by those identifier attributes (prefilled when a match exists, created
otherwise); neither ⇒ create. A field binds to a slot with
"in": "<slot>", "attribute": "<name>" (or "in": "config", "key": "<name>"
for a config binding), and its input type derives from the slot type's schema —
the field restates neither. A bound field takes no id: it renders, and its
value is published, as <slot>_<attribute> (config_<key>), e.g.
page.widgets.<form>.state.cert_expiry_date. links are the relationships the
submit maintains: { rule, from, to, direction, mode? }, where an endpoint is a
slot name, a concrete entity id, or { field } (a picker whose value is the
target id), direction names the endpoint the carrying slot occupies, and
mode: "replace" makes the listed targets the whole set for that rule. submit
is required with bindings; the writes (upsertEntity per slot, links attached)
are generated for you.
{
"widgetComponent": "form",
"config": {
"entities": {
"cert": { "type": "VesselCertificate", "seed": { "status": "valid" } }
},
"fields": [
{ "in": "cert", "attribute": "type", "label": "Certificate" },
{ "in": "cert", "attribute": "expiry_date", "label": "Expires" }
],
"links": [
{
"rule": "VesselToVesselCertificate",
"from": "{{ page.params.vessel_id }}",
"to": "cert",
"direction": "to"
}
],
"submit": { "label": "Add certificate" },
"onSuccess": [{ "kind": "closeModal" }, { "kind": "refresh" }]
}
}
#Fields filled from a chosen row
A process transition can take inputs from a select whose chosen row supplies
more fields — pick a template, and the template's own fields join the form
(the definition side is in
Apps → Forms whose chosen row supplies fields).
The page reads the transition's form with describeProcessForm, passing the
current choice as values; the read answers the combined field list, the select
marked suppliesFields: true and each field it supplied marked suppliedBy.
Draw it with three widgets:
"functions": {
"choice": "function() { ($s := $$.page.widgets.switcher.state.template; $s ? $s : $$.page.data.form.fields[suppliesFields = true].value) }"
},
// on the page:
"dataSources": {
"form": { "$job": { "action": "describeProcessForm", "config": {
"target": { "process": "inspection", "emit": "report", "anchorId": "{{ page.params.id }}" },
"transition": "complete",
"values": { "$jsonata": "{ 'template_key': $choice() }" }
} } }
},
"layout": [["switcher"], ["editor"]],
"widgets": {
"switcher": { "widgetComponent": "filter_bar", "config": { "fields": [ {
"id": "template", "label": "Template", "type": "string", "showAsDropdown": true,
"default": { "$jsonata": "$choice()" },
"options": { "$jsonata": "[page.data.form.fields[suppliesFields = true].options.values.{ 'value': $string(value), 'label': label }]" }
} ] } },
"editor": { "widgetComponent": "form", "config": {
"fields": { "$jsonata": "[page.data.form.fields[$not(suppliesFields = true)].{ 'id': name, 'label': label, 'type': 'string', 'required': required, 'default': value }]" },
"resetKey": { "$jsonata": "$choice()" },
"submit": { "label": "Send report", "command": [
{ "kind": "actions", "stepId": "send",
"input": {
"target": { "process": "inspection", "emit": "report", "anchorId": "{{ page.params.id }}" },
"inputs": { "$jsonata": "$merge([outputs.form, { 'template_key': $choice() }])" }
},
"actions": [ { "stepId": "move", "name": "performTransition", "config": {
"target": { "$jsonata": "input.target" }, "transition": "complete",
"inputs": { "$jsonata": "input.inputs" } } } ] },
{ "kind": "notify", "level": "INFO", "message": "Report sent" }
] }
} }
}
- The switcher sits in a
filter_baroutside the form, withshowAsDropdownso a pick re-renders at once. Its options are the select's own options from the read. $choice()is the switcher's value, else the choice the read started on — the select's default, or for a required select with none, its first option. The switcher's default, the source'svalues, the form'sresetKeyand the submitted select all follow it, so before the viewer picks, every one of them agrees on that first choice.- The editor maps every other field to a form field. A pick changes
resetKey, so typed values of the previous choice are dropped and the new fields start from their defaults. A chosen row whose fields cannot be read leaves the list as declared, withreason: { "code": "FIELDS_UNAVAILABLE" }on the select. - Submit performs the transition with the form's values and the choice. The move re-reads the chosen row as the viewer and judges the inputs against the combined list; a refused move arrives as the step's error (below).
#filter_bar
The free-input filter affordance: a pure STATE PUBLISHER. It renders inputs,
round-trips their values as widget state, and does nothing else — the page's
SOURCES read page.widgets.<bar>.state.<field> via optional filter particles,
and every interaction re-renders in full. No widget references the bar.
| Field | Type | Req | Notes |
|---|---|---|---|
fields |
array | ✓ | The same field vocabulary as form (string / multiselect / dateRange…); showAsDropdown / showAsButtons apply on CHANGE. |
apply |
object | { label } — rendered by default (label "Apply") when any field applies on submit; a bar of only change-to-apply fields renders none. |
|
clear |
boolean | object | A Clear button — a type-aware STATE reset of this bar's fields (strings → "", arrays → []). |
|
inline |
boolean | Row layout (default) vs stacked. | |
remember |
boolean | A literal true keeps the value of each field the bar stops showing and restores it when the field shows again — for a bar whose fields change with what it filters. Clear empties it too. A field gated with $if is never restored. |
{
"widgetName": "filter_bar",
"config": {
"fields": [
{
"id": "q",
"label": "Search",
"type": "string"
},
{
"id": "status",
"label": "Status",
"type": "string",
"options": [
{
"value": "open",
"label": "Open"
},
{
"value": "done",
"label": "Done"
}
],
"showAsDropdown": true
}
],
"clear": true
}
}
#chips
The preset-filter affordance — one row of exclusive chips publishing the
selected value as widget state (the preset face of what filter_bar does with
free inputs).
| Field | Type | Req | Notes |
|---|---|---|---|
id |
string | ✓ | The state key the selected value publishes to. |
options |
array | ✓ | { value, label }[] — one chip each. |
default |
string | Initially-selected value. |
{
"widgetName": "chips",
"config": {
"id": "status",
"options": [
{
"value": "all",
"label": "All"
},
{
"value": "open",
"label": "Open"
},
{
"value": "done",
"label": "Done"
}
],
"default": "open"
}
}
#page_tabs
A row of tab buttons that navigate between pages. A tab may carry params, so several tabs can open the same page with different params — the sections of one record, the modules of one list.
| Field | Type | Req | Notes |
|---|---|---|---|
tabs |
array | ✓ | { label, page, params?, children? }[]. params is a flat record of strings, numbers and booleans (smart values allowed) that the tab's navigate carries. children lists pages that count as this tab. |
The selected tab is the one whose page and params match the page being shown
(compared as text); on a page listed in some tab's children, that tab. When
several tabs match by page alone, the first is selected. Clicking a tab
navigates with exactly that tab's params — a navigate carries nothing over from
the page it leaves — so a tab that must keep the record in view states it:
"tabs": {
"widgetComponent": "page_tabs",
"config": { "tabs": [
{ "label": "Particulars", "page": "vessel", "params": { "id": "{{ page.params.id }}", "section": "particulars" } },
{ "label": "History", "page": "vessel", "params": { "id": "{{ page.params.id }}", "section": "history" } }
] }
}
The page then branches on page.params.section, and can keep the viewer's
section from one record to the next with remember — see
App Layouts → Nav params.
{
"widgetName": "page_tabs",
"config": {
"tabs": [
{
"label": "Overview",
"page": "root"
},
{
"label": "Details",
"page": "detail",
"params": {
"section": "details"
}
},
{
"label": "Activity",
"page": "detail",
"params": {
"section": "activity"
}
}
]
}
}
#section_switcher
A button group that writes a page.data key; sections gate visibility on it via
$if (no navigation).
| Field | Type | Req | Notes |
|---|---|---|---|
stateKey |
string | ✓ | The page.data key this switcher writes. |
options |
array | ✓ | { label, value }[] — the selectable sections. |
{
"widgetName": "section_switcher",
"config": {
"stateKey": "view",
"options": [
{
"label": "List",
"value": "list"
},
{
"label": "Board",
"value": "board"
}
]
}
}
#breadcrumbs
A breadcrumb trail — explicit crumbs or an entity ancestry chain, with an optional refresh button.
| Field | Type | Req | Notes |
|---|---|---|---|
crumbs |
array | Explicit { label, page? } crumbs; page is { id, params? }. A crumb without page is plain text. |
|
ancestry |
object | { dataSource, root?, detailPage, self } — walk the named detail read's parent chain into crumbs (dataSource is a dataSources entry name). |
|
refresh |
boolean | Append a ↻ button that re-renders the page. |
{
"widgetName": "breadcrumbs",
"config": {
"crumbs": [
{
"label": "Vessels",
"page": {
"id": "vessels"
}
},
{
"label": "MV Northern Star",
"page": {
"id": "vessel",
"params": {
"id": "v-100"
}
}
},
{
"label": "Voyage 2025-0142"
}
],
"refresh": true
}
}
#entity_info
A labelled read-only field list for one entity (a definition-list card). The
entity arrives through a named source — typically a single: true point-read in
the page's dataSources map. A missing value shows as empty.
| Field | Type | Req | Notes |
|---|---|---|---|
dataSource |
string | ✓ | Name of the dataSources entry carrying the entity. |
fields |
array | ✓ | { label, attr }[] — label + attribute, in order. |
header |
string | Optional card heading. |
{
"widgetName": "entity_info",
"config": {
"header": "Vessel",
"dataSource": "vessel",
"fields": [
{
"label": "Name",
"attr": "name"
},
{
"label": "IMO",
"attr": "imo"
},
{
"label": "Flag",
"attr": "flag"
}
]
},
"data": {
"vessel": {
"id": "v1",
"attributes": {
"name": "Aurora",
"imo": "9876543",
"flag": "Panama"
}
}
}
}
#custom_markup
An escape hatch: an authored markup element with named action handlers.
| Field | Type | Req | Notes |
|---|---|---|---|
markup |
element | element[] | ✓ | One content element (text, button, data list, …), or a list of them — drawn as one section, in order. |
actionHandlers |
record | Named handlers → step-sequences, keyed by the id of the element that presses them. |
{
"widgetName": "custom_markup",
"config": {
"markup": {
"type": "dataList",
"items": [
{
"label": "Vessel",
"content": "MV Northern Star"
},
{
"label": "Voyage",
"content": "2025-0142"
},
{
"label": "Status",
"content": "In transit"
},
{
"label": "ETA",
"content": "2025-09-14"
}
]
}
}
}
#Decided controls
A decided control — a descriptor — is a control the platform has already
decided for the viewer: its words, whether they may press it, why not, and what
a press does. Process reads hand them out — describeProcessActions through a
$job source, or a $query source's
process folding them onto each row — and three widgets draw them: an
actions row as buttons, a table and a
data_grid as a row menu
(fromRow). A widget decides nothing about them.
{
"actionId": "inspection:survey:b7fd4216-8dda-455c-a60d-ceb88dafe8b0:initiate",
"transition": "initiate",
"label": "Start survey",
"icon": null,
"variant": null,
"cta": false,
"surfaces": null,
"enabled": true,
"reason": null,
"details": null,
"confirm": null,
"intent": {
"kind": "process.perform",
"target": {
"process": "inspection",
"emit": "survey",
"anchorId": "b7fd4216-8dda-455c-a60d-ceb88dafe8b0"
},
"transition": "initiate",
"inputs": {},
"expectedUpdatedAt": null
}
}
| Field | Meaning |
|---|---|
actionId |
The control's identity; a button presses back to it. |
transition |
The transition it performs (null for a control that is not a move). |
label |
Its words. |
cta |
The call to action — drawn primary, and the one button of an overflow bar. |
variant |
primary, secondary or ghost; any other word draws secondary. |
surfaces |
Where the presentation shows it; null means everywhere. Filter with surfaces = null or '<surface>' in surfaces. |
enabled |
Whether the viewer may press it. |
reason |
{ code, label } when disabled, null when enabled. The label is drawn; the code is never printed. |
intent |
What a press does: process.perform, process.undo, process.release, or navigate (a transition that takes inputs opens its form page). |
A press is answered from a fresh read, never from what was drawn:
- the control, read fresh and enabled, runs its intent — a move as the viewer's
own, judged again when it runs; a
navigateto a page the spec declares only as a modal opens it as a modal; - read fresh and disabled, nothing runs, and the answer is an
ERRORnotification in the reason's words withdetails.refusal_code; - not offered any more (another person moved the step, it was pressed already),
nothing runs, and the answer is a
WARNINGthat the action is not available here any more; - on a row the page drew before its step moved, nothing runs, and the answer is
an
ERRORwithdetails.refusal_code: "STALE".
Every press re-renders the page, so it then shows where things stand. A
descriptor button's element id is action/<actionId> (URL-encoded) under its
widget — ":p/detail/bar/action/inspection%3Asurvey%3Ab7fd…%3Ainitiate:".
#Status cells
A process read gives each step a status view:
{ "state": "IN_PROGRESS", "label": "Surveying", "tone": "active", "badges": [], "stateAt": "2026-10-05T16:40:00Z" }
— its rung, the rung's word, its tone (idle, active, waiting, done,
failed), badge words (a step whose latest move failed carries Failed), and
when it reached the rung (null before its first move). A status column in a
table or a data_grid shows it as
text: the label, then the badges, joined by ·. A statusMap rewords it — the
first entry whose state, tone and badge conditions hold gives a glyph
and a label, where {label} is the view's own word and {date} the day it
reached the rung:
"statusMap": [
{ "state": "DONE", "glyph": "✅", "label": "{label} on {date}" },
{ "badge": "Failed", "glyph": "⚠" },
{ "tone": "idle", "glyph": "○" }
]
A done step reads ✅ Passed on Oct 6, 2026; a step whose last move failed
reads ⚠ Surveying · Failed; a step not started reads ○ Not started.
#Links to provider pages
An href — an authored action's, or the row field an hrefAccessor names — may
address a page of the host the app runs in. Name the target with
$link("<provider>.<route>", params) and the platform builds the address from
the provider's links block:
"providers": {
"sedna": {
"auth": { … }, "identity": { … },
"links": {
"baseUrl": "https://{company.external_id}.sednanetwork.com",
"routes": { "vessel": "/fleet/vessels/{id}", "search": "/fleet?q={q}" }
}
}
},
// on a page:
"open": { "widgetComponent": "actions", "config": { "actions": [
{ "label": "Open in fleet", "href": "{{ $link('sedna.search', { 'q': 'MV' }) }}" }
] } }
| Where the render is served | The href |
|---|---|
| By the target provider | The route alone: /fleet?q=MV. |
| By any other provider | The target's absolute address for your tenant: https://acme.sednanetwork.com/fleet?q=MV. |
| No integration with the target, or a path param left out | "" — the link renders inert, and the rest of the page draws. |
The full rules — placeholders, the strict strictHrefs mode, the schemes an
href may use — are in
Apps → Links to provider pages.
For a grid row, build the value in the source:
Data Grids → Links in a row.
#When a command fails
A command's steps run in order and stop at the first failure. Give the step an
onError to answer the failure yourself: its steps run in place of the default
notification and read the failure at outputs.__error__ —
| Field | Holds |
|---|---|
status |
The failure's HTTP status (422, 409, …); 0 when the failure is not a platform error. |
code |
The platform error code (VALIDATION_FAILED, UNPROCESSABLE_ENTITY, …); "" when there is none. |
message |
The user-facing message. |
name |
The failure's class name. |
Branch on status and code, never on the message:
{
"label": "Add vessel",
"command": [
{
"kind": "actions",
"stepId": "add",
"actions": [
{
"stepId": "upsert",
"name": "upsertEntity",
"config": {
"typeName": "Vessel",
"payload": { "attributes": { "flag": "PA" } }
}
}
],
"onError": [
{
"kind": "notify",
"level": "WARN",
"message": "{{ 'Not saved (' & $string(outputs.__error__.status) & ' ' & outputs.__error__.code & ')' }}"
}
]
}
]
}
A vessel without its required name answers with the WARNING "Not saved (422
VALIDATION_FAILED)". Without onError, the response carries an ERROR
notification whose details.error_code is the same code (and, for a refused
process move, details.refusal_code) — see
Apps → When a step fails.
#Layout widgets
Layout widgets structure a page rather than showing data. They are placed as
inline nodes in a page's layout tree —
{ "component": "...", "config": {...}, "children": [...] } — instead of
entries in the widgets record. Each is documented, with its rules and a
rendered example, in App Layouts:
| Component | Kind | What it is |
|---|---|---|
section |
container | A titled, optionally collapsible group with a column grid. |
row |
container | A horizontal run of elements; "align": "toolbar" splits it into a left and a right group. |
tabs |
container, top level, id |
Tabbed groups. |
header |
top level | The page's title bar; on a modal it carries the modal's Close button. |
footer |
container, top level | The pinned bottom bar for buttons. |
divider |
leaf | A horizontal rule. |
iframe |
leaf | An embedded external page. |
file_viewer |
leaf | An inline document viewer. |
The content widgets with a preview render live on this page. Data-bound widgets
(data_grid, entity_info) are shown with sample data supplied the way the
engine injects it at render time in a real app; descriptors and status views are
shown as a process read hands them out. Layout widgets render as part of a
page's layout tree, so they are shown in App Layouts; see
Apps (Bridge) for an end-to-end app and
Data Grids for grids in depth.