ObjectStackObjectStack

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:

kindYou writeJavaScriptUse when
full / slotted (this doc)structured regions / slotsrecord, detail, home, and app layouts from the component catalogue
htmlconstrained JSX = registered components + safe native HTML, parsed, never executednonefree-form layout, landing pages, or dashboards that compose blocks — no interactivity
reactreal React (hooks, .map, onClick, expressions)executed in the appcomplex 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 html tier — parse ≠ execute; the registry is the contract) and ADR-0081 (the trusted react tier). 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:activity

Page 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: 10

Visual 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:activity

Visual 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 14, and a field's width is span: auto | full (or the legacy absolute colSpan, also 14). 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, right

Rendered 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 row

Rendered 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
      - phone

Rendered:

┌─ 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_code

Rendered:

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 (14). 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 breakpoint

The 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.

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.

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                                    │
└─────────────────────────────────────────────────────────────┘

Earlier revisions of this page documented an Inline Editing Related Listmode: 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: success

A 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: true

Custom 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: true

properties 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:

LayerPredicate 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: 10

Staged 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?

On this page