#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.

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.

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 a row, header or footer. It can be a page widgets entry placed by id, or an inline node.
  • fileUrl must be a public https URL; 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 ERROR notification whose details.bridge_error_type is generated_layout_error and 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.

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 navigate to 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 remember on a modal is refused at publish (remember_on_modal); a remembered param the page does not list in params is 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.