Page schema — UI Protocol property reference
Page schemas of the ObjectStack UI Protocol: ElementApiDataSource, ElementDataSource and 8 more — each property with its type, default and a TypeScript example.
Source: packages/spec/src/ui/page.zod.ts
TypeScript Usage
import { ElementApiDataSourceSchema, ElementDataSourceSchema, InterfacePageConfigSchema, PageSchema, PageComponentSchema, PageComponentType, PagePrintSchema, PageRegionSchema, PageTypeSchema, PageVariableSchema } from '@objectstack/spec/ui';
import type { ElementApiDataSource, ElementDataSource, InterfacePageConfig, Page, PageComponent, PageComponentType, PagePrint, PageRegion, PageType, PageVariable } from '@objectstack/spec/ui';
// Validate data
const result = ElementApiDataSourceSchema.parse(data);ElementApiDataSource
[PLANNED — not yet rendered] API binding of a page element: { provider: 'api', read: { url }, path? } reads one same-origin endpoint with the console's signed-in fetch and binds the selected value, which the element reads as data in its expression holes and its visibleWhen. The console's generic stat, usage-bar and checklist blocks are the planned readers; until they ship, the binding parses and no block reads it.
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| provider | 'api' | ✅ | Binds the element to one same-origin API endpoint instead of an object |
| read | { url: string; method?: 'GET' } | ✅ | The read request — the same-origin endpoint the element binds |
| path | string | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. data.environments in a { success, data } envelope; omitted, the element binds the whole response |
Nested Shape: ElementApiDataSource.read
| Property | Type | Required | Description |
|---|---|---|---|
| url | string | ✅ | Same-origin API path the element reads — it opens with a single /, e.g. /api/v1/cloud/home. An absolute URL of any scheme, a protocol-relative //host, a backslash read as a slash, a space or control character, and a relative path are refused at parse. No credential is written here: the console reads the endpoint with its own signed-in fetch. |
| method | 'GET' | optional | HTTP method of the read — GET, the only method a page element's read is sent with |
ElementDataSource
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| object | string | ✅ | Object to query |
| view | string | optional | Named view to apply |
| filter | { field: string; operator: Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …>; value?: string | number | boolean | null | (string | number)[] }[] | optional | Additional filter criteria — the ViewFilterRule array form [{ field, operator, value }, ...], the one filter orthography every filter door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration element-data-source-and-object-block-filter-rule-array |
| sort | { field: string; order: Enum<'asc' | 'desc'> }[] | optional | Sort order |
| limit | integer | optional | Max records to display |
Nested Shape: ElementDataSource.filter[number]
View filter rule
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name to filter on |
| operator | Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …> | ✅ | Filter operator |
| value | string | number | boolean | null | (string | number)[] | optional | Filter value. The accepted SHAPE depends on the operator: in / not_in take an array (any length, including []), between takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: icontains takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. |
Nested Shape: ElementDataSource.sort[number]
Sort field and direction pair
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name to sort by |
| order | Enum<'asc' | 'desc'> | ✅ | Sort direction |
InterfacePageConfig
Interface-level page configuration (Airtable parity)
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| source | string | optional | Source object name for the page |
| columns | string[] | { field: string; label?: string | Record<string, string>; width?: number; align?: Enum<'left' | 'center' | 'right'>; … }[] | optional | Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance). |
| sort | { field: string; order: Enum<'asc' | 'desc'> }[] | optional | Default sort order for the page, defined directly on the page. |
| filterBy | { field: string; operator: Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …>; value?: string | number | boolean | null | (string | number)[] }[] | optional | Always-on page filter (base filter). |
| levels | integer | optional | Number of hierarchy levels to display |
| sourceView | string | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. |
| appearance | { showDescription?: boolean; allowedVisualizations?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>[] } | optional | Appearance and visualization configuration |
| userFilters | { element?: Enum<'dropdown' | 'tabs' | 'toggle'>; fields?: object[]; tabs?: object[]; showAllRecords?: boolean; … } | optional | End-user quick-filter bar for this page (overrides the source view's userFilters) |
| userActions | { sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … } | optional | User action toggles |
| addRecord | { enabled?: boolean; position?: Enum<'top' | 'bottom' | 'both'>; mode?: Enum<'inline' | 'form' | 'modal'>; formView?: string } | optional | Add record entry point configuration |
| buttons | string[] | optional | Toolbar buttons — names of the source object's actions to surface in the page toolbar |
| recordAction | Enum<'drawer' | 'page' | 'modal' | 'none'> | optional | How clicking a record opens its detail (drawer | page | modal | none). Default: drawer |
| showRecordCount | boolean | optional | Show record count at page bottom |
| emptyState | { title?: string | Record<string, string>; message?: string | Record<string, string>; icon?: string; link?: object } | optional | What the page draws in place of rows when its list has none to show: { title, message, icon, link }, the list view's own empty-state shape. The console's list page prefers it, whole, over its source view's empty state; with none declared, the page draws the source view's, if any. |
| allowPrinting | boolean | optional | Allow users to print the page |
Nested Shape: InterfacePageConfig.columns[number]
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name (snake_case) |
| label | string | Record<string, string> | optional | Display label override |
| width | number | optional | Column width in pixels |
| align | Enum<'left' | 'center' | 'right'> | optional | Text alignment |
| hidden | boolean | optional | Hide column by default |
| sortable | boolean | optional | Allow sorting by this column |
| resizable | boolean | optional | Allow resizing this column |
| wrap | boolean | optional | Allow text wrapping |
| type | string | optional | Renderer type override (e.g., "currency", "date") |
| pinned | Enum<'left' | 'right'> | optional | Pin/freeze column to left or right side |
| summary | Enum<'none' | 'count' | 'count_empty' | 'count_filled' | 'count_unique' | …> | { type: Enum<'none' | 'count' | 'count_empty' | 'count_filled' | 'count_unique' | …>; field?: string } | optional | Footer aggregation for this column — the function alone, or { type, field } to aggregate another field |
| prefix | { field: string; type?: Enum<'badge' | 'text'> } | optional | Field rendered inline before this cell value |
| link | boolean | optional | Functions as the primary navigation link (triggers View navigation) |
| action | string | optional | Registered Action ID to execute when clicked |
Nested Shape: InterfacePageConfig.sort[number]
Sort field and direction pair
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name to sort by |
| order | Enum<'asc' | 'desc'> | ✅ | Sort direction |
Nested Shape: InterfacePageConfig.filterBy[number]
View filter rule
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name to filter on |
| operator | Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …> | ✅ | Filter operator |
| value | string | number | boolean | null | (string | number)[] | optional | Filter value. The accepted SHAPE depends on the operator: in / not_in take an array (any length, including []), between takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: icontains takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. |
Nested Shape: InterfacePageConfig.appearance
| Property | Type | Required | Description |
|---|---|---|---|
| showDescription | boolean | optional (default: true) | Show the view description text |
| allowedVisualizations | Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>[] | optional | Whitelist of visualization types users can switch between (e.g. ["grid", "gallery", "kanban"]) |
Nested Shape: InterfacePageConfig.userFilters
| Property | Type | Required | Description |
|---|---|---|---|
| element | Enum<'dropdown' | 'tabs' | 'toggle'> | optional (default: "dropdown") | Filter control style: "dropdown" (per-field value selectors) or "tabs" (named presets). "toggle" is deprecated. |
| fields | { field: string; label?: string | Record<string, string>; type?: Enum<'select' | 'multi-select' | 'boolean' | 'date-range' | 'text'>; options?: object[]; … }[] | optional | Fields exposed as quick filters (dropdown/toggle elements) |
| tabs | { name: string; label?: string | Record<string, string>; icon?: string; view?: string; … }[] | optional | Named filter presets rendered as tabs (tabs element). Reuses ViewTabSchema |
| showAllRecords | boolean | optional | Show an "All records" tab before the presets (tabs element) |
| allowAddTab | boolean | optional | Let end users add their own tab after the presets (tabs element): the affordance asks for a name and snapshots the filters currently applied as a new tab. SESSION-SCOPED — an added tab lives only for the current mount, is never written back as metadata (ADR-0047), and carries a remove control the authored presets do not. Page lists only — object views use listViews for named presets |
Nested Shape: InterfacePageConfig.userActions
| Property | Type | Required | Description |
|---|---|---|---|
| sort | boolean | optional (default: true) | Allow users to sort records |
| search | boolean | optional (default: true) | Allow users to search records |
| filter | boolean | optional (default: true) | Allow users to filter records |
| refresh | boolean | optional (default: true) | Allow users to reload the view data from the backend without a full page reload |
| rowHeight | boolean | optional (default: true) | Allow users to toggle row height/density |
| group | boolean | optional (default: true) | Allow users to change record grouping from the toolbar. Toggle only — the grouping itself is configured in the view-level grouping block. |
| addRecordForm | boolean | optional (default: false) | Add records through a form instead of inline |
| editInline | boolean | optional (default: true) | Allow users to edit records inline. On by default: a user who may update the object edits a cell in place with the field's type-aware widget (the same control the form uses), under the permission gate that already decides whether the object is editable at all. Declare editInline: false to make the list read-only in place. |
| hideFields | boolean | optional (default: false) | Allow users to hide/show fields from the toolbar (the affordance behind the view-level hiddenFields list). Boolean toggle — distinct from the record-details component's hideFields, which is an array of field names to omit. Off by default: column hiding is opt-in. |
| rowColor | boolean | optional (default: false) | Allow users to configure row colouring from the toolbar. Boolean toggle — the colour rules themselves live in the view-level rowColor block. Off by default: row colouring is opt-in. |
| buttons | string[] | optional | Custom action button IDs to show in the toolbar |
Nested Shape: InterfacePageConfig.addRecord
| Property | Type | Required | Description |
|---|---|---|---|
| enabled | boolean | optional (default: true) | Show the add record entry point |
| position | Enum<'top' | 'bottom' | 'both'> | optional (default: "bottom") | Position of the add record button |
| mode | Enum<'inline' | 'form' | 'modal'> | optional (default: "inline") | How to add a new record |
| formView | string | optional | Named form view to use when mode is "form" or "modal" |
Nested Shape: InterfacePageConfig.emptyState
| Property | Type | Required | Description |
|---|---|---|---|
| title | string | Record<string, string> | optional | Heading of the empty state |
| message | string | Record<string, string> | optional | Line of text below the heading |
| icon | string | optional | Icon name drawn above the heading; a name that resolves to no icon draws the default empty-state glyph |
| link | { label: string | Record<string, string>; url: string } | optional | The one next step an empty list names: a link the user follows, { label, url } — for example to the how-to page that explains what belongs here when nobody can add a row. It opens a page or an address and runs no action. The console draws it in a list's empty state (the grid and gallery visualizations) and in an object-grid block's, beside the add-record action where one is offered. |
Page
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | ✅ | Page unique name (lowercase snake_case) |
| label | string | Record<string, string> | ✅ | Display label — the default-language string, or an inline locale map ({ en, "zh-CN" }) resolved at render time |
| description | string | Record<string, string> | optional | Display label — the default-language string, or an inline locale map ({ en, "zh-CN" }) resolved at render time |
| icon | string | optional | Page icon name |
| type | Enum<'record' | 'home' | 'app' | 'utility' | 'list'> | optional (default: "record") | Page type |
| variables | { name: string; type?: Enum<'string' | 'number' | 'boolean' | 'object' | 'array' | 'record_id'>; defaultValue?: any; source?: string }[] | optional | Local page state, exposed to expressions as page.<name> and writable by interactive elements via source (master/detail, filtered dashboards). |
| object | string | optional | Bound object (for Record pages) |
| template | string | optional (default: "default") | Layout template name (e.g. "header-sidebar-main") |
| regions | { name: string; width?: Enum<'small' | 'medium' | 'large' | 'full'>; components: object[] }[] | optional | Layout regions (header, main, sidebar, footer) with their components. Optional — list pages use interfaceConfig, slotted pages use slots, and an empty full page falls back to the synthesized default layout. |
| isDefault | boolean | optional (default: false) | Whether this record page is the default record page of its object — the page the record view opens. At most one isDefault: true record page may exist for an object in a deployment: a second is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Saving a page under the existing default's own name is that page, not a second default. Outside this rule for a page that is not a record page bound to an object. |
| assignedProfiles | never | optional | [REMOVED] page.assignedProfiles was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (named in a position's permissionSets) — those are the checks the runtime actually runs. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| interfaceConfig | { source?: string; columns?: string[] | object[]; sort?: object[]; filterBy?: object[]; … } | optional | Interface-level page configuration (for Airtable-style interface pages) |
| aria | { ariaLabel?: string | Record<string, string>; ariaDescribedBy?: string; role?: string } | optional | ARIA accessibility attributes |
{ paperSize?: Enum<'A4' | 'A5' | 'Letter' | 'Legal'>; orientation?: Enum<'portrait' | 'landscape'>; margins?: object; repeatHeader?: boolean; … } | optional | Print declaration — makes this page a print page, a document authored in the page's own blocks: paper size and orientation, margins in millimetres, the running header and footer (the page's own header / footer regions), page numbers and page-break hints, each mapped to print CSS. A print page is a kind: 'full' page of type: 'record', 'home' or 'app' with its blocks in regions, and every block in it must come from the printable block subset: containers that draw every child (page:section, page:card, page:footer), field blocks (record:details, record:highlights), the child-record table record:line_items, element:text, element:image, element:divider, element:definition-list, element:repeater, element:number and object-metric; os validate / os build / os lint and the metadata save door refuse any other block inside it. Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, the declaration is validated but nothing applies it. A list view's print button is allowPrinting, not this. | |
| kind | Enum<'full' | 'slotted' | 'html' | 'react'> | optional (default: "full") | Page override mode. full | slotted = structured authoring; html = author-written constrained JSX compiled (parsed, never executed) to the tree (ADR-0080; ADR-0081 named it html), styled by the registered components' structured props plus a JSON style object with hsl(var(--token)) theme colors; react = real-React source executed at render by the runtime (ADR-0081), styled by inline style with the same token colors; it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via the OS_PAGE_REACT=off env toggle. Do not author Tailwind classes in page source in either tier: source is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). |
| slots | { header?: object | object[]; actions?: object | object[]; alerts?: object | object[]; highlights?: object | object[]; … } | optional | Slot override map for slotted pages |
| source | string | optional | Page source text. For kind==='html' it is constrained JSX compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute), styled by the registered components' structured props plus a JSON style object with hsl(var(--token)) theme colors. For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier), styled by inline style with the same token colors. Do not author Tailwind classes in page source in either tier: source is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). Authoritative over regions in both. |
| requires | string[] | optional | Plugin namespaces the page's source uses, derived from the source at save — omit it. The key exists only on a kind==='html' page, the kind whose source is compiled at save; on a 'react', 'full' or 'slotted' page — and a page that omits kind, which is 'full' — it is refused at parse. On a server that has the deployment's SDUI component manifest, saving an html page compiles its source and stores the namespaces it uses here; a written list that disagrees with the source is refused (422 INVALID_METADATA, page-requires-disagrees-with-source) — on a draft save it is kept until the draft's publish, which refuses it. At load, a stored page whose list names a plugin no component in that manifest carries is reported, page and plugin named, and is still served. A server with no manifest checks neither and says so once at boot. |
| _lock | Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| _lockReason | string | optional | Human-readable reason shown when a write is refused by _lock. |
| _lockSource | Enum<'artifact' | 'package' | 'env-forced'> | optional | Layer that set _lock (artifact | package | env-forced). |
| _provenance | Enum<'package' | 'org' | 'env-forced'> | optional | Origin of the item (package | org | env-forced). |
| _packageId | string | optional | Owning package machine id. |
| _packageVersion | string | optional | Owning package version. |
| _lockDocsUrl | string | optional | Optional documentation link surfaced next to _lockReason. |
Nested Shape: Page.variables[number]
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | ✅ | Variable name. Exposed to expressions as page.<name>. |
| type | Enum<'string' | 'number' | 'boolean' | 'object' | 'array' | 'record_id'> | optional (default: "string") | |
| defaultValue | any | optional | Initial value. Defaults to a type-appropriate empty value when omitted. |
| source | string | optional | Component id that writes this variable (e.g. an element:record_picker whose id matches). |
Nested Shape: Page.regions[number]
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | ✅ | Region name (e.g. "sidebar", "main", "header") |
| width | Enum<'small' | 'medium' | 'large' | 'full'> | optional | |
| components | { type: Enum<'page:header' | 'page:footer' | 'page:sidebar' | 'page:tabs' | 'page:accordion' | …> | string; id?: string; label?: string | Record<string, string>; properties?: Record<string, any>; … }[] | ✅ | Components in this region |
Nested Shape: Page.interfaceConfig
| Property | Type | Required | Description |
|---|---|---|---|
| source | string | optional | Source object name for the page |
| columns | string[] | { field: string; label?: string | Record<string, string>; width?: number; align?: Enum<'left' | 'center' | 'right'>; … }[] | optional | Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance). |
| sort | { field: string; order: Enum<'asc' | 'desc'> }[] | optional | Default sort order for the page, defined directly on the page. |
| filterBy | { field: string; operator: Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …>; value?: string | number | boolean | null | (string | number)[] }[] | optional | Always-on page filter (base filter). |
| levels | integer | optional | Number of hierarchy levels to display |
| sourceView | string | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. |
| appearance | { showDescription?: boolean; allowedVisualizations?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>[] } | optional | Appearance and visualization configuration |
| userFilters | { element?: Enum<'dropdown' | 'tabs' | 'toggle'>; fields?: object[]; tabs?: object[]; showAllRecords?: boolean; … } | optional | End-user quick-filter bar for this page (overrides the source view's userFilters) |
| userActions | { sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … } | optional | User action toggles |
| addRecord | { enabled?: boolean; position?: Enum<'top' | 'bottom' | 'both'>; mode?: Enum<'inline' | 'form' | 'modal'>; formView?: string } | optional | Add record entry point configuration |
| buttons | string[] | optional | Toolbar buttons — names of the source object's actions to surface in the page toolbar |
| recordAction | Enum<'drawer' | 'page' | 'modal' | 'none'> | optional | How clicking a record opens its detail (drawer | page | modal | none). Default: drawer |
| showRecordCount | boolean | optional | Show record count at page bottom |
| emptyState | { title?: string | Record<string, string>; message?: string | Record<string, string>; icon?: string; link?: object } | optional | What the page draws in place of rows when its list has none to show: { title, message, icon, link }, the list view's own empty-state shape. The console's list page prefers it, whole, over its source view's empty state; with none declared, the page draws the source view's, if any. |
| allowPrinting | boolean | optional | Allow users to print the page |
Nested Shape: Page.aria
| Property | Type | Required | Description |
|---|---|---|---|
| ariaLabel | string | Record<string, string> | optional | Accessible label for screen readers (WAI-ARIA aria-label). Plain string, or an inline locale map — no translation-bundle slot addresses this key, so a plain string is announced in the source language. |
| ariaDescribedBy | string | optional | ID of element providing additional description (WAI-ARIA aria-describedby) |
| role | string | optional | WAI-ARIA role attribute (e.g., "dialog", "navigation", "alert") |
Nested Shape: Page.print
| Property | Type | Required | Description |
|---|---|---|---|
| paperSize | Enum<'A4' | 'A5' | 'Letter' | 'Legal'> | optional | Paper size — the size keyword of the print CSS @page { size } rule (A4 when omitted). Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, nothing applies it. |
| orientation | Enum<'portrait' | 'landscape'> | optional | Sheet orientation — the orientation keyword of the @page { size } rule (portrait when omitted). Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, nothing applies it. |
| margins | { top?: number; right?: number; bottom?: number; left?: number } | optional | Sheet margins in MILLIMETRES, one number per side — the @page { margin } rule; a side left out takes the renderer's default. Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, nothing applies it. |
| repeatHeader | boolean | optional | Repeat the page's header region (its regions entry named header) at the top of every printed sheet — the running header. The parse refuses it on a page with no header region. Off when omitted: the region prints once, at the top of the first sheet. Consumer: the console's browser print rendering; until it ships, nothing applies it. |
| repeatFooter | boolean | optional | Repeat the page's footer region (its regions entry named footer) at the bottom of every printed sheet — the running footer. The parse refuses it on a page with no footer region. Off when omitted: the region prints once, after the body. Consumer: the console's browser print rendering; until it ships, nothing applies it. |
| pageNumbers | boolean | optional | Print the sheet number and the sheet count in the bottom margin of every sheet — the @page margin box with counter(page) and counter(pages). Off when omitted. Consumer: the console's browser print rendering; until it ships, nothing applies it. |
| repeatTableHeaders | boolean | optional | Page-break hint: repeat a table's column headings at the top of every sheet the table runs onto (thead { display: table-header-group }). On when omitted. Consumer: the console's browser print rendering; until it ships, nothing applies it. |
| avoidBreakInside | boolean | optional | Page-break hint: keep each block whole on one sheet when it fits, moving it to the next sheet instead of splitting it (break-inside: avoid on every block). Off when omitted. Consumer: the console's browser print rendering; until it ships, nothing applies it. |
PageComponent
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| type | Enum<'page:header' | 'page:footer' | 'page:sidebar' | 'page:tabs' | 'page:accordion' | 'page:card' | 'page:section' | 'record:details' | 'record:highlights' | 'record:related_list' | 'record:activity' | 'record:chatter' | 'record:discussion' | 'record:path' | 'record:alert' | 'record:quick_actions' | 'record:reference_rail' | 'record:history' | 'record:approval_decision' | 'record:approvals' | 'record:attachments' | 'app:launcher' | 'nav:menu' | 'nav:breadcrumb' | 'global:search' | 'global:notifications' | 'ai:suggestion' | 'element:text' | 'element:number' | 'element:image' | 'element:divider' | 'element:button' | 'element:record_picker' | 'element:text_input'> | string | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. object-grid, mcp:connect-agent). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by os validate / os build / os lint (rule component-type-unknown); a type the vocabulary RETIRED by name (user:profile — shell chrome, not author-placeable; ai:chat_window — no renderer by design, the floating chat overlay is the AI chat entry point; element:filter and element:form — retired whole, no renderer for either ever shipped) is refused at the parse itself, with the retirement prescription. |
| id | string | optional | Unique instance ID |
| label | string | Record<string, string> | optional | Display label — the default-language string, or an inline locale map ({ en, "zh-CN" }) resolved at render time |
| properties | Record<string, any> | optional (default: {}) | Component props passed to the widget. See component.zod.ts for schemas. |
| events | Record<string, string> | optional | Event handlers map |
| style | Record<string, string> | optional | Inline styles or utility classes |
| className | string | optional | CSS class names |
| responsiveStyles | { large?: Record<string, string | number>; medium?: Record<string, string | number>; small?: Record<string, string | number>; xsmall?: Record<string, string | number> } | optional | Per-breakpoint scoped style maps (ADR-0065) |
| visibleWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: record, current_user (ADR-0068 aliases user / ctx.user — one object, three spellings), and page state as page.<var>. The shipping renderer additionally mounts features and os.user — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO data root here: a data.* read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is record. data is declared only for an element whose dataSource is the PLANNED API binding (provider: 'api'), which reads its bound value as data once that binding's renderer ships. ⚠️ A page:tabs item visibleWhen is a different evaluator: it binds data to the record ROW. e.g. "page.selectedProjectId != ''" |
| visibility | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | [DEPRECATED → visibleWhen] Visibility predicate (CEL). Normalized to visibleWhen at parse. |
| dataSource | { object: string; view?: string; filter?: object[]; sort?: object[]; … } | { provider: 'api'; read: object; path?: string } | optional | Per-element data binding: an object binding { object, view?, filter?, sort?, limit? } that overrides the page object, or an API binding { provider: 'api', read: { url }, path? } that reads one same-origin endpoint |
| responsive | never | optional | [REMOVED] page.components[].responsive was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling responsiveStyles (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. responsiveStyles: { xsmall: { display: 'none' } } to hide a component on the narrowest screens. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| aria | { ariaLabel?: string | Record<string, string>; ariaDescribedBy?: string; role?: string } | optional | ARIA accessibility attributes |
Nested Shape: PageComponent.responsiveStyles
| Property | Type | Required | Description |
|---|---|---|---|
| large | Record<string, string | number> | optional | Unconditional base (desktop-first) |
| medium | Record<string, string | number> | optional | Applied at ≤ medium breakpoint |
| small | Record<string, string | number> | optional | Applied at ≤ small breakpoint |
| xsmall | Record<string, string | number> | optional | Applied at ≤ xsmall breakpoint |
Nested Shape: PageComponent.dataSource[option 1]
| Property | Type | Required | Description |
|---|---|---|---|
| object | string | ✅ | Object to query |
| view | string | optional | Named view to apply |
| filter | { field: string; operator: Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …>; value?: string | number | boolean | null | (string | number)[] }[] | optional | Additional filter criteria — the ViewFilterRule array form [{ field, operator, value }, ...], the one filter orthography every filter door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration element-data-source-and-object-block-filter-rule-array |
| sort | { field: string; order: Enum<'asc' | 'desc'> }[] | optional | Sort order |
| limit | integer | optional | Max records to display |
Nested Shape: PageComponent.dataSource[option 2]
[PLANNED — not yet rendered] API binding of a page element: { provider: 'api', read: { url }, path? } reads one same-origin endpoint with the console's signed-in fetch and binds the selected value, which the element reads as data in its expression holes and its visibleWhen. The console's generic stat, usage-bar and checklist blocks are the planned readers; until they ship, the binding parses and no block reads it.
| Property | Type | Required | Description |
|---|---|---|---|
| provider | 'api' | ✅ | Binds the element to one same-origin API endpoint instead of an object |
| read | { url: string; method?: 'GET' } | ✅ | The read request — the same-origin endpoint the element binds |
| path | string | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. data.environments in a { success, data } envelope; omitted, the element binds the whole response |
Nested Shape: PageComponent.aria
| Property | Type | Required | Description |
|---|---|---|---|
| ariaLabel | string | Record<string, string> | optional | Accessible label for screen readers (WAI-ARIA aria-label). Plain string, or an inline locale map — no translation-bundle slot addresses this key, so a plain string is announced in the source language. |
| ariaDescribedBy | string | optional | ID of element providing additional description (WAI-ARIA aria-describedby) |
| role | string | optional | WAI-ARIA role attribute (e.g., "dialog", "navigation", "alert") |
PageComponentType
Allowed Values
page:headerpage:footerpage:sidebarpage:tabspage:accordionpage:cardpage:sectionrecord:detailsrecord:highlightsrecord:related_listrecord:activityrecord:chatterrecord:discussionrecord:pathrecord:alertrecord:quick_actionsrecord:reference_railrecord:historyrecord:approval_decisionrecord:approvalsrecord:attachmentsapp:launchernav:menunav:breadcrumbglobal:searchglobal:notificationsai:suggestionelement:textelement:numberelement:imageelement:dividerelement:buttonelement:record_pickerelement:text_input
PagePrint
Print declaration: paper size, orientation, margins, the running header and footer, page numbering and page-break hints. Its presence makes the page a print page, whose blocks must come from the printable block subset.
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| paperSize | Enum<'A4' | 'A5' | 'Letter' | 'Legal'> | optional | Paper size — the size keyword of the print CSS @page { size } rule (A4 when omitted). Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, nothing applies it. |
| orientation | Enum<'portrait' | 'landscape'> | optional | Sheet orientation — the orientation keyword of the @page { size } rule (portrait when omitted). Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, nothing applies it. |
| margins | { top?: number; right?: number; bottom?: number; left?: number } | optional | Sheet margins in MILLIMETRES, one number per side — the @page { margin } rule; a side left out takes the renderer's default. Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, nothing applies it. |
| repeatHeader | boolean | optional | Repeat the page's header region (its regions entry named header) at the top of every printed sheet — the running header. The parse refuses it on a page with no header region. Off when omitted: the region prints once, at the top of the first sheet. Consumer: the console's browser print rendering; until it ships, nothing applies it. |
| repeatFooter | boolean | optional | Repeat the page's footer region (its regions entry named footer) at the bottom of every printed sheet — the running footer. The parse refuses it on a page with no footer region. Off when omitted: the region prints once, after the body. Consumer: the console's browser print rendering; until it ships, nothing applies it. |
| pageNumbers | boolean | optional | Print the sheet number and the sheet count in the bottom margin of every sheet — the @page margin box with counter(page) and counter(pages). Off when omitted. Consumer: the console's browser print rendering; until it ships, nothing applies it. |
| repeatTableHeaders | boolean | optional | Page-break hint: repeat a table's column headings at the top of every sheet the table runs onto (thead { display: table-header-group }). On when omitted. Consumer: the console's browser print rendering; until it ships, nothing applies it. |
| avoidBreakInside | boolean | optional | Page-break hint: keep each block whole on one sheet when it fits, moving it to the next sheet instead of splitting it (break-inside: avoid on every block). Off when omitted. Consumer: the console's browser print rendering; until it ships, nothing applies it. |
Nested Shape: PagePrint.margins
| Property | Type | Required | Description |
|---|---|---|---|
| top | number | optional | Top margin, in millimetres |
| right | number | optional | Right margin, in millimetres |
| bottom | number | optional | Bottom margin, in millimetres |
| left | number | optional | Left margin, in millimetres |
PageRegion
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | ✅ | Region name (e.g. "sidebar", "main", "header") |
| width | Enum<'small' | 'medium' | 'large' | 'full'> | optional | |
| components | { type: Enum<'page:header' | 'page:footer' | 'page:sidebar' | 'page:tabs' | 'page:accordion' | …> | string; id?: string; label?: string | Record<string, string>; properties?: Record<string, any>; … }[] | ✅ | Components in this region |
Nested Shape: PageRegion.components[number]
| Property | Type | Required | Description |
|---|---|---|---|
| type | Enum<'page:header' | 'page:footer' | 'page:sidebar' | 'page:tabs' | 'page:accordion' | …> | string | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. object-grid, mcp:connect-agent). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by os validate / os build / os lint (rule component-type-unknown); a type the vocabulary RETIRED by name (user:profile — shell chrome, not author-placeable; ai:chat_window — no renderer by design, the floating chat overlay is the AI chat entry point; element:filter and element:form — retired whole, no renderer for either ever shipped) is refused at the parse itself, with the retirement prescription. |
| id | string | optional | Unique instance ID |
| label | string | Record<string, string> | optional | Display label — the default-language string, or an inline locale map ({ en, "zh-CN" }) resolved at render time |
| properties | Record<string, any> | optional (default: {}) | Component props passed to the widget. See component.zod.ts for schemas. |
| events | Record<string, string> | optional | Event handlers map |
| style | Record<string, string> | optional | Inline styles or utility classes |
| className | string | optional | CSS class names |
| responsiveStyles | { large?: Record<string, string | number>; medium?: Record<string, string | number>; small?: Record<string, string | number>; xsmall?: Record<string, string | number> } | optional | Per-breakpoint scoped style maps (ADR-0065) |
| visibleWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: record, current_user (ADR-0068 aliases user / ctx.user — one object, three spellings), and page state as page.<var>. The shipping renderer additionally mounts features and os.user — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO data root here: a data.* read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is record. data is declared only for an element whose dataSource is the PLANNED API binding (provider: 'api'), which reads its bound value as data once that binding's renderer ships. ⚠️ A page:tabs item visibleWhen is a different evaluator: it binds data to the record ROW. e.g. "page.selectedProjectId != ''" |
| visibility | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | [DEPRECATED → visibleWhen] Visibility predicate (CEL). Normalized to visibleWhen at parse. |
| dataSource | { object: string; view?: string; filter?: object[]; sort?: object[]; … } | { provider: 'api'; read: object; path?: string } | optional | Per-element data binding: an object binding { object, view?, filter?, sort?, limit? } that overrides the page object, or an API binding { provider: 'api', read: { url }, path? } that reads one same-origin endpoint |
| responsive | never | optional | [REMOVED] page.components[].responsive was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling responsiveStyles (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. responsiveStyles: { xsmall: { display: 'none' } } to hide a component on the narrowest screens. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| aria | { ariaLabel?: string | Record<string, string>; ariaDescribedBy?: string; role?: string } | optional | ARIA accessibility attributes |
PageType
Page type — the page KIND. Only types with a dedicated renderer are authorizable; visualizations of a list page live in interfaceConfig, not here.
Allowed Values
recordhomeapputilitylist
PageVariable
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | ✅ | Variable name. Exposed to expressions as page.<name>. |
| type | Enum<'string' | 'number' | 'boolean' | 'object' | 'array' | 'record_id'> | optional (default: "string") | |
| defaultValue | any | optional | Initial value. Defaults to a type-appropriate empty value when omitted. |
| source | string | optional | Component id that writes this variable (e.g. an element:record_picker whose id matches). |