Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/7926-page-node-refuses-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
'@object-ui/types': patch
---

Refuse `actions` by name on the `page` node (objectui#7926, maintainer ruling
2026-09-09, decision batch #107 item 2 — option A).

**Accept-set change, deliberately.** A `page` document carrying `actions` used to
parse GREEN and render nothing. `PageNodeSchema` never declared the key and
`PageRenderer` never read it — `git grep -ni action` on
`packages/components/src/renderers/layout/page.tsx` returns only the
`PageVariableActionBridge` import and its render — so the array survived purely
through `BaseSchema`'s `.passthrough()`. Measured through the real
`SchemaRenderer`: a `page` node with `actions: [{type:'button',label:'Add
Product'}, …]` drew **0** buttons and the label appeared nowhere in the DOM,
while the SAME two buttons in `body` drew **2**. Until objectui#7933 the array
also reached the wrapper element as `actions="[object Object],[object Object]"`.

`PageNodeSchema` now declares `actions` as an ADR-0049 refusal arm, so the same
document fails at parse with the remedy in the message. The TypeScript twin is
`actions?: never`, so `tsc` refuses it at the authoring site before anything runs.

**Why a refusal and not a reader.** This was the third surface carrying an
`actions` array no reader consumes (objectui#7469 — the app node; objectui#7693 —
the alert-dialog fixtures), and the authorable action FORM was already ruled on
2026-08-25 for objectui#6497 / #6182: the declarative action object. Growing a
reader here would have minted a fourth `actions` shape.

**Migration.** Put the buttons in `body` as nodes — a `button`, or an
`action:button` with a declared `actionType`:

```json
{
"type": "page",
"title": "Products",
"body": [
{ "type": "flex", "justify": "end", "gap": 2, "children": [
{ "type": "button", "label": "Add Product", "variant": "default" }
] }
]
}
```

On a record page the second door is the `page:header` block, whose own `actions`
are **action ids** resolved from the object's metadata (objectui#7182), not nodes
— that channel is unchanged.

**Scope.** One key, by name; the node is NOT strict. A census over this tree read
91 authored `page`-tagged objects with a blind-spot reading of 8 unreadable sites,
and found only `actions` (3 sites, all in `content/docs/guide/layout.md`) and
`breadcrumbs` (1 site, its own question, untouched) surviving passthrough on a
real `page` node — every other undeclared key belongs to a different declaration
that merely spells `type: 'page'`. `PageNodeSchema` still passes unknown renderer
props through.

The three teaching passages in `content/docs/guide/layout.md` are rewritten onto
the shape that draws, and pinned by their rendered result rather than their text.
120 changes: 80 additions & 40 deletions content/docs/guide/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,35 +145,54 @@ The `Page` component provides a consistent wrapper for individual pages with opt

### With Action Buttons

A `page` node has no action row of its own. Buttons are NODES, and they go in `body`:

```json
{
"type": "page",
"title": "Products",
"actions": [
"body": [
{
"type": "button",
"label": "Add Product",
"variant": "default",
"icon": "plus"
"type": "flex",
"justify": "end",
"gap": 2,
"children": [
{
"type": "button",
"label": "Add Product",
"variant": "default",
"icon": "plus"
},
{
"type": "button",
"label": "Export",
"variant": "outline",
"icon": "download"
}
]
},
{
"type": "button",
"label": "Export",
"variant": "outline",
"icon": "download"
"type": "object-grid",
"object": "products"
}
],
"body": {
"type": "object-grid",
"object": "products"
}
]
}
```

`label` is the button's text key — `text` is not a `ButtonSchema` key, and because
`BaseSchema` is `.passthrough()` nothing refuses it: the validator keeps the unknown key
and `button.tsx`, which reads `schema.label`, renders a button with no text.

> **⛔ `actions` on a `page` node is refused by name** (objectui#7926). This page used to
> teach `"actions": [ … ]` as a sibling of `title`, and it drew **nothing**: `PageRenderer`
> has never had a read point for the key, and `BaseSchema`'s `.passthrough()` kept the array
> rather than refusing it — so the author got a green validation and an empty page (before
> objectui#7933 it also reached the DOM as `actions="[object Object]"`). `PageNodeSchema`
> now declares the key as a refusal, so the same document fails with the remedy in the
> message instead of rendering silently short. Buttons in `body`, as above; on a record page,
> the `page:header` block's own `actions` — which are **action ids**, not nodes
> (see the [PageHeader reference](/docs/layout/page-header)).

### Schema API

<!-- doc-snippet: fragment — a SHAPE excerpt, not an expression — the keys carry `?` optional markers and trailing prose comments, so the object literal cannot parse as TypeScript (measured: TS1109 / TS1005 / TS1011) -->
Expand All @@ -189,8 +208,8 @@ and `button.tsx`, which reads `schema.label`, renders a button with no text.
label: string,
href?: string
}>,
actions?: SchemaNode[], // Action buttons
// NO `actions` — refused by name (objectui#7926); put the buttons in `body`

// Content
body: SchemaNode, // Main page content

Expand Down Expand Up @@ -509,6 +528,8 @@ Omit `sidebar` and the content fills the width under the top bar.

### Detail Page with Actions

Same rule as above: the buttons are nodes in `body`, not an `actions` key on the page.

```json
{
"type": "page",
Expand All @@ -518,30 +539,37 @@ Omit `sidebar` and the content fills the width under the top bar.
{ "label": "Customers", "href": "/customers" },
{ "label": "Acme Corporation" }
],
"actions": [
"body": [
{
"type": "action:button",
"name": "edit_record",
"label": "Edit",
"variant": "default",
"icon": "pencil",
"actionType": "editRecord"
"type": "flex",
"justify": "end",
"gap": 2,
"children": [
{
"type": "action:button",
"name": "edit_record",
"label": "Edit",
"variant": "default",
"icon": "pencil",
"actionType": "editRecord"
},
{
"type": "action:button",
"name": "delete_record",
"label": "Delete",
"variant": "destructive",
"icon": "trash",
"actionType": "deleteRecord"
}
]
},
{
"type": "action:button",
"name": "delete_record",
"label": "Delete",
"variant": "destructive",
"icon": "trash",
"actionType": "deleteRecord"
"type": "card",
"children": [
{ "type": "text", "content": "Record details..." }
]
}
],
"body": {
"type": "card",
"children": [
{ "type": "text", "content": "Record details..." }
]
}
]
}
```

Expand Down Expand Up @@ -660,20 +688,32 @@ Add breadcrumbs to help users navigate:
}
```

### 3. Action Buttons in Headers
### 3. Action Buttons at the Top of the Body

Place primary actions in page headers:
Place primary actions in the first `body` node, so they sit above the content:

```json
{
"type": "page",
"title": "Orders",
"actions": [
{ "type": "button", "label": "New Order", "variant": "default" }
"body": [
{
"type": "flex",
"justify": "end",
"gap": 2,
"children": [
{ "type": "button", "label": "New Order", "variant": "default" }
]
}
]
}
```

⛔ Not `"actions"` on the `page` node — that key has no reader and is refused by name
(objectui#7926). A record page has a second door: the `page:header` block, whose `actions`
are **action ids** resolved from the object's own actions metadata, not nodes
(see the [PageHeader reference](/docs/layout/page-header)).

### 4. Max Width for Forms

Use constrained width for forms and reading content:
Expand Down
Loading
Loading