Page Nodes & Layout

Applies to: ChengOS v0.1.0+ | Last verified: 2026-08-12 | Source: crates/cheng-nodes/src/nodes/builtin/ui, crates/cheng-nodes/src/nodes/builtin/ui/common/types.rs, chengflow-ui/src/runtime, chengflow-ui/scripts/generate-pages.ts

A page in ChengOS is not a template file. It is the output of a workflow: UI nodes each describe one region, ui/page_root assembles them, and the result is PageLayout JSON that the runtime renders.

That indirection is the point. Because a page is workflow output, its content can be computed — a table's rows can come from a database query node in the same graph.

The composition

ui/top_nav ──────────┐
ui/left_sidebar ─────┤
ui/right_sidebar ────┼──► ui/page_root ──► PageLayout JSON ──► runtime renderer
ui/header ───────────┤
ui/content ──────────┤
ui/footer ───────────┘

Each region node emits a configuration object; ui/page_root collects them on named input ports and produces the layout. Regions you do not connect simply do not appear.

NodeRegion
ui/page_rootThe skeleton — collects everything, outputs PageLayout
ui/top_navTop navigation: logo, menus, action buttons
ui/left_sidebarLeft navigation: menu items, icons, collapsible
ui/right_sidebarRight panel: properties, help, contextual info
ui/headerPage header: title, breadcrumb, actions, tabs
ui/contentMain content area: blocks
ui/footerFooter: copyright, links, social
ui/routeA node in the route tree
ui/rbac_guardAccess control — see Page Access Control

Page root

ui/page_root carries the page's identity and global look:

FieldMeaning
site_idWhich site this page belongs to
page_idPage identifier used to load the config at runtime
page_nameHuman name, for docs and breadcrumbs
themelight (default) or dark
layout_templatedefault, sidebar, topnav, or blank
logo, favicon, primary_colorBranding, primary_color as hex, e.g. #1890ff
output_base_dirArchive root, default site_archive

The archive path is {output_base_dir}/{site_id}/pages/{page_id}.json.

blank is the layout to reach for when you want no chrome at all — an embedded view or a landing page.

Content blocks

ui/content holds a list of blocks. A block is deliberately generic:

{
  "blockType": "table",
  "title": "User list",
  "span": 24,
  "config": { "columns": [{ "key": "name", "title": "Name" }] }
}
FieldMeaning
blockTypecard, table, form, chart, raw_html, iframe, tabs, grid, …
titleOptional block heading
spanGrid span, 1–24
configShape determined by the block type

The grid is 24 columns: span: 24 is full width, span: 12 is half, span: 8 is a third. A span of 0 is rejected rather than silently treated as full width.

ui/content also takes padding — a single number or a detailed {top, right, bottom, left} — and a background colour.

config is untyped by design: the block type decides its shape, so adding a new block type in the runtime does not require a backend schema change.

Routes

ui/route nodes form a tree: child routes connect into the parent's child port.

FieldMeaning
pathStatic (users), dynamic (:id), or catch-all (*)
nameShown in menus and titles
iconMenu icon
metaSEO and extension data — title, requiresAuth, keywords, description
page_workflow_idThe workflow that renders this route's page
site_idRoot node only; children inherit it through the child port
output_base_dirRoot node only, default site_archive

A route binds a page workflow: visiting the route executes that workflow to render the page. A route may also override the layout template, taking priority over the page workflow's own default.

A route carries a status — ready (page workflow configured and usable), pending (not configured yet), or failed (the page workflow errored) — so a half-built site is navigable and its gaps are visible rather than producing blank pages.

Runtime versus build time

Two renderers consume the same PageLayout:

This is why pnpm build needs the backend running: gen:pages is part of it, and it cannot render pages without executing the workflows that produce them. See Development Setup.

Static generation gives fast, cacheable pages for content that does not change per request; runtime rendering is for pages whose content depends on who is asking. You can mix them within one site.

Practical shape

Page workflow:
  data source nodes ──► ui/content ──┐
  ui/top_nav ────────────────────────┼──► ui/page_root
  ui/left_sidebar ───────────────────┘

Route workflow:
  ui/route (root, site_id) ──► ui/route (child, path ":id")
                    │
                    └── page_workflow_id → the page workflow above

Keep one page per workflow. A workflow that assembles three pages is harder to test than three workflows, and the route tree already gives you composition.

Next steps