| name | objectstack-ui |
| description | 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` / `*.app.ts` / `*.dashboard.ts` / `*.action.ts` / `src/docs/*.md` files or designing a Studio-rendered UI surface, including dataset-bound dashboard/report widgets. Do not use for: data schema (see objectstack-data), interactive screen flows / wizards (those are `*.flow.ts` with `type: 'screen'` — see objectstack-automation), the React renderer implementation (lives in `packages/client-react`, not metadata), or Studio's own admin UI (that ships with the platform). CEL expressions in visibility/conditional rules: load objectstack-formula alongside.
|
| license | Apache-2.0 |
| compatibility | Requires @objectstack/spec 16.x (Zod v4 schemas) |
| metadata | {"author":"objectstack-ai","version":"1.2","domain":"ui","tags":"view, app, page, dashboard, report, chart, action, widget, doc"} |
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.
When to Use This Skill
- You are creating a list view (grid, kanban, calendar, gantt, map, …).
- You are designing a form layout (simple, tabbed, wizard).
- You are building an app with structured navigation menus.
- You need a dashboard with widget grids.
- You are adding reports (tabular, summary, matrix, joined).
- You are configuring actions (buttons, URL jumps, screen flows).
- You are writing package documentation (
src/docs/*.md) that ships
with the package and renders at /docs/<name>.
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 — set inlineEdit: true on the child's master_detail field that
references the parent (see the objectstack-data skill → Relationships →
Inline Editing). 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 value picks the
form factor: 'grid' (editable line-item grid — thin children), 'form'
(read-only list whose Add / per-row edit opens the child's FULL form — fat
children with rich types), or true (smart default: form when the child
has rich/form-only fields or >~8 fields, else grid).
-
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: [{ label: 'Invoice', fields: ['number', 'account'] }],
subforms: [
{ childObject: 'invoice_line',
title: 'Line Items',
addLabel: 'Add line' },
],
},
},
-
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). All
of the following come from the DATA MODEL — no UI config — so they apply to any
inline grid, not just invoices:
- Computed columns. A child field with an arithmetic
expression
(e.g. amount: Field.currency({ expression: 'record.quantity * record.unit_price' }))
renders read-only and is recomputed live client-side as its inputs
change, then persisted. Keep it a stored field (currency/number), NOT a
formula field, so a parent summary can still roll it up — the server only
treats type: 'formula' as computed, so a stored field's expression is a
client-side display/compute hint and the sent value is stored as-is. The
evaluator supports + - * / %, parens and record.<field> refs only.
- Trailing "ghost" row. The grid always shows one empty line at the bottom;
typing in it materialises a real row and a fresh ghost appears — users never
click "Add line", and an untouched ghost is never persisted.
- Item typeahead auto-fill. When a
lookup cell's record is picked, the grid
copies the chosen record's fields into any same-named sibling columns
(e.g. a product's unit_price / description drop into the line). Model it by
giving the line a lookup to the catalog plus columns whose names match the
catalog fields. Opt out per column with autofill: false.
- Persisted drag-reorder. Add a numeric sort field to the child named
position (or sort_order / sequence / line_no). The grid auto-detects
it, hides it from the editable columns, and stamps row[position] = index on
reorder so line order survives a reload.
- Totals stack. Give the PARENT a tax-rate field named
tax_rate (percent
number). The master-detail form then renders a live Subtotal → Tax → Total
block under the lines (override the field name with the form's taxRateField).
The parent summary persists the line subtotal; the tax-inclusive grand total
is a live entry-time aid.
- Per-cell inline validation (required-empty cells flag red in place) and a
hover duplicate action come for free.
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
For conditions that belong to a field's lifecycle, declare the rule on the
DATA MODEL field, not in the form view. ObjectUI forms consume:
| Field property | UI behavior | Server behavior |
|---|
visibleWhen | Hide the field when the CEL predicate is false | UX-only visibility hint |
readonlyWhen | Render read-only when true | ObjectQL ignores incoming writes when true |
requiredWhen | Mark required when true | ObjectQL validates requiredness on submit |
Inline master-detail grids evaluate these rules row-by-row against the child
row. Use requiredWhen for new metadata; conditionalRequired is only a
back-compat alias. Load objectstack-formula when authoring non-trivial CEL.
Configuring a List View
The defineView container (*.view.ts file shape)
Views ship inside a defineView container — one per object, aggregating
the default list, named listViews, and formViews. The loader expands it
into <object>.<key> view items that power the view switcher.
import { defineView } from '@objectstack/spec';
const data = { provider: 'object' as const, object: 'support_case' };
export const CaseViews = defineView({
list: { label: 'All Cases', type: 'grid', data, columns: ['subject', 'status'] },
listViews: {
open: { label: 'Open', type: 'grid', data, columns: ['subject', 'status'],
filter: [{ field: 'status', operator: 'equals', value: 'open' }] },
},
formViews: {
edit: { type: 'simple', data, sections: [{ label: 'Case', fields: ['subject', 'status'] }] },
},
});
Never export a bare flat view object ({ name, label, type, columns }
at top level). It is not a valid view container — nothing registers and no
view appears in the switcher. Every view lives under list / listViews /
formViews, exactly as in the defineView example above.
Data Source (data)
Every view connects to data via one of three providers:
data: { provider: 'object', object: 'support_case' }
data: { provider: 'api', read: { url: '/api/cases', method: 'GET' } }
data: { provider: 'value', items: [...] }
Best practice: Always use provider: 'object' when the data source is
an ObjectStack-managed object. It enables automatic CRUD, real-time updates,
filtering, and pagination.
Columns
Columns can be defined as a simple string array or detailed config:
columns: ['subject', 'status', 'priority', 'assigned_to', 'due_date']
columns: [
{ field: 'subject', link: true, width: 300 },
{ field: 'status', width: 120, align: 'center' },
{ field: 'priority' },
{ field: 'assigned_to', label: 'Owner' },
{
field: 'due_date',
summary: 'min',
sortable: true,
},
]
Column Features
| Property | Purpose |
|---|
field | Field name (snake_case) — required |
label | Display label override |
width | Pixel width |
align | left / center / right |
hidden | Hide by default (user can show) |
pinned | Freeze column: left / right |
sortable | Allow sorting |
resizable | Allow resizing |
link | Make this the primary navigation link |
summary | Footer aggregation: count, sum, avg, min, max, etc. |
Filtering
filter: [
{ field: 'status', operator: 'not_equals', value: 'closed' },
{ field: 'assigned_to', operator: 'equals', value: '$currentUser' },
]
Common operators: equals, not_equals, contains, starts_with,
greater_than, less_than, is_empty, is_not_empty, in, not_in,
this_week, this_month, this_quarter, last_n_days.
$currentUser is a runtime variable — the logged-in user's ID.
End-User Quick Filters (userFilters, ADR-0047)
filter is the always-on base criteria. For the end-user-facing filter bar
(Airtable "User filters") use userFilters — dropdowns, filter tabs, or
toggles the user combines at runtime:
userFilters: {
element: 'dropdown',
fields: [
{ field: 'status' },
{ field: 'priority', showCount: true },
],
},
tabs: [
{ name: 'all', label: 'All', isDefault: true },
{ name: 'urgent', label: 'Urgent', filter: [{ field: 'priority', operator: 'equals', value: 'urgent' }] },
],
appearance: { allowedVisualizations: ['grid', 'kanban', 'gallery'] },
Rules:
- Every
field MUST exist on the source object — reference diagnostics
(_diagnostics) flag unknown fields; treat valid: false as a failed write.
- Tabs XOR dropdowns — never both on one view. The toolbar renders ONE
filter element style (Airtable's Elements choice). If a view configures
both
tabs and userFilters, tabs win and the dropdowns never render.
Want both demos? Put them on different views.
- On an object list view (
*.view.ts list / listViews), only
element: 'dropdown' (value chips) is allowed — tabs is page-only
(ADR-0047 amendment, framework #2679 / objectui #2338). An object view's
saved-view ViewTabBar already owns the tab-bar role, so a tabs
user-filter would render a second, colliding tab bar. The spec narrows it
(ObjectUserFiltersSchema — a tabs element is untypable at author time
and dropped at parse) and the validate list-view-mode lint reports it.
Need named presets on an object? Add a listViews entry instead. The full
dropdown | tabs | toggle range applies only to page lists /
interfaceConfig.userFilters (the block above).
- Omit
userFilters when unsure — omission means a clean toolbar. Filter
elements render only when explicitly configured; nothing is auto-derived.
In data mode the saved-views switcher already covers the preset use case,
so most views need no filter elements at all.
userFilters: { element: 'dropdown' } (no fields) is valid shorthand:
the renderer fills the field list from the object's select/boolean fields.
element is dropdown or tabs; toggle is deprecated (ADR-0047 §3.4a)
— it stays in the enum for back-compat rendering, but author dropdown/tabs.
- The visualization switcher renders as a compact dropdown in the toolbar's
right cluster. Authors only control the
allowedVisualizations whitelist;
a single-entry whitelist locks the visualization (no switcher).
Sorting
sort: 'created_at desc'
sort: [
{ field: 'priority', order: 'desc' },
{ field: 'created_at', order: 'asc' },
]
Configuring Kanban Views
Board settings nest under kanban: (KanbanConfigSchema) — there is no
top-level groupBy. The top-level columns is required on every list view
(including kanban), while kanban.columns picks the fields shown on each card.
{
type: 'kanban',
data: { provider: 'object', object: 'project_task' },
columns: ['title', 'assignee', 'priority'],
kanban: {
groupByField: 'status',
summarizeField: 'estimate_hours',
columns: ['title', 'assignee', 'priority'],
},
sort: 'priority desc',
}
Key rule: kanban.groupByField should be a select type with
well-defined options. Each option becomes a column on the board.
Configuring Gantt Views
Timeline settings nest under gantt: (GanttConfigSchema);
startDateField / endDateField / titleField are required in it.
{
type: 'gantt',
data: { provider: 'object', object: 'project_task' },
columns: ['name', 'assigned_to', 'status'],
gantt: {
startDateField: 'start_date',
endDateField: 'end_date',
titleField: 'name',
progressField: 'progress',
dependenciesField: 'depends_on',
parentField: 'parent',
},
}
Rows with children (or type: 'summary') render as summary bars — they
move the whole group on drag and have no resize handles. Leaf tasks resize
freely unless locked: true.
Shift segmentation — timeSegments (排班分段, ObjectUI extension)
timeSegments splits each day column into ordered bands (e.g. 白班 / 夜班)
for shift-based scheduling. It is an ObjectUI display extension, not part
of the upstream GanttConfigSchema in @objectstack/spec — it lives inside
the nested gantt: {} view config and is read by the ObjectUI gantt runtime.
{
type: 'gantt',
data: { provider: 'object', object: 'work_order' },
columns: ['name', 'assignee'],
gantt: {
startDateField: 'start', endDateField: 'end', titleField: 'name',
timeSegments: {
dayStart: '08:00',
bands: [
{ key: 'day', label: '白班', start: '08:00', end: '20:00' },
{ key: 'night', label: '夜班', start: '20:00', end: '08:00', color: '#6366f1' },
],
},
},
}
Field shapes:
| Field | Required | Notes |
|---|
dayStart | no | 'HH:mm' (24h). The "day" column starts here and runs a full 24h, so a cross-midnight band sits wholly inside one column. Default '00:00'. |
bands[].key | no | Stable id ('day'/'night'); defaults to band{index}. |
bands[].label | yes | Header text for the band (白班 / 夜班). |
bands[].start / bands[].end | yes | 'HH:mm'. When end <= start the band crosses midnight. Bands must tile the 24h day from dayStart. |
bands[].color | no | Any CSS color. Tints that band's column; omit for no tint. |
showMidnight | no | Draw the dashed calendar-midnight cue inside cross-midnight bands. Default true; set false to hide it. |
Behavior:
- Day scale only.
timeSegments applies when the gantt is in day mode; in
week/month/quarter scales it is ignored (no-op).
- Two-tier header. Top tier = the 排班日 date (starting at
dayStart),
bottom tier = one cell per band (each half-width for two equal bands).
- Attribution by
start. A task is placed in the 排班日 its start falls
in, so a 夜班 spanning 20:00→次日08:00 stays in a single column.
- Drag-snaps to band boundaries (the band duration, e.g. 12h) instead of
whole days.
- Calendar-midnight cue. A subtle dashed vertical line marks local 0:00
inside a cross-midnight band — the 排班日 cell itself stays unbroken. Set
showMidnight: false to turn it off.
- Default off = zero regression. Omit
timeSegments and the gantt behaves
exactly as before. Tints render only for bands that declare color.
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: [...] }).
import { App } from '@objectstack/spec/ui';
export const CrmApp = App.create({
name: 'crm_enterprise',
label: 'Enterprise CRM',
icon: 'briefcase',
defaultAgent: 'sales_copilot',
branding: {
primaryColor: '#4169E1',
logo: '/assets/crm-logo.png',
favicon: '/assets/crm-favicon.ico',
},
navigation: [
{
id: 'group_sales', type: 'group', label: 'Sales', icon: 'chart-line',
expanded: true,
children: [
{ id: 'nav_lead', type: 'object', objectName: 'lead', label: 'Leads', icon: 'user-plus' },
{ id: 'nav_opportunity', type: 'object', objectName: 'opportunity', label: 'Opportunities', icon: 'target' },
{ id: 'nav_pipeline', type: 'object', objectName: 'opportunity', viewName: 'pipeline_kanban', label: 'Sales Pipeline', icon: 'columns-3' },
{ id: 'nav_my_open', type: 'object', objectName: 'opportunity', filters: { owner_id: '{current_user_id}', status: 'open' }, label: 'My Open Deals', icon: 'user-check' },
{ id: 'nav_dash', type: 'dashboard', dashboardName: 'sales_dashboard', label: 'Sales Dashboard', icon: 'chart-bar' },
{ id: 'nav_report', type: 'report', reportName: 'opportunities_by_stage', label: 'Opps by Stage', icon: 'bar-chart-3' },
],
},
{
id: 'group_approvals', type: 'group', label: 'Approvals', icon: 'check-circle',
children: [
{ id: 'nav_approval_requests', type: 'object', objectName: 'sys_approval_request', label: 'Approval Requests', icon: 'inbox', requiresObject: 'sys_approval_request' },
],
},
],
});
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 |
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.
Dashboards
Dashboards are a grid of widgets (columns × rowHeight) sharing a
dateRange scrubber and globalFilters. Each widget binds a dataset and
selects named dimensions + values, picks a chart type, and sets a
layout: {x,y,w,h} (ADR-0021).
Widget Types
A widget's type is its chart type (ChartTypeSchema; defaults to
metric) — there are no separate list / calendar / custom widget kinds:
| Family | type values |
|---|
| Single value | metric, kpi, gauge, solid-gauge, bullet (all render the number today; gauge variants gain a dial when a gauge renderer lands) |
| Comparison | bar, horizontal-bar, column |
| Trend | line, area |
| Distribution | pie, donut, funnel |
| Relationship | scatter |
| Composition | treemap, sankey |
| Advanced | radar |
| Tabular | table, pivot |
See the Production Pattern section below for the full
Dashboard shape with refreshInterval, header actions, date range,
global filters, widget options, and the period-over-period (compareTo)
modifier; date bucketing comes from the bound dataset dimension's
dateGranularity (ADR-0021).
Dataset-Bound Widgets
Every persisted chart is dataset-backed (ADR-0021 single-form cutover): a
dashboard widget, a report, and a list type:'chart' view all bind a dataset
and select named dimensions + values; the dataset owns the base object,
allowed joins, intrinsic filter, dimensions, and certified measures. The legacy
per-widget inline query (object + categoryField + valueField + aggregate)
was removed — a widget now requires dataset + values; the inline fields are
dropped and a widget lacking dataset fails os validate. Reports bind the same
way (dataset + rows + values + runtimeFilter). The dataset shape is
DatasetSchema — see node_modules/@objectstack/spec/src/ui/dataset.zod.ts.
A widget's presentation-scope filter flows into the query as the runtime
filter; keep filter on the widget when binding a dataset.
{
id: 'revenue_by_region',
type: 'bar',
title: 'Revenue by Region',
dataset: 'sales',
dimensions: ['region'],
values: ['revenue'],
layout: { x: 0, y: 0, w: 6, h: 4 },
}
The real decision is not "inline vs dataset" — it is "can a dataset express
this?" The shape is already fixed (always a dataset), so what you decide is
whether the data need fits the dataset envelope, and if not, which lower layer to
escalate to. Decide on expressibility; reuse/governance is Level B.
Level A — the dataset envelope:
| Fits a dataset → author one | Beyond the envelope → escalate |
|---|
one base object + to-one joins (include, ≤3 hops) | a join that changes grain / a to-many rollup onto the parent |
0..N dimensions; date-bucket day/week/month/quarter/year | a computed dimension / CASE bucket / numeric bin |
measures count/sum/avg/min/max/count_distinct | array_agg/string_agg or any custom-SQL metric |
derived measures — ratio/sum/difference/product of other measures | scalar math on raw fields (amount*0.8), aggregate-of-aggregate |
WHERE ($and/$or/$not on the base object) + measure-scoped filters | HAVING (filtering the aggregate result) |
compareTo (previous period/year) + totals (matrix subtotals) | window (rank, running total, lag/lead, %-of-total); union; reshaping params |
The iron rule: a dataset is a governed, narrow semantic layer — NOT a
general analytics escape hatch (no raw SQL, no hand-authored joins, no
window/having). If the need is in the right column, a dataset cannot express
it — escalate to a hand-authored Cube (raw SQL / explicit joins), a stored
rollup or formula field on the object (to-many rollups, computed columns), or
app code. Do not force it into a dataset: it fails to compile or renders an empty
series.
Standardized answers to the recurring ambiguous cases:
- "Count of child tasks per project." Base the dataset on the child (
task)
and group by the parent lookup (project) — grain = child. A rollup onto the
parent grain ("on project, count related tasks") is a to-many rollup:
not dataset-expressible — use a stored rollup field on project.
- To-one enrichment ("revenue by
account.industry") is fine and does not
change grain — put account in include, add account.industry as a dimension.
- Computed column. Formatting/currency → a measure's
format/currency;
arithmetic over declared measures (margin = difference(revenue, cost)) → a
derived measure; CASE / bins / revenue*0.8 / computed dimensions → not a
dataset.
- Filter by a parent's attribute → model it as a dimension (guaranteed to
join); a lookup-path filter is not a reliable analytics-path construct.
- A dashboard filter driving several charts (date/region) → not a dataset:
a dashboard variable + per-chart
filterBindings broadcast into each chart's
WHERE (#2501). A dataset is implied only when a parameter reshapes the query
(grain/window/join) — and those are beyond the envelope anyway.
Level B — naming is governance, not expressibility. An inline dataset draft
(Studio Live Canvas) and a saved named dataset have identical expressibility;
naming one is a reuse/governance call (canonical/shared metric, RLS, shared
labels/formats → defineDataset). Persisted widgets already require a named
dataset, so Level B only surfaces in Studio previews and hand-coded react-page
<ObjectChart> blocks (a single-object inline aggregate, no dataset binding).
- Dataset-bound widgets need at least one
values entry, and every
dataset/dimensions/values name must resolve to its defineDataset —
os validate fails on an unresolved name (an empty chart otherwise).
- Studio's Dashboard Widget Inspector can author per-widget
dataset,
dimensions, and values; curated metadata-admin forms merge
server-only fields back into the payload, so saving through Studio should
not drop newer schema fields.
- The analytics runtime applies SecurityPlugin read scope via
security.getReadFilter, so dashboard/report datasets remain RLS-aware.
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 (see the example below).
Report Configuration
import { defineReport } from '@objectstack/spec/ui';
export const PipelineCoverageReport = defineReport({
name: 'pipeline_coverage_by_quarter',
label: 'Pipeline Coverage (Quarter)',
type: 'matrix',
dataset: 'opportunity_metrics',
rows: ['forecast_category'],
columns: ['close_date'],
values: ['amount_sum'],
runtimeFilter: { stage: { $ne: 'closed_lost' } },
chart: { type: 'bar', xAxis: 'forecast_category', yAxis: 'amount_sum' },
});
dateGranularity lives on the dataset's date dimension
(day | week | month | quarter | year); selecting that dimension buckets the
field server-side in a single aggregate query — do not pre-compute virtual
columns for this.
rows are the report's grouping dimensions (selected from the dataset by
name). A summary groups down by rows. A matrix pivots rows (down) ×
columns (across, ADR-0021 D2) with values in the cells — do not
put both axes in rows. Multi-level grouping on either axis = multiple
dimension names in that array. drilldown (default true) makes cells
click-through to the underlying records.
Three Run Modes: Object Nav vs Filters Slice vs Interface Pages (ADR-0047 / objectui ADR-0055)
Object list UI has three run modes, selected by the navigation item shape:
| Data mode (type: 'object') | Bare slice (type: 'object' + filters) | Interface mode (type: 'page') |
|---|
| What renders | ALL list views as switcher tabs | The URL-defined slice, no saved-view tabs | One curated page with its own list definition |
| Anchored to | Saved views | The URL itself (/:objectName/data?filter[...]) | Page config |
| User-created views | Allowed | "Save as view" exit only | Never |
| Quick filters | Auto-derived (or view userFilters — dropdown only) | Auto-derived + removable URL chips | Only what the author enabled |
| Visualization | Switchable (whitelist) | Switchable (URL filter state survives) | Locked unless whitelisted |
Decision rule — default to data mode. Generate ONLY objects + list views +
navigation pointing at objects. Escalate only on explicit signals:
filters slice — the entry is a one-off / parameterized condition
(dashboard drill-through, "assigned to me" link, a shared URL). Don't
author a view for it; a slice graduates to a named view only when it is
curated and reused. Values support {current_user_id} / {current_org_id}.
Never treat it as security: the surface shows what row-level permissions
allow. (Canonical rules: objectui ADR-0055, "parameterized bare data
surface".)
- Interface page — persona split ("sales reps see…", customer portal,
给业务部门的简化界面); capability narrowing ("users must not change views",
"only filter by X"); curation language (workspace / 工作台 / "Airtable
interface-like").
Ambiguity resolves to no page and no view — data mode is a functional
superset; a missing page costs polish, a superfluous page (or a view authored
for a one-off slice) is a permanently-maintained duplicate asset.
One-sentence rule: prefer the object's default view over a pinned
viewName; prefer URL filters over authoring a view for one-off slices;
prefer a named view over a page; use a page only for composition a single
object view cannot express. Every target appears exactly once.
The iron rule (revised): an interface page IS the view definition. It
binds an object (interfaceConfig.source) and carries its own columns /
sort / filterBy directly (Airtable parity — there is no "inherit from a
named view" concept), plus presentation policy (userFilters,
appearance.allowedVisualizations, userActions). The old
sourceView ("inherit from a named object view") is deprecated legacy: it
is still honored at runtime as a fallback when the page defines no columns of
its own, but new pages define columns/sort/filterBy on the page.
import { definePage } from '@objectstack/spec/ui';
export const TaskWorkbenchPage = definePage({
name: 'task_workbench',
label: 'Task Workbench',
type: 'list',
object: 'task',
interfaceConfig: {
source: 'task',
columns: ['subject', 'status', 'due_date'],
sort: [{ field: 'due_date', order: 'asc' }],
filterBy: [{ field: 'status', operator: 'not_equals', value: 'done' }],
userFilters: { element: 'dropdown', fields: [{ field: 'status' }] },
appearance: { allowedVisualizations: ['grid'] },
userActions: { sort: true, search: true, filter: false },
},
});
Record Presentation — surface, width & columns are auto-derived
A record's create / edit / detail presents itself adaptively. You do not
author the surface, the overlay width, or the column count — all three are derived
at runtime from how heavy the record is + the client viewport, because an author
(especially an AI) cannot know the client's screen. Write the data (fields,
fieldGroups); let the platform lay it out.
- Surface (page vs drawer). Derived from field count: a field-heavy object
opens create/edit/detail as a full page; a light one as a drawer. Mobile
always pages. Don't set it. To force it for a specific object, set
navigation.mode (page | drawer | modal) on the list view (or object) — or,
for bespoke layout, assign a record Page (below).
- Field width. Use the relative
span: 'full' to make a field take the
whole row; otherwise omit it (auto sizes by widget type × current columns —
textarea / rich-text / file take the row automatically). Do not use the
absolute colSpan — it only lines up at one width and is deprecated.
- Overlay width. Never author pixels. If you must nudge, use the
size
bucket (sm | md | lg | xl | full) on navigation; the pixel
width / drawerWidth are deprecated (they can't be chosen without knowing the
client viewport).
- Column count. Not authored. The form grid follows its real rendered width
via container queries — the same form is 1 column in a narrow drawer and up to 4
on a wide page. Author grouping with
fieldGroups + Field.group; the columns
adapt themselves.
Rule of thumb: presentation (surface / width / columns) is not metadata.
Write fields + semantic roles; the renderer decides the pixels. Reach for
navigation.mode / size / a Page only to override — never as the default.
Pages — Lightning-Style Page Layouts
A Page is a Salesforce-Lightning-style layout composed of regions
populated with components. Pages let designers assemble record details,
home pages, app launchers, and utility bars without writing React.
Register under defineStack({ pages: [...] }).
Page Types
PageTypeSchema has exactly five values — only types with a dedicated
renderer are authorizable (ADR-0049 enforce-or-remove):
type | Purpose |
|---|
record | Component-based record layout with regions (overrides the default record detail) |
home | App home / landing page |
app | App-level page with navigation context |
utility | Floating utility panel (e.g. notes, phone dialer) |
list | Record list/grid interface page — configured via interfaceConfig (see the iron rule above) |
Disambiguation: there is no record_detail, app_launcher, or
utility_bar type — a record layout is type: 'record', an app-level page is
type: 'app', a utility panel is type: 'utility'. Likewise
grid/kanban/calendar/gallery/timeline are NOT page types — they are
visualizations of a list page
(interfaceConfig.appearance.allowedVisualizations). Former roadmap-only types
(dashboard, form, record_detail, record_review, overview, blank)
were removed from the enum because they never shipped a renderer.
Templates & Regions
template controls the column layout (e.g. 'three-column',
'two-column', 'single-column'). Each template exposes named
regions (header, left_sidebar, main, right_sidebar, footer)
which contain components.
Component Catalogue (selection)
type | Use |
|---|
page:header | Title + subtitle + breadcrumb + inline actions: Action[] |
page:card | Bordered/un-bordered card with body: Component[] |
flex | Generic styleable box (properties.children) — the workhorse for custom layout; style via responsiveStyles (see Styling below) |
element:text | Text node — properties.content; style via responsiveStyles |
element:button | Button — properties.label + variant/size + optional action |
record:highlights | Salesforce highlights panel — strip of key fields |
record:path | Stage progress bar driven by a status field |
record:related_list | Related-list (child records via lookup) |
nav:menu | Quick-create / nav menu bound to current context |
object-metric | Single KPI widget (count/sum/avg) |
object-chart | Embedded chart |
Example — Record Detail Page
import { definePage } from '@objectstack/spec/ui';
import { ConvertLeadAction } from '../actions/lead.actions';
export const LeadDetailPage = definePage({
name: 'lead_detail_page',
label: 'Lead Detail',
type: 'record',
object: 'lead',
template: 'three-column',
regions: [
{
name: 'header', width: 'full',
components: [
{
type: 'page:header', id: 'lead_header', label: 'Lead Information',
properties: {
title: '{first_name} {last_name}',
subtitle: '{company}',
icon: 'user-plus',
breadcrumb: true,
actions: [ConvertLeadAction],
},
},
{
type: 'record:highlights', id: 'lead_highlights',
properties: { fields: ['status', 'rating', 'lead_source', 'owner', 'email', 'phone'] },
},
{
type: 'record:path', id: 'lead_path',
properties: {
statusField: 'status',
stages: [
{ value: 'new', label: 'New' },
{ value: 'contacted', label: 'Contacted' },
{ value: 'qualified', label: 'Qualified' },
{ value: 'unqualified', label: 'Unqualified' },
],
},
},
],
},
],
});
Variable substitution — {first_name}, {current_user.first_name},
{current_quarter_start} etc. resolve from the page's variables block,
the bound record, and the runtime context. Declare variables: [...] at
the page root for any non-record value. For relative-date placeholders
({today}, {30_days_ago}, {N_<unit>_(ago|from_now)} …) see the
Date Macros reference below — the
full token list is published as DATE_MACRO_TOKENS in @objectstack/spec/data.
Actions in header — pass full Action objects into
page:header.properties.actions; do not create a sibling action node.
The header renders them inline in the action slot.
AI-authored source pages — kind:'html' and kind:'react' (ADR-0080/0081)
Besides the structured regions model above, a page's whole body can be written
as a source string in source, with kind choosing the authoring tier. Pick
by what the page needs:
kind | Author writes | JS runs? | Use when |
|---|
full / slotted | structured regions / slots (no source) | — | record/detail/home layouts from the component catalogue |
html | constrained JSX = registered components + safe native HTML, parsed, never executed | no | free-form layout / landing / dashboard that just composes blocks — no interactivity |
react | real React (hooks, .map, onClick, expressions) | yes (main React tree) | complex interactive business UIs — master/detail, wizards, state-driven filters |
source is the source-of-truth in both source tiers; regions is ignored. A
kind:'html'/'react' page with no source fails the build (ADR-0078). The legacy
value kind:'jsx' is a deprecated alias for kind:'html'.
kind:'html' — constrained JSX, parsed (safe by construction)
Tags are the registered components (bare names: <flex>, <grid>, <card>,
<object-grid>, <object-form>, <object-metric>, …) plus the safe native HTML
set (<h1>–<h6>, <p>, <a>, <ul>/<ol>/<li>, <img>, <blockquote>, <strong>,
…). Props come from each component's registry inputs (e.g. <text content=…>,
<badge label=…>). No JavaScript — onClick, {expr} logic and .map() are NOT
available; use kind:'react' for those. os build parses the source and fails loudly
on unknown tags / missing required props / forbidden constructs (event handlers,
dangerouslySetInnerHTML).
export const ReleaseNotesPage = definePage({
name: 'release_notes', label: 'Release Notes', type: 'home', kind: 'html',
source: `
<flex direction="col" gap={6} style={{"maxWidth":"768px","margin":"0 auto","padding":"40px"}}>
<h1 style={{"fontSize":"32px","fontWeight":700,"color":"hsl(var(--foreground))"}}>Release Notes</h1>
<object-metric objectName="ticket" aggregate="count" label="Open tickets" />
</flex>`,
});
kind:'react' — real React, executed (trusted tier)
The source is real React executed at render by the runtime. The injected scope are
closure variables (NOT props) — reference them directly:
React — hooks (React.useState, React.useEffect, …)
useAdapter() — live data: adapter.find('obj', {…}) / .findOne / .create / .update
- the public data blocks as PascalCase components —
<ObjectForm>, <ListView>,
<ObjectMetric>, <ObjectChart>, <ObjectKanban>, … The scope is built at
runtime from the public block registry (every non-container public block gets a
PascalCase wrapper), so blocks like <ObjectMetric> / <ObjectKanban> exist
even though the written contract below documents only the curated core set;
<Block type="…" …/> is the escape hatch for any other registered type
data / variables / page
Compose layout with inline style={{…}} (real CSS — see Styling, below); use the
injected blocks for data. Do NOT use Tailwind className — page source is runtime
metadata the build never scans, so utility classes silently do nothing. Real component
props/callbacks flow through — e.g. <ObjectForm> honors objectName / mode / recordId /
formType / onSuccess / onCancel; <ListView> honors objectName / fields /
onRowClick / navigation.
Do not guess props — read the contract. Each injected block's full prop set
(name, type, data/controlled/callback kind, required, description) is the
React-tier component contract, generated from
contracts/react-blocks.contract.json.
It is the authoritative answer to "what props does <ObjectForm>/<ListView>/…
take?" — author against it, not from memory. The data props are sourced from the platform's spec schemas (FormView,
ListView, RecordDetails, Chart, …) — the same protocol the server validates;
binding/controlled/callback are the React overlay. The contract covers
the curated core set; runtime-injected blocks outside it (<ObjectMetric>,
<ObjectKanban>, …) read their props from the block registry at render time.
(Maintainers: regenerate with pnpm --filter @objectstack/spec gen:react-blocks.)
Master/detail (click a row → edit it → save refreshes the list):
export const CrmWorkbenchPage = definePage({
name: 'crm_workbench', label: 'CRM Workbench', type: 'home', kind: 'react',
source: `
function Page() {
const [sel, setSel] = React.useState(null);
const [reload, setReload] = React.useState(0);
return (
<div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 24, padding: 32, alignItems: 'start' }}>
<ListView key={reload} objectName="project"
fields={['name','status','owner']} navigation={{ mode: 'none' }}
onRowClick={(r) => setSel(r)} />
{sel
? <ObjectForm objectName="project" mode="edit" recordId={sel.id}
onSuccess={() => { setSel(null); setReload((k) => k + 1); }} />
: <p style={{ color: 'hsl(var(--muted-foreground))' }}>Select a project to edit.</p>}
</div>
);
}`,
});
Safety / availability. kind:'react' executes author code in the app, so it is gated
by the host capability react-pages — ON by default (the platform trusts reviewed,
draft-gated authors). A deployment that does not trust its authors turns it off server-side
with OS_PAGE_REACT=off; the page then shows a "disabled" notice instead of executing.
os build does NOT lint react source (it is real JS, not constrained JSX) — errors surface
at render behind an error boundary, so always test a react page in the browser.
Styling a page (ADR-0065) — responsiveStyles, NOT className
To style a metadata-authored block, give it a responsiveStyles object — a
per-breakpoint map of CSS properties. The renderer compiles each styled node to
id-scoped CSS at render time. Do NOT put Tailwind classes in className
expecting them to render: Tailwind is compiled at the renderer's build over the
renderer's source, never over your metadata, so a class only happens to work if
objectui already uses it — arbitrary classes (text-[27px], bg-[#1a2b3c],
grid-cols-7) silently do nothing. responsiveStyles has no such trap (values
are compiled from your data at render).
Rules:
responsiveStyles and id are top-level envelope fields; child nodes go
in properties.children (the renderer hoists properties to schema level).
- Every styled node needs a stable
id (the CSS is scoped to it).
- Values should be design tokens for consistency: spacing
var(--space-1..12),
radius var(--radius) / var(--radius-xl), shadow var(--shadow-sm|md|lg),
colors var(--surface) / var(--surface-sunken) / var(--text-strong) /
var(--text-muted) / var(--brand) / var(--brand-foreground) /
var(--hairline), or hsl(var(--primary)) etc. (theme tokens track light/dark).
- Responsive lives in the breakpoint maps —
large (base, desktop-first),
then medium / small / xsmall as max-width overrides. Never author
md:-style variant classes.
- Compose from generic styleable blocks —
flex, element:text,
element:button — and style each block's root. (page:card etc. are fine for
structure but style what you control.)
{
id: 'plan_solo', type: 'flex',
responsiveStyles: {
large: {
display: 'flex', flexDirection: 'column', gap: 'var(--space-4)',
padding: 'var(--space-6)', borderRadius: 'var(--radius-xl)',
backgroundColor: 'var(--surface)', border: '1px solid hsl(var(--primary))',
boxShadow: '0 0 0 3px hsl(var(--primary) / 0.25), var(--shadow-lg)',
},
small: { padding: 'var(--space-4)', gap: 'var(--space-3)' },
},
properties: {
children: [
{ id: 'plan_solo_price', type: 'element:text',
responsiveStyles: { large: { fontSize: '40px', fontWeight: '700', color: 'var(--text-strong)' }, small: { fontSize: '32px' } },
properties: { content: '$29' } },
{ id: 'cta_solo', type: 'element:button',
responsiveStyles: { large: { marginTop: 'auto', width: '100%' } },
properties: { label: 'Upgrade', variant: 'primary', size: 'large' } },
],
},
}
Why this model: it's build-independent (no Tailwind compile dependency),
collision-free (per-node scoped, beats base utilities without @layer
games), and responsive-correct (breakpoint maps → generated @media). The
spec field is PageComponentSchema.responsiveStyles (ResponsiveStylesSchema —
see node_modules/@objectstack/spec/src/ui/responsive.zod.ts). See ADR-0065
(SDUI styling model).
In the source tiers (kind:'html' / kind:'react') the same rule holds — no
Tailwind className — but the primitive differs:
kind:'html' — lay out with the registered components' own structured props
(<flex direction="col" gap={6}>, <grid columns={4}> compile their own,
already-shipped classes) and add CSS with a style object written as JSON
(quoted keys/values): style={{"padding":"40px","color":"hsl(var(--foreground))"}}.
A JS-style object ({{padding: 40}}) is parsed as a deferred expression and will
NOT apply — keys and string values must be double-quoted.
kind:'react' — it's real React, so style with an ordinary inline
style={{}} object using hsl(var(--token)) theme colors:
color: 'hsl(var(--foreground))', background: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))', borderRadius: 'var(--radius)'. Tokens
are HSL triplets, so always wrap them: hsl(var(--card)), never bare
var(--card); a translucent scrim is hsl(0 0% 0% / 0.5). For a drawer/modal,
render <ObjectForm formType="drawer"|"modal" open onOpenChange={…}> — it ships a
pre-styled Sheet/Dialog with backdrop + animation (open/onOpenChange are
read by the component at runtime; they sit outside the contract's data prop
tables); never hand-roll a fixed inset-0 overlay (its utility classes won't
compile, so it renders as unstyled boxes with no backdrop).
Docs — Package Documentation (ADR-0046)
A Doc is a page of package documentation shipped as metadata. You
author plain Markdown in a flat src/docs/ directory; os build
compiles each *.md into a doc item that travels inside the package
artifact and renders in the console at /docs/<name>. Docs are also the
grounding the AI assistant reads about a package.
src/docs/
crm_index.md → doc "crm_index" → /docs/crm_index
crm_user_guide.md → doc "crm_user_guide" → /docs/crm_user_guide
Authoring rules (each enforced by os build)
- Flat directory. Every
.md lives directly in src/docs/;
subdirectories are a build error. Flatness is what keeps links stable
— a reference resolves by basename, never by path.
- Namespace-prefixed filename. The filename stem becomes the doc
name (^[a-z][a-z0-9_]*$) and must start with the package namespace
(crm_…). Names share one flat, instance-global space with the URL, so
a bare user_guide would collide across packages and fail at install
(ADR-0048).
- Title resolves: frontmatter
title: → first # heading → name.
Optional frontmatter description: is a one-line summary the docs portal
shows under the title — add it on index/overview docs.
- Pure Markdown. CommonMark + GFM only, plus heading anchors, fenced
code highlighting, and GitHub alerts (
> [!NOTE], > [!WARNING], …).
MDX and image references are rejected at build time — docs are
publisher content rendered inside the platform (no authored code across
the trust boundary; images await a content-addressed asset service).
- Cross-references use plain relative links —
[overview](./crm_index.md).
The console rewrites *.md → /docs/<target> (anchors preserved);
broken same-package links fail the build.
Routing model — platform-level viewer, opt-in entry
The viewer is platform-level: one global /docs/<name> route
resolves any doc regardless of which app you came from. The URL is
single-coordinate — no package or app prefix — so a doc has exactly
one URL. Do not design per-app or per-package doc URLs; that gives one
doc many addresses and breaks cross-references.
To surface a doc inside an app, add a navigation item that links into
that global URL. There is no dedicated doc nav-item type yet, so use a
url item pointing at /docs/<name>:
navigation: [
{ id: 'nav_help', type: 'url', url: '/docs/crm_user_guide',
label: 'User Guide', icon: 'book-open' },
]
A platform-level "Documentation" portal (browse/search all docs by
package) is a later, additive concern — author-side, nothing to model now.
Live instances vs. structural views. For a live, interactive
instance — a dashboard, a report, a record table — don't embed it:
link to it by URL and let the platform render it (one source, never a
stale copy). But for structural metadata that no single screen shows as
one picture — a state machine, a flow, a permission matrix — embed a
read-only view inline with a metadata fence (below).
Inline metadata views — the metadata fence (ADR-0051)
A reader who can't open Studio (a business user, a PM, an auditor) can't
see the whole shape of a process or the full set of legal state
transitions from a running screen. A metadata fenced block embeds a
live, read-only view of one metadata item, resolved from the current
metadata at render time — change the rule and the diagram follows, it is
never a screenshot. The body is flat key: value data, not code, so it
stays inside the §3.4 trust boundary (it compiles to the read-only
element:metadata_viewer component — the same one a page can render).
Three view kinds:
type | renders | required | optional |
|---|
state_machine | a record's lifecycle transition graph (from a state_machine validation rule) | object + name (the rule) | detail, mode |
flow | a flow's steps; detail: business (default) folds purely technical nodes | name | detail (business|technical), mode |
permission | a permission set's object-level C/R/U/D matrix | name | mode |
Tasks move across the board only by these rules:
```metadata
type: state_machine
object: crm_task
name: crm_task_status_flow
```
os build lints every fence: type must be one of the three (typo →
did-you-mean), name is required, state_machine also needs object, and
the referenced object-rule / flow / permission set must exist in this
package — a dead same-package reference fails the build (same posture as
a broken link). At render time a missing or forbidden reference degrades to
a placeholder, never a crash.
Scope is deliberately narrow: only state_machine, flow,
permission. Embedding an object (data model) or an arbitrary SDUI
component is not supported. permission caveat: the matrix is not
yet projected to the reader's own permissions (ADR-0051 P3) — do not place a
permission embed in a doc reachable by less-privileged or anonymous
readers until that lands.
Example
---
title: CRM Overview
description: Accounts, contacts, and opportunities — start here.
---
# CRM
Manages accounts, contacts, and opportunities.
> [!TIP]
> New here? Start with the [user guide](./crm_user_guide.md).
| Object | Purpose |
| :--- | :--- |
| `crm_account` | Companies and organizations |
| `crm_contact` | People at an account |
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 |
| Dashboards | src/dashboards/*.dashboard.ts | Combine KPI + chart + table widgets with shared dateRange and globalFilters |
| Reports | src/reports/*.report.ts | Bind a dataset + rows (dimensions) + values (measures) for tabular/summary/matrix/joined analytics |
| 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.
Dashboards (cont.) — KPI Widgets, Filters, Drilldown
Dashboards (Dashboard) are first-class metadata. Beyond the basic widget
layout shown above, the production-grade pattern uses:
import type { Dashboard } from '@objectstack/spec/ui';
export const SalesDashboard: Dashboard = {
name: 'sales_dashboard',
label: 'Sales Performance',
columns: 12,
gap: 4,
refreshInterval: 180,
header: {
showTitle: true,
actions: [
{ label: 'New Opportunity', icon: 'Plus', actionType: 'modal', actionUrl: 'create_opportunity' },
{ label: 'Forecast', icon: 'TrendingUp', actionType: 'url', actionUrl: '/reports/forecast' },
{ label: 'Export', icon: 'Download', actionType: 'script', actionUrl: 'export_dashboard_pdf' },
],
},
dateRange: { field: 'close_date', defaultRange: 'this_quarter', allowCustomRange: true },
globalFilters: [
{ field: 'owner', label: 'Sales Rep', type: 'lookup', scope: 'dashboard',
optionsFrom: { object: 'user', valueField: 'id', labelField: 'name' } },
],
widgets: [
{
id: 'total_pipeline_value', type: 'metric',
title: 'Total Pipeline',
dataset: 'opportunity_metrics', values: ['total_amount'],
filter: { stage: { $nin: ['closed_won', 'closed_lost'] } },
layout: { x: 0, y: 0, w: 3, h: 2 },
options: { icon: 'DollarSign' },
compareTo: 'previousPeriod',
actionType: 'url', actionUrl: '/objects/opportunity?filter=open',
},
{
id: 'revenue_vs_last_year', type: 'line',
title: 'Revenue — This Year vs Last',
dataset: 'order_metrics', dimensions: ['closed_at'], values: ['total_sum'],
filter: { closed_at: { $gte: '{current_year_start}', $lte: '{current_year_end}' } },
compareTo: 'previousYear',
layout: { x: 3, y: 0, w: 9, h: 4 },
},
],
};
Tokens in filters: {current_quarter_start}, {current_user.id} are
resolved at request time. Avoid baking absolute dates into definitions.
The full list of supported date placeholders is documented in
Date Macros below.
Period-over-period — compareTo
Set compareTo on any data-bound widget to add a second query against a
shifted time window. The renderer derives the comparison automatically;
no second filter is required.
| Value | Behaviour |
|---|
'previousPeriod' | Inspect the widget filter for date-macro tokens ({current_month_start}, {last_7_days}, …) and shift the window back by one period of the same kind. |
'previousYear' | Shift the resolved filter window back by one calendar year. |
{ offset: '7d' } | Shift by an explicit duration. Units: d (days), w (weeks), M (months), y (years). |
- Metric widgets — the prior-period value renders as a small caption
beneath the headline number, alongside a green/red delta arrow and an
i18n trend label resolved from the comparison kind (e.g.
vs previous period, vs previous year, vs previous 7d). Authors should not
hand-author options.trend when compareTo is set; the renderer wins
and overwrites it.
- Cartesian charts (
line / area / bar / horizontal-bar /
scatter) — the comparison series is appended after the primary series
with variant: 'comparison' and styled as a muted overlay (opacity: 0.5
strokeDasharray: '4 4' for line/area/scatter; opacity: 0.4 for
bars). Override per-series with series.dashArray / series.opacity.
- Pie / donut / funnel —
compareTo is silently ignored; there is no
meaningful "two-period" composition for part-of-whole charts.
- Requirements —
compareTo is a no-op when the filter contains no
resolvable date macros and no global dateRange is configured. The
shifted query reuses the original filter shape and replaces only the
date-bound clauses.
{ id: 'done_this_week', type: 'metric', dataset: 'task_metrics', values: ['task_count'],
filter: { assignee: '{current_user_id}', status: 'done',
completed_at: { $gte: '{week_start}' } },
compareTo: 'previousPeriod' }
{ id: 'headcount_by_dept', type: 'bar', dataset: 'employee_metrics',
dimensions: ['department'], values: ['headcount'],
filter: { status: { $ne: 'terminated' } },
compareTo: 'previousYear' }
Server-side date bucketing — dateGranularity (ADR-0021)
Date bucketing lives on the dataset dimension, not the widget. Give a date
dimension a dateGranularity and any presentation that selects it groups by that
bucket server-side — without it every distinct timestamp becomes its own
category, collapsing a 12-row seed into a 12-point flat line. (The old widget-level
categoryGranularity was removed in the single-form cutover.)
defineDataset({
name: 'contract_metrics', label: 'Contract Metrics', object: 'contract',
dimensions: [{ name: 'signed_date', field: 'signed_date', type: 'date', dateGranularity: 'month' }],
measures: [{ name: 'signed_count', aggregate: 'count' }],
});
{ id: 'signed_by_month', type: 'line',
dataset: 'contract_metrics', dimensions: ['signed_date'], values: ['signed_count'],
filter: { signed_date: { $gte: '{12_months_ago}' } },
compareTo: 'previousYear' }
Drilldown
Dashboards drill in two ways: drill-through turns an aggregate into the rows
behind it; drill-to-record opens one record.
table / pivot widgets drill through. Clicking an aggregated table row
or pivot cell opens a side drawer listing the underlying records. The dataset
preserves each grouped row's raw group keys, so the drawer filters to the
exact records (no label→id guessing). Automatic — no per-widget config.
- The drilled record list drills to record. Any row in that drawer opens the
single record's detail, completing the group → records → record chain.
- Escape hatch — "Open in list →". The drawer header offers a link to the
object's full list page (sort / bulk-select / export / shareable URL),
scoped by the same drill filter. The in-place drawer is the default (peek
without losing the dashboard); the escape hatch escalates when the user wants
the full surface — the Looker / Power BI "see records → open page" model.
metric / chart widgets are not click-drillable in the dataset form
(they render the aggregate only; compareTo still applies). Surface the detail
through a table / pivot widget instead.
Reports drill the same way. A summary / matrix report (drilldown
defaults true) opens the identical in-place drawer on row/cell click — peek the
records, click a row to open one, or "Open in list →" for the full list page.
Dashboard and report drill are unified.
Renderer note (object/record-backed surfaces). The ObjectUI renderer
exposes a richer options.drillDown block for non-dataset list/table widgets
and the drill drawers — enabled, mode ('filter' = aggregate → filtered
list; 'record' = row → that record), target ('drawer' | 'dialog' |
'navigate', where 'navigate' skips the drawer and opens the list page
directly), columns (whitelist), and title (${event.*} interpolation). At
the renderer level drill-through covers the bar / line / area / pie /
donut / funnel / scatter / treemap / sankey families and pivot
cell/row/column/total clicks (radar is excluded — no single clickable
category point). The "Open in list →" escape hatch appears whenever the host
app wired drill navigation (the console does). Dataset-bound dashboards use
the semantic-layer drill above and ignore the rest of this block.
dateGranularity | Rendered bucket label |
|---|
'day' | YYYY-MM-DD |
'week' | ISO date of the bucket (YYYY-MM-DD) |
'month' | YYYY-MM |
'quarter' | YYYY-Qn |
'year' | YYYY |
- Engine support — Postgres
date_trunc, MySQL date_format, SQLite
strftime, MongoDB $dateTrunc, in-memory fallback. All emitted by the
analytics service, not the client.
- Human labels are automatic — the analytics layer formats the bucket value
to the label above, and resolves
select/lookup dimension values to their
option label / related-record name. Measures carry their label + format
(e.g. $0,0) so KPIs and legends read "Total Spent / $616,000", not
"spent_sum / 616000". Authors do not format dimension/measure values by hand.
- Combines with
compareTo — the comparison query is issued with the same
granularity, so the muted overlay aligns bucket-for-bucket.
- Rule of thumb —
day for ≤30d windows, week for ~90d, month for
6–12 months, quarter for multi-year, year for retention / compliance.
Date Macros — Filter Placeholders
Dashboards, reports, list-view filters, and other UI metadata can embed
relative-date placeholders that are resolved on the client just before
the request leaves the browser. The canonical contract is published as
DATE_MACRO_TOKENS in @objectstack/spec/data (source:
node_modules/@objectstack/spec/src/data/date-macros.zod.ts); the resolver
lives in @object-ui/core (resolveDateMacros). Keep the two in lockstep.
Both {token} and ${token} forms are accepted.
Fixed tokens (36)
| Category | Tokens |
|---|
| Instants | today, yesterday, tomorrow, now |
| Current period | current_week_start / _end, current_month_start / _end, current_quarter_start / _end, current_year_start / _end |
| Last period | last_week_start / _end, last_month_start / _end, last_quarter_start / _end, last_year_start / _end |
| Next period | next_week_start, next_month_start, next_quarter_start, next_year_start |
| Bare aliases | week_start, week_end, month_start, month_end, quarter_start, quarter_end, year_start, year_end (same as current_*) |
Parameterised tokens — {N_<unit>_(ago|from_now)}
N is any positive integer; <unit> is one of
minute(s) | hour(s) | day(s) | week(s) | month(s) | year(s).
minute/hour resolve to a full ISO timestamp; coarser units resolve to
YYYY-MM-DD.
{30_days_ago} {7_days_from_now} {1_day_ago}
{2_weeks_ago} {6_months_from_now} {1_year_ago}
{15_minutes_ago} {2_hours_from_now}
DO / DON'T
- DO type-check tokens against the spec —
isDateMacroToken(tok) from
@objectstack/spec/data returns false for anything unsupported.
- DO prefer
Field.datetime() for "near-now" filters (minute/hour
precision); driver-sql automatically coerces ISO macros to the stored
ms-epoch representation.
- DON'T invent tokens. Unknown placeholders silently pass through as
literal strings — the resulting SQL compares text against
'{my_made_up_token}' and matches zero rows.
- DON'T combine multiple tokens inside one value without resolution
semantics (
'{today}-{tomorrow}' is fine; {today_or_tomorrow} is
not — there is no such token).
Analytics Cubes — Semantic Layer
Cube definitions sit between objects and dashboards/reports — they expose
named measures (aggregates) and dimensions (groupings) that BI
widgets can compose without hand-rolling each query. Register under
defineStack({ analyticsCubes: [...] }).
import { defineCube } from '@objectstack/spec/data';
export const opportunityCube = defineCube({
name: 'opportunity',
title: 'Opportunities',
sql: 'opportunity',
public: true,
measures: {
count: { name: 'count', label: 'Count', type: 'count', sql: '*' },
amount: { name: 'amount', label: 'Total Amount', type: 'sum', sql: 'amount', format: 'currency' },
},
dimensions: {
stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' },
close_date: { name: 'close_date', label: 'Close', type: 'time', sql: 'close_date',
granularities: ['day', 'week', 'month', 'quarter', 'year'] },
account_industry: { name: 'account_industry', label: 'Industry', type: 'string', sql: 'account.industry' },
owner: { name: 'owner', label: 'Owner', type: 'string', sql: 'owner' },
},
});
Cube Best Practices
sql = object name (e.g. 'opportunity'). The ObjectQL strategy
reads it via cube.sql.trim() — do not put raw SQL there.
- Use dotted lookups in
dimensions[*].sql ('account.industry') to
reach across relations — the engine auto-joins.
- Always declare
granularities on time dimensions so dashboards can
bucket by day / month / quarter without ad-hoc queries.
- Keep
public: true for any cube referenced by a dashboard widget; an
internal-only cube should be public: false.
- One cube per object usually beats omnibus cubes — composability stays high.
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 |
Where Actions Appear (locations)
locations is an array — an action can live in multiple surfaces:
| Value | Surface |
|---|
record_header | Detail page header (single record) |
record_more | Detail page overflow menu (the "More" / ⋯ button) |
record_related | Related-list section inside a record |
record_section | Body section/tab of a record (e.g. a Security tab) |
list_item | Per-row action in list views |
list_toolbar | Bulk action on selected rows (input.selectedIds) |
global_nav | Global navigation / command-palette level |
Visibility, Disable & Feedback
visible — CEL predicate (prefer the P\...`` tagged template); when false the action is hidden.
disabled — boolean or a CEL predicate; when true the action shows but greys out. Use this (not visible) when the action should stay discoverable but locked in the current state.
confirmText — set for any destructive or irreversible operation.
successMessage / errorMessage — author-controlled toast copy on success / failure. Always set successMessage for non-obvious outcomes; without it the UI shows a generic "Action completed" toast.
undoable: true — on a single-record update, offers an Undo in the success toast (and Ctrl+Z); the runtime snapshots prior values and restores them.
Predicates are bare CEL — record.status == "converted", evaluated against
the current record. record.<field> resolves identically on every surface
(record_header, list_item, …); prefer it over the bare-field form. Never
wrap a predicate in ${…} or {…} braces (see objectstack-formula).
import { defineAction } from '@objectstack/spec/ui';
import { P } from '@objectstack/spec';
export const ReassignLeadAction = defineAction({
name: 'reassign_lead',
label: 'Reassign Lead',
objectName: 'lead',
type: 'api',
target: 'lead',
locations: ['record_header', 'list_item'],
disabled: P`record.status == "converted"`,
params: [{ field: 'assigned_to', required: true }],
undoable: true,
successMessage: 'Lead reassigned.',
errorMessage: "Couldn't reassign this lead — try again.",
});
Examples
Flow-typed action (delegates to a screen flow):
import { defineAction } from '@objectstack/spec/ui';
import { P } from '@objectstack/spec';
export const ConvertLeadAction = defineAction({
name: 'convert_lead',
label: 'Convert Lead',
objectName: 'lead',
icon: 'arrow-right-circle',
type: 'flow',
target: 'lead_conversion',
locations: ['record_header', 'list_item'],
visible: P`record.status == "qualified" && record.is_converted == false`,
confirmText: 'Are you sure you want to convert this lead?',
successMessage: 'Lead converted successfully!',
refreshAfter: true,
});
Modal-typed action (collect params, then execute server body):
import { defineAction } from '@objectstack/spec/ui';
export const AddToCampaignAction = defineAction({
name: 'create_campaign',
label: 'Add to Campaign',
objectName: 'lead',
icon: 'send',
type: 'modal',
target: 'create_campaign',
locations: ['list_toolbar'],
params: [
{ field: 'campaign_id', objectOverride: 'campaign', required: true },
],
body: {
language: 'js',
source: `
const campaignId = input.campaign_id;
const ids = Array.isArray(input.selectedIds) ? input.selectedIds : [];
for (const leadId of ids) {
await ctx.api.object('campaign_member').insert({
campaign_id: campaignId, lead_id: leadId, status: 'sent',
});
}
return { count: ids.length };
`,
capabilities: ['api.write'],
timeoutMs: 10000,
},
successMessage: 'Leads added to campaign!',
refreshAfter: true,
});
Action body context (ctx)
A server-side action body (and a registered function handler) receives a
ctx with input (the modal params), record (the target row, when a
recordId is in scope), api (scoped cross-object CRUD), and the caller
identity. Read the caller's active organization under the blessed
organizationId name — the same value as the organization_id column and
current_user.organizationId in RLS, so it matches hooks and seed data with
zero relearning:
const org = ctx.user?.organizationId ?? ctx.session?.organizationId;
The former ctx.session.tenantId alias was removed in v11 (#3290); read the
caller's active org under organizationId.
Action bodies execute trusted (the ctx.engine / ctx.api facade bypasses
RLS/FLS), so a body that must scope by org reads it from ctx explicitly.
ctx.user is undefined for a context-less / self-invoked call; read
ctx.session?.organizationId when the action must work regardless. (Same two
isolation axes as hooks — organization_id row-scoping vs environment /
database-per-tenant; see the objectstack-data hooks reference.)
Opening in a New Tab (openIn / opensInNewTab / newTabUrl)
There are two mechanisms here. Pick by whether the URL is static or computed:
openIn: 'new-tab' — simplest case (static target)
When you have a static target URL (relative or absolute) you just want
opened in a new tab, set openIn: 'new-tab' on a type: 'url' action. No
handler, no synchronous pre-open. openIn: 'self' forces in-place navigation;
omit it and external/absolute URLs open in a new tab while relative URLs