Layout DSL
Declarative layout description language for pages, sections, and responsive grids
The Layout DSL (Domain-Specific Language) is ObjectUI's declarative syntax for defining page structure, sections, and responsive grids. It abstracts away CSS Grid, Flexbox, and platform-specific layout engines.
Philosophy: Constraints Enable Creativity
Instead of giving you infinite layout freedom (and infinite ways to break responsive design), ObjectUI provides a constrained, opinionated layout system:
- 12-Column Grid: All layouts use a 12-column responsive grid
- Section-Based: Pages are composed of sections, sections contain fields/widgets
- Auto-Responsive: Breakpoints applied automatically (no media queries)
- Platform-Agnostic: The DSL describes structure, not raw CSS — the web renderer maps it to CSS Grid today. Native iOS/Android renderers (Auto Layout / ConstraintLayout) are a design goal, not yet implemented.
Why Constraints?
- Consistency: All forms look professionally designed
- Accessibility: Keyboard navigation and screen readers work automatically
- Maintainability: Designers can't create 47 different button sizes
Authoring Modes: Structured, HTML, and React
A page's kind selects how it is authored. This document describes the
structured mode (regions + the 12-column grid below) — but for layouts the
fixed schema can't express, two source-authoring tiers let an author (or AI)
write the page body as a string. Pick by what the page needs:
kind | You write | JavaScript | Use when |
|---|---|---|---|
full / slotted (this doc) | structured regions / slots | — | record, detail, home, and app layouts from the component catalogue |
html | constrained JSX = registered components + safe native HTML, parsed, never executed | none | free-form layout, landing pages, or dashboards that compose blocks — no interactivity |
react | real React (hooks, .map, onClick, expressions) | executed in the app | complex interactive business UIs — master/detail, wizards, state-driven filters |
For html / react, the page body is the source string (it is the
source-of-truth; regions is ignored). html is safe by construction (it
is parsed into the same SDUI tree this document describes, never executed).
react executes author code, so it is gated by a host capability that is ON
by default and disabled per-deployment with the OS_PAGE_REACT=off env
variable. Both tiers can embed the platform's real data components
(<ObjectTable>, <ObjectForm>, charts, metrics).
Learn more: ADR-0080 (the
htmltier — parse ≠ execute; the registry is the contract) and ADR-0081 (the trustedreacttier). The rest of this page covers the structured mode.
Layout Hierarchy
A page holds regions; a region holds components; only a form-shaped component holds sections, and a section holds fields. Related lists and widgets are components in their own right — they are not children of a section.
Page
├─ Region: header
│ └─ Component: page:header (title, actions, breadcrumb)
├─ Region: main
│ ├─ Component: record:details
│ │ └─ Section
│ │ ├─ Field A (span: auto)
│ │ └─ Field B (span: full)
│ └─ Component: record:related_list
└─ Region: sidebar
├─ Component: object-metric
└─ Component: record:activityPage Templates
Top-level layout structures that define macro organization.
Standard Template
A page is identified by name + label, and its content lives in regions,
each holding typed components. There is no context: key — the binding is
object plus type (record here).
name: account_record
label: Account Record Page
type: record
object: account
template: standard
regions:
- name: header
components:
- type: page:header
properties:
title: Customer Details
actions: [edit, delete, share]
- name: main
components:
- type: record:details
properties:
fields: [name, email, phone]
- name: sidebar
width: small
components:
- type: record:activity
properties:
limit: 10Visual Layout:
┌────────────────────────────────────────────────────┐
│ Header: Customer Details [Edit] [Delete] │
├──────────────────────────────┬─────────────────────┤
│ │ │
│ Main Region │ Sidebar │
│ │ │
│ ┌────────────────────────┐ │ ┌───────────────┐ │
│ │ Contact Information │ │ │ Activity Feed │ │
│ │ Name: ____________ │ │ │ • Called │ │
│ │ Email: ___________ │ │ │ • Emailed │ │
│ │ Phone: ___________ │ │ └───────────────┘ │
│ └────────────────────────┘ │ │
│ │ │
└──────────────────────────────┴─────────────────────┘Breakpoint Behavior:
- Desktop (≥1024px): Sidebar on right (25% width)
- Tablet (768-1023px): Sidebar below main (full width)
- Mobile (less than 768px): Sidebar below main (full width)
Console Template
High-density layout for power users (like Salesforce Service Console).
name: service_console
label: Service Console
type: app
template: console
regions:
- name: left
width: small
components:
- type: object-grid
properties:
objectName: case
- name: right
width: full
components:
- type: page:tabs
properties:
items:
- label: Details
value: details
children:
- type: record:details
properties:
fields: [subject, priority, status]
- label: Related
value: related
children:
- type: record:related_list
properties:
objectName: task
relationshipField: case_id
- label: Activity
value: activity
children:
- type: record:activityVisual Layout:
┌──────────────┬─────────────────────────────────────┐
│ │ Case #12345 │
│ Case List │ ┌───┬─────────┬──────────┬────────┐│
│ │ │Det│ Related │ Activity │ ││
│ □ Case #123 │ └───┴─────────┴──────────┴────────┘│
│ ■ Case #124 │ │
│ □ Case #125 │ Subject: Email not working │
│ □ Case #126 │ Priority: High │
│ │ Status: In Progress │
│ │ │
│ │ [Resolve] [Escalate] │
└──────────────┴─────────────────────────────────────┘Use Cases:
- Customer service (Case management)
- Sales (Opportunity pipeline + details)
- Helpdesk (Ticket queue + ticket details)
There is no wizard template
Earlier revisions of this page documented a Wizard Template — a page whose
body was a steps: list, each step carrying its own label and fields — and
a matching Multi-Step Onboarding Wizard example further down. steps exists
on no schema. PageSchema is .strict(), so it is refused by name
(Unrecognized key(s) on this page: steps), and no other shape in the spec
declares it either. template: wizard compounds it quietly: template is a
free-form string, so that half parses while naming a layout template no
renderer provides.
Both are removed rather than implemented — multi-step authoring is an
implementation card first, exactly like the deferred tab loading and the
virtualization block this page removed before it. To collect a long form in
stages today, use the real, declared shapes: a tabbed form view (every
section renders as its own tab — see Tabs), or
a flow when the steps need to branch or
persist between visits.
The 12-Column Grid System
"12 columns" describes the renderer's internal grid, not an authorable span
vocabulary. What an author declares is much narrower, and both keys are
strict: a section's columns is a scalar 1–4, and a field's width is
span: auto | full (or the legacy absolute colSpan, also 1–4). There is
no span: 6 and no twelfth of anything in the metadata. The examples below
are written in the vocabulary the schemas accept.
All layouts use a responsive grid that divides space into columns.
Basic Grid Layout
section:
label: Contact Information
columns: 2 # two fields per row (50% width each)
fields:
- name # row 1, left
- email # row 1, right
- phone # row 2, left
- company # row 2, rightRendered Grid:
┌──────────────────────┬──────────────────────┐
│ Name: ______________ │ Email: _____________ │
├──────────────────────┼──────────────────────┤
│ Phone: _____________ │ Company: ___________ │
└──────────────────────┴──────────────────────┘Custom Span Widths
A field's width is set on the field itself, inside the section's own fields
list — there is no section-level layout: block. span: full takes the whole
row at any column count; omitting span (auto) lets the renderer size the
field from its widget type and the current column count.
section:
label: Product Details
columns: 3
fields:
- field: product_name
span: full # whole row
- field: price # auto — one of three columns
- field: quantity
- field: total
- field: description
span: full # whole rowRendered Grid:
┌─────────────────────────────────────────────────┐
│ Product Name: _________________________________│
├───────────────┬───────────────┬────────────────┤
│ Price: $_____ │ Qty: ________ │ Total: $______ │
├───────────────┴───────────────┴────────────────┤
│ Description: │
│ ____________________________________________ │
│ ____________________________________________ │
└─────────────────────────────────────────────────┘Responsive Breakpoints
Grid automatically collapses on smaller screens:
section:
columns: 3 # Desktop: 3 columns, Tablet: 2 columns, Mobile: 1 column
fields: [field_a, field_b, field_c, field_d, field_e, field_f]Desktop (≥1024px): 3 columns
┌──────────┬──────────┬──────────┐
│ Field A │ Field B │ Field C │
├──────────┼──────────┼──────────┤
│ Field D │ Field E │ Field F │
└──────────┴──────────┴──────────┘Tablet (768-1023px): 2 columns
┌──────────────┬──────────────┐
│ Field A │ Field B │
├──────────────┼──────────────┤
│ Field C │ Field D │
├──────────────┼──────────────┤
│ Field E │ Field F │
└──────────────┴──────────────┘Mobile (less than 768px): 1 column
┌────────────────────────┐
│ Field A │
├────────────────────────┤
│ Field B │
├────────────────────────┤
│ Field C │
├────────────────────────┤
│ Field D │
├────────────────────────┤
│ Field E │
├────────────────────────┤
│ Field F │
└────────────────────────┘Sections: Organizing Fields
Sections are collapsible containers for related fields.
Basic Section
sections:
- label: Contact Information
collapsible: true
collapsed: false # Expanded by default
fields:
- name
- email
- phoneRendered:
┌─ Contact Information ──────────────────────────▼─┐
│ │
│ Name: __________________________________________│
│ Email: __________________________________________│
│ Phone: __________________________________________│
│ │
└──────────────────────────────────────────────────┘Conditional Sections
Show sections based on field values. The key is visibleWhen and its value is a
CEL predicate string — never a { field, value } rule object:
sections:
- label: Basic Info
fields: [name, email]
- label: Billing Information
visibleWhen: "record.account_type == 'premium'" # Only show for premium accounts
fields: [payment_method, billing_address]There is no section-level visible key — and no permission test belongs here.
FormSectionSchema is .strict() and declares visibleWhen (plus the @deprecated
alias visibleOn); visible is refused by name, before any value shape is examined,
and the rejection points you back at visibleWhen. Neither a { field, value } rule
object nor a { permission } name is a value visibleWhen accepts either — it takes a
CEL predicate string, normalized to a { dialect: 'cel', source } envelope at parse.
Earlier revisions of this page also showed an Admin Settings section gated on
permission: admin. It is removed rather than translated, because rewriting it as a
visibleWhen position test would teach the anti-pattern this page warns about further
down — twice over. Nothing server-side evaluates a form-view section predicate, so a
position test here hides the controls and protects no data: the record still carries
every value in the section, and every other read surface still returns them. Worse, on
the console's public form route (/f/:slug) no host publishes a predicate scope, so the
root is unbound, the predicate faults open, and the section it was meant to hide is
shown to everyone. To withhold a group of fields from some users, declare
field-level security on a permission set, or
row-level security — both enforced on the server.
There are no section variants
Earlier revisions of this page documented a section-level variant: key with
the values compact / spacious / danger. It existed on no schema.
FormSectionSchema is .strict() and declares no variant, so authoring one
is a parse failure (Unrecognized key(s) on this form section: variant) — not
a section that renders with default padding. It is removed rather than
implemented: per-section density and emphasis are a theming decision with no
renderer behind them today. A section's authorable presentation is label,
description, columns, collapsible and collapsed.
Field Groups and Inline Layout
There is no field_group, and no section-level layout: list. Earlier
revisions of this page grouped fields two ways — an inline field_group nested
under a section's layout:, and a bare - type: field_group list — with
twelfth-based span: numbers on the fields inside. None of it existed:
FormSectionSchema refuses layout by name, and field_group is not a type
on any schema. Removed rather than implemented.
Both examples were doing something the declared shape already does — see the
replacement below. For a value that is genuinely one composite thing rather
than neighbouring fields, the grouping belongs on the field: a field of
type address (or composite) carries its own fields list.
Fields sit on the same row because the section says how many columns it has, and
a field widens itself with span: full:
sections:
- label: Name
columns: 2 # first_name and last_name share a row
fields:
- field: first_name
placeholder: First
- field: last_name
placeholder: Last
- label: Address
columns: 3
fields:
- field: street
span: full # whole row above the three below
- field: city
- field: state
- field: postal_codeRendered:
Name
┌──────────────────────┬──────────────────────┐
│ First: _____________ │ Last: ______________ │
└──────────────────────┴──────────────────────┘
Address
┌─────────────────────────────────────────────────┐
│ Street: _______________________________________│
├──────────────────────┬───────────┬────────────┤
│ City: _______________ │ State: __ │ ZIP: _____ │
└──────────────────────┴───────────┴────────────┘Tabs: Multi-Page Layouts
Organize large forms into tabs. A tabbed form has no separate tab list — set
type: tabbed and every section renders as its own tab, in declaration
order. defaultTab names the section that opens first; tabPosition places the
strip.
Basic Tabs
type: tabbed
tabPosition: top # top | bottom | left | right
defaultTab: details # a section `name`
sections:
- name: details
label: Details
columns: 2
fields: [name, email, phone]
- name: address
label: Address
columns: 2
fields: [street, city, state, zip]
- name: preferences
label: Preferences
fields: [email_notifications, sms_notifications]Rendered:
┌──────────────────────────────────────────────────┐
│ [Details] [Address] [Preferences] │
├──────────────────────────────────────────────────┤
│ │
│ Name: __________________________________________│
│ Email: __________________________________________│
│ Phone: __________________________________________│
│ │
└──────────────────────────────────────────────────┘A tab carries no options of its own
The tab is the section, so a section's keys are the whole surface: name,
label, description, fields, columns, collapsible, collapsed,
visibleWhen — plus pane, which split forms alone accept. There is no
per-tab lazy, source, badge or badgeVariant key, and no
layout: { mode: tabbed, ... } wrapper: FormViewSchema.layout is a string
enum (vertical / horizontal / inline / grid) and the form schema
declares no tabs key at all. FormViewSchema and FormSectionSchema
(packages/spec/src/ui/view.zod.ts) are .strict(), so authoring any of them
is a parse failure — a loud rejection, not a silent no-op.
Earlier revisions of this page documented tab-level lazy / source and tab
badge / badgeVariant (both the scalar badge: 5 + badgeVariant: danger
form and the object badge: { count, variant } form), nested under a
layout: { mode: tabbed, tabs: [...] } wrapper. None of them existed on any
schema — lazy is authorable nowhere in the spec — so they are removed
rather than implemented; deferred tab loading is an implementation card
first. Counter-badges are real, but on app navigation items (badge /
badgeVariant on ui/ObjectNavItem and its siblings), never on a form tab.
For the keys a section really accepts, see the
View Reference.
Looking for tabs that carry their own name, icon, filter, order,
pinned and isDefault? That surface exists, but it belongs to list views,
not forms: ui/ViewTab declares exactly nine keys (filter, icon,
isDefault, label, name, order, pinned, view, visible), and each
tab points at a named list view. See
View Reference → ViewTab.
Responsive Layout Modifiers
Responsive behaviour is not a form-field or form-section key. A form field's
only conditional-visibility key is visibleWhen — a CEL predicate over the
record, so it varies per record, never per viewport — and a section's
columns is a single scalar (1–4). Both shapes are strict, so a breakpoint
or orientation map written on either is a loud parse error naming the key,
not a layout that quietly does nothing.
Per-Breakpoint Styling
Breakpoint-driven show/hide and per-breakpoint layout live one tier up, on a
page component, and are expressed as scoped CSS through responsiveStyles
(ADR-0065) — desktop-first buckets compiled to id-scoped CSS at
render:
# On a page component (*.page.ts) — NOT on a form field or a form section
responsiveStyles:
large: { gridColumn: 'span 6' } # unconditional base (desktop-first)
medium: { gridColumn: 'span 12' } # applied at ≤ medium
xsmall: { display: 'none' } # hidden at the smallest breakpointThe four buckets are large / medium / small / xsmall; values are a
CSS-property map with camelCase keys. Prefer design tokens
(var(--space-6)) over literals.
Earlier revisions of this page documented a field-level visible: breakpoint
map (desktop / tablet / mobile) and a section-level columns:
orientation map (portrait / landscape). Neither existed on any schema.
visible is not a form-field key at all — FormFieldSchema declares hidden
(static, and inverted: visible: false is hidden: true) and
visibleWhen (CEL predicate) — and FormSection.columns has always been a
scalar. Both are removed rather than implemented, because field-level
breakpoint visibility exists nowhere in the spec: there is no key to rename
them onto.
Nor is responsive.hiddenOn the answer. That layout block was retired in
v17.x under ADR-0049 D2 precisely because no renderer ever applied it;
responsiveStyles above is the only per-breakpoint channel that is.
Related Lists: Embedding Child Records
Display child records within a parent record's page.
A related list is a page component, not a form section. It lives in a page
region's components, with the type record:related_list and its configuration
under properties. FormSectionSchema refuses type, object and
relationField by name — a form section holds fields, and nothing else.
Basic Related List
type: record:related_list
properties:
objectName: contact
relationshipField: account_id # contact.account_id → account.id
columns: [name, email, phone]
limit: 5
actions: [standard_new]Note the spellings: objectName and relationshipField (not object /
relationField), and actions is a list of action ids, not objects.
Rendered:
┌─ Contacts ─────────────────────────────── [+ New Contact] ─┐
│ │
│ Name Email Phone │
│ ──────────────── ───────────────── ───────────────── │
│ John Doe john@acme.com 555-1234 │
│ Jane Smith jane@acme.com 555-5678 │
│ │
│ Showing 2 of 2 contacts │
└─────────────────────────────────────────────────────────────┘A related list has no inline-edit mode
Earlier revisions of this page documented an Inline Editing Related List —
mode: inline_edit on the list, with per-column type / editable / formula
definitions. None of it existed. RecordRelatedListProps is .strict() and
declares no mode; its columns is an array of field-name strings, never
column-definition objects, and the column's type, editability and formula are
properties of the field on the child object — not of the list that displays it.
Removed rather than implemented.
Inline editing does exist, one component over: record:details declares
inlineEdit (renderer default on, where the object itself is editable). For an
editable child collection — the invoice-lines shape this example reached for —
the declared block is object-master-detail-form with a details entry; see
Master-Detail Forms below.
Widgets: Embedding Rich Components
Widgets are pre-built UI components that display data or provide functionality.
Like related lists, they are page components — there is no type: widget
wrapper and no component: / config: pair. The widget is the component
type, and its configuration is properties.
Metric Widget
type: object-metric
properties:
objectName: opportunity
label: Open Opportunities
aggregate:
field: id
function: count
icon: trending-up
colorVariant: successA metric aggregates its object — it has no literal value: key to hard-code
a number into. colorVariant (not color) is an enum: default, blue,
teal, orange, purple, success, warning, danger.
Rendered:
┌─────────────────────┐
│ Open Opportunities │
│ │
│ 47 ↑ +12% │
│ │
└─────────────────────┘Activity Feed Widget
The feed is bound to the record the page is already on, so it takes no object
or record filter of its own.
type: record:activity
properties:
limit: 10
showFilterToggle: true
unifiedTimeline: trueCustom Widget
A component type outside the platform's own namespaces is an open extension
point — a registered custom or plugin component:
type: custom.approval_timeline
properties:
recordId: '{recordId}'
showComments: trueproperties is an open bag (Record<string, unknown>), so it is the one part
of a component the spec parse does not check. For the platform's own types the
authoring rules dispatch ComponentPropsMap and reject a misspelled prop; a
custom.* type has no entry there, so its props are validated only by whatever
registered the component.
Advanced Layouts
There is no page-level layout: block. Earlier revisions of this page
described three advanced layouts as a top-level layout: mapping —
split_view, card_grid and kanban. On PageSchema, layout is an
alias for template, a plain string naming a layout template, so a mapping
written there is refused with exactly that rename hint (Did you mean layout → template?). None of split_view, card_grid, list_view or detail_view is
a type on any schema.
Two of the three describe capabilities the platform really has — they were
written at the wrong tier, and are restated below as the components that
deliver them. The third, card_grid, is removed rather than implemented:
besides having no schema, its per-breakpoint columns: { desktop, tablet, mobile } map is the same breakpoint-map defect class removed from this page
before. Per-breakpoint layout is responsiveStyles (ADR-0065), documented
above.
Master-Detail Forms
A parent record edited alongside its child collections is the declared
object-master-detail-form block. details entries derive the foreign key and
columns from the child object's metadata when you omit them.
type: object-master-detail-form
properties:
objectName: invoice
mode: edit
formType: tabbed
fields: [invoice_number, customer, invoice_date]
details:
- title: Line Items
childObject: invoice_line
relationshipField: invoice_id
columns: [product, quantity, unit_price]For the master/detail browsing pattern — pick a record on the left, show it on
the right — the mechanism is page state rather than a layout block: declare a
page variables entry whose source names the picker component, and read it in
sibling components as page.<var>.
Kanban Board
A board is the object-kanban block. Its columns are swimlane definitions
({ id, title } per groupBy value), not a field projection — the fields drawn
on each card are cardFields.
type: object-kanban
properties:
objectName: project_task
groupBy: status
columns:
- id: todo
title: To Do
- id: in_progress
title: In Progress
- id: done
title: Done
cardTitle: title
cardFields: [assignee, due_date]Layout Schema Reference
Page Definition
// Source: packages/spec/src/ui/page.zod.ts (PageSchema)
interface Page {
name: string; // lowercase snake_case
label: string;
description?: string;
icon?: string;
type?: 'record' | 'home' | 'app' | 'utility' | 'list'; // default 'record'
// NOTE: dashboard / form / record_detail / record_review / overview / blank
// were removed from the live enum (ADR-0049 enforce-or-remove — no renderer
// ever shipped). They live in PAGE_TYPE_ROADMAP; authoring one now fails
// validation. See page.zod.ts (PageTypeSchema / PAGE_TYPE_ROADMAP).
object?: string; // bound object for record pages
template?: string; // layout template name, default 'default'
regions?: PageRegion[]; // named regions, each holding components (optional; defaults to [])
}
interface PageRegion {
name: string; // e.g. "header", "main", "sidebar"
width?: 'small' | 'medium' | 'large' | 'full';
components: PageComponent[];
}Section Definition
// Source: packages/spec/src/ui/view.zod.ts (FormSectionSchema)
interface FormSection {
name?: string; // stable snake_case id for i18n
label?: string;
description?: string;
collapsible?: boolean; // default false
collapsed?: boolean; // default false
columns?: 1 | 2 | 3 | 4; // default 1
visibleWhen?: string; // CEL visibility predicate; hides section when false
fields: (string | FormField)[];
}Field Layout
// Source: packages/spec/src/ui/view.zod.ts (FormFieldSchema)
interface FormField {
field: string; // Field name (snake_case)
type?: FieldType; // auto-infers widget when omitted
colSpan?: number; // Column span within the section grid (1-4)
label?: string; // Override label
placeholder?: string;
helpText?: string;
required?: boolean;
readonly?: boolean;
hidden?: boolean;
widget?: string; // custom widget override
visibleWhen?: string; // CEL visibility predicate
}Visibility Rule
Visibility is a single CEL expression string, not a structured rule object.
Since ADR-0089 the one canonical key is visibleWhen across every
layer — data fields, view form sections/fields, and page components — aligning with
the readonlyWhen / requiredWhen family. The element is shown only when the
expression evaluates truthy.
// e.g. on a PageComponent — `record` and `current_user` are both bound:
visibleWhen: "record.account_type == 'premium'"
// e.g. on a view FormField or FormSection — `record`, `previous` and
// `current_user` are all bound (objectui#6010, then #6110 + #6111):
visibleWhen: "record.status != 'closed'"The predicate's binding root is set by the layer, not the key:
| Layer | Predicate binds |
|---|---|
Page components (*.page.ts) | record + current_user (plus page.<var>) |
Runtime record form fields (*.view.ts) | record + previous + current_user (objectui#6010) |
Runtime record form sections (*.view.ts) | record + previous + current_user (objectui#6110 + #6111) |
Metadata-editing forms (*.form.ts) | data — the row under edit |
The two form rows were one row until objectui#6010 bound the host predicate scope
on the SDUI form renderer's authored-predicate call sites — at that point only the
field half moved. The section half has since caught up: objectui#6110 threads the
host shell's scope into the console form renderer's isSectionVisible, where it
used to pass undefined, and objectui#6111 stopped the object-view chain dropping
the key before an evaluator sees it — ObjectForm / SplitForm / ModalForm /
DrawerForm copy an authored section visibleWhen onto the section-divider
pseudo-field, whose predicate the renderer evaluates with the same scope bound.
Two measured caveats travel with both rows, and both fail in the direction an
author will not notice. First, the scope belongs to the host, so it is empty
wherever no host publishes one. The public /f/:slug route is mounted outside
any provider deliberately — an anonymous visitor has no principal — so
current_user is unbound there, the predicate faults, and visibility's fallback is
visible. The authed /forms/:name route renders inside a shell that publishes
the session principal and binds normally. Second, the binding is client-side
only. Nothing on the write path evaluates a form-view field or section
visibleWhen — it evaluates field readonlyWhen / requiredWhen and per-option
visibleWhen, and that is the whole list — so a current_user.positions test
written here hides controls and protects no data.
The legacy spellings visibleOn (view) and visibility (page) are @deprecated
aliases: still accepted and folded into visibleWhen at parse time, so existing
metadata keeps working. Author new metadata with visibleWhen.
Since @objectstack/spec 15 (ADR-0089 D3a) the form field/section and page
component schemas are strict: a key they do not declare — a visibleWhen
typo, a page-only visibility pasted onto a view field (or vice-versa), or a
stale key — is now a loud parse error instead of being silently dropped. The
error names the offending key and, when it looks like a visibility predicate,
points you back at visibleWhen.
Breakpoint-based show/hide is handled separately via the component's
responsiveStyles scoped CSS (ADR-0065), e.g. xsmall: { display: 'none' } —
see packages/spec/src/ui/responsive.zod.ts. (The former responsive.hiddenOn
layout key was retired in v17.x; no renderer ever applied it.)
Position-gated visibility
There is no hasRole(): neither the CEL stdlib nor the
EvalUser contract defines such a call —
EvalUser exposes memberships as data, not as methods. Membership is tested
against current_user.positions, the canonical string[] of
position names — ADR-0090 D3 retired the older
vocabulary, and ADR-0068 D1 binds the same object under the user and ctx.user
aliases:
import { P } from '@objectstack/spec';
// On a PageComponent, an app/nav entry, a per-option `visibleWhen`, or a view
// form field. `P` emits the canonical `{ dialect: 'cel' }` envelope:
visibleWhen: P`'sales_manager' in current_user.positions`Write the predicate as a P envelope rather than a bare string wherever the
schema does not go through spec parse. Authored metadata is normalized for you —
ExpressionInputSchema turns a bare string into { dialect: 'cel', source } at
parse time — but a component tree handed straight to the renderer keeps the bare
string, and objectui routes bare strings to its legacy expression evaluator,
which has no in operator: the membership test above is rejected there
(Unexpected token "i" at position 16) and then fails open. That routing is
deliberate and documented (objectui#2661); the envelope is what makes one
predicate text mean one thing on every surface.
Two limits come with it. First, the binding is per surface, not per key. A
view form field predicate binds this scope since objectui#6010 and a form
section predicate since objectui#6110 + #6111; an object-level field rule
(Field.*({ visibleWhen }), ADR-0036) is the exception — that one is evaluated by
the server as well as by the client, its write-path evaluator binds record (plus
previous, parent) only, and naming current_user there is refused by
@objectstack/lint at build time rather than faulting at runtime. Second, visibleWhen is presentation, not
access control: it decides what a client draws from data it already holds. To
stop someone from reading something,
use the permission layer — field-level security
and permission sets, or
row-level security, whose using clause accepts the very
same '…' in current_user.positions predicate and is enforced on the server.
An unresolvable predicate — an unbound root, or a call to a function that does not exist — is fail-open: the renderer logs one warning and falls back to its safe default, which for visibility is visible. A gate that faults does not hide the element from anyone; it shows it to everyone.
Real-World Examples
Customer 360 Page
regions is an array of named regions, each with a components list — not a
map keyed by region name.
name: customer_360
label: Customer 360
type: record
object: customer
template: standard
regions:
- name: header
components:
- type: page:header
properties:
title: Customer
actions: [edit, delete, share]
- name: main
components:
- type: record:details
properties:
columns: '2'
fields: [name, status, industry, employee_count, website, phone]
- type: record:related_list
properties:
title: Key Contacts
objectName: contact
relationshipField: customer_id
columns: [name, title, email, phone]
- type: record:related_list
properties:
title: Open Opportunities
objectName: opportunity
relationshipField: customer_id
columns: [name, amount, close_date, stage]
- name: sidebar
width: small
components:
- type: object-metric
properties:
objectName: opportunity
label: Total Revenue
aggregate:
field: amount
function: sum
format: '$0,0'
- type: record:activity
properties:
limit: 10Staged Onboarding Form
The steps: wizard this example used does not exist (see There is no wizard
template). The declared way to collect a long form
in stages is a tabbed form view: set type: tabbed, and each section becomes
its own tab in declaration order.
type: tabbed
defaultTab: personal_info
sections:
- name: personal_info
label: Personal Information
columns: 2
fields: [first_name, last_name, email, phone, date_of_birth]
- name: employment
label: Employment Details
fields: [job_title, department, manager, start_date, employment_type]
- name: compensation
label: Compensation
fields: [base_salary, bonus_eligible, equity_grant, benefits_plan]There is no summary_view review step and no showAllFields key; a read-only
recap is a section whose fields are marked readonly.
Performance
The Layout DSL has no performance surface. A section declares no
virtualScroll, itemHeight, lazy or source key, and there is no
layout.renderStrategy. FormSectionSchema
(packages/spec/src/ui/view.zod.ts) is .strict(), so authoring any of them is
a parse failure — a loud rejection, not a silent no-op.
Nor is there an authorable virtual-scrolling switch anywhere else: since 17.0.0
(#7176) the view-level boolean virtualScroll is retired too, under ADR-0049
enforce-or-remove — every measured reader only copied the key forward and the
grid renderer never applied it. Large datasets page via the view's pagination
block. See
Widget Contract → Performance.
Earlier revisions of this page documented section-level virtualScroll /
itemHeight / lazy / source and a layout.renderStrategy: progressive
block. None of them existed on any schema, so they are removed rather than
implemented — real virtualization is an implementation card first. For the
keys a section really accepts, see the
View Reference.
What's Next?
Widget Contract
Standard props and events for UI components
Action Protocol
Buttons, triggers, and navigation flows
Related Resources
- Page Reference - Page, region, and component schemas
- View Reference - Form, list, kanban, and calendar views
- Responsive Reference - Per-breakpoint scoped responsive styles (ADR-0065)