#App Layouts
A page's layout says where its widgets go. The widgets themselves — what each
shows and does — are in the Widget Catalogue; this
guide covers the layout grammar, the layout components that structure a page
(section, row, tabs, header, footer, divider, iframe,
file_viewer), layouts generated from configuration, and how a page's nav
params carry a viewer's place between pages.
#A page, laid out
This page has a title bar, navigation tabs, a toolbar, a collapsible two-column section, a tabbed area holding a comment thread, panels chosen by configuration, and a footer button that opens a modal with its own header:
"config": { "app": { "panels": ["notes", "contacts"] } },
"dashboards": {
"main": {
"name": "Vessel",
"surfaces": { "dashboard": "home" },
"pages": {
"home": {
"dataSources": { "comments": { "$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" }
] } },
"layout": [
{ "component": "header", "config": { "title": "Vessel" } },
["nav"],
{ "component": "row", "align": "toolbar", "children": [["search"], ["refresh"]] },
{ "component": "section", "config": { "title": "Overview", "columns": 2, "collapsible": true },
"children": [["notes", "contacts"]] },
{ "component": "divider" },
{ "component": "tabs", "id": "detail_tabs", "config": { "variant": "segment", "items": [
{ "id": "comments", "label": "Comments", "children": [["thread"]] },
{ "id": "files", "label": "Files", "children": [["files"]] }
] } },
{ "$jsonata": "$map(config.app.panels, function($p) { [$p] })" },
{ "component": "footer", "children": [["open"]] }
],
"widgets": {
"nav": { "widgetComponent": "page_tabs", "config": { "tabs": [
{ "label": "Summary", "page": "home" },
{ "label": "History", "page": "home", "params": { "section": "history" } }
] } },
"search": { "widgetComponent": "filter_bar", "config": { "fields": [ { "id": "q", "label": "Search", "type": "string" } ] } },
"refresh": { "widgetComponent": "actions", "config": { "actions": [ { "label": "Refresh", "command": [ { "kind": "refresh" } ] } ] } },
"notes": { "widgetComponent": "headline", "config": { "text": "Notes", "headerLevel": 3 } },
"contacts": { "widgetComponent": "headline", "config": { "text": "Contacts", "headerLevel": 3 } },
"thread": { "widgetComponent": "cards", "config": {
"rows": { "$jsonata": "page.data.comments" },
"title": "author", "body": "text", "caption": "at", "captionType": "date",
"noResultsMessage": "No comments yet" } },
"files": { "widgetComponent": "headline", "config": { "text": "Files", "headerLevel": 3 } },
"open": { "widgetComponent": "actions", "config": { "actions": [
{ "label": "Edit", "variant": "primary", "command": [ { "kind": "navigate", "page": "edit", "modal": true } ] }
] } }
}
}
},
"modals": {
"edit": {
"layout": [ { "component": "header", "config": { "title": "Edit vessel" } }, ["form"] ],
"widgets": { "form": { "widgetComponent": "form", "config": {
"fields": [ { "id": "name", "label": "Name", "type": "string" } ],
"submit": { "label": "Save", "command": [ { "kind": "closeModal" } ] } } } }
}
}
}
}
It renders, top to bottom, as: a header titled "Vessel"; the two tabs; one line
with the search input and its Apply button on the left and Refresh on the right;
the "Overview" section with Notes and Contacts side by side; a rule; the
Comments / Files tabs, the first holding the two comment cards (Ana · "Hull
inspected, no findings." · Sep 30, 2026); the Notes and Contacts panels the
configuration lists; and a footer with the Edit button. Pressing Edit opens the
modal, whose header line reads "Edit vessel" with a Close button at its
right.
#The layout grammar
A layout is a list of entries. Each entry is one of:
| Entry | Shape | What it places |
|---|---|---|
| A row of widget ids | ["a", "b"] |
The page's widgets, side by side — one column each. |
| A gated cell | [{ "$if": "<expr>", "$then": "a" }] |
A widget shown only while the bare JSONata expression is truthy. |
| An inline node | { "component", "id"?, "config"?, "children"?, "align"? } |
A layout component (or another widget, anonymously) at that position. |
| A gated node | { "$if": "<expr>", "$then": { "component": … } } |
An inline node shown only while the expression is truthy. |
| A generated entry | { "$jsonata": "<expr>" } |
Entries produced at render — top level only. See Generated layouts. |
"layout": [
["header"],
[{ "$if": "permissions.policy = 'admin'", "$then": "tools" }],
["tasks", "activity"]
]
A row of several ids renders as a section with one column per id, so
[["viewer", "review"]] shows a document beside a review form. A gated-out
entry is dropped and a row it empties is removed, so a hidden widget is never
built.
Inline nodes. component names any layout component or registered widget;
config is its config, resolved like a widget's; children is the container's
slot, itself a list of entries. An inline node is never interactive: a node
whose config carries dataSource, stateKey or actionHandlers is refused at
publish — declare that widget in the page's widgets record and place it by id.
An id is required on tabs and optional elsewhere; ids are unique across the
layout.
Publish rules. A layout that breaks one of these is refused with 422:
| Rule | Refused with |
|---|---|
| Every widget id anywhere in the tree names a widget of the page, its dashboard or the spec. | layout_widget_missing (in details.issues[]) |
tabs, header and footer sit at the top level only. |
"tabs" is top-level only (in details.validation_errors[]) |
tabs carries an id. |
"tabs" requires an explicit id |
iframe and file_viewer sit at the top level, in a tab, or in a section. |
"iframe" may live top-level, in tabs, or in a section only |
| An inline node is not interactive. | interactive inline "<component>" is not supported — … |
Two nodes never share an id. |
id "<id>" is already used by another node of this layout — … |
| A generated entry sits at the top level only. | refused inside children and inside a tab item |
#Layout components
#section
A group of entries, optionally titled and collapsible, laid out on a column
grid. It is the general container: any entry may sit inside one, including
rows, other sections, divider, iframe and file_viewer.
| Field | Type | Notes |
|---|---|---|
title |
string | The section heading. |
columns |
1–6 | The column grid inside the section. |
columnSpan |
1–6 | How many of its parent section's columns this section spans. |
collapsible |
boolean | Draw a collapse control. A collapsible section always carries a title ("" without one). |
defaultCollapsed |
boolean | Start collapsed (with collapsible). |
justify |
enum | left, center or right. |
{
"component": "section",
"config": { "title": "Overview", "columns": 2, "collapsible": true },
"children": [["notes", "contacts"]]
}
A section is a grouping, not a surface of its own: it draws no box or border
around its content. To separate items visually, put a divider between them, or
show a list as cards, which draws a divider
between consecutive cards.
#row
A single line of elements — buttons, inputs, text. Its children's widgets contribute their elements to the line; a structural block (a section, a tab, an iframe) inside a row fails to render.
| Field | Type | Notes |
|---|---|---|
justify |
enum | left, center, right or between. |
columnSpan |
1–6 | Span when nested in a section grid. |
A toolbar. "align": "toolbar" (beside component, not in config) makes
the row two groups on one line: children is exactly two lists of widget ids,
[left, right], and left is not empty.
{
"component": "row",
"align": "toolbar",
"children": [["search"], ["export", "refresh"]]
}
The left group sits at the line's start and the right group at its end. A group
whose widgets are all hidden at render draws nothing, and the other group stands
alone at its side. Any other children shape is refused at publish
(a toolbar row's children are exactly two groups of widget ids, [left, right], with left non-empty).
#tabs
Tabbed groups at the top level of a page. Each item of config.items carries
its own children; the tab strip switches between them in the client.
| Field | Type | Req | Notes |
|---|---|---|---|
items |
array | ✓ | { id, label, children }[] — one pane per tab. |
variant |
enum | inline or segment. |
|
scrollable |
boolean | Scroll a tab strip wider than the page. |
The node needs an id. A tab with nothing in it renders an empty pane. Tabs
switch panes within one page; to switch pages — or one page's params — use
the page_tabs widget instead.
#header
The page's title bar, at the top level. It takes a title and no children.
| Field | Type | Notes |
|---|---|---|
title |
string | The title. |
On a modal the header also closes it: the title on the left and a ghost
Close button on the right (on the right alone when there is no title). The
button is the client's own close — it closes the modal and returns to its opener
as a closeModal step would — so no modal needs a button whose only job is to
close it:
"modals": {
"edit": {
"layout": [ { "component": "header", "config": { "title": "Edit vessel" } }, ["form"] ],
"widgets": { "form": { … } }
}
}
renders its first line as:
{
"type": "row",
"justify": "between",
"elements": [
{ "type": "text", "typeface": "header2", "content": "Edit vessel" },
{
"type": "button",
"label": "Close",
"variant": "ghost",
"action": "closeModal"
}
]
}
A header is not the headline widget, which
is a heading inside the page. Place navigation tabs on their own row below the
header: a header given children fails to render.
#footer
The pinned bottom bar, at the top level — on a modal, its action bar. Its
children contribute buttons and text: place an
actions widget in it by id.
{ "component": "footer", "children": [["save_bar"]] }
#divider
A horizontal rule. No config; it may sit anywhere.
#iframe
An embedded external page, at the top level, in a tab, or in a section.
| Field | Type | Req | Notes |
|---|---|---|---|
src |
string | ✓ | An https:// URL. |
height / width |
number | Pixel dimensions. |
#file_viewer
An inline document viewer (PDF and other browser-renderable types) served through the platform's signed component URLs — the source is signed at render time, so the file URL itself never reaches the client unsigned.
- Placement. At the top level, in a tab, or in a
section(including a row of widget ids, which renders as a section) — not in arow,headerorfooter. It can be a pagewidgetsentry placed by id, or an inline node. fileUrlmust be a publichttpsURL; anything else fails the viewer with an error notification rather than rendering.- Office documents (Word, Excel, PowerPoint types) render through Microsoft's Office Online viewer, which means the document is fetched by Microsoft's servers.
| Field | Type | Req | Notes |
|---|---|---|---|
fileUrl |
string | ✓ | The file to render (often a {{ }} value). |
mimeType |
string | ✓ | The file's MIME type. |
fileName |
string | Display name. | |
startPage |
number | Initial page (paged formats). | |
height |
number | Viewer height (default 500). |
"layout": [["viewer", "review"]] shows the document and a review form side by
side.
#Generated layouts
An entry may be { "$jsonata": "<expr>" } — at the top level of layout, or as
the whole layout. The expression yields entries (rows of widget ids, or inline
nodes) from the app's configuration, so a page derives which panels it shows,
and in what order, without enumerating every variant:
"config": { "app": { "panels": ["notes", "contacts"] } },
// on the page:
"layout": [
["title"],
{ "$jsonata": "$map(config.app.panels, function($p) { [$p] })" },
{ "$jsonata": "[{ 'component': 'divider' }, ['notes', 'contacts']]" }
]
The first expression places each listed panel on its own row; the second places a divider and one row of two panels.
- What it may yield. Zero, one or many entries. A list whose members are all
entries is spliced in place; a list of strings is one row (
["a", "b"]). - Only what the page has. A generated row may place only widgets the page defines, and a generated node only a registered component; it cannot add a widget, take a widget's id, or reuse another node's id.
- Limits. A row holds at most six cells, and a page's generated entries place at most 200 entries in all.
- Output is final. A
{ "$if", "$then" }the expression yields is not a gate — it is dropped. Gate inside the expression. - Failures are local. An entry that fails (an unknown id, an expression that
throws) is dropped on its own, with one
ERRORnotification whosedetails.bridge_error_typeisgenerated_layout_errorand whose message names it ("missing" is not a widget of this page); the rest of the page renders. - Checked at render. Publish cannot see inside the expression, so the placement rules and widget-id checks for what it yields run when the page renders.
#Nav params
A page's params are its place: which record, which section, which module. A
page names the params it takes in params and reads them as
page.params.<name>. A navigate step passes exactly the params it states;
nothing carries over from the page it leaves.
"pages": {
"list": {
"dataSources": { "vessels": { "$query": { "typeName": "Vessel" } } },
"layout": [["fleet"]],
"widgets": { "fleet": { "widgetComponent": "data_grid", "config": {
"dataSource": "vessels",
"columns": [ { "type": "string", "field": "name", "header": "Vessel" } ],
"actions": [ { "icon": "chevronRight",
"command": [ { "kind": "navigate", "page": "detail", "params": { "id": "{{ itemId }}" } } ] } ]
} } }
},
"detail": {
"params": ["id", "section"],
"remember": { "params": ["section"] },
"dataSources": { "vessel": { "$query": { "typeName": "Vessel", "single": true,
"filter": { "operator": "eq", "path": ["id"], "value": "{{ page.params.id }}" } } } },
"layout": [["tabs"], ["title"], ["back"]],
"widgets": {
"tabs": { "widgetComponent": "page_tabs", "config": { "tabs": [
{ "label": "Particulars", "page": "detail", "params": { "id": "{{ page.params.id }}", "section": "particulars" } },
{ "label": "History", "page": "detail", "params": { "id": "{{ page.params.id }}", "section": "history" } }
] } },
"title": { "widgetComponent": "headline", "config": {
"text": "{{ page.data.vessel.attributes.name & ' / ' & $string(page.params.section) }}" } },
"back": { "widgetComponent": "actions", "config": { "actions": [
{ "label": "Back", "command": [ { "kind": "navigate", "page": "list" } ] } ] } }
}
}
}
Tabs with params. Each page_tabs tab navigates with its own params, so two
tabs can open the same page in two sections. The tab whose page and params match
the page being shown is the selected one. A tab states the record's id as well
as its section, because a navigate carries nothing over.
Params that persist across navigations. remember names the params a page
keeps for the viewer from one visit to the next:
- Written whenever the page renders as a base page with the param present (a
tab click, a
refresh, a navigate that states it). - Read by a non-modal
navigateto the page that does not state the param: the page renders with the value it last had, whatever record that was. - A stated param always wins, whatever it resolves to.
- The viewer's own. The memory rides the round-tripped state, per page.
- Modals remember nothing, and
rememberon a modal is refused at publish (remember_on_modal); a remembered param the page does not list inparamsis refused too (remember_param_undeclared).
So in the example: open vessel A (no section yet — the Particulars tab is selected as the first that matches by page), click History, go back to the list, and open vessel B: its detail page opens on History, the section the viewer was reading, with the History tab selected.
#Modals
A modal is a page in a dashboard's modals (or the spec's), opened only by
navigate with "modal": true; it renders as an overlay on whichever placement
asked for it, and modals stack. Give it a header for its title and Close
button, and a footer for its action bar:
"modals": {
"confirm_archive": {
"params": ["id"],
"layout": [
{ "component": "header", "config": { "title": "Archive vessel?" } },
["warning"],
{ "component": "footer", "children": [["confirm_bar"]] }
],
"widgets": {
"warning": { "widgetComponent": "headline", "config": { "text": "Archived vessels leave every list.", "headerLevel": 3 } },
"confirm_bar": { "widgetComponent": "actions", "config": { "actions": [
{ "label": "Archive", "variant": "primary", "command": [ /* … */ { "kind": "closeModal" } ] }
] } }
}
}
}
closeModal pops the top modal and returns to its opener, optionally handing a
result back — see Apps → Modals and
Apps → Commands.