UI Design — ObjectStack UI Protocol
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) |
page |
Mounts a published Page (pageName); no rows of its own |
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 — set inlineEdit on the child's master_detail field 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. The
true / '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:
formViews: {
default: {
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-form page block (bespoke layout). Use a page only
for free-form layouts. Same details: [{ 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's
highlightFields). 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, 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: [...] }).
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. Target precedence: recordId → filters → viewName |
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 (`type: 'home' |
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), params become props |
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 / anonymous form |
src/views/*.view.ts (formView with sharing.allowAnonymous: true) |
Web-to-Lead / Web-to-Case. Auto-exposed 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 modal 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.
AppContextSelector ids (e.g. {active_package}) resolve in navigation
recordId / params only. 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 a dialog (typically collecting params, then executing body) |
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 hidden
for secondary columns.
Forgetting link: true on 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 is layout: { x, y, w, h } — position is not a
widget key and the closed schema REJECTS it by name (it was silently
dropped before protocol 17). layout is 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 /
disabled or a field visibleWhen (done instead of record.done)
evaluates to null and hides the control on every record (the
"button never shows" trap).
- Dashboard widget binding — a widget
dataset / dimensions / values
that 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.
1---2name: objectstack-ui3description: Author ObjectStack UI metadata — Views (list/form/kanban/calendar/gantt), Apps (navigation), Pages (structured plus the HTML and React source-authoring tiers, ADR-0080/0081), Dashboards, Reports, Charts, Actions, and package Docs (`src/docs/*.md`). Use when the user is adding `*.view.ts` / `*.page.ts` / `*.app.ts` / `*.dashboard.ts` / `*.report.ts` / `*.dataset.ts` / `*.action.ts` / `src/docs/*.md` files or designing a Studio-rendered UI surface. Do not use for: data schema (see objectstack-data), multi-step flows that BRANCH on logic between steps (those are `*.flow.ts` with `type: 'screen'` — see objectstack-automation; a stepped form over ONE object is this skill's `formViews` `type: 'wizard'`), or Studio's own admin UI (that ships with the platform). CEL expressions in visibility/conditional rules: load objectstack-formula alongside.4license: Apache-2.05---67# UI Design — ObjectStack UI Protocol89Expert instructions for designing user interfaces using the ObjectStack10specification. This skill covers Views (list, form, kanban, calendar, …),11App navigation, Dashboards, Reports, and Actions.1213---1415## View Types1617### List Views1819| Type | When to Use |20|:-----|:------------|21| `grid` | Standard data table — default for most objects |22| `kanban` | Visual board with columns (status-driven workflows) |23| `gallery` | Card-based masonry layout (visual catalogues, contacts) |24| `calendar` | Date-based scheduling (events, tasks, bookings) |25| `timeline` | Chronological activity stream |26| `gantt` | Project management with dependency tracking |27| `map` | Geospatial records with `location` fields |28| `chart` | Aggregate visualisation over the object (mini chart view) |29| `tree` | Self-referencing hierarchy (tree-grid) |30| `page` | Mounts a published Page (`pageName`); no rows of its own |3132### Form Views3334| Type | When to Use |35|:-----|:------------|36| `simple` | Single-page form — suitable for objects with ≤ 15 fields |37| `tabbed` | Tabbed sections — for complex objects with many field groups |38| `wizard` | Step-by-step flow — guided data entry (onboarding, applications) |3940### Master-Detail Forms (parent + child line items)4142To let users enter a record **together with its child line items** (invoice +43lines, project + tasks) and save them **atomically**, you almost never need a44custom page or form config. Prefer, in order:45461. **Relationship `inlineEdit` (default, zero UI config).** Declare it in the47 DATA MODEL — set `inlineEdit` on the child's `master_detail` field that48 references the parent. Every standard New/Edit form for the parent (modal,49 drawer, full-page) then auto-renders the children and saves parent + children50 in one atomic `/api/v1/batch`. **No view metadata needed.** The51 `true` / `'grid'` / `'form'` ladder and what each value picks:52 **objectstack-data → Relationships → Inline Editing**.53542. **Form view `subforms` (override / tuning).** Add to a form view only when you55 need to override the derived columns/order, or expose a child the56 relationship didn't mark inline:5758 ```typescript59 formViews: {60 default: {61 type: 'simple',62 sections: [{ group: 'invoice_header' }], // a declared fieldGroup63 subforms: [64 { childObject: 'invoice_line', // relationshipField + columns are65 title: 'Line Items', // derived from the child object;66 addLabel: 'Add line' }, // set `columns` here only to override.67 ],68 },69 },70 ```71723. **`object-master-detail-form` page block (bespoke layout).** Use a page only73 for free-form layouts. Same `details: [{ childObject }]` shorthand.7475The relationship FK and grid columns are derived from the child object's76metadata in every case; select options and lookups carry through. A parent77`summary` field rolls child values up server-side (see objectstack-data).7879**Line-item grid behaviors (`grid` mode).** The editable grid is a real80spreadsheet-style line editor (the QuickBooks / Stripe / NetSuite pattern), and81every behaviour is derived from the DATA MODEL — computed columns, catalog82typeahead auto-fill, persisted drag-reorder, and the live Subtotal → Tax → Total83stack: **objectstack-data → Relationships → Inline Editing**. Three things are84authored on this side: a trailing **ghost** row always exists, so never add an85"Add line" button (typing in the ghost materialises a row; an untouched one is86never persisted); `autofill: false` opts one column out of the typeahead copy;87and the form's `taxRateField` overrides which parent field drives the totals.8889**Read side — detail-page related lists.** The mirror of `inlineEdit` is the90related list on the parent's record DETAIL page. You don't author it: every91child relationship is shown as a related list by default (owned `master_detail`92children first). Refine on the relationship:93- `relatedList: 'primary'` — mark a CORE relationship; the detail page promotes94 it to its **own tab** (see layout below). A *prominence* intent, not a layout95 switch (ADR-0085).96- `relatedList: false` — suppress a noisy child from the detail page.97- `relatedListTitle` / `relatedListColumns` — override the derived title /98 columns (both optional; columns otherwise auto-derive from the child object's99 `highlightFields`). See objectstack-data → Relationships → Detail-page related lists.100101**Related-list layout.** On the synthesized record detail page, each102`relatedList: 'primary'` child gets its **own tab**; every other related list103stacks under a single shared **Related** tab. Promoting a child table to a104first-class tab is therefore a one-word change on the relationship — no custom105page needed. The object still declares no per-surface *layout* hints: the old106`detail.relatedLayout` toggle and object-level `detail: {...}` block stay107**removed** (ADR-0085); `relatedLayout: 'tabs' | 'stack'` survives only as an108app-level default override, not an object key. For arrangements the109relationship layer can't express — filtered splits (e.g. Open vs Closed tabs), a110chart/report tab, exact tab ordering — assign the object a **custom record111Page** and lay it out explicitly with `record:related_list` (or inline-editable112`line_items`) blocks.113114### Field Conditional Rules in Forms115116Conditions that belong to a field's lifecycle — `visibleWhen`, `readonlyWhen`,117`requiredWhen` — are declared on the **DATA MODEL field**, not in the form view;118ObjectUI forms consume them. Their semantics and server behaviour:119**objectstack-data → Conditional Field Rules**.120121UI-side: inline master-detail grids evaluate these rules **row-by-row** against122each child row. Use `requiredWhen` — the `conditionalRequired` alias was REMOVED123in protocol 17 and is now a parse error. Load **objectstack-formula** when124authoring non-trivial CEL.125126---127128## Detailed Rules129130- [List, Kanban & Gantt Views](./rules/list-views.md) — `defineView`, `data`, columns, filtering, `userFilters`, toolbar search, sorting, kanban, gantt.131- [Navigation & Run Modes](./rules/navigation.md) — `App.create`, the three run modes, record presentation.132- [Dashboards, Reports & Cubes](./rules/dashboards.md) — widgets, dataset binding, filters, `compareTo`, bucketing, `options`, drilldown, report config, cubes.133- [Pages & Docs](./rules/pages.md) — page types, regions, components, the html/react source tiers, styling, package docs.134- [Actions](./rules/actions.md) — `locations`, visibility, examples, `ctx`, new tab, params.135136---137138## App Navigation139140An **App** groups objects, dashboards, reports, and custom pages into a141structured navigation tree. Build with `App.create({...})` from142`@objectstack/spec/ui` and register under `defineStack({ apps: [...] })`.143144### Navigation Item Types145146| Type | Properties | Purpose |147|:-----|:-----------|:--------|148| `group` | `label`, `icon`, `expanded`, `children[]` | Collapsible group of items |149| `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. Target precedence: `recordId` → `filters` → `viewName` |150| `dashboard` | `dashboardName`, `label`, `icon` | Link to a dashboard |151| `report` | `reportName`, `label`, `icon` | Link to a report |152| `page` | `pageName`, `label`, `icon` | Link to a custom Page (`type: 'home' | 'list' | ...`) |153| `url` | `url`, `label`, `icon` | External or custom URL |154| `action` | `actionDef` (`{ actionName, params? }`), `label`, `icon` | Run an action instead of navigating |155| `component` | `componentRef`, `params?`, `label`, `icon` | Built-in platform component; `componentRef` is a colon-joined registry key (`metadata:resource`), `params` become props |156| `separator` | — | Visual separator |157158> **`requiresObject` / `requiresService`:** Use these on any item that159> depends on an optional system object or kernel service so the nav item is160> automatically hidden when missing — never hard-code conditional UI.161162---163164## Report Types165166| Type | When to Use |167|:-----|:------------|168| `tabular` | Flat data table with columns and filters |169| `summary` | Grouped data with subtotals (e.g., revenue by region) |170| `matrix` | Cross-tab / pivot table (`rows` down × `columns` across) |171| `joined` | Multi-block analytic surface (combines several sub-reports) |172173There is no `chart` report type — a report *visualizes* via its embedded174`chart:` config.175176---177178## CRM UI Blueprint (Metadata-First)179180Use this CRM-style structure as the canonical UI assembly reference:181182| UI Surface | Typical Location | Pattern to Follow |183|:--|:--|:--|184| Multi-view object UI | `src/views/*.view.ts` | Define default `list` + `form`, then named `listViews` / `formViews` for scenarios |185| **Public / anonymous form** | `src/views/*.view.ts` (formView with `sharing.allowAnonymous: true`) | Web-to-Lead / Web-to-Case. Auto-exposed at `GET/POST /api/v1/forms/:slug` |186| App navigation | `src/apps/*.app.ts` | Use grouped nav trees, `viewName` shortcuts, and `requiresObject` for capability-aware visibility |187| **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 |188| Dashboards | `src/dashboards/*.dashboard.ts` | Combine KPI + chart + table widgets with shared `dateRange` and `globalFilters` |189| Reports | `src/reports/*.report.ts` | Select `rows` (dimensions) + `values` (measures) from that dataset; tabular/summary/matrix/joined |190| Record pages | `src/pages/*.page.ts` | Compose `regions` + components (`page:header`, `record:highlights`, related lists, tabs) |191| User actions | `src/actions/*.actions.ts` | Use `flow` for orchestration and `modal` for parameterized bulk mutations |192193This blueprint is the default for “build a complete metadata app UI” tasks.194195---196197## Date Macros — Filter Placeholders198199Filter values in list views, dashboards, reports and pages accept relative-date200tokens (`{today}`, `{current_month_start}`, `{30_days_ago}`) and the two session201tokens `{current_user_id}` / `{current_org_id}`. **The vocabulary, both accepted202spellings, the two resolvers and the near-miss list are owned by203objectstack-query → `rules/filters.md` — load it before writing a filter token.**204An unrecognised token is a build error and a runtime throw, never a silent205literal.206207Two rules this package owns, because they are about UI surfaces rather than the208token vocabulary:209210* A filter token is **presentation scope, not security**. It decides what a211 surface *shows*; RLS decides what a caller may *read*. Removing a212 `{current_user_id}` filter widens a view — it must never be the thing standing213 between a user and someone else's data.214* `AppContextSelector` ids (e.g. `{active_package}`) resolve in navigation215 `recordId` / `params` only. Filters are not evaluated with the sidebar's216 selector state.217218---219220## Actions221222Actions are user-triggered operations attached to an object or a view.223Register them under `defineStack({ actions: [...] })`.224225### Action Types226227| `type` | Purpose | Required field |228|:---------|:-------------------------------------------------------------------|:---------------|229| `script` | Run an inline L2 hook body (sandboxed JS) on the server | `body` (or `target` = registered function name) |230| `url` | Navigate to an internal route or external URL | `target` |231| `modal` | Open a dialog (typically collecting `params`, then executing `body`) | `target` |232| `flow` | Launch a screen/auto-launched flow by name | `target` |233| `api` | Call a registered API endpoint | `target` |234| `form` | Open a FormView by name (routed to `/_console/forms/:name`) | `target` |235236---237238## Common Pitfalls2392401. **Putting too many columns in a grid view.**241 Users rarely need more than 6–8 columns visible by default. Use `hidden`242 for secondary columns.2432442. **Forgetting `link: true` on the primary column.**245 The first meaningful column (usually the name/subject) should be the246 navigation link to the record detail.2472483. **Putting widget grid placement in `position`.**249 The grid-placement field is `layout: { x, y, w, h }` — `position` is not a250 widget key and the closed schema REJECTS it by name (it was silently251 dropped before protocol 17). `layout` is optional: omit it and the widget252 auto-flows (the Studio designer relies on this); set it only when you want253 an explicit grid position.254255---256257## Verify your work258259After authoring any `*.view.ts` / `*.action.ts` / `*.dashboard.ts`, run the260author-time gate before reporting done:261262```bash263os validate # CEL predicates (record.<field>) + widget bindings + schema264# or: os build # the same gates, plus emits dist/265```266267Two UI-specific traps it catches, both **silent at runtime** otherwise:268269- **Action / field predicate** — a bare field ref in an action `visible` /270 `disabled` or a field `visibleWhen` (`done` instead of `record.done`)271 evaluates to `null` and hides the control on *every* record (the272 "button never shows" trap).273- **Dashboard widget binding** — a widget `dataset` / `dimensions` / `values`274 that doesn't resolve to a declared dataset/field renders an empty chart275 (ADR-0021).276277Don't report a view/action/dashboard done until `os validate` passes. In a278scaffolded project the gate is `npm run validate`.279280---281282## References283284See [references/_index.md](./references/_index.md) for the full list of Zod285schemas (with one-line descriptions) — pointers into286`node_modules/@objectstack/spec/src/`. Always `Read` the source for exact field287shapes; do not rely on memory of property names.