Expert instructions for designing user interfaces using the ObjectStack specification. This skill covers Views (list, form, kanban, calendar, …), App navigation, Dashboards, Reports, and Actions.
View Types
List Views
| Type | When to Use |
|---|---|
grid |
Standard data table — default for most objects |
kanban |
Visual board with columns (status-driven workflows) |
gallery |
Card-based masonry layout (visual catalogues, contacts) |
calendar |
Date-based scheduling (events, tasks, bookings) |
timeline |
Chronological activity stream |
gantt |
Project management with dependency tracking |
map |
Geospatial records with location fields |
chart |
Aggregate visualisation over the object (mini chart view) |
tree |
Self-referencing hierarchy (tree-grid) |
Form Views
| Type | When to Use |
|---|---|
simple |
Single-page form — suitable for objects with ≤ 15 fields |
tabbed |
Tabbed sections — for complex objects with many field groups |
wizard |
Step-by-step flow — guided data entry (onboarding, applications) |
Master-Detail Forms (parent + child line items)
To let users enter a record together with its child line items (invoice + lines, project + tasks) and save them atomically, you almost never need a custom page or form config. Prefer, in order:
-
Relationship
inlineEdit(default, zero UI config). Declare it in the DATA MODEL — setinlineEditon the child'smaster_detailfield that references the parent. Every standard New/Edit form for the parent (modal, drawer, full-page) then auto-renders the children and saves parent + children in one atomic/api/v1/batch. No view metadata needed. Thetrue/'grid'/'form'ladder and what each value picks: objectstack-data → Relationships → Inline Editing. -
Form view
subforms(override / tuning). Add to a form view only when you need to override the derived columns/order, or expose a child the relationship didn't mark inline:form: { type: 'simple', sections: [{ group: 'invoice_header' }], // a declared fieldGroup subforms: [ { childObject: 'invoice_line', // relationshipField + columns are title: 'Line Items', // derived from the child object; addLabel: 'Add line' }, // set `columns` here only to override. ], }, -
object-master-detail-formpage block (bespoke layout). Use a page only for free-form layouts. Samedetails: [{ childObject }]shorthand.
The relationship FK and grid columns are derived from the child object's
metadata in every case; select options and lookups carry through. A parent
summary field rolls child values up server-side (see objectstack-data).
Line-item grid behaviors (grid mode). The editable grid is a real
spreadsheet-style line editor (the QuickBooks / Stripe / NetSuite pattern), and
every behaviour is derived from the DATA MODEL — computed columns, catalog
typeahead auto-fill, persisted drag-reorder, and the live Subtotal → Tax → Total
stack: objectstack-data → Relationships → Inline Editing. Three things are
authored on this side: a trailing ghost row always exists, so never add an
"Add line" button (typing in the ghost materialises a row; an untouched one is
never persisted); autofill: false opts one column out of the typeahead copy;
and the form's taxRateField overrides which parent field drives the totals.
Read side — detail-page related lists. The mirror of inlineEdit is the
related list on the parent's record DETAIL page. You don't author it: every
child relationship is shown as a related list by default (owned master_detail
children first). Refine on the relationship:
relatedList: 'primary'— mark a CORE relationship; the detail page promotes it to its own tab (see layout below). A prominence intent, not a layout switch (ADR-0085).relatedList: false— suppress a noisy child from the detail page.relatedListTitle/relatedListColumns— override the derived title / columns (both optional; columns otherwise auto-derive from the child object'shighlightFields). See objectstack-data → Relationships → Detail-page related lists.
Related-list layout. On the synthesized record detail page, each
relatedList: 'primary' child gets its own tab; every other related list
stacks under a single shared Related tab. Promoting a child table to a
first-class tab is therefore a one-word change on the relationship — no custom
page needed. The object still declares no per-surface layout hints: the old
detail.relatedLayout toggle and object-level detail: {...} block stay
removed (ADR-0085); relatedLayout: 'tabs' | 'stack' survives only as an
app-level default override, not an object key. For arrangements the
relationship layer can't express — filtered splits (e.g. Open vs Closed tabs), a
chart/report tab, exact tab ordering — assign the object a custom record
Page and lay it out explicitly with record:related_list (or inline-editable
line_items) blocks.
Field Conditional Rules in Forms
Conditions that belong to a field's lifecycle — visibleWhen, readonlyWhen,
requiredWhen — are declared on the DATA MODEL field, not in the form view;
ObjectUI forms consume them. Their semantics and server behaviour:
objectstack-data → Conditional Field Rules.
UI-side: inline master-detail grids evaluate these rules row-by-row against
each child row. Use requiredWhen — the conditionalRequired alias was REMOVED
in protocol 17 and is now a parse error. Load objectstack-formula when
authoring non-trivial CEL.
Detailed Rules
- List, Kanban & Gantt Views —
defineView,data, columns, filtering,userFilters, toolbar search, sorting, kanban, gantt. - Navigation & Run Modes —
App.create, the three run modes, record presentation. - Dashboards, Reports & Cubes — widgets, dataset binding, filters,
compareTo, bucketing,options, drilldown, report config, cubes. - Pages & Docs — page types, regions, components, print pages, the html/react source tiers, styling, package docs.
- Actions —
locations, visibility, examples,ctx, new tab, params.
App Navigation
An App groups objects, dashboards, reports, and custom pages into a
structured navigation tree. Build with App.create({...}) from
@objectstack/spec/ui and register under defineStack({ apps: [...] }).
⛔ Exactly one App per type: 'app' package — defineStack refuses a
second at load, per ADR-0019 (app-as-consumer-unit) D3. More audiences are
requiredPermissions-gated groups inside that one app, never a second app.
Navigation Item Types
| Type | Properties | Purpose |
|---|---|---|
group |
label, icon, expanded, children[] |
Collapsible group of items |
object |
objectName, viewName?, recordId?, filters?, label, icon |
Link to an object list, a named view, a record deep-link, or a filters slice on the bare data surface |
dashboard |
dashboardName, label, icon |
Link to a dashboard |
report |
reportName, label, icon |
Link to a report |
page |
pageName, label, icon |
Link to a custom Page |
url |
url, label, icon |
External or custom URL |
action |
actionDef ({ actionName, params? }), label, icon |
Run an action instead of navigating |
component |
componentRef, params?, label, icon |
Built-in platform component; componentRef is a colon-joined registry key (metadata:resource) |
doc |
book? / doc? (one required), label, icon |
Package docs; inherits docs audience |
separator |
— | Visual separator |
requiresObject/requiresService: Use these on any item that depends on an optional system object or kernel service so the nav item is automatically hidden when missing — never hard-code conditional UI.
Report Types
| Type | When to Use |
|---|---|
tabular |
Flat data table with columns and filters |
summary |
Grouped data with subtotals (e.g., revenue by region) |
matrix |
Cross-tab / pivot table (rows down × columns across) |
joined |
Multi-block analytic surface (combines several sub-reports) |
There is no chart report type — a report visualizes via its embedded
chart: config.
CRM UI Blueprint (Metadata-First)
Use this CRM-style structure as the canonical UI assembly reference:
| UI Surface | Typical Location | Pattern to Follow |
|---|---|---|
| Multi-view object UI | src/views/*.view.ts |
Define default list + form, then named listViews / formViews for scenarios |
| Public form | src/views/*.view.ts (formView with sharing: { enabled: true, allowAnonymous: true, publicLink: 'slug' }) |
Web-to-Lead at GET/POST /api/v1/forms/:slug |
| App navigation | src/apps/*.app.ts |
Use grouped nav trees, viewName shortcuts, and requiresObject for capability-aware visibility |
| Analytics dataset | src/datasets/*.dataset.ts |
One per object you want reportable — dashboards and reports bind a declared dataset by name, so an object without one has no analytics face |
| Dashboards | src/dashboards/*.dashboard.ts |
Combine KPI + chart + table widgets with shared dateRange and globalFilters |
| Reports | src/reports/*.report.ts |
Select rows (dimensions) + values (measures) from that dataset; tabular/summary/matrix/joined |
| Record pages | src/pages/*.page.ts |
Compose regions + components (page:header, record:highlights, related lists, tabs) |
| User actions | src/actions/*.actions.ts |
Use flow for orchestration and script for parameterized bulk mutations |
This blueprint is the default for “build a complete metadata app UI” tasks.
Date Macros — Filter Placeholders
Filter values in list views, dashboards, reports and pages accept relative-date
tokens ({today}, {current_month_start}, {30_days_ago}) and the two session
tokens {current_user_id} / {current_org_id}. The vocabulary, both accepted
spellings, the two resolvers and the near-miss list are owned by
objectstack-query → rules/filters.md — load it before writing a filter token.
An unrecognised token is a build error and a runtime throw, never a silent
literal.
Two rules this package owns, because they are about UI surfaces rather than the token vocabulary:
- A filter token is presentation scope, not security. It decides what a
surface shows; RLS decides what a caller may read. Removing a
{current_user_id}filter widens a view — it must never be the thing standing between a user and someone else's data. AppContextSelectorids (e.g.{active_package}) resolve in navigationrecordId/paramsonly. Filters are not evaluated with the sidebar's selector state.
Actions
Actions are user-triggered operations attached to an object or a view.
Register them under defineStack({ actions: [...] }).
Action Types
type |
Purpose | Required field |
|---|---|---|
script |
Run an inline L2 hook body (sandboxed JS) on the server | body (or target = registered function name) |
url |
Navigate to an internal route or external URL | target |
modal |
Open the page named by target; a body runs only on script |
target |
flow |
Launch a screen/auto-launched flow by name | target |
api |
Call a registered API endpoint | target |
form |
Open a FormView by name (routed to /_console/forms/:name) |
target |
Common Pitfalls
-
Putting too many columns in a grid view. Users rarely need more than 6–8 columns visible by default. Use
hiddenfor secondary columns. -
Forgetting
link: trueon the primary column. The first meaningful column (usually the name/subject) should be the navigation link to the record detail. -
Putting widget grid placement in
position. The grid-placement field islayout: { x, y, w, h }—positionis not a widget key and the closed schema REJECTS it by name (it was silently dropped before protocol 17).layoutis optional: omit it and the widget auto-flows (the Studio designer relies on this); set it only when you want an explicit grid position.
Verify your work
After authoring any *.view.ts / *.action.ts / *.dashboard.ts, run the
author-time gate before reporting done:
os validate # CEL predicates (record.<field>) + widget bindings + schema
# or: os build # the same gates, plus emits dist/
Two UI-specific traps it catches, both silent at runtime otherwise:
- Action / field predicate — a bare field ref in an action
visible/disabledor a fieldvisibleWhen(doneinstead ofrecord.done) evaluates tonulland hides the control on every record (the "button never shows" trap). - Dashboard widget binding — a widget
dataset/dimensions/valuesthat doesn't resolve to a declared dataset/field renders an empty chart (ADR-0021).
Don't report a view/action/dashboard done until os validate passes. In a
scaffolded project the gate is npm run validate.
References
See references/_index.md for the full list of Zod
schemas (with one-line descriptions) — pointers into
node_modules/@objectstack/spec/src/. Always Read the source for exact field
shapes; do not rely on memory of property names.