# 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

```text
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.

| Node | Region |
|---|---|
| `ui/page_root` | The skeleton — collects everything, outputs `PageLayout` |
| `ui/top_nav` | Top navigation: logo, menus, action buttons |
| `ui/left_sidebar` | Left navigation: menu items, icons, collapsible |
| `ui/right_sidebar` | Right panel: properties, help, contextual info |
| `ui/header` | Page header: title, breadcrumb, actions, tabs |
| `ui/content` | Main content area: blocks |
| `ui/footer` | Footer: copyright, links, social |
| `ui/route` | A node in the route tree |
| `ui/rbac_guard` | Access control — see [Page Access Control](/docs/app-rbac-access) |

## Page root

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

| Field | Meaning |
|---|---|
| `site_id` | Which site this page belongs to |
| `page_id` | Page identifier used to load the config at runtime |
| `page_name` | Human name, for docs and breadcrumbs |
| `theme` | `light` (default) or `dark` |
| `layout_template` | `default`, `sidebar`, `topnav`, or `blank` |
| `logo`, `favicon`, `primary_color` | Branding, `primary_color` as hex, e.g. `#1890ff` |
| `output_base_dir` | Archive 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:

```json
{
  "blockType": "table",
  "title": "User list",
  "span": 24,
  "config": { "columns": [{ "key": "name", "title": "Name" }] }
}
```

| Field | Meaning |
|---|---|
| `blockType` | `card`, `table`, `form`, `chart`, `raw_html`, `iframe`, `tabs`, `grid`, … |
| `title` | Optional block heading |
| `span` | Grid span, **1–24** |
| `config` | Shape 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.

| Field | Meaning |
|---|---|
| `path` | Static (`users`), dynamic (`:id`), or catch-all (`*`) |
| `name` | Shown in menus and titles |
| `icon` | Menu icon |
| `meta` | SEO and extension data — `title`, `requiresAuth`, `keywords`, `description` |
| `page_workflow_id` | The workflow that renders this route's page |
| `site_id` | **Root node only**; children inherit it through the child port |
| `output_base_dir` | Root 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`:

- **Runtime** — `chengflow-ui/src/runtime/` renders PageLayout JSON into live pages and blocks:
  a miniature app-rendering engine with its own router, layouts, and block components.
- **Build time** — `scripts/generate-pages.ts` (`pnpm gen:pages`) executes the workflows against
  the backend, extracts `PageLayout` and route trees, and writes them both to
  `public/site_archive/{siteId}/` and to the `pages` and `routes` tables. Configured through
  `scripts/pages-config.json`.

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](/docs/dev-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

```text
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

- [Publishing a Workflow as an App](/docs/app-publish-workflow) — getting a URL for this.
- [Page Access Control & RBAC Guard](/docs/app-rbac-access) — restricting routes.
- [Table Workspace](/docs/data-table-workspace) — a natural data source for table blocks.
