# ObjectUI Documentation # ObjectUI Documentation [#objectui-documentation] ObjectUI is a schema-driven UI engine for React. It renders JSON metadata into accessible, themeable components built with Tailwind CSS, Shadcn UI, and Radix primitives. Use it when your application needs server-driven pages, metadata-defined forms, reusable enterprise views, or an embeddable renderer that stays independent from any single backend. ## Start Here [#start-here] 1. [Quick Start](/docs/guide/quick-start) - install ObjectUI and render a first schema. 2. [Schema Rendering](/docs/guide/schema-rendering) - learn how JSON becomes React UI. 3. [Data Connectivity](/docs/guide/data-source) - connect a backend through the `DataSource` contract. 4. [Schema Reference](/docs/api/schema-reference) - inspect the supported schema shapes. ## A Small Schema [#a-small-schema] ```json { "type": "data-table", "caption": "Users", "className": "rounded-lg border", "columns": [ { "header": "Name", "accessorKey": "name", "sortable": true }, { "header": "Email", "accessorKey": "email" } ], "data": [ { "name": "Ada Lovelace", "email": "ada@example.com" }, { "name": "Grace Hopper", "email": "grace@example.com" } ] } ``` That schema renders through `SchemaRenderer` after the component package is imported once for registration side effects. ## Build Paths [#build-paths] ### Render Schemas [#render-schemas] * [Schema Renderer](/docs/core/schema-renderer) explains the runtime component. * [Component Registry](/docs/guide/component-registry) explains how `type` maps to React components. * [Expressions](/docs/guide/expressions) covers visibility, disabled state, and dynamic values. ### Build Applications [#build-applications] * [App Schema](/docs/core/app-schema) defines app navigation, branding, and layout. * [Layout Guide](/docs/guide/layout) covers page structure and layout primitives. * [Building a CRUD App](/docs/guide/building-crud-app) walks through a full task manager. ### Connect Data [#connect-data] * [Data Connectivity](/docs/guide/data-source) covers the backend adapter contract. * [ObjectStack Adapter](/docs/utilities/data-objectstack) connects ObjectUI to ObjectStack backends. * [User State Persistence](/docs/guide/user-state-persistence) covers favorites and recent items. ### Extend ObjectUI [#extend-objectui] * [Plugin Guide](/docs/guide/plugins) explains lazy-loaded feature packages. * [Plugin Development](/docs/guide/plugin-development) walks through a custom plugin. * [Theming](/docs/guide/theming) covers design tokens, Tailwind, and runtime themes. ## Reference [#reference] * [Components](/docs/components) - core renderers grouped by category. * [Fields](/docs/guide/fields) - field widgets and cell renderers. * [Plugins](/docs/plugins) - heavier views such as grids, kanban, charts, maps, and reports. * [Utilities](/docs/utilities) - CLI, runner, plugin scaffolding, and editor tooling. ## Console [#console] The ObjectUI Console is the reference application for rendering ObjectStack metadata as an admin UI. Start with [Console](/docs/guide/console), then use [Console Architecture](/docs/guide/console-architecture) and [Metadata Diagnostics](/docs/guide/metadata-diagnostics) when integrating a real backend. # API Reference # API Reference [#api-reference] Comprehensive reference documentation for all ObjectUI types, schemas, and APIs. ## Schema Types [#schema-types] ### [Schema Type Reference](/docs/api/schema-reference) [#schema-type-reference] Complete reference for every ObjectUI schema type with annotated JSON examples covering: * **Base** — `SchemaNode`, `BaseSchema` * **Layout** — `PageSchema`, `DivSchema`, `CardSchema`, `GridSchema`, `TabsSchema` * **Forms** — `FormSchema`, `InputSchema`, `SelectSchema`, `ButtonSchema` * **Data Display** — `TableSchema`, `ChartSchema`, `TreeViewSchema` * **CRUD** — `ActionSchema`, `DetailSchema`, `CRUDDialogSchema` * **ObjectQL** — `ObjectGridSchema`, `ObjectFormSchema`, `ObjectViewSchema` * **Complex** — `KanbanSchema`, `DashboardSchema`, `CalendarViewSchema` * **Views** — `DetailViewSchema`, `ViewSwitcherSchema` # Schema Type Reference # Schema Type Reference [#schema-type-reference] This reference documents every ObjectUI schema type with annotated JSON examples. Each schema extends `BaseSchema` and can be rendered by the ObjectUI engine from pure JSON. > **Import:** All types are available from `@object-ui/types`. > > ```typescript > import type { PageNodeSchema, FormSchema, TableSchema, /* ... */ } from '@object-ui/types'; > ``` *** ## Base Schema [#base-schema] ### SchemaNode [#schemanode] The foundational building block of ObjectUI. Every component in the system is described by a `SchemaNode`. It can be a full schema object, or a primitive value rendered as text. ```typescript import type { BaseSchema } from '@object-ui/types'; // The definition `@object-ui/types` declares type SchemaNode = BaseSchema | string | number | boolean | null | undefined; ``` ### BaseSchema [#baseschema] All schema types extend `BaseSchema`. These shared properties are available on every component. ```json { "type": "div", "id": "my-component", "name": "wrapper", "label": "Wrapper", "description": "A container element", "className": "p-4 bg-white rounded-lg", "visible": true, "visibleOn": "${data.showWrapper}", "disabled": false, "disabledOn": "${data.isLocked}", "testId": "wrapper-element", "ariaLabel": "Content wrapper", "body": [] } ``` One row per declared member, in declaration order, so the list can be checked against `BaseSchema` by reading the two side by side. | Property | Type | Description | | ------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | `string` | **Required.** Component type identifier (e.g. `"page"`, `"form"`, `"table"`). | | `id` | `string` | Unique instance identifier. | | `name` | `string` | Component name, used for form fields and data binding. | | `label` | `string \| I18nLabel` | Human-readable display label. `I18nLabel` is the spec's **inline locale map** (`string \| Record`, keyed by BCP-47 locale tag such as `en` or `zh-CN`), resolved against the display locale by `resolveI18nLabel`. | | `description` | `string \| I18nLabel` | Help text or tooltip content. Same inline-locale-map vocabulary and resolver as `label`. | | `placeholder` | `string` | Hint text for input components. | | `className` | `string` | Tailwind CSS utility classes. | | `style` | `Record` | Inline CSS styles. Use sparingly — prefer `className`. | | `data` | `any` | Arbitrary data attached to the node. `any` because the shape is defined by the consuming component rather than by `BaseSchema`. | | `bind` | `string` | Data-scope path this node draws its rows or value from, resolved by `useDataScope()`. Honoured only by components that call it. | | `body` | `SchemaNode \| SchemaNode[]` | Child components rendered inside this component. | | `children` | `SchemaNode \| SchemaNode[]` | Alias for `body`. | | `visible` | `boolean \| string \| { dialect?: string; source: string }` | Visibility control. Accepts a boolean, a predicate expression string, **or** the CEL envelope object (`{ dialect: 'cel', source }` — what `objectstack build` emits for every authored predicate) — the renderer evaluates this key rather than reading it as a boolean. The string-or-envelope half is `ExpressionWire`, the one wire type `visibleWhen` on form fields already carries. | | `visibleWhen` | `string` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Evaluated **before** `visible` and `visibleOn`, and outranks both. | | `visibleOn` | `string` | Expression for conditional visibility. **Deprecated** (ADR-0089) — use `visibleWhen`. | | `hidden` | `boolean \| string \| { dialect?: string; source: string }` | Inverse of `visible` — the node is not rendered. Accepts a boolean, a predicate expression string **or** the CEL envelope object (`ExpressionWire`), which the renderer evaluates rather than reading as a boolean; `hiddenOn` remains the sibling spelling. | | `hiddenOn` | `string` | Expression for conditional hiding. | | `disabled` | `boolean \| string \| { dialect?: string; source: string }` | Disabled state. Accepts a boolean, a predicate expression string **or** the CEL envelope object (`ExpressionWire`), on the same evaluated path as `visible`. | | `disabledOn` | `string` | Expression for conditional disabling. | | `testId` | `string` | Test identifier, rendered as `data-testid`. | | `ariaLabel` | `string \| KeyedI18nLabel` | Accessibility label, rendered as `aria-label`. `KeyedI18nLabel` is the **keyed** form (`{ key, defaultValue?, params? }`), resolved by `resolveKeyedI18nLabel` — **not** the `I18nLabel` that `label` and `description` carry. The two are structurally confusable and each returns nothing useful for the other's input. | Two things the table cannot show in a cell: * **A concrete schema may narrow an inherited member, and its own declaration wins.** Many component schemas restate `label`, `description` or `disabled` more narrowly than `BaseSchema` declares them, so the unions above are what a node gets when its own schema does not restate the key. Check the component's own property table before writing a predicate string or a locale map into an inherited slot. * **This list is exhaustive for *declared* members, not for *accepted* keys.** `BaseSchema` carries an index signature (`[key: string]: any`) and its Zod mirror is `.passthrough()`, so an undeclared key — a misspelling included — is still accepted by both halves. Absence from this table does not mean a key is rejected. *** ## Layout Schemas [#layout-schemas] ### PageNodeSchema [#pagenodeschema] Top-level page container. Defines a full page with optional regions (header, sidebar, footer). ```json { "type": "page", "title": "User Dashboard", "icon": "LayoutDashboard", "description": "Overview of user activity", "pageType": "detail", "object": "User", "variables": [ { "name": "userId", "type": "string", "defaultValue": "current" } ], "regions": [ { "name": "header", "body": [{ "type": "text", "body": "Welcome back" }] } ], "body": [ { "type": "card", "title": "Activity", "body": [] } ] } ``` | Property | Type | Description | | ------------------ | ---------------- | ------------------------------------------------------------------------------------ | | `title` | `string` | Page title displayed in the header. | | `icon` | `string` | Lucide icon name for the page. | | `pageType` | `PageType` | Page purpose: `"list"`, `"detail"`, `"form"`, `"dashboard"`, `"report"`, `"custom"`. | | `object` | `string` | ObjectQL object name this page operates on. | | `template` | `string` | Template name for page layout. | | `variables` | `PageVariable[]` | Page-level variables with types and defaults. | | `regions` | `PageRegion[]` | Named layout regions (header, sidebar, footer). | | `body` | `SchemaNode[]` | Main page content. | | `isDefault` | `boolean` | Whether this is the default page for the object. | | `assignedProfiles` | `string[]` | Security profiles that can access this page. | **Related:** [AppSchema](/docs/core/app-schema), [DivSchema](#divschema), [GridSchema](#gridschema) *** ### DivSchema [#divschema] A generic container element. The simplest layout primitive. ```json { "type": "div", "className": "flex items-center gap-4 p-6", "children": [ { "type": "text", "body": "Hello World" }, { "type": "button", "label": "Click me" } ] } ``` | Property | Type | Description | | ---------- | ---------------------------- | ---------------------------------------- | | `children` | `SchemaNode \| SchemaNode[]` | Child elements to render inside the div. | **Related:** [GridSchema](#gridschema), [CardSchema](#cardschema) *** ### CardSchema [#cardschema] A styled container with optional header, body, and footer regions. ```json { "type": "card", "title": "Revenue Summary", "description": "Monthly revenue breakdown", "variant": "outline", "hoverable": true, "header": [ { "type": "badge", "label": "Live", "variant": "success" } ], "body": [ { "type": "statistic", "label": "Total Revenue", "value": "$12,400" } ], "footer": [ { "type": "button", "label": "View Details", "variant": "ghost" } ] } ``` | Property | Type | Description | | ------------- | ----------------------------------- | ------------------------------------ | | `title` | `string` | Card title text. | | `description` | `string` | Subtitle / description text. | | `variant` | `"default" \| "outline" \| "ghost"` | Visual style variant. | | `hoverable` | `boolean` | Add hover elevation effect. | | `clickable` | `boolean` | Make the entire card a click target. | | `header` | `SchemaNode \| SchemaNode[]` | Content rendered in the card header. | | `body` | `SchemaNode \| SchemaNode[]` | Main card content. | | `footer` | `SchemaNode \| SchemaNode[]` | Content rendered in the card footer. | **Related:** [DivSchema](#divschema), [GridSchema](#gridschema) *** ### GridSchema [#gridschema] A responsive grid layout. Columns can be a fixed number or responsive breakpoints. ```json { "type": "grid", "columns": { "sm": 1, "md": 2, "lg": 3 }, "gap": 6, "children": [ { "type": "card", "title": "Card 1", "body": [] }, { "type": "card", "title": "Card 2", "body": [] }, { "type": "card", "title": "Card 3", "body": [] } ] } ``` | Property | Type | Description | | ---------- | ---------------------------------- | ---------------------------------------------------------------------- | | `columns` | `number \| Record` | Number of columns, or responsive map (e.g. `{ sm: 1, md: 2, lg: 3 }`). | | `gap` | `number` | Gap between grid items (Tailwind spacing scale). | | `children` | `SchemaNode \| SchemaNode[]` | Grid items. | **Related:** [DivSchema](#divschema), [CardSchema](#cardschema), [DashboardComponentSchema](#dashboardcomponentschema) *** ### TabsSchema [#tabsschema] A tabbed interface for organizing content into switchable panels. ```json { "type": "tabs", "defaultValue": "overview", "orientation": "horizontal", "items": [ { "value": "overview", "label": "Overview", "icon": "Info", "content": { "type": "div", "body": [{ "type": "text", "body": "Overview content" }] } }, { "value": "settings", "label": "Settings", "icon": "Settings", "content": { "type": "form", "fields": [] } } ] } ``` | Property | Type | Description | | -------------- | ---------------------------- | ---------------------------------------------------------------------------------------- | | `defaultValue` | `string` | Initially active tab value. | | `value` | `string` | Controlled active tab value. | | `orientation` | `"horizontal" \| "vertical"` | Tab bar orientation. | | `items` | `TabItem[]` | Tab definitions, each with `value`, `label`, `icon`, `content`, and optional `disabled`. | **Related:** [CardSchema](#cardschema), [PageNodeSchema](#pagenodeschema) *** ## Form Schemas [#form-schemas] ### FormSchema [#formschema] A complete form with fields, validation, layout, and actions. ```json { "type": "form", "layout": "horizontal", "columns": 2, "validationMode": "onBlur", "submitLabel": "Save Changes", "showCancel": true, "cancelLabel": "Discard", "defaultValues": { "name": "", "email": "", "role": "viewer" }, "fields": [ { "name": "name", "label": "Full Name", "type": "text", "required": true }, { "name": "email", "label": "Email", "type": "email", "required": true }, { "name": "role", "label": "Role", "type": "select", "options": [ { "label": "Admin", "value": "admin" }, { "label": "Editor", "value": "editor" }, { "label": "Viewer", "value": "viewer" } ]} ] } ``` | Property | Type | Description | | ---------------- | -------------------------------------------------------------- | ------------------------------------------ | | `fields` | `FormField[]` | Field definitions for the form. | | `defaultValues` | `Record` | Initial form values. | | `layout` | `"vertical" \| "horizontal"` | Field label placement. | | `columns` | `number` | Number of columns for field layout. | | `validationMode` | `"onSubmit" \| "onBlur" \| "onChange" \| "onTouched" \| "all"` | When validation triggers. | | `submitLabel` | `string` | Text for the submit button. | | `cancelLabel` | `string` | Text for the cancel button. | | `showCancel` | `boolean` | Whether to show a cancel button. | | `showActions` | `boolean` | Whether to show the action buttons row. | | `resetOnSubmit` | `boolean` | Reset form after successful submit. | | `mode` | `"edit" \| "read" \| "disabled"` | Form interaction mode. | | `actions` | `SchemaNode[]` | Custom action buttons to replace defaults. | **Related:** [InputSchema](#inputschema), [SelectSchema](#selectschema), [ObjectFormSchema](#objectformschema) *** ### InputSchema [#inputschema] A text input field supporting multiple input types with validation. ```json { "type": "input", "name": "email", "label": "Email Address", "inputType": "email", "placeholder": "you@example.com", "required": true, "description": "We'll never share your email", "maxLength": 255 } ``` | Property | Type | Description | | ------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `name` | `string` | Field name for form data binding. | | `inputType` | `string` | HTML input type: `"text"`, `"email"`, `"password"`, `"number"`, `"tel"`, `"url"`, `"search"`, `"date"`, `"time"`, `"datetime-local"`. | | `placeholder` | `string` | Placeholder text. | | `required` | `boolean` | Whether the field is required. | | `readOnly` | `boolean` | Render as read-only. | | `error` | `string` | Error message to display. | | `min` / `max` | `number` | Numeric range constraints. | | `step` | `number` | Step increment for number inputs. | | `maxLength` | `number` | Maximum character length. | | `pattern` | `string` | Regex pattern for validation. | **Related:** [FormSchema](#formschema), [SelectSchema](#selectschema) *** ### SelectSchema [#selectschema] A dropdown select field with predefined options. ```json { "type": "select", "name": "priority", "label": "Priority", "placeholder": "Choose priority...", "required": true, "options": [ { "label": "🔴 Critical", "value": "critical" }, { "label": "🟠 High", "value": "high" }, { "label": "🟡 Medium", "value": "medium" }, { "label": "🟢 Low", "value": "low" } ], "defaultValue": "medium" } ``` | Property | Type | Description | | -------------- | ---------------- | ---------------------------------------- | | `name` | `string` | Field name for form data binding. | | `options` | `SelectOption[]` | Array of `{ label, value }` objects. | | `placeholder` | `string` | Placeholder text when no value selected. | | `required` | `boolean` | Whether selection is required. | | `defaultValue` | `string` | Initial selected value. | | `error` | `string` | Error message to display. | **Related:** [FormSchema](#formschema), [InputSchema](#inputschema) *** ### ButtonSchema [#buttonschema] An interactive button with variants, icons, and loading states. ```json { "type": "button", "label": "Deploy to Production", "variant": "default", "size": "lg", "icon": "Rocket", "iconPosition": "left", "loading": false, "buttonType": "submit" } ``` | Property | Type | Description | | -------------- | ----------------------------------------------------------------------------- | --------------------------------------------- | | `label` | `string` | Button text. | | `variant` | `"default" \| "secondary" \| "destructive" \| "outline" \| "ghost" \| "link"` | Visual style. | | `size` | `"default" \| "sm" \| "lg" \| "icon"` | Button size. | | `icon` | `string` | Lucide icon name. | | `iconPosition` | `"left" \| "right"` | Icon placement relative to label. | | `loading` | `boolean` | Show loading spinner and disable interaction. | | `buttonType` | `"button" \| "submit" \| "reset"` | HTML button type. | **Related:** [ActionSchema](#actionschema), [FormSchema](#formschema) *** ## Data Display Schemas [#data-display-schemas] ### TableSchema [#tableschema] A simple static table: it renders inline `data` against `columns`, nothing more. For row hover highlighting, striping, sorting, filtering, selection or inline editing use the interactive `data-table` — the static `table` deliberately does not implement those (objectui#5474 retired the keys that once suggested it did; authoring them is now refused by validation instead of silently ignored). ```json { "type": "table", "caption": "Recent Orders", "columns": [ { "accessorKey": "id", "header": "#", "width": 60 }, { "accessorKey": "customer", "header": "Customer" }, { "accessorKey": "amount", "header": "Amount", "cellClassName": "text-right" }, { "accessorKey": "status", "header": "Status" } ], "data": [ { "id": 1, "customer": "Acme Corp", "amount": "$1,200", "status": "Paid" }, { "id": 2, "customer": "Globex Inc", "amount": "$3,400", "status": "Pending" } ], "footer": { "type": "text", "body": "Showing 2 of 156 orders" } } ``` | Property | Type | Description | | --------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `caption` | `string` | Table caption / title text. | | `columns` | `StaticTableColumn[]` | Column definitions — the static subset: `accessorKey` (the row key to read) and `header` (heading text) are required; `width`, `className`, and `cellClassName` are the only other keys this renderer honours. Interactive column keys (`align`, `sortable`, `filterable`, `resizable`, `editable`, `cell`, `fixed`, `minWidth`, `type`) belong to `data-table`'s rich `TableColumn` and are refused here. | | `data` | `any[]` | Array of row data objects. | | `footer` | `SchemaNode \| string` | Footer content below the table. | **Related:** [ObjectGridSchema](#objectgridschema) *** ### ChartSchema [#chartschema] A chart visualization supporting multiple chart types. ```json { "type": "chart", "chartType": "bar", "title": "Monthly Revenue", "description": "Revenue by month for 2024", "height": 350, "showLegend": true, "showGrid": true, "animate": true, "xAxisKey": "month", "data": [ { "month": "Jan", "Revenue": 4200, "Expenses": 3100 }, { "month": "Feb", "Revenue": 5100, "Expenses": 3400 }, { "month": "Mar", "Revenue": 4800, "Expenses": 3200 }, { "month": "Apr", "Revenue": 6200, "Expenses": 3800 }, { "month": "May", "Revenue": 5800, "Expenses": 3600 }, { "month": "Jun", "Revenue": 7100, "Expenses": 4000 } ], "series": [ { "name": "Revenue", "color": "#3b82f6" }, { "name": "Expenses", "color": "#ef4444" } ] } ``` The rows live on the chart node's own `data`, one object per row keyed by column name. Each series' `name` (or `dataKey`) selects the column it plots within those rows, and `xAxisKey` names the column on the category axis. A series carries no numbers of its own: `ChartDataSeries.data` is a retirement tombstone (objectui#6896) and an authored array is refused by name at parse. | Property | Type | Description | | ------------------ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `chartType` | `ChartType` | **Required.** `"bar"`, `"line"`, `"area"`, `"pie"`, `"donut"`, `"radar"`, `"scatter"`, `"heatmap"`. | | `title` | `string` | Chart title. | | `description` | `string` | Chart description / subtitle. | | `categories` | `string[]` | An **alternative series list** — column names to plot, read only when `series` is absent, and ignored outright when it is present. Not axis labels: the category axis comes from `xAxisKey`. | | `series` | `ChartDataSeries[]` | Data series. Each entry's `name` (or `dataKey`) names the column it plots within a `data` row; optional `label`, `color`, a per-series `type` (`"bar"`, `"line"`, `"area"`) for combo charts, `stack`, `yAxis` (`"left"` / `"right"`), `variant` (`"primary"` / `"comparison"`), `dashArray` and `opacity`. `chartType` on a series is refused by name — it is the renderer's internal spelling of `type`; write `type`. | | `data` | `Array>` | Rows to plot — one object per row, keyed by column name. | | `xAxisKey` | `string` | Row key holding the category (x) axis. The bare-string `xAxis: "month"` spelling folds onto this key at parse. | | `height` / `width` | `string \| number` | Chart dimensions. | | `showLegend` | `boolean` | Display the legend. | | `showGrid` | `boolean` | Display grid lines. | | `animate` | `boolean` | Enable entry animations. | | `config` | `Record` | Additional chart library configuration. | **Related:** [DashboardComponentSchema](#dashboardcomponentschema), [CardSchema](#cardschema) *** ### TreeViewSchema [#treeviewschema] A hierarchical tree component for nested data with expand/collapse and selection. ```json { "type": "tree-view", "multiSelect": false, "showLines": true, "defaultExpandedIds": ["root", "src"], "nodes": [ { "id": "root", "label": "project", "icon": "Folder", "children": [ { "id": "src", "label": "src", "icon": "Folder", "children": [ { "id": "app", "label": "App.tsx", "icon": "FileCode" }, { "id": "index", "label": "index.ts", "icon": "FileCode" } ] }, { "id": "readme", "label": "README.md", "icon": "FileText" } ] } ] } ``` | Property | Type | Description | | -------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `nodes` | `TreeNode[]` | Optional. Nested tree data — the spelling the renderer reads FIRST, and the one the component's own `inputs` and `defaultProps` use. Each node has `id`, `label`, optional `icon` and `children`. | | `data` | `TreeNode[]` | Optional. Nested tree data, read only when `nodes` is absent (the renderer reads `nodes` first — objectui#6939). | | `defaultExpandedIds` | `string[]` | Node IDs expanded on initial render. | | `defaultSelectedIds` | `string[]` | Node IDs selected on initial render. | | `expandedIds` | `string[]` | Controlled expanded state. | | `selectedIds` | `string[]` | Controlled selection state. | | `multiSelect` | `boolean` | Allow selecting multiple nodes. | | `showLines` | `boolean` | Show tree connector lines. | **Related:** [TableSchema](#tableschema) *** ## CRUD Schemas [#crud-schemas] ### CRUDSchema — retired [#crudschema--retired] `CRUDSchema` and the `crud` node type were **removed** in objectui#5373 under ADR-0049 (enforce-or-remove). The type had four declaration faces — a TypeScript interface, a zod mirror, a branch in the schema validator and a `CRUDBuilder` — and no registered renderer, for the whole life of the key. A node that spelled it painted the OBJUI-001 "Unknown component type" panel, so this page was teaching a shape that could not render. There is no drop-in replacement, because a CRUD screen is a composition rather than one node. Build it from the shapes that do render: | What `CRUDSchema` promised | What to author instead | | ------------------------------------------------------------------------- | --------------------------------------------------------------------- | | The record table, with toolbar, filters, pagination and row/batch actions | [ObjectGridSchema](#objectgridschema) | | The create/edit form | [ObjectFormSchema](#objectformschema) | | The single-record read view | [DetailSchema](#detailschema) / [DetailViewSchema](#detailviewschema) | | Whole-object screens that bundle the above | [ObjectViewSchema](#objectviewschema) | The `defaultSort` and `defaultSortOrder` keys documented here were `CRUDSchema`'s own — a flat field name plus a separate direction. They are gone with it. [ObjectGridSchema](#objectgridschema) declares its own, differently shaped `defaultSort` (an object with `field` and `order`); that key is unaffected. Authoring `crud` is now refused by name: `validateSchema` from `@object-ui/core` returns a `RETIRED_TYPE` error on `schema.type` naming the migration above, and `objectui check` reports the type as unknown. *** ### ActionSchema [#actionschema] A powerful action definition supporting API calls, confirmations, dialogs, chaining, and conditional execution. ```json { "type": "action", "label": "Submit Order", "level": "primary", "icon": "Send", "actionType": "ajax", "api": "/api/orders", "method": "POST", "data": { "status": "submitted" }, "confirmText": "This will send the order to the warehouse.", "successMessage": "Order submitted successfully", "errorMessage": "Failed to submit order", "chain": [ { "type": "action", "label": "Refresh", "actionType": "button", "reload": true } ], "chainMode": "sequential", "condition": "${data.items.length > 0}", "retry": { "maxAttempts": 3, "delay": 1000 } } ``` | Property | Type | Description | | --------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `label` | `string` | **Required.** Action display text. | | `level` | `string` | Semantic level: `"primary"`, `"secondary"`, `"success"`, `"warning"`, `"danger"`, `"info"`. | | `icon` | `string` | Lucide icon name. | | `actionType` | `string` | Action kind: `"button"`, `"link"`, `"dropdown"`, `"ajax"`, `"confirm"`, `"dialog"`. | | `api` | `string` | API endpoint for `ajax` actions. | | `method` | `string` | HTTP method: `"GET"`, `"POST"`, `"PUT"`, `"DELETE"`, `"PATCH"`. | | `data` | `any` | Request body data. | | `confirmText` | `string` | Confirmation message shown before executing — the one confirm spelling, addressed by the translation bundle. (A structured `confirm` object was retired in objectui#4314.) | | `dialog` | `object` | Modal dialog with `title`, `content`, `size`, `actions`. | | `chain` | `ActionSchema[]` | Actions to execute after this action completes. | | `chainMode` | `"sequential" \| "parallel"` | How chained actions execute. | | `condition` | `boolean \| string \| { dialect?, source }` | Execution gate — the action runs only while this predicate holds (boolean, bare CEL, `${...}` template, or the normalized envelope). Declared and false skips the action; absent executes it. It is a **gate, not a branch**: express a branch as separate actions with mutually exclusive `condition`s. (The `{ expression, then, else }` branch shape was retired in objectui#3917 — nothing read it, so it ran unconditionally.) | | `successMessage` / `errorMessage` | `string` | Toast messages on success/failure. | | `reload` | `boolean` | Reload data after action completes. | | `redirect` | `string` | URL to navigate to after action. | | `retry` | `object` | Retry config with `maxAttempts` and `delay`. | **Related:** [DetailSchema](#detailschema), [ButtonSchema](#buttonschema) *** ### DetailSchema [#detailschema] A single-record detail view with grouped fields, actions, and tabs. ```json { "type": "detail", "title": "Order #1042", "api": "/api/orders/1042", "showBack": true, "groups": [ { "title": "Customer Info", "fields": [ { "name": "customer", "label": "Customer", "type": "text" }, { "name": "email", "label": "Email", "type": "email" }, { "name": "created", "label": "Created", "type": "date", "format": "MMM d, yyyy" } ] }, { "title": "Order Details", "fields": [ { "name": "total", "label": "Total", "type": "text" }, { "name": "status", "label": "Status", "type": "badge" } ] } ], "actions": [ { "type": "action", "label": "Edit", "icon": "Pencil", "level": "primary" }, { "type": "action", "label": "Delete", "icon": "Trash2", "level": "danger", "actionType": "confirm" } ], "tabs": [ { "key": "items", "label": "Line Items", "content": { "type": "table", "columns": [], "data": [] } }, { "key": "history", "label": "History", "content": { "type": "timeline", "events": [] } } ] } ``` | Property | Type | Description | | ------------ | ------------------ | ------------------------------------------------------------- | | `title` | `string` | Detail page title. | | `api` | `string` | API endpoint to fetch record data. | | `resourceId` | `string \| number` | ID of the record to display. | | `groups` | `array` | Field groups, each with `title`, `description`, and `fields`. | | `actions` | `ActionSchema[]` | Available actions (edit, delete, etc.). | | `tabs` | `array` | Additional tabbed content with `key`, `label`, and `content`. | | `showBack` | `boolean` | Show a back navigation button. | | `loading` | `boolean` | Show loading state. | **Related:** [DetailViewSchema](#detailviewschema), [ObjectGridSchema](#objectgridschema) *** ## ObjectQL Schemas [#objectql-schemas] These schemas integrate with [ObjectStack](https://objectstack.ai) for automatic data fetching, but work with any backend through the `data` prop. ### ObjectGridSchema [#objectgridschema] A data grid that auto-fetches from an ObjectQL object definition. Includes search, filters, pagination, grouping, and inline editing. ```json { "type": "object-grid", "objectName": "Contact", "title": "All Contacts", "description": "Manage your contacts", "showSearch": true, "showFilters": true, "showPagination": true, "pageSize": 25, "resizableColumns": true, "striped": true, "columns": [ { "field": "name" }, { "field": "email" }, { "field": "company" }, { "field": "phone" }, { "field": "status", "label": "Status", "sortable": true } ], "defaultSort": { "field": "name", "order": "asc" }, "operations": { "create": true, "read": true, "update": true, "delete": true, "export": true }, "rowActions": ["edit", "delete"], "selection": { "enabled": true, "mode": "multiple" }, "pagination": { "enabled": true, "pageSize": 25, "pageSizeOptions": [10, 25, 50, 100] } } ``` | Property | Type | Description | | ---------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `objectName` | `string` | **Required.** ObjectQL object API name. | | `columns` | `string[] \| ListColumn[]` | Columns to display. Either a plain array of field names (`["name", "email"]`), which auto-resolve from object metadata, or an array of `ListColumn` objects whose identity key is `field` (`{ "field": "status", "label": "Status" }`) — never `name`. **Do not mix the two forms in one array:** the array is dispatched on its first entry, so column objects sitting behind a bare string are dropped. | | `filter` | `any[]` | Pre-applied filter conditions. | | `sort` | `string \| SortConfig[]` | Default sort configuration. | | `searchableFields` | `string[]` | Fields included in search. | | `selection` | `SelectionConfig` | Row selection configuration. | | `pagination` | `PaginationConfig` | Pagination settings. | | `operations` | `object` | Enabled CRUD operations. | | `rowActions` / `bulkActions` | `string[]` | Action identifiers for rows and batch selection. `bulkActions` is the spec-aligned key; `batchActions` is a legacy alias that takes precedence when both are set. | | `editable` | `boolean` | Enable inline cell editing. | | `grouping` | `GroupingConfig` | Row grouping configuration. **Page-scoped**: the grid groups the rows it has fetched, so group counts are page slices and a group beyond the page is absent — the grid marks the grouping partial when it can tell. | | `frozenColumns` | `number` | Number of columns frozen on scroll. | | `navigation` | `ViewNavigationConfig` | SPA navigation configuration. | **Related:** [ObjectViewSchema](#objectviewschema), [TableSchema](#tableschema) *** ### ObjectFormSchema [#objectformschema] A smart form that auto-generates fields from an ObjectQL object. Supports simple, tabbed, wizard, split, drawer, and modal layouts. ```json { "type": "object-form", "objectName": "Contact", "mode": "create", "formType": "tabbed", "title": "New Contact", "layout": "vertical", "columns": 2, "fields": ["firstName", "lastName", "email", "phone", "company"], "sections": [ { "label": "Basic Info", "fields": ["firstName", "lastName", "email"] }, { "label": "Details", "fields": ["phone", "company", "address"] } ], "showSubmit": true, "submitText": "Create Contact", "showCancel": true, "cancelText": "Cancel" } ``` | Property | Type | Description | | ----------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `objectName` | `string` | **Required.** ObjectQL object API name. | | `mode` | `"create" \| "edit" \| "view"` | **Required.** Form interaction mode. | | `formType` | `string` | Layout type: `"simple"`, `"tabbed"`, `"wizard"`, `"split"`, `"drawer"`, `"modal"`. Aligned with `@objectstack/spec` `FormViewSchema.type`. | | `recordId` | `string \| number` | Record ID for edit/view modes. | | `fields` | `string[]` | Field API names to include (auto-resolved from object metadata). | | `customFields` | `FormField[]` | Manually defined fields that override auto-generated ones. | | `sections` | `ObjectFormSection[]` | Field sections (simple groups, tabs, or wizard steps depending on `formType`). Spec-aligned key. | | `groups` | `array` | **Deprecated.** Legacy alias of `sections` (spec defines `groups` as an alias); normalized into `sections` when `sections` is absent. Legacy shape: `title`→`label`, `defaultCollapsed`→`collapsed`. | | `layout` | `string` | Label layout: `"vertical"`, `"horizontal"`, `"inline"`, `"grid"`. | | `columns` | `number` | Number of form columns. | | `submitText` / `cancelText` | `string` | Button labels. | | `showSubmit` / `showCancel` / `showReset` | `boolean` | Toggle action buttons. | | `drawerSide` | `string` | Drawer position: `"top"`, `"bottom"`, `"left"`, `"right"`. | | `modalSize` | `string` | Modal size: `"sm"`, `"default"`, `"lg"`, `"xl"`, `"full"`. | #### Spec alignment & extension keys [#spec-alignment--extension-keys] `ObjectFormSchema` keys fall into three classes (#2545): * **Spec-aligned** — same name and semantics as `@objectstack/spec` `FormViewSchema`: `title`, `description`, `layout`, `columns`, `sections`, `defaultTab`, `tabPosition`, `allowSkip`, `showStepIndicator`, `splitDirection`/`splitSize`/`splitResizable`, `drawerSide`/`drawerWidth`, `modalSize`, `subforms`, `submitBehavior` (plus `formType` ↔ spec `type`). * **ObjectUI extensions** — serializable extras with no spec backing yet: `showSubmit`/`submitText`, `showCancel`/`cancelText`, `showReset`, `nextText`/`prevText`, `successMessage`, `navigateOnSuccess`, `resetOnSuccess`, `modalCloseButton`, `className`, `initialValues`, `fields`, `customFields`. Sanctioned and documented here; candidates for upstreaming into the spec are tracked in #2545. * **Runtime-only** — non-serializable renderer concerns that never appear in view metadata: `mode`, `recordId`, `open`/`onOpenChange`, `readOnly`, and all callbacks (`onSuccess`, `onError`, `onCancel`, `onStepChange`, `submitHandler`). **Related:** [FormSchema](#formschema), [ObjectViewSchema](#objectviewschema) *** ### ObjectViewSchema [#objectviewschema] A complete object management interface combining grid, form, search, filters, and view switching. ```json { "type": "object-view", "objectName": "Deal", "title": "Sales Pipeline", "description": "Manage your deals", "defaultViewType": "kanban", "showSearch": true, "showFilters": true, "showCreate": true, "showViewSwitcher": true, "operations": { "create": true, "read": true, "update": true, "delete": true }, "searchableFields": ["name", "company", "owner"], "filterableFields": ["stage", "owner", "value"], "listViews": { "all": { "label": "All Deals", "filter": [], "sort": [{ "field": "value", "order": "desc" }] }, "my-deals": { "label": "My Deals", "filter": [["owner", "=", "${currentUser.id}"]], "default": true } }, "table": { "columns": ["name", "stage", "value", "owner", "closeDate"], "pageSize": 25 }, "form": { "formType": "drawer", "drawerSide": "right", "fields": ["name", "stage", "value", "owner", "closeDate", "notes"] } } ``` | Property | Type | Description | | ------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------ | | `objectName` | `string` | **Required.** ObjectQL object API name. | | `title` | `string` | View title. | | `defaultViewType` | `string` | Initial view: `"grid"`, `"kanban"`, `"gallery"`, `"calendar"`, `"timeline"`, `"gantt"`, `"map"`. | | `listViews` | `Record` | Named list views with filters and sort. | | `defaultListView` | `string` | Key of the default list view. | | `table` | `Partial` | Grid configuration overrides. | | `form` | `Partial` | Form configuration overrides. | | `showSearch` / `showFilters` / `showCreate` | `boolean` | Toggle toolbar features. | | `showViewSwitcher` | `boolean` | Show view type toggle (grid, kanban, etc.). | | `operations` | `object` | Enabled CRUD operations. | | `navigation` | `ViewNavigationConfig` | SPA-aware navigation. | **Related:** [ObjectGridSchema](#objectgridschema), [ObjectFormSchema](#objectformschema), [ViewSwitcherSchema](#viewswitcherschema) *** ## Complex Schemas [#complex-schemas] ### KanbanSchema [#kanbanschema] A drag-and-drop Kanban board. The `kanban` type key validates the shape the registered renderer (`@object-ui/plugin-kanban`) reads: bind the board to an object with `objectName` + `groupBy` (the lanes come from the group field's options), or author it statically with `columns`, each carrying its `cards`. ```json { "type": "kanban", "objectName": "tasks", "groupBy": "status", "cardTitle": "title", "cardFields": ["assignee", "due_date"], "quickAdd": true } ``` A static board carries its cards inline: ```json { "type": "kanban", "columns": [ { "id": "todo", "title": "To Do", "cards": [ { "id": "task-1", "title": "Design mockups", "description": "Create wireframes for new feature", "badges": [{ "label": "High", "variant": "destructive" }] }, { "id": "task-2", "title": "Write tests", "description": "Unit tests for auth module" } ] }, { "id": "in-progress", "title": "In Progress", "limit": 3, "cards": [ { "id": "task-3", "title": "API integration", "description": "Connect to payment gateway" } ] }, { "id": "done", "title": "Done", "cards": [] } ] } ``` | Property | Type | Description | | ----------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `objectName` | `string` | Object to fetch records from. | | `groupBy` | `string` | Field whose values become the lanes (maps to column ids). | | `swimlaneField` | `string` | Field for swimlane rows (2D grouping). | | `cardTitle` | `string` | Field used as the card title. | | `cardFields` | `string[]` | Fields rendered on each card. | | `data` | `any[]` | Inline records, bucketed into lanes by `groupBy`. | | `limit` | `number` | Fetch window for the board (default 100). | | `columns` | `KanbanColumn[]` | Lanes, each with `id`, `title`, `cards`, and optional `limit` / `className` / `collapsed`. A card has `id`, `title`, optional `description` and `badges`. | | `quickAdd` | `boolean` | Show a Quick Add button at the bottom of each column. | | `coverImageField` | `string` | Field whose URL renders as the card cover image. | | `allowCollapse` | `boolean` | Allow columns to be collapsed. | | `conditionalFormatting` | `KanbanConditionalFormattingRule[]` | Card colouring rules — native `{ field, operator, value }` or spec `{ condition, style }`. | | `cardTemplates` | `CardTemplate[]` | Predefined quick-add templates. | | `columnWidths` | `ColumnWidthConfig` | Column width configuration. | | `grouping` | `GroupingConfig` | ListView grouping config; its first field is the swimlane fallback. | | `onCardMove` | `function` | Runtime slot supplied by a React host, `(cardId, fromColumnId, toColumnId, newIndex)`; not authorable in JSON. | | `onCardClick` | `function` | Runtime slot supplied by a React host, `(card, event?)`; not authorable in JSON. On the object-bound board the host's handler runs alongside the record-detail overlay. | | `onQuickAdd` | `function` | Runtime slot supplied by a React host, `(columnId, title)`; not authorable in JSON. | > The former `@object-ui/types` kanban dialect — `DeclarativeKanbanSchema`, with a board-level `draggable`, a column `color` and card `labels` / `priority` — was retired in objectui#7664: no registered renderer read it, so a board written that way validated and rendered empty. `draggable` and a column `color` are now refused by name; a static board written with `columns[].cards[]` as above is the same document in both dialects and renders every card. **Related:** [ObjectViewSchema](#objectviewschema), [ObjectGridSchema](#objectgridschema) *** ### DashboardComponentSchema [#dashboardcomponentschema] A widget-based dashboard with configurable grid layout and auto-refresh. ```json { "type": "dashboard", "columns": 4, "gap": 6, "refreshInterval": 30000, "widgets": [ { "id": "revenue", "title": "Total Revenue", "description": "Monthly revenue", "colSpan": 1, "rowSpan": 1, "body": { "type": "statistic", "label": "Revenue", "value": "$48,200", "trend": { "value": 12, "direction": "up" } } }, { "id": "chart", "title": "Sales Trend", "colSpan": 2, "rowSpan": 1, "body": { "type": "chart", "chartType": "area", "xAxisKey": "day", "data": [ { "day": "Mon", "Sales": 120 }, { "day": "Tue", "Sales": 180 }, { "day": "Wed", "Sales": 150 }, { "day": "Thu", "Sales": 210 }, { "day": "Fri", "Sales": 190 } ], "series": [{ "name": "Sales" }] } }, { "id": "tasks", "title": "Recent Tasks", "colSpan": 1, "rowSpan": 1, "body": { "type": "list", "items": [] } } ] } ``` | Property | Type | Description | | ----------------- | ------------------------- | -------------------------------------------------------------------------------------- | | `columns` | `number` | Number of grid columns. | | `gap` | `number` | Gap between widgets (Tailwind spacing scale). | | `widgets` | `DashboardWidgetSchema[]` | **Required.** Widget definitions with `id`, `title`, `colSpan`, `rowSpan`, and `body`. | | `refreshInterval` | `number` | Auto-refresh interval in milliseconds. | Each widget supports `colSpan` and `rowSpan` to control its size in the grid. The `body` can be any `SchemaNode`. **Related:** [GridSchema](#gridschema), [ChartSchema](#chartschema), [CardSchema](#cardschema) *** ### CalendarViewSchema [#calendarviewschema] A multi-view calendar computed from the node's `data` records. There is no authorable `events` key: the renderer builds one event per record in `data`, reading the fields the field-name properties point at (objectui#5667; an authored `events` is dropped by design, objectui#4433). ```json { "type": "calendar-view", "view": "month", "currentDate": "2024-03-15T12:00:00.000Z", "data": [ { "id": "evt-1", "title": "Team Standup", "start": "2024-03-15T09:00:00", "end": "2024-03-15T09:30:00", "color": "#3b82f6" }, { "id": "evt-2", "title": "Sprint Review", "start": "2024-03-15T14:00:00", "end": "2024-03-15T15:00:00", "color": "#8b5cf6", "allDay": false } ], "allowCreate": true, "className": "h-[600px] border rounded-lg" } ``` | Property | Type | Description | | ---------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `data` | `any` | Records rendered as events — an array, or a binding expression that resolves to one. | | `titleField` | `string` | Record field for the event title. Default `"title"`. | | `startDateField` | `string` | Record field for the event start date/time. Default `"start"`. | | `endDateField` | `string` | Record field for the event end date/time. Default `"end"`. | | `allDayField` | `string` | Record field for the all-day flag. Default `"allDay"`. | | `colorField` | `string` | Record field for the event color. Default `"color"`. | | `view` | `CalendarViewMode` | View mode: `"month"`, `"week"`, `"day"` — the full union. `"agenda"` was retired in objectui#5740 and now fails validation. Default `"month"`. | | `currentDate` | `string \| Date` | Initial calendar date — an ISO date string when authored as JSON. | | `allowCreate` | `boolean` | Show the "New event" affordance; clicking it dispatches a `create` action. Default `false`. | | `onEventClick` | `function` | Host-only: forwarded when a React host supplies a function; authored JSON cannot produce one. | | `onViewChange` | `function` | Host-only: same rule as `onEventClick`. | Nine formerly declared keys — `events` (was required, and dropped by the renderer), `defaultView`, `defaultDate`, `date`, `views`, `editable`, `onEventCreate`, `onEventUpdate`, `onDateChange` — were retired in objectui#5667: nothing read them on the authored-node path. **Related:** [ObjectViewSchema](#objectviewschema), [DashboardComponentSchema](#dashboardcomponentschema) *** ## View Schemas [#view-schemas] ### DetailViewSchema [#detailviewschema] An enhanced detail view for a single record with sections, tabs, related records, and navigation. ```json { "type": "detail-view", "title": "Contact Details", "objectName": "Contact", "resourceId": "contact-123", "layout": "grid", "columns": 2, "showBack": true, "backUrl": "/contacts", "showEdit": true, "editUrl": "/contacts/contact-123/edit", "showDelete": true, "deleteConfirmation": "Are you sure you want to delete this contact?", "sections": [ { "title": "Personal Information", "icon": "User", "columns": 2, "collapsible": true, "fields": [ { "name": "firstName", "label": "First Name", "type": "text" }, { "name": "lastName", "label": "Last Name", "type": "text" }, { "name": "email", "label": "Email", "type": "email" }, { "name": "avatar", "label": "Photo", "type": "image" } ] } ], "tabs": [ { "key": "activities", "label": "Activities", "icon": "Activity", "badge": 5, "content": { "type": "timeline", "events": [] } } ], "related": [ { "title": "Recent Orders", "type": "table", "api": "/api/contacts/contact-123/orders", "columns": [ { "name": "id", "label": "Order #" }, { "name": "total", "label": "Total" }, { "name": "status", "label": "Status" } ] } ], "actions": [ { "type": "action", "label": "Send Email", "icon": "Mail", "level": "primary" } ] } ``` | Property | Type | Description | | ----------------------------------- | -------------------------------------- | --------------------------------------------------------------- | | `title` | `string` | Detail page title. | | `objectName` | `string` | ObjectQL object name for data binding. | | `resourceId` | `string \| number` | Record ID to display. | | `api` | `string` | API endpoint to fetch record data. | | `data` | `any` | Static data (if not fetching from API). | | `layout` | `"vertical" \| "horizontal" \| "grid"` | Field layout mode. | | `columns` | `number` | Grid columns (for grid layout). | | `sections` | `DetailViewSection[]` | Field groups with `title`, `icon`, `fields`, `collapsible`. | | `fields` | `DetailViewField[]` | Direct fields (without sections). | | `tabs` | `DetailViewTab[]` | Tabbed content with `key`, `label`, `icon`, `badge`, `content`. | | `related` | `array` | Related record sections with `title`, `type`, `api`, `columns`. | | `actions` | `ActionSchema[]` | Available actions. | | `showBack` / `backUrl` | `boolean` / `string` | Back navigation. | | `showEdit` / `editUrl` | `boolean` / `string` | Edit navigation. | | `showDelete` / `deleteConfirmation` | `boolean` / `string` | Delete with confirmation message. | | `header` / `footer` | `SchemaNode` | Custom header/footer content. | **Related:** [DetailSchema](#detailschema), [ObjectViewSchema](#objectviewschema) *** ### ViewSwitcherSchema [#viewswitcherschema] A toggle control that switches between different view types (list, grid, kanban, calendar, etc.). ```json { "type": "view-switcher", "defaultView": "list", "variant": "tabs", "position": "top", "persistPreference": true, "storageKey": "contacts-view-pref", "views": [ { "type": "list", "label": "List View", "icon": "List", "schema": { "type": "object-grid", "objectName": "Contact", "columns": ["name", "email", "phone"] } }, { "type": "grid", "label": "Card View", "icon": "LayoutGrid", "schema": { "type": "grid", "columns": 3, "children": [] } }, { "type": "kanban", "label": "Kanban", "icon": "Kanban", "schema": { "type": "kanban", "columns": [] } } ] } ``` | Property | Type | Description | | ------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `views` | `array` | **Required.** Available views, each with `type`, `label`, `icon`, and `schema`. | | `defaultView` | `ViewType` | Initially active view: `"list"`, `"detail"`, `"grid"`, `"kanban"`, `"calendar"`, `"timeline"`, `"map"`. | | `activeView` | `ViewType` | Controlled active view. | | `variant` | `"tabs" \| "buttons" \| "dropdown"` | Switcher UI style. | | `position` | `"top" \| "bottom" \| "left" \| "right"` | Switcher position relative to content. | | `persistPreference` | `boolean` | Save the user's view preference to storage. | | `storageKey` | `string` | Storage key for persisting the preference. | | `onViewChange` | `string` | Expression or callback invoked on view change. | **Related:** [ObjectViewSchema](#objectviewschema), [KanbanSchema](#kanbanschema), [CalendarViewSchema](#calendarviewschema) *** ## Schema Composition [#schema-composition] Schemas are designed to compose. Nest any `SchemaNode` inside another to build complex interfaces: ```json { "type": "page", "title": "CRM Dashboard", "body": [ { "type": "grid", "columns": { "sm": 1, "lg": 2 }, "gap": 6, "children": [ { "type": "card", "title": "Quick Stats", "body": { "type": "dashboard", "columns": 2, "widgets": [ { "id": "w1", "title": "Leads", "body": { "type": "statistic", "value": "142" } }, { "id": "w2", "title": "Revenue", "body": { "type": "statistic", "value": "$24k" } } ] } }, { "type": "card", "title": "Recent Activity", "body": { "type": "tabs", "items": [ { "value": "deals", "label": "Deals", "content": { "type": "table", "columns": [], "data": [] } }, { "value": "tasks", "label": "Tasks", "content": { "type": "table", "columns": [], "data": [] } } ] } } ] }, { "type": "object-grid", "objectName": "Lead", "title": "All Leads", "showSearch": true, "columns": ["name", "company", "status", "value"] } ] } ``` ## Type Imports [#type-imports] Import only the types you need: ```typescript // Layout import type { PageNodeSchema, DivSchema, CardSchema, GridSchema, TabsSchema } from '@object-ui/types'; // Forms import type { FormSchema, InputSchema, SelectSchema, ButtonSchema } from '@object-ui/types'; // Data Display import type { TableSchema, ChartSchema, TreeViewSchema } from '@object-ui/types'; // CRUD import type { ActionSchema, DetailSchema } from '@object-ui/types'; // ObjectQL import type { ObjectGridSchema, ObjectFormSchema, ObjectViewSchema } from '@object-ui/types'; // Complex import type { KanbanSchema, DashboardComponentSchema, CalendarViewSchema } from '@object-ui/types'; // Views import type { DetailViewSchema, ViewSwitcherSchema } from '@object-ui/types'; // Base import type { BaseSchema, SchemaNode } from '@object-ui/types'; ``` ## Next Steps [#next-steps] * **[Schema Overview](/docs/guide/schema-overview)** — High-level guide to ObjectUI schemas * **[Quick Start](/docs/guide/quick-start)** — Build your first ObjectUI application * **[Expressions](/docs/guide/expressions)** — Dynamic expressions with `visibleOn`, `disabledOn` * **[Fields Guide](/docs/guide/fields)** — Deep dive into form fields * **[Plugin Development](/docs/guide/plugin-development)** — Build custom schema renderers # Authentication Blocks # Authentication Blocks [#authentication-blocks] Beautiful, accessible authentication forms and flows. Every block below is sourced from the canonical schema catalog (`@object-ui/example-schema-catalog`), which is smoke-tested on every CI run — if a schema breaks, the build fails. Copy the JSON from the **Code** tab and drop it into your application. ## Login Form [#login-form] A clean login form with email and password fields, "remember me", and a social-provider button. ## Sign Up Form [#sign-up-form] Two-column registration form with terms acceptance. ## Forgot Password [#forgot-password] Request a password reset link by email. ## Two-Factor Authentication [#two-factor-authentication] Six-digit code verification with a resend link. ## Customizing [#customizing] All authentication blocks are plain JSON — tweak any node to change the look or behaviour. Common customisations: ### Change Colors [#change-colors] ```json { "type": "button", "className": "bg-blue-600 hover:bg-blue-700" } ``` ### Wire Up Submission [#wire-up-submission] ```json { "type": "button", "label": "Sign In", "action": { "type": "submit", "endpoint": "/api/auth/login", "method": "POST" } } ``` ### Adjust Layout [#adjust-layout] ```json { "type": "card", "className": "max-w-lg p-8" } ``` ## Next Steps [#next-steps] * Browse [Dashboard Blocks](/docs/blocks/dashboard) * Explore [Form Blocks](/docs/blocks/forms) * Learn about [Actions](/docs/core/enhanced-actions) # Dashboard Blocks # Dashboard Blocks [#dashboard-blocks] Professional dashboard components for displaying metrics, statistics, and data visualizations. ## Stats Card [#stats-card] Display key metrics with trend indicators. ## Recent Activity [#recent-activity] Display recent user activities or transactions. ## Overview Dashboard [#overview-dashboard] Complete dashboard layout with multiple sections. ## Usage [#usage] Customize dashboard blocks for your needs: ### Add Real Data [#add-real-data] ```json { "type": "card", "dataSource": { "api": "/api/stats/revenue", "method": "GET" } } ``` ### Customize Colors [#customize-colors] ```json { "type": "text", "content": "+20.1%", "className": "text-emerald-500" } ``` ### Add Actions [#add-actions] ```json { "type": "button", "label": "View Details", "action": { "type": "navigate", "path": "/dashboard/details" } } ``` ## Next Steps [#next-steps] * Explore [Form Blocks](/docs/blocks/forms) * View [Marketing Blocks](/docs/blocks/marketing) * Learn about [Data Sources](/docs/guide/data-source) # E-commerce Blocks # E-commerce Blocks [#e-commerce-blocks] Professional e-commerce components for online stores and marketplaces. ## Product Card [#product-card] Product display with image, title, price, and actions. ## Product Grid [#product-grid] Multiple products in a responsive grid. ## Shopping Cart [#shopping-cart] Cart summary with items and checkout. ## Order Summary [#order-summary] Checkout order review. ## Usage [#usage] Customize e-commerce blocks for your store: ### Add Product Data [#add-product-data] ```json { "type": "card", "dataSource": { "api": "/api/products", "method": "GET" } } ``` ### Add to Cart Action [#add-to-cart-action] ```json { "type": "button", "label": "Add to Cart", "action": { "type": "submit", "endpoint": "/api/cart", "method": "POST" } } ``` ### Customize Pricing Display [#customize-pricing-display] ```json { "type": "text", "content": "$299.99", "className": "text-2xl font-bold text-primary" } ``` ## Next Steps [#next-steps] * Browse [Authentication Blocks](/docs/blocks/authentication) * Explore [Dashboard Blocks](/docs/blocks/dashboard) * Learn about [Actions](/docs/core/enhanced-actions) # Form Blocks # Form Blocks [#form-blocks] Professional form layouts for various use cases. All forms are accessible and include proper validation. ## Contact Form [#contact-form] Simple contact form with name, email, and message fields. ## Settings Form [#settings-form] User settings panel with various input types. ## Newsletter Signup [#newsletter-signup] Simple newsletter subscription form. ## Payment Form [#payment-form] Payment details form with card information. ## Usage [#usage] Customize form blocks for your application: ### Add Validation [#add-validation] The blocks on this page are built from plain `input` nodes, and an `input` declares its own validation keys — `required`, `pattern`, `maxLength`, `min`, `max` and `step` — which the renderer forwards to the native HTML input attributes: ```json { "type": "input", "name": "email", "inputType": "email", "required": true, "pattern": "^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$" } ``` `inputType: "email"` already gets the browser's own email check; `pattern` tightens it. The browser enforces these constraints when the input is submitted inside a `
`. An `input` node has **no `validation` key**. Rule objects with custom messages belong to the [form component](/docs/components/form/form), whose `fields[]` entries carry them. Every rule there is a `{ value, message }` object, and `validation.required` supplies the message only — whether the field is required is decided by `required` on the field itself: ```json { "type": "form", "fields": [ { "name": "message", "type": "textarea", "label": "How can we help?", "required": true, "validation": { "required": "Please tell us how we can help", "minLength": { "value": 20, "message": "Please use at least 20 characters" }, "maxLength": { "value": 2000, "message": "Please keep it under 2000 characters" } } } ] } ``` `pattern` is the one rule that route cannot take from JSON: react-hook-form runs it only when the rule's `value` is a compiled `RegExp`, and no JSON document can hold one. Declare the pattern on the object field's metadata instead (`pattern`, a string, which `@object-ui/fields` compiles before the rule reaches the form), or in a TypeScript-authored schema pass the real thing — `pattern: { value: /.../, message: '...' }`. The full rule table is in the [Form Plugin](/docs/plugins/plugin-form) reference. ### Add Submit Action [#add-submit-action] ```json { "type": "button", "label": "Submit", "action": { "type": "submit", "endpoint": "/api/contact", "method": "POST" } } ``` ### Customize Layout [#customize-layout] ```json { "type": "div", "className": "grid grid-cols-1 md:grid-cols-2 gap-6" } ``` ## Next Steps [#next-steps] * Browse [Marketing Blocks](/docs/blocks/marketing) * Explore [E-commerce Blocks](/docs/blocks/ecommerce) * Learn about [Form Validation](/docs/guide/fields) # Building Blocks # Building Blocks for the Web [#building-blocks-for-the-web] Pre-built, production-ready UI blocks powered by ObjectUI's schema-driven architecture. Copy the JSON, paste into your application, and ship faster. ## What are Blocks? [#what-are-blocks] Blocks are complete, ready-to-use UI patterns built with ObjectUI components. Unlike individual components, blocks are composed sections like login forms, pricing tables, dashboard cards, and more—ready to be dropped into your application. ### Quick Preview [#quick-preview] ### Why Use Blocks? [#why-use-blocks] * 🚀 **Ship Faster** - Start with working examples instead of building from scratch * 📋 **Copy & Paste** - Simple JSON schemas you can copy directly into your project * 🎨 **Beautiful Design** - Built with Tailwind CSS and Shadcn UI * ♿️ **Accessible** - WCAG 2.1 AA compliant * 🌓 **Theme Ready** - Supports light and dark modes out of the box * 📱 **Responsive** - Mobile-first design that works everywhere ## Available Block Categories [#available-block-categories] ### [Authentication Blocks](/docs/blocks/authentication) [#authentication-blocks] Login forms, signup pages, password reset flows, and more. ### [Dashboard Blocks](/docs/blocks/dashboard) [#dashboard-blocks] Stats cards, metric displays, chart layouts for admin panels and dashboards. ### [Form Blocks](/docs/blocks/forms) [#form-blocks] Contact forms, multi-step wizards, settings panels, and profile editors. ### [Marketing Blocks](/docs/blocks/marketing) [#marketing-blocks] Pricing tables, feature grids, testimonials, CTAs, and hero sections. ### [E-commerce Blocks](/docs/blocks/ecommerce) [#e-commerce-blocks] Product cards, shopping carts, checkout flows, and order summaries. ## How to Use Blocks [#how-to-use-blocks] 1. **Browse** the block categories to find what you need 2. **Preview** the block in action with live examples 3. **Copy** the JSON schema from the code tab 4. **Paste** into your ObjectUI application 5. **Customize** by modifying the schema to match your needs ### Example: Using a Block [#example-using-a-block] All blocks are defined as JSON schemas. Here's how simple it is: ```tsx import { SchemaRenderer } from '@object-ui/react'; // Copy this JSON from any block const loginBlockSchema = { type: "card", className: "max-w-md mx-auto", children: [ // ... block schema here ] }; // Render it in your app function App() { return ; } ``` ## Customization [#customization] Every block is fully customizable: * **Styling**: Add or modify `className` properties with Tailwind classes * **Content**: Change text, labels, and placeholders * **Layout**: Adjust spacing, sizing, and arrangement * **Behavior**: Add actions, data sources, and expressions * **Themes**: Works automatically with your theme configuration ## Block Architecture [#block-architecture] All blocks follow ObjectUI best practices: * Built with composable components * Use semantic HTML structure * Follow accessibility guidelines * Leverage Tailwind utility classes * Support responsive breakpoints * Include proper ARIA attributes ## Open Source & Free Forever [#open-source--free-forever] All blocks are open source under the MIT license. Use them in personal projects, commercial applications, or as inspiration for your own designs. ## Next Steps [#next-steps] * Browse [Authentication Blocks](/docs/blocks/authentication) * Explore [Dashboard Blocks](/docs/blocks/dashboard) * Check out [Form Blocks](/docs/blocks/forms) * View [Marketing Blocks](/docs/blocks/marketing) * Discover [E-commerce Blocks](/docs/blocks/ecommerce) ## Contributing [#contributing] Have a block idea? We'd love to see it! Check out our [contribution guidelines](https://github.com/objectstack-ai/objectui/blob/main/CONTRIBUTING.md) to submit your own blocks. # Marketing Blocks # Marketing Blocks [#marketing-blocks] Professional marketing components for landing pages and promotional content. ## Pricing Cards [#pricing-cards] Responsive pricing table with three tiers. ## Feature Grid [#feature-grid] Showcase product features in a grid layout. ## CTA Section [#cta-section] Call-to-action section for conversions. ## Testimonials [#testimonials] Customer testimonials and social proof. ## Usage [#usage] Customize marketing blocks for your campaigns: ### Update Colors [#update-colors] ```json { "type": "card", "className": "bg-gradient-to-r from-blue-600 to-purple-600" } ``` ### Add Analytics [#add-analytics] ```json { "type": "button", "label": "Get Started", "action": { "type": "analytics", "event": "cta_clicked" } } ``` ### Customize Content [#customize-content] ```json { "type": "text", "content": "Your custom headline here", "className": "text-4xl font-bold" } ``` ## Next Steps [#next-steps] * Browse [E-commerce Blocks](/docs/blocks/ecommerce) * Explore [Authentication Blocks](/docs/blocks/authentication) * Learn about [Theming](/docs/guide/theming) # Component Gallery # Component Gallery [#component-gallery] ObjectUI provides a comprehensive set of components built on React, Tailwind CSS, and Shadcn UI. All components are defined through JSON schemas and rendered with pixel-perfect quality. ## Quick Navigation [#quick-navigation] Browse components by category to find what you need: ### [Basic Components](/docs/components/basic/text) [#basic-components] Essential building blocks: Text, Icon, Image, Separator, HTML ### [Form Components](/docs/components/form/button) [#form-components] Interactive inputs: Button, Input, Select, Checkbox, Switch, Textarea, Slider ### [Layout Components](/docs/components/layout/container) [#layout-components] Structure your UI: Container, Card, Grid, Flex, Stack, Tabs ### [Data Display](/docs/components/data-display/badge) [#data-display] Show information: Badge, Avatar, Alert, List ### [Feedback Components](/docs/components/feedback/loading) [#feedback-components] User feedback: Loading, Progress, Skeleton ### [Overlay Components](/docs/components/overlay/dialog) [#overlay-components] Floating elements: Dialog, Drawer, Tooltip, Popover ### [Disclosure Components](/docs/components/disclosure/accordion) [#disclosure-components] Show/hide content: Accordion, Collapsible ### [Complex Components](/docs/components/complex/table) [#complex-components] Advanced patterns: Table (with sorting, filtering, pagination) ## Component Categories [#component-categories] ### Basic Components [#basic-components-1] The foundation of your UI. These are simple, single-purpose components: * **[Text](/docs/components/basic/text)** - Display text with typography control * **[Icon](/docs/components/basic/icon)** - Render icons from Lucide React * **[Image](/docs/components/basic/image)** - Display images with lazy loading * **[Separator](/docs/components/basic/separator)** - Visual divider between content * **[HTML](/docs/components/basic/html)** - Render raw HTML content ### Form Components [#form-components-1] Interactive elements for user input: * **[Button](/docs/components/form/button)** - Trigger actions with multiple variants * **[Input](/docs/components/form/input)** - Text input with validation * **[Select](/docs/components/form/select)** - Dropdown selection * **[Checkbox](/docs/components/form/checkbox)** - Boolean selection * **[Switch](/docs/components/form/switch)** - Toggle switch * **[Textarea](/docs/components/form/textarea)** - Multi-line text input * **[Slider](/docs/components/form/slider)** - Numeric range selection ### Layout Components [#layout-components-1] Structure and organize your interface: * **[Container](/docs/components/layout/container)** - Responsive container with max-width * **[Card](/docs/components/layout/card)** - Content card with header and footer * **[Grid](/docs/components/layout/grid)** - CSS Grid layout * **[Flex](/docs/components/layout/flex)** - Flexbox layout * **[Stack](/docs/components/layout/stack)** - Vertical or horizontal stack * **[Tabs](/docs/components/layout/tabs)** - Tabbed interface ### Data Display [#data-display-1] Present data to users: * **[Badge](/docs/components/data-display/badge)** - Small status indicators * **[Avatar](/docs/components/data-display/avatar)** - User profile images * **[Alert](/docs/components/data-display/alert)** - Contextual messages * **[List](/docs/components/data-display/list)** - Ordered or unordered lists ### Feedback Components [#feedback-components-1] Provide visual feedback: * **[Loading](/docs/components/feedback/loading)** - Loading spinner * **[Progress](/docs/components/feedback/progress)** - Progress bar * **[Skeleton](/docs/components/feedback/skeleton)** - Loading placeholder ### Overlay Components [#overlay-components-1] Floating UI elements: * **[Dialog](/docs/components/overlay/dialog)** - Modal dialog * **[Drawer](/docs/components/overlay/drawer)** - Slide-out drawer * **[Tooltip](/docs/components/overlay/tooltip)** - Hover tooltips * **[Popover](/docs/components/overlay/popover)** - Floating popover ### Disclosure Components [#disclosure-components-1] Expandable content: * **[Accordion](/docs/components/disclosure/accordion)** - Expandable sections * **[Collapsible](/docs/components/disclosure/collapsible)** - Toggle content visibility ### Complex Components [#complex-components-1] Advanced, feature-rich components: * **[Table](/docs/components/complex/table)** - Data table with sorting, filtering, and pagination ## Usage Pattern [#usage-pattern] All ObjectUI components follow the same schema-based pattern: ```json { "type": "component-name", "className": "tailwind-classes", "componentSpecificProperty": "value" } ``` ### Example: Button [#example-button] ```json { "type": "button", "label": "Click Me", "variant": "default", "size": "md", "className": "mt-4" } ``` ### Example: Card with Form [#example-card-with-form] ```json { "type": "card", "title": "User Profile", "className": "max-w-md", "body": { "type": "form", "fields": [ { "type": "input", "name": "name", "label": "Full Name" }, { "type": "input", "name": "email", "label": "Email", "inputType": "email" } ] } } ``` ## Features [#features] All ObjectUI components share these characteristics: * ✅ **Schema-Driven** - Define with JSON, not code * ✅ **Tailwind CSS** - Use utility classes directly in schemas * ✅ **Accessible** - WCAG 2.1 AA compliant * ✅ **Responsive** - Mobile-first design * ✅ **Themeable** - Light/dark mode support * ✅ **Type-Safe** - Full TypeScript support * ✅ **Performant** - Lazy-loaded and tree-shakable ## Next Steps [#next-steps] * **[Quick Start Guide](/docs/guide/quick-start)** - Build your first ObjectUI app * **[Schema Rendering](/docs/guide/schema-rendering)** - Learn how the engine works * **[Component Registry](/docs/guide/component-registry)** - Register custom components * **[Expressions](/docs/guide/expressions)** - Dynamic values with expressions ## Need Help? [#need-help] Can't find what you're looking for? Check out: * [Architecture](/docs/guide/architecture) - Core concepts and package boundaries * [API Reference](/docs/api) - Schema documentation and protocol specs * [GitHub](https://github.com/objectstack-ai/objectui) - Report issues or contribute # Application Schema (AppComponentSchema) # Application Schema [#application-schema] The `AppComponentSchema` defines the top-level configuration for your entire ObjectUI application, including navigation menus, branding, layout strategies, and global actions. ## Overview [#overview] AppComponentSchema provides a declarative way to configure: * **Navigation menus** - Hierarchical menu structures with icons and badges * **Branding** - Logo, title, favicon * **Layout strategies** - Sidebar, header, or empty layout * **Global actions** - User menu, global toolbar buttons ## Interactive Examples [#interactive-examples] ### Navigation Menu Preview [#navigation-menu-preview] ### Application Header Bar [#application-header-bar] ## Basic Usage [#basic-usage] ```ts import type { AppComponentSchema } from '@object-ui/types'; const app: AppComponentSchema = { type: 'app', name: 'my-crm', title: 'My CRM Application', logo: '/logo.svg', favicon: '/favicon.ico', layout: 'sidebar', menu: [ { type: 'item', label: 'Dashboard', icon: 'layout-dashboard', path: '/dashboard' } ], actions: [ { type: 'user', label: 'John Doe', avatar: '/avatar.jpg' } ] }; ``` ## Properties [#properties] The tables in this section describe `AppComponentSchema`, declared by `@object-ui/types` (`packages/types/src/app.ts`). They group its properties by topic; the declaration remains the complete list. ### Basic Configuration [#basic-configuration] | Property | Type | Description | | ------------- | -------- | ------------------------------------ | | `type` | `'app'` | Component type identifier (required) | | `name` | `string` | Application system identifier | | `title` | `string` | Display title shown in browser | | `description` | `string` | Application description | | `logo` | `string` | Logo URL or icon name | | `favicon` | `string` | Favicon URL | ### Layout Configuration [#layout-configuration] | Property | Type | Default | Description | | -------- | ---------------------------------- | ----------- | ---------------------- | | `layout` | `'sidebar' \| 'header' \| 'empty'` | `'sidebar'` | Global layout strategy | **Layout Options:** * **`sidebar`** - Standard admin layout with left sidebar navigation * **`header`** - Top navigation bar only * **`empty`** - No layout, pages handle their own structure ### Navigation Menu [#navigation-menu] The `menu` property accepts an array of `AppMenuItem` objects: ```ts interface AppMenuItem { type?: 'item' | 'group' | 'separator'; label?: string; icon?: string; // Lucide icon name path?: string; // Route path href?: string; // External link children?: AppMenuItem[]; // Submenu items badge?: string | number; hidden?: boolean | string; // Visibility condition } ``` #### Menu Types [#menu-types] **Item** - Single navigation link ```json { "type": "item", "label": "Dashboard", "icon": "layout-dashboard", "path": "/dashboard", "badge": "New" } ``` **Group** - Collapsible menu group ```json { "type": "group", "label": "Sales", "icon": "dollar-sign", "children": [ { "type": "item", "label": "Leads", "path": "/leads" }, { "type": "item", "label": "Deals", "path": "/deals" } ] } ``` **Separator** - Visual separator ```json { "type": "separator" } ``` ### Global Actions [#global-actions] The `actions` property defines global toolbar buttons: ```ts // `AppAction.items` uses the navigation-item shape documented under "Navigation // Menu" above — declared and published as `AppMenuItem`. The bare `MenuItem` // export is the unrelated overlay menu type used by `ui:dropdown-menu`. import type { AppMenuItem } from '@object-ui/types'; interface AppAction { type: 'button' | 'dropdown' | 'user'; label?: string; icon?: string; onClick?: never; // RETIRED (objectui#7344): the handler-expression string is refused by name — author an action:button node instead avatar?: string; // For type='user' description?: string; // For type='user' items?: AppMenuItem[]; // For type='dropdown' or 'user' shortcut?: string; // Keyboard shortcut variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; size?: 'default' | 'sm' | 'lg' | 'icon'; } ``` ## Complete Example [#complete-example] ```ts import type { AppComponentSchema } from '@object-ui/types'; const crm: AppComponentSchema = { type: 'app', name: 'acme-crm', title: 'Acme CRM', description: 'Customer Relationship Management System', logo: '/acme-logo.svg', favicon: '/favicon.ico', layout: 'sidebar', menu: [ { type: 'item', label: 'Dashboard', icon: 'layout-dashboard', path: '/dashboard' }, { type: 'separator' }, { type: 'group', label: 'Sales', icon: 'dollar-sign', children: [ { type: 'item', label: 'Leads', icon: 'users', path: '/leads', badge: 12 }, { type: 'item', label: 'Opportunities', icon: 'target', path: '/opportunities' }, { type: 'item', label: 'Quotes', icon: 'file-text', path: '/quotes' } ] }, { type: 'group', label: 'Marketing', icon: 'megaphone', children: [ { type: 'item', label: 'Campaigns', path: '/campaigns' }, { type: 'item', label: 'Email Templates', path: '/templates' } ] }, { type: 'separator' }, { type: 'item', label: 'Settings', icon: 'settings', path: '/settings', hidden: '${user.role !== "admin"}' } ], actions: [ { type: 'button', label: 'Quick Actions', icon: 'zap', variant: 'outline' }, { type: 'user', label: 'John Doe', avatar: '/avatars/john.jpg', description: 'john@acme.com', items: [ { type: 'item', label: 'Profile', icon: 'user', path: '/profile' }, { type: 'item', label: 'Settings', icon: 'settings', path: '/settings' }, { type: 'separator' }, { type: 'item', label: 'Logout', icon: 'log-out', path: '/logout' } ] } ] }; ``` ## Runtime Validation [#runtime-validation] Use Zod validation to ensure your app configuration is correct: ```ts import { AppComponentSchema } from '@object-ui/types/zod'; const myAppConfig = { type: 'app', name: 'my-crm', title: 'My CRM Application', }; const result = AppComponentSchema.safeParse(myAppConfig); if (result.success) { console.log('Valid app configuration'); } else { console.error('Validation errors:', result.error); } ``` ## Conditional Navigation [#conditional-navigation] Use expression syntax to show/hide menu items based on user permissions: ```json { "type": "item", "label": "Admin Panel", "path": "/admin", "hidden": "${user.role !== 'admin'}" } ``` ## Use Cases [#use-cases] AppComponentSchema is ideal for: * **Multi-page applications** - Configure complex application structures with multiple routes * **Admin dashboards** - Create comprehensive admin panels with role-based navigation * **CRM systems** - Build customer relationship management interfaces * **Internal tools** - Develop enterprise internal tools with centralized navigation * **SaaS products** - Configure multi-tenant applications with customizable layouts ## Best Practices [#best-practices] 1. **Use semantic icons** - Choose Lucide icons that clearly represent the section 2. **Group related items** - Use menu groups to organize navigation 3. **Limit top-level items** - Keep the main menu concise (5-7 items) 4. **Add badges for notifications** - Show counts or status indicators 5. **Hide admin features** - Use conditional visibility for role-based access 6. **Provide keyboard shortcuts** - Add shortcuts for frequently used actions ## Related [#related] * [Layout Schema](/docs/guide/layout) - Page layout configuration * [Navigation Components](/docs/components/basic/navigation-menu) - Navigation UI components * [Theme Schema](/docs/core/theme-schema) - Application theming # Enhanced Actions # Enhanced Actions [#enhanced-actions] ObjectUI's action system has been significantly enhanced to support complex workflows, including AJAX API calls, confirmation dialogs, action chaining, conditional execution, and comprehensive tracking. ## Overview [#overview] The enhanced `ActionSchema` provides: * **New action types**: `ajax`, `confirm`, `dialog` * **Action chaining**: Execute multiple actions sequentially or in parallel * **Conditional execution**: a `condition` predicate gates whether an action runs * **Notices**: `successMessage` / `errorMessage` strings * **Tracking**: Event logging and analytics * **Retry logic**: Automatic retry with configurable backoff ## Interactive Examples [#interactive-examples] ### Action Buttons [#action-buttons] ### Confirmation Dialog Pattern [#confirmation-dialog-pattern] ### Action Toolbar [#action-toolbar] ## Action Types [#action-types] ### Ajax Actions [#ajax-actions] Execute API calls with full request configuration: ```ts import type { ActionSchema } from '@object-ui/types'; const ajaxAction: ActionSchema = { type: 'action', label: 'Load Data', actionType: 'ajax', api: '/api/users', method: 'GET', headers: { 'Authorization': 'Bearer token' }, data: { filter: 'active' }, successMessage: 'Data loaded successfully', errorMessage: 'Failed to load data' }; ``` **Ajax Properties:** * `api` - API endpoint URL * `method` - HTTP method (GET, POST, PUT, DELETE, PATCH) * `data` - Request body/payload * `headers` - Custom HTTP headers * `timeout` - Request timeout in milliseconds * `retry` - Retry configuration ### Confirm Actions [#confirm-actions] Show confirmation dialog before executing: ```ts import type { ActionSchema } from '@object-ui/types'; const confirmAction: ActionSchema = { type: 'action', label: 'Delete Record', actionType: 'confirm', confirmText: 'Are you sure you want to delete this record?', api: '/api/records/123', method: 'DELETE' }; ``` **Confirm Properties:** * `confirmText` - Confirmation message shown in the dialog. This is the one confirm spelling — it is what the translation bundle addresses (`{ns}.objects.{obj}._actions.{name}.confirmText`), matching `@objectstack/spec`'s action surface. A structured `confirm` object (`{ title, message, ... }`) was retired in objectui#4314: it had no translation key, so its dialogs could never be localized. ### Dialog Actions [#dialog-actions] Open a modal or dialog: ```ts import type { ActionSchema } from '@object-ui/types'; const dialogAction: ActionSchema = { type: 'action', label: 'Edit Details', actionType: 'dialog', dialog: { title: 'Edit Record', size: 'lg', content: { type: 'form', fields: [ { name: 'name', type: 'input', label: 'Name' }, { name: 'email', type: 'input', label: 'Email' } ] }, actions: [ { type: 'action', label: 'Save', actionType: 'ajax', api: '/api/records/123', method: 'PUT' } ] } }; ``` ## Action Params (input collection) [#action-params-input-collection] An action may declare `params` (spec `ActionParamSchema`) to collect user input in a dialog before it runs. Each param renders through the **same field-widget renderer the object form uses**, so a param of *any* form-supported field type — `select`, `lookup`, `date`, `file`, `image`, `richtext`, `color`, `address`, … — gets its real widget, not a text box (ADR-0059): ```json { "name": "approve", "label": "Approve", "params": [ { "name": "comment", "type": "textarea", "label": "Comment", "required": true }, { "name": "attachments", "type": "file", "multiple": true, "accept": ["application/pdf"] }, { "name": "assignee", "field": "owner_id" }, { "name": "notify", "type": "boolean", "label": "Notify the requester", "defaultValue": true } ] } ``` * **Inline params** declare `name` + `type` (any spec `FieldType`), plus widget config: `options`, `multiple`, `accept`, `maxSize`, `placeholder`, `helpText`, `defaultValue`. * **Field-backed params** declare `field` (+ optional `objectOverride`) and inherit label, type, options, lookup picker config, `multiple`, `accept`, and `maxSize` from the object's field definition; inline properties override. * `required` blocks submit while the value is empty; `visible` (a CEL predicate over `features` / `current_user` / `app` / `data`) hides a param entirely — e.g. gate a param on an opt-in server capability. * Values are passed through to the action exactly as the widget emits them (`number` → number, `date` → `YYYY-MM-DD`, lookup → record id(s), `file` → uploaded file descriptor(s); arrays when `multiple`). File/image params upload through the ambient `UploadProvider`; lookup/user params query through the surrounding `SchemaRendererContext` data source — no extra wiring per action. While a file/image upload is in flight the dialog's **Confirm** button is disabled (labelled "Uploading…"), so a param can't be submitted before its uploaded fileId is ready. ## Action Chaining [#action-chaining] Execute multiple actions in sequence or parallel: ```ts import type { ActionSchema } from '@object-ui/types'; const chainedAction: ActionSchema = { type: 'action', label: 'Process Order', actionType: 'ajax', api: '/api/orders/process', method: 'POST', chain: [ { type: 'action', label: 'Send Email', actionType: 'ajax', api: '/api/emails/send', method: 'POST' }, { type: 'action', label: 'Update Inventory', actionType: 'ajax', api: '/api/inventory/update', method: 'PUT' }, { type: 'action', label: 'Log Event', actionType: 'ajax', api: '/api/events/log', method: 'POST' } ], chainMode: 'sequential' // or 'parallel' }; ``` **Chain Modes:** * `sequential` - Execute actions one after another (default) * `parallel` - Execute all actions simultaneously ## Conditional Execution [#conditional-execution] `condition` is a **gate**, not a branch: the action executes only while the predicate holds. ```ts import type { ActionSchema } from '@object-ui/types'; const gatedAction: ActionSchema = { type: 'action', label: 'Require Manager Approval', actionType: 'confirm', confirmText: 'Amount exceeds $1000. Manager approval needed.', // Offered only for amounts over 1000 condition: '${data.amount > 1000}' }; ``` **How the gate is written** - four spellings, all read by the same evaluator: | Spelling | Example | | ------------------- | ------------------------------------------------------------- | | Boolean | `condition: false` | | Bare CEL predicate | `condition: 'data.amount > 1000'` | | `${...}` template | `condition: '${data.amount > 1000}'` | | Normalized envelope | `condition: { dialect: 'cel', source: 'data.amount > 1000' }` | **How the gate is read:** * Declared and **false** - the action does not execute; the runner reports `Action condition not met`. * Declared and **true** - the action executes normally. * **Not declared** (key absent, or an empty predicate) - the action executes. The envelope is what `objectstack build` emits for a compiled predicate, so authored metadata and built metadata both parse. This is the same predicate vocabulary `visible` and `disabled` carry. ### Branching: one action per branch [#branching-one-action-per-branch] There is no `then` / `else` - a branch is expressed as **separate actions with mutually exclusive conditions**. Each action is gated on its own, so exactly one of them runs: ```ts import type { ActionSchema } from '@object-ui/types'; const approvalActions: ActionSchema[] = [ { type: 'action', label: 'Require Manager Approval', actionType: 'confirm', confirmText: 'Amount exceeds $1000. Manager approval needed.', condition: '${data.amount > 1000}' }, { type: 'action', label: 'Auto Approve', actionType: 'ajax', api: '/api/approve', method: 'POST', condition: '${data.amount <= 1000}' } ]; ``` Sibling actions - a toolbar's `actions`, a table's `rowActions` / `batchActions`, or an event's action list - are each dispatched through their own gate, which is what makes this an either/or. A `chain` step also carries its own `condition`, but a **sequential** chain stops at the first step whose condition does not hold (a blocked step reports `Action condition not met`, and sequential chaining stops on the first failure). So use a step `condition` to gate an **optional follow-up**, and sibling actions for an either/or. > **Retired (objectui#3917):** `condition` used to be documented here as > `{ expression, then, else }`. Nothing ever read `expression`, `then` or > `else`. The runner has always read this key as the predicate above, and an > object without a `source` reads as "no gate declared", so an action written > that way ran **unconditionally**, with no error at author time and no > diagnostic at runtime. The shape is now refused by `ActionSchema`'s zod schema > instead of being accepted and ignored. ## Post-success behaviour [#post-success-behaviour] This legacy `ActionSchema` has no callback slot. The Phase-2 pair it used to declare — `onSuccess` / `onFailure` carrying an `ActionCallback` object (`{ type: 'toast' | 'message' | 'redirect' | 'reload' | 'custom' | 'ajax' | 'dialog', message, url, api, … }`) — was RETIRED (objectui#7068): nothing ever read it (the runner never consumed the shape), and `@objectstack/spec`'s `ActionSchema` refuses it at publish. Both keys are `never` on the TypeScript face and named refusals on the Zod mirror, so an authored callback fails at the authoring site with the migration in the message. What to write instead: * **A notice** — `successMessage` / `errorMessage`, plain strings (the runner surfaces `successMessage` as a toast after a successful action). * **Post-success navigation** — the spec's `onSuccess` block, `{ navigate, openIn }`, declared on `UIActionSchema` and forwarded to the runner (objectui#5934). It is a spec key, not a member of this legacy type, so it is not shown in a fence here. * **Follow-up work** — `chain` (see [Action Chaining](#action-chaining)): declared actions, not callbacks. ## Action Tracking [#action-tracking] Track actions for analytics: ```ts import type { ActionSchema } from '@object-ui/types'; const trackedAction: ActionSchema = { type: 'action', label: 'Download Report', actionType: 'ajax', api: '/api/reports/download', tracking: { enabled: true, event: 'report_downloaded', metadata: { reportType: 'sales', format: 'pdf', dateRange: '2024-01' } } }; ``` ## Retry Logic [#retry-logic] Automatically retry failed requests: ```ts import type { ActionSchema } from '@object-ui/types'; const retryAction: ActionSchema = { type: 'action', label: 'Submit', actionType: 'ajax', api: '/api/submit', method: 'POST', timeout: 30000, // 30 seconds retry: { maxAttempts: 3, delay: 1000 // 1 second between retries } }; ``` ## Complete Example [#complete-example] A comprehensive action combining multiple features: ```ts import type { ActionSchema } from '@object-ui/types'; const complexAction: ActionSchema = { type: 'action', label: 'Process Order', icon: 'shopping-cart', variant: 'default', // Confirm before processing actionType: 'confirm', confirmText: 'Process this order for ${data.customerName}?', // Execution gate - this action is the premium path; the standard path is a // sibling action carrying the opposite predicate condition: '${data.totalAmount > 1000}', // Action chain chain: [ { type: 'action', label: 'Send Confirmation Email', actionType: 'ajax', api: '/api/emails/order-confirmation', method: 'POST', data: { orderId: '${data.id}', customerEmail: '${data.customerEmail}' } }, { type: 'action', label: 'Update Inventory', actionType: 'ajax', api: '/api/inventory/update', method: 'PUT', data: { items: '${data.items}' } }, { type: 'action', label: 'Create Invoice', actionType: 'ajax', api: '/api/invoices/create', method: 'POST' } ], chainMode: 'sequential', // Notices successMessage: 'Order processed successfully!', errorMessage: 'Unable to process order. Please try again.', // Tracking tracking: { enabled: true, event: 'order_processed', metadata: { source: 'web_app', amount: '${data.totalAmount}' } }, // Retry timeout: 60000, retry: { maxAttempts: 3, delay: 2000 }, // Post-action behavior reload: true, close: true, redirect: '/orders/success' }; ``` ## Runtime Validation [#runtime-validation] ```ts import { ActionSchema } from '@object-ui/types/zod'; const myAction = { type: 'action', label: 'Delete Record', actionType: 'confirm', confirmText: 'Are you sure?', }; const result = ActionSchema.safeParse(myAction); if (result.success) { console.log('Valid action configuration'); } else { console.error('Validation errors:', result.error); } ``` ## Use Cases [#use-cases] Enhanced Actions are ideal for: * **API integration** - Connect to backend services and external APIs * **Multi-step processes** - Execute complex workflows with multiple stages * **Form submissions** - Handle form data with validation and chained follow-up actions * **Confirmation dialogs** - Add safety checks for critical operations * **Event tracking** - Monitor user interactions for analytics * **Batch operations** - Process multiple items in sequence or parallel ## Best Practices [#best-practices] 1. **Use confirm for destructive actions** - Always confirm delete, archive, etc. 2. **Provide clear feedback** - Set `successMessage` / `errorMessage` so users learn what happened 3. **Chain related operations** - Group logically related API calls 4. **Track important events** - Enable tracking for business-critical actions 5. **Set appropriate timeouts** - Don't let users wait indefinitely 6. **Retry transient failures** - Use retry for network-related errors 7. **Keep chains short** - Long chains can be hard to debug ## Related [#related] * [Building a CRUD App](/docs/guide/building-crud-app) - CRUD operations with actions * [Form](/docs/components/form/form) - Form submission actions * [Data Source](/docs/guide/data-source) - API integration # Report Schema (ReportComponentSchema) # Report Schema [#report-schema] The `ReportComponentSchema` enables creating comprehensive data reports with field aggregation, multiple export formats, and automated scheduling. ## Overview [#overview] ReportComponentSchema provides: * **Field aggregation** - Sum, average, count, min, max, distinct * **Export formats** - PDF, Excel, CSV, JSON, HTML * **Scheduled reports** - Daily, weekly, monthly, quarterly, yearly * **Email distribution** - Automatic report delivery * **Interactive builder** - Report configuration UI * **Data filtering** - Complex filter criteria ## Interactive Examples [#interactive-examples] ### Sales Report Header [#sales-report-header] ### Report Data Table [#report-data-table] ### Schedule Configuration Preview [#schedule-configuration-preview] ## Basic Usage [#basic-usage] ```ts import type { ReportComponentSchema } from '@object-ui/types'; const salesReport: ReportComponentSchema = { type: 'report', title: 'Monthly Sales Report', description: 'Sales performance analysis', fields: [ { name: 'total_sales', label: 'Total Sales', type: 'number', aggregation: 'sum', format: 'currency' }, { name: 'order_count', label: 'Orders', type: 'number', aggregation: 'count' } ], filters: [ { field: 'date', operator: 'between', values: ['2024-01-01', '2024-01-31'] } ], showExportButtons: true }; ``` ## Properties [#properties] The tables in this section describe `ReportComponentSchema`, declared by `@object-ui/types` (`packages/types/src/reports.ts`) and imported by name in the example above. They group its properties by topic; the declaration remains the complete list. ### Basic Configuration [#basic-configuration] | Property | Type | Description | | ------------- | ---------- | ------------------------------------ | | `type` | `'report'` | Component type identifier (required) | | `title` | `string` | Report title | | `description` | `string` | Report description | > **Retired (objectui#6121):** `ReportComponentSchema.dataSource` and > `ReportBuilderSchema.dataSources` used to be documented and declared here. > Both were annotated with `DataSource`, the runtime **adapter** interface > (`find(resource, params)`), which no JSON document can author — and no > renderer ever read either key off a schema: the report renderers take their > adapter as a React prop or from the renderer context. Both keys are now > `never` on the TypeScript face and are refused **by name** by the published > validator, so an authored value fails loudly instead of being accepted and > ignored. A report binds its data through the semantic-layer `dataset` form > (ADR-0021); a legacy presentation report receives already-fetched rows under > `data`. ### Report Fields [#report-fields] ```plaintext interface ReportField { name: string; // Field name label?: string; // Display label // Drives type-aware cell rendering. Author-provided only — the renderer // reads `type` straight off this field and does not infer it from any // bound object (ReportComponentSchema has no `objectName`). type?: | 'string' | 'text' | 'number' | 'date' | 'datetime' | 'time' | 'boolean' | 'select' | 'multi_select' | 'status' | 'lookup' | 'reference' | 'master_detail' | 'email' | 'url' | 'phone' | 'currency' | 'percent' | 'image' | 'file' | 'user' | 'richtext' | 'html' | 'markdown' | 'json' | 'tags'; // Used when type is select / multi_select / status. options?: Array<{ value: string | number; label: string; color?: string }>; // Used when type is lookup / reference / master_detail. Enables // deep-links to the related record's detail page. referenceTo?: string; aggregation?: 'sum' | 'avg' | 'min' | 'max' | 'count' | 'distinct'; format?: string; // Display format showInSummary?: boolean; // Show in summary section sortOrder?: number; // Sort order // Legacy opt-in: render a plain string cell as a Badge. // For most cases prefer `type: 'select'` + `options`. renderAs?: 'badge' | 'text'; colorMap?: Record; // value → CSS class } ``` #### Type-aware rendering [#type-aware-rendering] The runtime maps `field.type` to a cell renderer from `@object-ui/fields`'s `getCellRenderer` registry. Examples: `select` becomes a coloured `Badge`, `lookup` becomes a deep link to the related record, `boolean` becomes ✓/✗, `email`/`url`/`phone` become `mailto:`/external/`tel:` links, `image` becomes a thumbnail. Any unknown type falls back to plain text. There is no automatic hydration from a bound object — `ReportComponentSchema` has no `objectName` property, and nothing in `packages/plugin-report` resolves one. `ReportViewer`'s `renderCellValue` reads `field.type` directly off each `ReportField` entry and passes it (plus `field.options` / `field.referenceTo`) straight into `getCellRenderer`; `field.label || field.name` is what renders the column header. Declare `type` (and `options` / `referenceTo` where relevant) on every field that needs type-aware rendering — a field with no `type` falls back to plain text. ### Aggregation Types [#aggregation-types] * **`sum`** - Total of all values * **`avg`** - Average value * **`min`** - Minimum value * **`max`** - Maximum value * **`count`** - Count of records * **`distinct`** - Count of unique values ### Filters [#filters] ```plaintext interface ReportFilter { field: string; operator: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than' | 'between' | 'in' | 'not_in'; value?: any; values?: any[]; // For 'between' and 'in' operators } ``` ### Group By [#group-by] ```plaintext interface ReportGroupBy { field: string; label?: string; sort?: 'asc' | 'desc'; } ``` ## Report Sections [#report-sections] Define report structure with sections: ```plaintext interface ReportSection { type: 'header' | 'summary' | 'chart' | 'table' | 'text' | 'page-break'; title?: string; content?: SchemaNode | SchemaNode[]; chart?: ChartSchema; // For type='chart' columns?: ReportField[]; // For type='table' text?: string; // For type='text' visible?: boolean | string; // Visibility condition } ``` ## Export Configuration [#export-configuration] ```plaintext interface ReportExportConfig { format: 'pdf' | 'excel' | 'csv' | 'json' | 'html'; filename?: string; includeHeaders?: boolean; orientation?: 'portrait' | 'landscape'; // PDF only pageSize?: 'A4' | 'A3' | 'Letter' | 'Legal'; // PDF only options?: Record; } ``` ## Scheduling [#scheduling] ```plaintext interface ReportSchedule { enabled?: boolean; frequency?: 'once' | 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly'; dayOfWeek?: number; // For weekly (0-6) dayOfMonth?: number; // For monthly (1-31) time?: string; // HH:mm format timezone?: string; recipients?: string[]; // Email addresses subject?: string; body?: string; formats?: ReportExportFormat[]; // Formats to attach } ``` ## Complete Example [#complete-example] ```plaintext const comprehensiveReport: ReportComponentSchema = { type: 'report', title: 'Quarterly Sales Analysis', description: 'Comprehensive sales performance analysis by region and product', // Report fields fields: [ { name: 'region', label: 'Region', type: 'string' }, { name: 'product', label: 'Product', type: 'string' }, { name: 'revenue', label: 'Revenue', type: 'number', aggregation: 'sum', format: 'currency', showInSummary: true }, { name: 'units_sold', label: 'Units Sold', type: 'number', aggregation: 'sum', showInSummary: true }, { name: 'avg_price', label: 'Average Price', type: 'number', aggregation: 'avg', format: 'currency' } ], // Filters filters: [ { field: 'date', operator: 'between', values: ['2024-01-01', '2024-03-31'] }, { field: 'status', operator: 'equals', value: 'completed' } ], // Grouping groupBy: [ { field: 'region', label: 'Region', sort: 'asc' }, { field: 'product', label: 'Product', sort: 'desc' } ], // Report sections sections: [ { type: 'header', title: 'Executive Summary' }, { type: 'summary', title: 'Key Metrics' }, { type: 'chart', title: 'Revenue Trend', chart: { type: 'chart', chartType: 'line', xAxisKey: 'month', data: [ { month: 'January', Revenue: 120000 }, { month: 'February', Revenue: 145000 }, { month: 'March', Revenue: 132000 } ], series: [ { name: 'Revenue', type: 'line' } ] } }, { type: 'table', title: 'Detailed Breakdown', columns: [ { name: 'region', label: 'Region' }, { name: 'product', label: 'Product' }, { name: 'revenue', label: 'Revenue', aggregation: 'sum' }, { name: 'units_sold', label: 'Units', aggregation: 'sum' } ] }, { type: 'page-break' }, { type: 'text', text: 'Report generated on ${new Date().toLocaleDateString()}' } ], // Schedule configuration schedule: { enabled: true, frequency: 'monthly', dayOfMonth: 1, time: '09:00', timezone: 'America/New_York', recipients: [ 'sales-team@company.com', 'management@company.com' ], subject: 'Monthly Sales Report - ${date}', body: 'Please find attached the monthly sales report.', formats: ['pdf', 'excel'] }, // Export configuration defaultExportFormat: 'pdf', exportConfigs: { pdf: { format: 'pdf', filename: 'sales-report-${date}.pdf', orientation: 'landscape', pageSize: 'A4', includeHeaders: true }, excel: { format: 'excel', filename: 'sales-report-${date}.xlsx', includeHeaders: true }, csv: { format: 'csv', filename: 'sales-data-${date}.csv', includeHeaders: true } }, // UI options showExportButtons: true, showPrintButton: true, showScheduleButton: true, refreshInterval: 300 // Auto-refresh every 5 minutes }; ``` ## Report Builder [#report-builder] Use `ReportBuilderSchema` for interactive report creation: ```ts import type { ReportBuilderSchema } from '@object-ui/types'; const builder: ReportBuilderSchema = { type: 'report-builder', report: { type: 'report', title: 'Untitled Report' }, availableFields: [ { name: 'revenue', label: 'Revenue', type: 'number' }, { name: 'units', label: 'Units Sold', type: 'number' } ], showPreview: true }; ``` ## Report Viewer [#report-viewer] Use `ReportViewerSchema` to display generated reports: ```ts import type { ReportComponentSchema, ReportViewerSchema } from '@object-ui/types'; // The report defined under "Basic Usage" above, and the rows a run produced. declare const salesReport: ReportComponentSchema; declare const reportData: Array>; const viewer: ReportViewerSchema = { type: 'report-viewer', report: salesReport, data: reportData, showToolbar: true, allowExport: true, allowPrint: true, loading: false }; ``` ## Runtime Validation [#runtime-validation] ```ts import { ReportComponentSchema } from '@object-ui/types/zod'; // The report configuration to validate. declare const myReport: unknown; const result = ReportComponentSchema.safeParse(myReport); if (result.success) { console.log('Valid report configuration'); } else { console.error('Validation errors:', result.error); } ``` ## Use Cases [#use-cases] ReportComponentSchema is perfect for: * **Analytics dashboards** - Display key business metrics and KPIs * **Business intelligence** - Generate insights from operational data * **Automated reporting** - Schedule regular reports for stakeholders * **Data exports** - Provide data in multiple formats (PDF, Excel, CSV) * **Compliance reporting** - Generate audit trails and regulatory reports * **Executive summaries** - Create high-level overviews for management ## Best Practices [#best-practices] 1. **Use meaningful aggregations** - Choose aggregation types that make sense for the data 2. **Limit field count** - Too many fields make reports hard to read 3. **Group logically** - Group by dimensions that provide insights 4. **Test exports** - Verify all export formats render correctly 5. **Set reasonable schedules** - Don't over-email recipients 6. **Include filters** - Allow users to customize date ranges 7. **Add summary sections** - Provide key metrics at the top ## Related [#related] * [Charts Plugin](/docs/plugins/plugin-charts) - Data visualization * [Data Table](/docs/components/complex/data-table) - Tabular data display * [Data Source](/docs/guide/data-source) - Data integration # SchemaRenderer The SchemaRenderer is the heart of ObjectUI. It takes a JSON schema and dynamically renders the appropriate React components based on the schema's `type` field. ## How It Works [#how-it-works] The SchemaRenderer uses the Component Registry to look up the appropriate component for each schema type and renders it with the provided props. ```tsx import { SchemaRenderer } from '@object-ui/react'; ``` ## Basic Example [#basic-example] ## Nested Schemas [#nested-schemas] The SchemaRenderer automatically handles nested schemas: ## Schema Structure [#schema-structure] ```ts interface SchemaNode { type: string; // Component type (e.g., 'card', 'button', 'text') id?: string; // Unique identifier className?: string; // Tailwind CSS classes children?: SchemaNode | SchemaNode[]; // Child components props?: Record; // Component-specific props [key: string]: any; // Any other component props } ``` ## Error Handling [#error-handling] When an unknown component type is encountered, SchemaRenderer displays a helpful error message: ## Component Registry [#component-registry] The SchemaRenderer uses the Component Registry to resolve component types: ```tsx import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer } from '@object-ui/react'; function MyWidgetComponent() { return
My widget
; } // Register a custom component ComponentRegistry.register('my-widget', MyWidgetComponent); // Now you can use it in schemas ``` ## Usage in Applications [#usage-in-applications] ### Simple Rendering [#simple-rendering] ```tsx import { SchemaRenderer } from '@object-ui/react'; function App() { const schema = { type: 'page', title: 'Dashboard', body: [ { type: 'text', content: 'Welcome to your dashboard' } ] }; return ; } ``` ### Dynamic Schemas [#dynamic-schemas] ```tsx import { SchemaRenderer } from '@object-ui/react'; import { useState, useEffect } from 'react'; function DynamicPage() { const [schema, setSchema] = useState(null); useEffect(() => { // Fetch schema from API fetch('/api/page-schema') .then(res => res.json()) .then(setSchema); }, []); if (!schema) return
Loading...
; return ; } ``` ### With Props [#with-props] You can pass additional props to the rendered component: ```plaintext console.log('Clicked!')} className="extra-classes" /> ``` ## Data Attributes [#data-attributes] SchemaRenderer automatically adds data attributes for debugging: * `data-obj-id`: The schema's id field * `data-obj-type`: The schema's type field These can be used for debugging, testing, or styling: ```css [data-obj-type="card"] { /* Target all card components */ } ``` ## Advanced Features [#advanced-features] ### String Rendering [#string-rendering] If a schema is just a string, it's rendered as text: ```plaintext // Renders: Hello World ``` ### Null/Undefined Handling [#nullundefined-handling] Null or undefined schemas render nothing: ```plaintext // Renders: null ``` ## Related [#related] * [Component Registry](/docs/guide/component-registry) - Register custom components * [Schema Rendering Guide](/docs/guide/schema-rendering) - Deep dive into schema rendering # Theme Schema (Theme) # Theme Schema [#theme-schema] ObjectUI theming is driven by a **theme document** — a JSON object typed as `Theme` from `@object-ui/types`. A theme is **not a component**: there is no `type: 'theme'` node to declare on a page (the component wrapper this page documented until objectui#5489 was never implemented by any renderer — declaring one produced an "Unknown component type" panel, never a theme manager). Instead, the document is handed to `ThemeProvider`, which turns it into CSS custom properties and applies them to the DOM. The theme system has three parts: * **`Theme`** (`@object-ui/types`) — the authoring document: colors, typography, border radii, shadows, custom variables, inheritance. `@object-ui/types` owns this vocabulary: the spec retired its theme module, and the shapes moved here (objectui#5716). * **ThemeEngine** (`@object-ui/core`) — pure functions that convert a `Theme` into a CSS custom-property map (`generateThemeVars`), resolve inheritance (`resolveThemeInheritance`) and resolve the effective mode (`resolveMode`). * **`ThemeProvider` / `useTheme`** (`@object-ui/react`) — the React context that injects the variables, toggles the `light` / `dark` class, and optionally persists the user's choice. ## Interactive Examples [#interactive-examples] ### Color Palette Preview [#color-palette-preview] ### Theme-Aware Components [#theme-aware-components] ## Basic Usage [#basic-usage] ```ts import type { Theme } from '@object-ui/types'; const professional: Theme = { name: 'professional', label: 'Professional', mode: 'auto', colors: { primary: '#3b82f6', background: '#ffffff', text: '#0f172a', }, }; ``` `name`, `label` and `colors` are required; `colors.primary` is the only required color. Everything else is optional — an absent `mode` is treated as `'auto'`. ## Applying a Theme [#applying-a-theme] `ThemeProvider` wraps a subtree, registers the available themes, resolves inheritance and mode, generates the CSS variables and injects them (on `document.documentElement` by default): ```tsx import type { ReactNode } from 'react'; import type { Theme } from '@object-ui/types'; import { ThemeProvider } from '@object-ui/react'; const corporate: Theme = { name: 'corporate', label: 'Corporate', colors: { primary: '#2563eb' }, }; export function App({ children }: { children: ReactNode }) { return ( {children} ); } ``` Inside the provider, `useTheme()` exposes the resolved state and the switching actions: ```tsx import { useTheme } from '@object-ui/react'; export function ThemeControls() { const { resolvedMode, setMode, setTheme, themes } = useTheme(); return (
{themes.map((t) => ( ))}
); } ``` `useTheme()` throws outside a provider; `useOptionalTheme()` returns `null` instead. ### ThemeProvider Props [#themeprovider-props] | Prop | Type | Default | Description | | -------------- | --------------------- | -------------------------- | ----------------------------------------------------------------- | | `themes` | `Theme[]` | `[]` | Available theme documents | | `defaultTheme` | `string` | first theme's `name` | Initially active theme | | `defaultMode` | `ThemeMode` | `'auto'` | Initial mode | | `persist` | `boolean` | `false` | Persist theme + mode to `localStorage` | | `storageKey` | `string` | `'objectui-theme'` | `localStorage` key prefix (stored as `-name` / `-mode`) | | `target` | `HTMLElement \| null` | `document.documentElement` | Element receiving the CSS variables and mode class | Persistence is a **provider** concern — there is no `persistPreference` or `storageKey` key on the theme document itself. ## Theme Properties [#theme-properties] | Property | Type | Required | Description | | -------------- | ------------------------------------ | -------- | -------------------------------------------------------------- | | `name` | `string` | yes | Unique theme identifier | | `label` | `string` | yes | Human-readable display name | | `description` | `string` | no | Optional description | | `mode` | `'light' \| 'dark' \| 'auto'` | no | Display mode; absence means `'auto'` | | `colors` | `ColorPalette` | yes | Color palette — the only required token group | | `typography` | `{ fontFamily?: { base?: string } }` | no | Only `fontFamily.base` is live (see [Typography](#typography)) | | `borderRadius` | scale object | no | Rounded-corner scale (see [Border Radius](#border-radius)) | | `shadows` | scale object | no | Box-shadow scale (see [Shadows](#shadows)) | | `customVars` | `Record` | no | Emitted verbatim as `--: ` | | `extends` | `string` | no | Name of a theme to inherit from | ## Theme Modes [#theme-modes] `ThemeMode` is `'light' | 'dark' | 'auto'` — **there is no `'system'` member**; the OS-following mode is spelled `'auto'`. The vocabulary is also exported as a runtime tuple: ```ts import { THEME_MODES, type ThemeMode } from '@object-ui/types'; const mode: ThemeMode = 'auto'; console.log(mode, THEME_MODES); // auto ['auto', 'light', 'dark'] ``` With `'auto'`, `ThemeProvider` resolves the effective mode from `prefers-color-scheme` and re-resolves live when the OS preference changes. The resolved mode is applied as a `light` / `dark` class on the target element, so Tailwind `dark:` variants respond to it. A theme document carries a **single `colors` map**, not per-mode palettes: the same variables are injected in both modes. For a palette that differs between light and dark, author two theme documents — typically a dark variant that `extends` the light one (see [Theme Inheritance](#theme-inheritance)) — and switch between them with `setTheme`. ## Color Palette [#color-palette] `colors.primary` is required; every other key is optional. Keys are emitted as the Shadcn CSS variables ObjectUI components already consume: | Key | Required | CSS variable | | ---------------- | -------- | -------------------- | | `primary` | yes | `--primary` | | `secondary` | | `--secondary` | | `accent` | | `--accent` | | `success` | | `--success` | | `warning` | | `--warning` | | `error` | | `--destructive` | | `info` | | `--info` | | `background` | | `--background` | | `surface` | | `--card` | | `text` | | `--foreground` | | `textSecondary` | | `--muted-foreground` | | `border` | | `--border` | | `disabled` | | `--muted` | | `primaryLight` | | `--primary-light` | | `primaryDark` | | `--primary-dark` | | `secondaryLight` | | `--secondary-light` | | `secondaryDark` | | `--secondary-dark` | Hex values (`#3b82f6`) are converted to the `H S% L%` channel format Shadcn variables expect; any other CSS color syntax (`rgb(...)`, `hsl(...)`, `oklch(...)`) passes through unchanged. ```ts import type { ColorPalette } from '@object-ui/types'; const colors: ColorPalette = { primary: '#3b82f6', secondary: '#64748b', accent: '#8b5cf6', error: '#ef4444', background: '#ffffff', surface: '#f8fafc', text: '#0f172a', textSecondary: '#64748b', border: '#e2e8f0', }; ``` ## Typography [#typography] `typography.fontFamily.base` is the only live typography key — it is emitted as `--font-sans`: ```ts import type { Theme } from '@object-ui/types'; const branded: Theme = { name: 'branded', label: 'Branded', colors: { primary: '#3b82f6' }, typography: { fontFamily: { base: 'Inter, system-ui, sans-serif' }, }, customVars: { 'font-size-base': '16px', 'line-height-base': '1.5', }, }; ``` The former typography scales (`fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`, `fontFamily.heading`, `fontFamily.mono`) were retired upstream (objectstack#5021): a theme declaring them is **refused, not accepted-and-stripped**. `customVars` is the declared replacement — an entry is emitted verbatim, so `customVars: { 'font-size-lg': '1.125rem' }` puts the same `--font-size-lg` on the document that the retired scale used to. The retired keys are typed `never`, which makes the refusal a compile-time error: ```ts import type { Theme } from '@object-ui/types'; const legacy: Theme = { name: 'legacy', label: 'Legacy', colors: { primary: '#3b82f6' }, typography: { // @ts-expect-error -- `fontSize` was retired (objectstack#5021); author `customVars` instead fontSize: 16, }, }; ``` ## Border Radius [#border-radius] The key is `borderRadius` (not `radius`), and the middle step is `base` (not `default`): ```ts import type { Theme } from '@object-ui/types'; const rounded: Theme = { name: 'rounded', label: 'Rounded', colors: { primary: '#3b82f6' }, borderRadius: { sm: '0.25rem', base: '0.5rem', md: '0.75rem', lg: '1rem', xl: '1.5rem', }, }; ``` | Key | CSS variable | | ------ | --------------- | | `none` | `--radius-none` | | `sm` | `--radius-sm` | | `base` | `--radius` | | `md` | `--radius-md` | | `lg` | `--radius-lg` | | `xl` | `--radius-xl` | | `2xl` | `--radius-2xl` | | `full` | `--radius-full` | ## Shadows [#shadows] The `shadows` scale has the same shape, with `inner` in place of `full`: | Key | CSS variable | | ------- | ---------------- | | `none` | `--shadow-none` | | `sm` | `--shadow-sm` | | `base` | `--shadow` | | `md` | `--shadow-md` | | `lg` | `--shadow-lg` | | `xl` | `--shadow-xl` | | `2xl` | `--shadow-2xl` | | `inner` | `--shadow-inner` | ## Custom Variables [#custom-variables] `customVars` entries are emitted verbatim onto the target element as `--: ` (a leading `--` is added when the key does not carry one). This is the declared door for any token the schema does not model — z-index steps, animation durations, layout dimensions: ```ts import type { Theme } from '@object-ui/types'; const dashboard: Theme = { name: 'dashboard', label: 'Dashboard', colors: { primary: '#3b82f6' }, customVars: { 'header-height': '4rem', 'sidebar-width': '16rem', '--z-modal': '1400', }, }; ``` ## Theme Inheritance [#theme-inheritance] A theme can extend another by `name`. On resolution the chain is merged deep for `colors`, `typography`, `borderRadius`, `shadows` and `customVars` — the child overrides key by key and inherits the rest. Cycles are detected and stop the walk. ```ts import type { Theme } from '@object-ui/types'; const acmeLight: Theme = { name: 'acme-light', label: 'Acme', colors: { primary: '#3b82f6', background: '#ffffff', text: '#0f172a' }, borderRadius: { base: '0.5rem' }, }; const acmeDark: Theme = { name: 'acme-dark', label: 'Acme Dark', extends: 'acme-light', colors: { primary: '#60a5fa', background: '#0f172a', text: '#f1f5f9' }, }; ``` Register both on the provider: `acme-dark` resolves against `acme-light`, inheriting the `borderRadius` scale while its `colors` override key by key. The engine functions are exported for direct use: ```ts import { generateThemeVars, resolveMode } from '@object-ui/core'; import type { Theme } from '@object-ui/types'; const probe: Theme = { name: 'probe', label: 'Probe', colors: { primary: '#3b82f6' }, }; const vars = generateThemeVars(probe); // { '--primary': '217 91% 60%' } const effective = resolveMode('auto'); // 'light' | 'dark', from prefers-color-scheme console.log(vars, effective); ``` ## Validation [#validation] Theme documents are validated at the **type level**: retired keys are `never`-typed tombstones, so an invalid document fails to compile rather than being silently stripped at runtime. There is no runtime validator for theme documents — the zod schemas `@objectstack/spec` used to publish (`ThemeDefinitionSchema` and its token sub-schemas) were retired with its theme module, and `@object-ui/types/zod` does not export a replacement. For the runtime check that matters to theming — accessibility — the engine ships WCAG helpers: ```ts import { contrastRatio, meetsContrastLevel } from '@object-ui/core'; const ratio = contrastRatio('#0f172a', '#ffffff'); // ≈ 14.9 const readable = meetsContrastLevel('#0f172a', '#ffffff', 'AA'); // true console.log(ratio, readable); ``` ## Complete Theme Example [#complete-theme-example] ```ts import type { Theme } from '@object-ui/types'; const professional: Theme = { name: 'professional', label: 'Professional', description: 'Default corporate look', mode: 'auto', colors: { primary: '#3b82f6', secondary: '#64748b', accent: '#8b5cf6', success: '#10b981', warning: '#f59e0b', error: '#ef4444', info: '#0ea5e9', background: '#ffffff', surface: '#f8fafc', text: '#0f172a', textSecondary: '#64748b', border: '#e2e8f0', }, typography: { fontFamily: { base: 'Inter, system-ui, sans-serif' }, }, borderRadius: { sm: '0.25rem', base: '0.5rem', md: '0.75rem', lg: '1rem', }, shadows: { sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)', base: '0 1px 3px 0 rgb(0 0 0 / 0.1)', lg: '0 10px 15px -3px rgb(0 0 0 / 0.1)', }, customVars: { 'header-height': '4rem', 'sidebar-width': '16rem', }, }; ``` ## Best Practices [#best-practices] 1. **Use the semantic keys** — map brand colors onto `primary` / `accent` / `error` rather than inventing custom variables for tokens the palette already models. 2. **Test both modes** — an `'auto'` theme renders under both the `light` and `dark` classes. 3. **Maintain contrast** — check WCAG pairs with `meetsContrastLevel` from `@object-ui/core`. 4. **Default to `'auto'`** — respect the OS preference; it is the provider's default mode. 5. **Persist on the provider** — user preference is `ThemeProvider`'s `persist` / `storageKey`, not a key on the theme document. 6. **Share tokens with `extends`** — author variants as small overrides of a base theme. ## Related [#related] * [App Schema](/docs/core/app-schema) - Application configuration * [Schema Overview](/docs/guide/schema-overview) - Where theming sits among the schema families * [CSS Variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) - MDN documentation * [Tailwind Theming](https://tailwindcss.com/docs/theme) - Tailwind CSS theming guide # AutoNumber Field The AutoNumber Field component displays auto-generated sequence numbers. This is a read-only field where the value is automatically generated by the backend when records are created. ## Basic Usage [#basic-usage] ## Custom Format [#custom-format] ## Date-Based Format [#date-based-format] ## Field Schema [#field-schema] An auto-number field is authored as `AutoNumberFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the sequence format and its starting point. ```ts import type { AutoNumberFieldMetadata } from '@object-ui/types'; const invoiceNumber: AutoNumberFieldMetadata = { type: 'auto_number', name: 'invoice_number', label: 'Invoice Number', help: 'Assigned by the platform when the record is created.', readonly: true, format: 'INV-{0000}', starting_number: 1000, }; ``` The generated value, and the `className` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Format Templates [#format-templates] Common format patterns: ### Simple Sequential [#simple-sequential] ```plaintext format: '{0000}' // 0001, 0002, 0003... format: '{00000}' // 00001, 00002, 00003... ``` ### With Prefix [#with-prefix] ```plaintext format: 'ORD-{0000}' // ORD-0001, ORD-0002... format: 'INV-{00000}' // INV-00001, INV-00002... format: 'CUST-{000}' // CUST-001, CUST-002... ``` ### Date-Based [#date-based] ```plaintext format: '{YYYY}-{0000}' // 2024-0001, 2024-0002... format: '{YY}{MM}-{000}' // 2403-001, 2403-002... format: 'ORD-{YYYYMMDD}-{00}' // ORD-20240315-01... ``` ### Mixed Format [#mixed-format] ```plaintext format: 'PO-{YYYY}-{MM}-{0000}' // PO-2024-03-0001 format: '{YY}Q{Q}-{000}' // 24Q1-001, 24Q1-002... ``` ## Format Placeholders [#format-placeholders] * `{0}`, `{00}`, `{000}`, etc. - Sequential number with padding * `{YYYY}` - Four-digit year (2024) * `{YY}` - Two-digit year (24) * `{MM}` - Two-digit month (03) * `{DD}` - Two-digit day (15) * `{Q}` - Quarter (1-4) ## Backend Implementation [#backend-implementation] AutoNumber values are generated on record creation: ```plaintext const generateAutoNumber = (format: string, sequence: number) => { const now = new Date(); return format .replace('{YYYY}', now.getFullYear().toString()) .replace('{YY}', now.getFullYear().toString().slice(-2)) .replace('{MM}', (now.getMonth() + 1).toString().padStart(2, '0')) .replace('{DD}', now.getDate().toString().padStart(2, '0')) .replace('{Q}', Math.ceil((now.getMonth() + 1) / 3).toString()) .replace(/\{0+\}/, (match) => { const padding = match.length - 2; return sequence.toString().padStart(padding, '0'); }); }; // Example usage generateAutoNumber('ORD-{YYYY}-{0000}', 42); // Returns: "ORD-2024-0042" ``` ## Sequence Management [#sequence-management] Allocating the next number is the backend's job, not the renderer's. ObjectUI never generates a value: `AutoNumberField` displays whatever the saved record already carries, and renders a muted placeholder dash while the field is still empty. What ObjectUI owns is the metadata the backend reads — where the sequence starts, and how each value is formatted: ```ts import type { AutoNumberFieldMetadata } from '@object-ui/types'; const orderNumber: AutoNumberFieldMetadata = { type: 'auto_number', name: 'order_number', label: 'Order Number', format: 'ORD-{YYYY}-{0000}', starting_number: 1, }; ``` On the backend, keep one counter per object-and-field pair and increment it in the same transaction that inserts the record, so two concurrent inserts cannot read the same current value. Partition the counter (per year, per prefix) only if the format resets — `'ORD-{YYYY}-{0000}'` needs one counter per year if the sequence is meant to restart each January. Because the number is assigned at insert time, it does not exist while the record is still being drafted: a create form shows the field empty, and the value appears once the saved record comes back. ## Use Cases [#use-cases] * **Order Management**: Order numbers, PO numbers * **Invoicing**: Invoice IDs, receipt numbers * **Ticketing**: Support ticket IDs, case numbers * **Customer Management**: Customer IDs, account numbers * **Inventory**: SKU numbers, serial numbers * **Document Management**: Document IDs, revision numbers ## Best Practices [#best-practices] 1. **Choose appropriate padding**: Use enough digits for expected volume 2. **Include year for long-running systems**: Helps with archival and partitioning 3. **Use meaningful prefixes**: Makes numbers self-documenting 4. **Don't expose internal IDs**: Use auto-numbers for user-facing identifiers 5. **Consider reset policies**: Decide if/when sequences reset (yearly, monthly, etc.) # Boolean Field The Boolean Field component provides a switch or checkbox input for collecting true/false boolean values. ## Basic Usage [#basic-usage] ## With Description [#with-description] ## Default Value [#default-value] ## Field Schema [#field-schema] A boolean field is authored as `BooleanFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set. It adds nothing of its own to `BaseFieldMetadata` beyond the discriminant — a checkbox needs no extra configuration. ```ts import type { BooleanFieldMetadata } from '@object-ui/types'; const isActive: BooleanFieldMetadata = { type: 'boolean', name: 'is_active', label: 'Active', description: 'Inactive records stay searchable but are excluded from lists.', required: false, defaultValue: true, }; ``` The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Use Cases [#use-cases] * **Feature Toggles**: Enable/disable features * **Preferences**: User settings and preferences * **Permissions**: Access control flags * **Status**: Active/inactive, published/draft states * **Agreements**: Terms acceptance, consent flags ## Cell Renderer [#cell-renderer] In tables/grids, boolean values are displayed as badges: ```ts import { BooleanCellRenderer } from '@object-ui/fields'; // Renders: // ✓ True (green badge) // ✗ False (gray badge) ``` ## Styling [#styling] The boolean field uses the Switch component from Shadcn UI, providing: * Smooth animations * Accessible keyboard navigation * Focus states * Disabled states # Currency Field The Currency Field component provides a formatted currency input with proper locale formatting and currency symbol display. ## Basic Usage [#basic-usage] ## Different Currencies [#different-currencies] ## Field Schema [#field-schema] A currency field is authored as `CurrencyFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the currency code, the decimal precision and the two numeric bounds. ```ts import type { CurrencyFieldMetadata } from '@object-ui/types'; const amount: CurrencyFieldMetadata = { type: 'currency', name: 'amount', label: 'Amount', placeholder: '0.00', required: true, currency: 'USD', precision: 2, min: 0, max: 1000000, }; ``` The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## What the browser rewrites before this field sees it [#what-the-browser-rewrites-before-this-field-sees-it] This widget renders a native `type="number"` input, so the browser decides what the box accepts. Two things can happen and **only one of them is announced**: * **Announced.** Text the browser cannot read at all — `1e`, a lone `-`, a lone `.` — leaves the box visibly showing what was typed while its value reads empty. The field is marked `aria-invalid` and draws *"Not saved: the text in this box is not a number."* * ⚠️ **Not announced.** Entries the browser silently **truncates**: pasting `1.2.3` stores `1.23`, and `0x10` stores `10`. No warning is possible here — the browser discards the extra characters as they arrive, so nothing reaches ObjectUI to check. ⛔ **"No warning" therefore does not mean "the value is right."** Full explanation and the reasoning: [What a number field silently rewrites](/docs/guide/fields#what-a-number-field-silently-rewrites). ## Supported Currencies [#supported-currencies] * **USD**: US Dollar ($) * **EUR**: Euro (€) * **GBP**: British Pound (£) * **JPY**: Japanese Yen (¥) * And all other ISO 4217 currency codes ## Cell Renderer [#cell-renderer] In tables/grids, currency values are formatted with the appropriate symbol: ```ts import { CurrencyCellRenderer } from '@object-ui/fields'; // Renders: $1,234.56 or €1.234,56 based on locale ``` ## Use Cases [#use-cases] * **Pricing**: Product prices, service costs * **Financial Data**: Revenue, expenses, budgets * **Transactions**: Payment amounts, invoice totals * **Salaries**: Compensation amounts # Date Field The Date Field component provides a date picker for selecting dates and optionally times. ## Basic Usage [#basic-usage] ## With Default Value [#with-default-value] ## Field Schema [#field-schema] A date field is authored as `DateFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with a display format and the two range bounds. ```ts import type { DateFieldMetadata } from '@object-ui/types'; const closeDate: DateFieldMetadata = { type: 'date', name: 'close_date', label: 'Close Date', placeholder: 'Pick a date', required: true, format: 'yyyy-MM-dd', min_date: '2024-01-01', max_date: '2030-12-31', dueLike: true, }; ``` The range bounds are `min_date` and `max_date` — the same spelling the datetime field uses. `datetime` is its own type with its own metadata (`DateTimeFieldMetadata`); see [DateTime Field](/docs/fields/datetime). The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Date Formats [#date-formats] The date field supports various display formats: * **Short**: 12/31/2024 * **Medium**: Dec 31, 2024 (default) * **Long**: December 31, 2024 * **Full**: Monday, December 31, 2024 ## Use Cases [#use-cases] * **Birthdates**: User birth dates * **Deadlines**: Task or project deadlines * **Events**: Event start/end dates * **Appointments**: Meeting or appointment scheduling * **Releases**: Product or content release dates ## Cell Renderer [#cell-renderer] In tables/grids, dates are formatted consistently: ```ts import { DateCellRenderer } from '@object-ui/fields'; // Renders: Dec 31, 2024 // Or: Dec 31, 2024 2:30 PM (for datetime) ``` ## DateTime Variant [#datetime-variant] For collecting both date and time: ```plaintext { type: 'datetime', name: 'appointment', label: 'Appointment Time' } ``` # DateTime Field The DateTime Field component provides a combined date and time input for collecting both date and time information in a single field. ## Basic Usage [#basic-usage] ## With Default Value [#with-default-value] ## Required Field [#required-field] ## Read-Only [#read-only] ## Field Schema [#field-schema] A datetime field is authored as `DateTimeFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with a display format and the two range bounds. ```ts import type { DateTimeFieldMetadata } from '@object-ui/types'; const startsAt: DateTimeFieldMetadata = { type: 'datetime', name: 'starts_at', label: 'Starts At', placeholder: 'Pick a date and time', required: true, format: 'yyyy-MM-dd HH:mm', min_date: '2024-01-01T00:00:00Z', max_date: '2030-12-31T23:59:59Z', }; ``` The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Date Format [#date-format] The datetime field stores values in ISO 8601 format: `YYYY-MM-DDTHH:mm` Example: `2024-03-15T14:30` represents March 15, 2024 at 2:30 PM ## Cell Renderer [#cell-renderer] When used in data tables or grids: ```ts import { DateTimeCellRenderer } from '@object-ui/fields'; // Renders: Mar 15, 2024, 02:30 PM ``` ## Use Cases [#use-cases] * **Event Scheduling**: Meeting times, appointments * **Timestamps**: Order placed, task deadline * **Booking Systems**: Reservation date and time * **Notifications**: Scheduled notification time # Email Field The Email Field component provides a text input with built-in email validation and formatting. ## Basic Usage [#basic-usage] ## Required Email [#required-email] ## Field Schema [#field-schema] An email field is authored as `EmailFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with a single length bound. Address-shape validation is the widget's, not a metadata key. ```ts import type { EmailFieldMetadata } from '@object-ui/types'; const contactEmail: EmailFieldMetadata = { type: 'email', name: 'contact_email', label: 'Email Address', placeholder: 'name@example.com', required: true, max_length: 254, }; ``` The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Validation [#validation] The email field automatically validates: * Proper email format ([user@domain.com](mailto:user@domain.com)) * Presence of @ symbol * Valid domain structure * No spaces or invalid characters ## Cell Renderer [#cell-renderer] In tables/grids, email addresses are clickable links: ```ts import { EmailCellRenderer } from '@object-ui/fields'; // Renders as: user@example.com // Clicking opens default email client ``` ## Use Cases [#use-cases] * **User Registration**: Account email addresses * **Contact Forms**: Customer contact information * **Support**: Support ticket emails * **Notifications**: Email notification addresses * **Team Members**: Team member email addresses ## Features [#features] * **Autocomplete**: Browser email autocomplete * **Validation**: Real-time email format validation * **Clickable Links**: Email links in read-only mode * **Accessible**: Proper ARIA labels and keyboard navigation # File Field The File Field component provides a file upload interface with support for multiple files, file type filtering, and file metadata display. ## Basic Usage [#basic-usage] ## Multiple Files [#multiple-files] ## File Type Restrictions [#file-type-restrictions] ## Field Schema [#field-schema] A file field is authored as `FileFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the upload limits. A stored file is `UploadedFileMetadata`, exported from the same package. ```ts import type { FileFieldMetadata, UploadedFileMetadata } from '@object-ui/types'; const attachments: FileFieldMetadata = { type: 'file', name: 'attachments', label: 'Attachments', help: 'PDF or Word, up to 10 MB each.', multiple: true, accept: ['application/pdf', 'application/msword'], max_size: 10 * 1024 * 1024, max_files: 5, }; // The shape of one stored file — the field's VALUE, not its metadata. const storedFile: UploadedFileMetadata = { name: 'contract.pdf', original_name: 'Contract (signed).pdf', size: 248_310, mime_type: 'application/pdf', url: 'https://files.example.com/contract.pdf', }; ``` The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Accepted File Types [#accepted-file-types] Common MIME type examples: ```plaintext // Documents accept: ['application/pdf', 'application/msword'] // Images accept: ['image/png', 'image/jpeg', 'image/gif'] // Archives accept: ['application/zip', 'application/x-rar'] // Spreadsheets accept: ['application/vnd.ms-excel', 'text/csv'] // Wildcards accept: ['image/*'] // All images ``` ## Features [#features] * **File List Display**: Shows uploaded files with names and sizes * **Remove Files**: Individual file removal with X button * **Size Display**: File sizes shown in KB/MB * **Upload Button**: Clear upload interface * **Multiple Selection**: Support for batch file uploads ## Cell Renderer [#cell-renderer] In tables/grids, displays file count: ```ts import { FileCellRenderer } from '@object-ui/fields'; // Renders: "3 files" or "document.pdf" ``` ## Integration Notes [#integration-notes] This component creates object URLs for file preview. Actual file upload requires backend integration: ```plaintext // Frontend creates preview const fileData = { name: 'document.pdf', size: 1024000, mime_type: 'application/pdf', url: URL.createObjectURL(file) }; // Backend handles actual upload const uploadToServer = async (file) => { const formData = new FormData(); formData.append('file', file); const response = await fetch('/api/upload', { method: 'POST', body: formData }); return response.json(); }; ``` ## Use Cases [#use-cases] * **Document Management**: PDF uploads, contracts * **File Attachments**: Email attachments, support tickets * **Media Libraries**: Asset uploads, resources * **Data Import**: CSV files, batch uploads # Formula Field The Formula Field component displays computed values calculated from other fields. This is a read-only field where the value is automatically calculated by the backend. ## Basic Usage [#basic-usage] ## Text Formula [#text-formula] ## Date Formula [#date-formula] ## Field Schema [#field-schema] A formula field is authored as `FormulaFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the expression, its declared return type and the recompute switch. `return_type` is a closed union — `'text' | 'number' | 'boolean' | 'date' | 'datetime'`. ```ts import type { FormulaFieldMetadata } from '@object-ui/types'; const totalPrice: FormulaFieldMetadata = { type: 'formula', name: 'total_price', label: 'Total Price', readonly: true, formula: 'quantity * unit_price', return_type: 'number', auto_compute: true, }; ``` The computed value, and the `className` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Return Types [#return-types] The formula field formats values based on return type: * **number**: Displays with decimal precision * **currency**: Displays with currency symbol * **boolean**: Displays as Yes/No * **date**: Displays formatted date * **text**: Displays as string ## Formula Examples [#formula-examples] Common formula patterns: ```plaintext // Arithmetic formula: 'price * quantity' formula: '(subtotal - discount) * tax_rate' // Text concatenation formula: 'first_name + " " + last_name' formula: 'city + ", " + state + " " + zip' // Conditional formula: 'IF(age >= 18, "Adult", "Minor")' formula: 'IF(status == "closed", completed_at, null)' // Date calculations formula: 'created_at + 7 days' formula: 'end_date - start_date' ``` ## Cell Renderer [#cell-renderer] In tables/grids, displays with monospace font: ```ts import { FormulaCellRenderer } from '@object-ui/fields'; // Renders computed value in monospace font ``` ## Backend Implementation [#backend-implementation] Formula fields are computed on the backend: ```plaintext // Example backend calculation const calculateFormula = (formula: string, record: any) => { // Parse and evaluate formula if (formula === 'quantity * price') { return record.quantity * record.price; } // Use expression parser for complex formulas return evaluateExpression(formula, record); }; ``` ## Use Cases [#use-cases] * **Calculations**: Totals, subtotals, tax amounts * **Aggregations**: Sum of related records * **Concatenations**: Full names, addresses * **Derived Values**: Age from birthdate, days until deadline * **Conditional Logic**: Status based on other fields # Grid Field The Grid Field component provides an inline table for managing related records or tabular data within a parent record. It displays data in rows and columns with optional editing capabilities. ## Basic Usage [#basic-usage] ## With Data [#with-data] ## Read-Only [#read-only] ## Field Schema [#field-schema] A grid field is authored as `GridFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the column list and the row-count and row-action limits. Each column is a `GridColumnDefinition`, so the columns are checked by the same compiler that checks the field. ```ts import type { GridFieldMetadata } from '@object-ui/types'; const lineItems: GridFieldMetadata = { type: 'grid', name: 'line_items', label: 'Line Items', columns: [ { name: 'product', label: 'Product', type: 'lookup', required: true, width: 240 }, { name: 'quantity', label: 'Qty', type: 'number', defaultValue: 1, width: 80 }, { name: 'unit_price', label: 'Unit Price', type: 'currency', width: 120 }, ], min_rows: 1, max_rows: 50, allow_add: true, allow_delete: true, allow_reorder: false, }; ``` A column's `width` is a **number** of pixels, and there is no per-column `editable` key: whether cells can be edited follows the field's own read-only state. The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Column Types [#column-types] Columns can use any field type: ```plaintext columns: [ { name: 'name', label: 'Name', type: 'text', required: true }, { name: 'quantity', label: 'Qty', type: 'number', min: 1 }, { name: 'price', label: 'Price', type: 'currency', currency: 'USD' }, { name: 'date', label: 'Date', type: 'date' }, { name: 'status', label: 'Status', type: 'select', options: [...] }, { name: 'active', label: 'Active', type: 'boolean' }, { name: 'receipt', label: 'Receipt', type: 'file', accept: ['image/*', '.pdf'] } ] ``` ### File / image columns [#file--image-columns] A `file` column renders a real upload control inside the cell — a compact "Upload" button that opens the native file picker, with uploaded files shown as removable chips (image files show a thumbnail). This covers the common "attach a receipt per expense line" pattern without opening the per-row form. * `accept?: string[]` — restrict the picker (e.g. `['image/*', '.pdf']`). * `multiple?: boolean` — allow several files per cell. * Uploads go through the configured `UploadProvider` adapter, exactly like the full-size file field. When grid columns are auto-derived from a child object's schema (master-detail subforms), `file` / `image` / `avatar` fields map to `file` columns automatically — image-flavoured fields default to `accept: ['image/*']`. Because file/image/avatar now render in-grid, a child object with a *single* such field keeps the inline **grid** form factor by default (the smart `inlineEdit` heuristic no longer forces a per-row form for one attachment column). Only a truly form-only field (textarea / rich text / JSON / location) or **several** rich fields tips the default to the per-row form; an explicit `inlineEdit: 'grid' | 'form'` always wins. ## Data Format [#data-format] Grid data is stored as an array of objects: ```plaintext const gridValue = [ { product: 'Item 1', quantity: 2, price: 29.99 }, { product: 'Item 2', quantity: 1, price: 49.99 }, { product: 'Item 3', quantity: 5, price: 9.99 } ]; ``` ## Common Patterns [#common-patterns] ### Invoice Line Items [#invoice-line-items] ```plaintext { type: 'grid', name: 'line_items', label: 'Line Items', columns: [ { name: 'description', label: 'Description', type: 'text', required: true }, { name: 'quantity', label: 'Quantity', type: 'number', min: 1, required: true }, { name: 'unit_price', label: 'Unit Price', type: 'currency', required: true }, { name: 'amount', label: 'Amount', type: 'currency', readonly: true } ] } ``` ### Order Details [#order-details] ```plaintext { type: 'grid', name: 'order_details', label: 'Order Details', columns: [ { name: 'sku', label: 'SKU', type: 'text' }, { name: 'product', label: 'Product', type: 'lookup', reference_to: 'products' }, { name: 'quantity', label: 'Qty', type: 'number' }, { name: 'price', label: 'Price', type: 'currency' }, { name: 'discount', label: 'Discount', type: 'percent' }, { name: 'total', label: 'Total', type: 'currency', readonly: true } ] } ``` ### Task Checklist [#task-checklist] ```plaintext { type: 'grid', name: 'tasks', label: 'Tasks', columns: [ { name: 'task', label: 'Task', type: 'text', required: true }, { name: 'assigned_to', label: 'Assigned To', type: 'user' }, { name: 'due_date', label: 'Due Date', type: 'date' }, { name: 'completed', label: 'Done', type: 'boolean' } ] } ``` ## Features [#features] * **Table Display**: Clean tabular layout * **Pagination Preview**: Shows first 5 rows with "Showing X of Y" indicator * **Type-Specific Rendering**: Each column renders according to its type * **Read-Only Mode**: Full table view without editing * **Responsive**: Scrollable for many columns ## Cell Renderer [#cell-renderer] In tables/grids, a `grid` value is shown as a compact placeholder, not as a nested table. It has no named renderer export of its own — the component is resolved by field type, which is the supported path for every type: ```ts import { getCellRenderer } from '@object-ui/fields'; const GridCell = getCellRenderer('grid'); // renders: [Grid] ``` ## Full Grid Functionality [#full-grid-functionality] For advanced grid features, use the grid plugin (`@object-ui/plugin-grid`): ```plaintext // Basic inline grid (simple display) { type: 'grid', name: 'items', columns: [...] } // Advanced grid with full features (requires plugin) // Keys sit on the node — a `props` envelope is never read by the renderer { type: 'object-grid', // the registered type name — there is no `plugin:grid` bind: 'items', // resolves the array from the surrounding data scope columns: [ // spec `ListColumn[]` — each entry is keyed by `field` { field: 'product', label: 'Product' }, { field: 'quantity', label: 'Quantity', sortable: false } ], editable: true, // Plugin feature: inline cell editing pagination: { pageSize: 20 } // Plugin feature: pagination } ``` **Note**: `editable` and `pagination` are read off the node by `@object-ui/plugin-grid`, not by the basic grid field. Sorting and search are **not** node keys: column sorting is on by default and is turned off per column (`{ field, sortable: false }`), and there is no node-level `filterable` — a grid that fetches its own rows declares its query with `objectName` plus `filter` / `sort`. ## Use Cases [#use-cases] * **Invoice/Order Line Items**: Product lines, services * **Expense Reports**: Expense entries, receipts * **Time Tracking**: Time entries, work logs * **Inventory**: Stock items, materials * **Checklists**: Task lists, requirements * **Schedules**: Appointments, bookings * **Configurations**: Settings lists, parameters ## Backend Storage [#backend-storage] Grid data is typically stored as JSON: ```plaintext // Database column type: JSONB (PostgreSQL) interface OrderRecord { id: string; customer_id: string; line_items: Array<{ product: string; quantity: number; price: number; }>; } // Store in database const order = { customer_id: 'CUST-123', line_items: [ { product: 'Widget A', quantity: 2, price: 29.99 }, { product: 'Widget B', quantity: 1, price: 49.99 } ] }; await db.insert('orders', order); ``` ## Validation [#validation] Example validation for grid data: ```plaintext const validateGridData = (data: any[], columns: ColumnDefinition[]) => { const errors: string[] = []; data.forEach((row, index) => { columns.forEach(col => { // Check required columns if (col.required && !row[col.name]) { errors.push(`Row ${index + 1}: ${col.label} is required`); } // Validate by type if (col.type === 'number' && isNaN(row[col.name])) { errors.push(`Row ${index + 1}: ${col.label} must be a number`); } // Check min/max if (col.min !== undefined && row[col.name] < col.min) { errors.push(`Row ${index + 1}: ${col.label} must be >= ${col.min}`); } }); }); return errors; }; ``` ## Integration with Advanced Grid [#integration-with-advanced-grid] For full-featured grids, use the `@object-ui/plugin-grid` package which provides: * Inline editing * Add/remove rows * Sorting and filtering * Drag-and-drop reordering * Export functionality * Formula columns * Aggregation rows # Image Field The Image Field component provides an image upload interface with thumbnail previews, multiple image support, and visual file management. ## Basic Usage [#basic-usage] ## Multiple Images [#multiple-images] ## Field Schema [#field-schema] An image field is authored as `ImageFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the upload limits and the two pixel bounds. A stored image is `UploadedFileMetadata`, the same value shape the file field stores. ```ts import type { ImageFieldMetadata } from '@object-ui/types'; const productPhotos: ImageFieldMetadata = { type: 'image', name: 'product_photos', label: 'Product Photos', multiple: true, accept: ['image/png', 'image/jpeg', 'image/webp'], max_size: 5 * 1024 * 1024, max_files: 8, max_width: 4096, max_height: 4096, }; ``` The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Accepted Image Types [#accepted-image-types] By default, accepts all common image formats: ```plaintext // Default: 'image/*' // Specific formats: accept: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'] ``` ## Features [#features] * **Thumbnail Grid**: Images displayed in 4-column grid * **Preview**: Visual thumbnails for uploaded images * **Hover to Remove**: Delete button appears on hover * **Batch Upload**: Multiple image selection * **Size Restrictions**: Configurable file size limits ## Cell Renderer [#cell-renderer] In tables/grids, displays image thumbnails: ```ts import { ImageCellRenderer } from '@object-ui/fields'; // Shows up to 3 thumbnails plus count if more ``` ## Image Optimization [#image-optimization] For best results, consider: ```plaintext { type: 'image', name: 'product_images', label: 'Product Photos', multiple: true, max_size: 5242880, // 5MB max_files: 10, max_width: 2048, max_height: 2048, accept: ['image/jpeg', 'image/png', 'image/webp'] } ``` ## Integration Notes [#integration-notes] This component creates object URLs for preview. For production use, implement server-side upload: ```plaintext const uploadImage = async (file: File) => { // Validate dimensions const img = new Image(); img.src = URL.createObjectURL(file); await new Promise((resolve) => { img.onload = resolve; }); if (img.width > maxWidth || img.height > maxHeight) { throw new Error('Image dimensions too large'); } // Upload to server const formData = new FormData(); formData.append('image', file); const response = await fetch('/api/images', { method: 'POST', body: formData }); return response.json(); }; ``` ## Use Cases [#use-cases] * **User Profiles**: Avatar/profile pictures * **Product Catalogs**: Product images, SKU photos * **Photo Galleries**: Image collections, portfolios * **Content Management**: Blog post images, media assets * **Real Estate**: Property photos, listings # Location Field The Location Field component provides an input for geographic coordinates, storing latitude and longitude as a structured object. ## Basic Usage [#basic-usage] ## With Default Value [#with-default-value] ## Read-Only [#read-only] ## Field Schema [#field-schema] A location field is authored as `LocationFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the map's default zoom level. ```ts import type { LocationFieldMetadata } from '@object-ui/types'; const officeLocation: LocationFieldMetadata = { type: 'location', name: 'office_location', label: 'Office Location', placeholder: 'latitude, longitude', required: false, default_zoom: 12, }; ``` The coordinates themselves are the field's **value**, not metadata: the widget stores an object carrying a `lat` and an `lng` and displays it as a comma-separated pair. That value shape is exported as `LocationValue` by `@objectstack/spec/data` — `{ lat, lng, altitude?, accuracy? }` — and it is what `valueSchemaFor({ type: 'location' })` validates a stored location against. The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Data Format [#data-format] The location field stores coordinates as an object: ```plaintext { lat: 37.7749, lng: -122.4194 } ``` `altitude` and `accuracy` are optional numbers on the same object. **Note**: the stored keys are `lat` and `lng`. The older `{ latitude, longitude }` spelling is deprecated and is **rejected** by `valueSchemaFor({ type: 'location' })` with `invalid_type` at `[lat]` and `[lng]` — do not author it. Input format: `latitude, longitude` — a user still types a comma-separated latitude-then-longitude pair; only the stored key names are `lat` / `lng`. * Example: `37.7749, -122.4194` ## Coordinate Ranges [#coordinate-ranges] * **Latitude** (`lat`): -90 to 90 (negative = South, positive = North) * **Longitude** (`lng`): -180 to 180 (negative = West, positive = East) Examples: * New York: `40.7128, -74.0060` * Tokyo: `35.6762, 139.6503` * Sydney: `-33.8688, 151.2093` * London: `51.5074, -0.1278` ## Use Cases [#use-cases] * **Store Locator**: Retail locations, branch offices * **Delivery Zones**: Service areas, delivery points * **Event Venues**: Conference locations, meeting points * **Asset Tracking**: Equipment locations, vehicle tracking * **Real Estate**: Property coordinates, land parcels ## Integration with Maps [#integration-with-maps] For full map functionality, consider integrating with map services. The example below has two halves: the **input** that captures a coordinate pair, and the **map node** that plots what it stored. **1. The input.** `LocationField` is the widget behind the `location` field type. Render it directly when you build the form yourself: ```tsx import { useState } from 'react'; import { LocationField } from '@object-ui/fields'; import type { LocationFieldMetadata } from '@object-ui/types'; const field: LocationFieldMetadata = { type: 'location', name: 'location', label: 'Store location', }; export function StoreLocationInput() { const [value, setValue] = useState<{ lat: number; lng: number } | null>(null); return ; } ``` **2. The map over the same field.** With the map plugin installed, an `object-map` node reads that field off every record. It is a metadata node, not a component call — the keys sit on the node itself, and a `props` envelope is never read by the renderer: ```jsonc { "type": "object-map", // the registered type name — there is no `plugin:map` "objectName": "store", // the records to plot "map": { // the declared config input; markers are derived from the data "locationField": "location", // this page's field, read as { lat, lng } "titleField": "name", // field used as the marker label "zoom": 12, // initial zoom level // Initial centre as [latitude, longitude]. Used only when no record // matches — with records the view fits to their bounds instead. "center": [37.7749, -122.4194] } } ``` **Note**: the map plots the records it fetches, so it takes an `objectName` (or an explicit `data` array) rather than a `bind` path, and it derives its markers from those records — a `markers` key on the node is not read. Every marker setting lives under the declared `map` input. ## Validation [#validation] The field validates coordinate ranges: ```plaintext // Valid coordinates { lat: 37.7749, lng: -122.4194 } ✓ // Invalid - latitude out of range { lat: 95, lng: -122.4194 } ✗ // Invalid - longitude out of range { lat: 37.7749, lng: 200 } ✗ ``` # Lookup Field The Lookup Field component provides a reference field for creating relationships between objects and records. It supports dynamic data loading from a DataSource with debounced search, loading/error/empty states, keyboard navigation, and optional quick-create entry. ## Basic Usage [#basic-usage] ## Multiple References [#multiple-references] ## Field Schema [#field-schema] A lookup field is authored as `LookupFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the reference target, the display and id fields, an optional static option list, and the Record Picker's column and paging configuration. Static options are `SelectOptionMetadata`, and picker columns are `LookupColumnDef` — both exported, both checked here. ```ts import type { LookupFieldMetadata } from '@object-ui/types'; const accountId: LookupFieldMetadata = { type: 'lookup', name: 'account_id', label: 'Account', placeholder: 'Search accounts…', required: true, reference_to: 'accounts', reference_field: 'name', descriptionField: 'industry', idField: '_id', multiple: false, searchable: true, allow_create: true, // Record Picker dialog (Enterprise): columns accept a field name or a descriptor. lookup_columns: ['name', { field: 'industry', label: 'Industry', width: '160px' }], lookup_page_size: 10, lookupFilters: [{ field: 'active', operator: 'eq', value: true }], }; ``` When no data source is available the field falls back to a static option list: ```ts import type { LookupFieldMetadata } from '@object-ui/types'; const priority: LookupFieldMetadata = { type: 'lookup', name: 'priority', label: 'Priority', reference_to: 'priorities', options: [ { label: 'High', value: 'high' }, { label: 'Normal', value: 'normal' }, ], }; ``` A static option may also carry a `description` — secondary text the picker's typeahead searches alongside the label. It is a declared `SelectOptionMetadata` member (declared for exactly that consumption — [objectui#6153](https://github.com/objectstack-ai/objectui/issues/6153) — and aligned with `@objectstack/spec`'s `SelectOptionSchema.description`). The `dataSource` a host injects and the `onCreateNew` callback it passes are widget props, not metadata. The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Dynamic Data Source [#dynamic-data-source] When a `DataSource` is available (via `SchemaRendererContext`, explicit prop, or field config), the Lookup popup **automatically** fetches records from the referenced object: ```plaintext // Automatic — DataSource from SchemaRendererContext // (works out-of-the-box in ObjectForm, DrawerForm, etc.) { type: 'lookup', name: 'customer', label: 'Customer', reference_to: 'customers', reference_field: 'name', // Display field (default: 'name') descriptionField: 'industry', // Optional secondary field } ``` The popup will: 1. Fetch records via `dataSource.find(reference_to, { $top: 50 })` on open 2. Send `$search` queries with 300ms debounce as the user types 3. Show loading spinner, error state with retry, and empty state 4. Display "Showing X of Y" when more records exist than the page size 5. Show a **"Show All Results"** button (inside the popover) to open the full Record Picker dialog when total exceeds page size ## Browse All Button [#browse-all-button] Every Lookup field with a `dataSource` always renders a **"Browse All"** button (table icon) next to the quick-select trigger. This button opens the full **RecordPickerDialog** directly, regardless of dataset size — ensuring enterprise features like multi-column tables, sort/filter bar, and cell renderers are always discoverable. * Always visible when `dataSource` is configured * Opens the Record Picker dialog without needing to open the popover first * Keyboard accessible and screen-reader friendly (`aria-label="Browse all records"`) ## Record Picker Dialog (Enterprise) [#record-picker-dialog-enterprise] The full **RecordPickerDialog** can be opened in two ways: 1. **"Browse All" button** (table icon) — always visible next to the quick-select trigger 2. **"Show All Results"** link inside the popover — shown when total records exceed the page size ```plaintext // Configure the Record Picker with lookup_columns { type: 'lookup', name: 'order', label: 'Order', reference_to: 'orders', reference_field: 'order_number', descriptionField: 'customer_name', lookup_columns: [ { field: 'order_number', label: 'Order #' }, { field: 'customer_name', label: 'Customer' }, { field: 'total_amount', label: 'Amount' }, { field: 'status', label: 'Status' }, ], lookup_page_size: 15, } ``` The Record Picker dialog provides: * **Multi-column table** with configurable columns via `lookup_columns` * **Search** with debounced server-side querying * **Column sorting** via clickable headers (sends `$orderby` to DataSource) * **Pagination** with page-by-page navigation * **Keyboard navigation** — Arrow keys to move between rows, Enter/Space to select * **Single/Multi-select** with visual check indicators and confirmation flow * **Responsive layout** — Mobile-friendly width (95vw on small screens) * **Loading, error, and empty states** * Auto-inferred columns from `reference_field` when `lookup_columns` is not set ## Lookup vs Master-Detail [#lookup-vs-master-detail] * **Lookup**: Standard reference field, can be deleted independently * **Master-Detail**: Parent-child relationship, deleting parent deletes children ## Cell Renderer [#cell-renderer] In tables/grids, lookup values display the referenced record name: ```ts import { LookupCellRenderer } from '@object-ui/fields'; // Single value: Display name/label // Multiple values: Multiple chips/badges ``` ## Use Cases [#use-cases] * **Assignments**: Assign tasks to users * **Relationships**: Link related records * **Categories**: Reference to category objects * **Parent Records**: Master-detail relationships * **Team Members**: Multi-user references ## Features [#features] * **Two-Level Interaction**: Popover typeahead (Level 1) + full Record Picker dialog (Level 2) * **Record Picker Dialog**: Enterprise-grade table with multi-column, pagination, search, sorting * **Inline Popover**: Level 1 opens as anchored dropdown (non-modal) for fast typeahead * **Column Sorting**: Clickable column headers with `$orderby` server-side sort * **Dynamic DataSource Loading**: Automatically fetches records from referenced objects * **Search**: Debounced type-ahead search with `$search` parameter * **Multi-Select**: Support for multiple references with confirmation flow * **Keyboard Navigation**: Arrow keys to navigate rows, Enter to select in both levels * **Responsive**: Mobile-friendly width, adapts to screen size * **Loading/Error/Empty States**: Friendly feedback for all states * **Secondary Field Display**: Show description/subtitle per option * **Quick-Create Entry**: Optional "Create new" button when no results * **Configurable Columns**: `lookup_columns` for multi-column picker display * **Base Filters**: `lookupFilters` to restrict selectable records * **Pagination**: Page-by-page navigation in Record Picker dialog * **Backward Compatible**: Falls back to static options when no DataSource # Number Field The Number Field component provides a numeric input for collecting integer or decimal numbers with optional precision control. ## Basic Usage [#basic-usage] ## With Precision [#with-precision] ## With Min/Max [#with-minmax] ## Field Schema [#field-schema] A number field is authored as `NumberFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the numeric bounds, the stored precision and scale, and the stepper increment. ```ts import type { NumberFieldMetadata } from '@object-ui/types'; const quantity: NumberFieldMetadata = { type: 'number', name: 'quantity', label: 'Quantity', placeholder: '0', required: true, min: 0, max: 9999, precision: 10, scale: 0, step: 1, }; ``` The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## What the browser rewrites before this field sees it [#what-the-browser-rewrites-before-this-field-sees-it] This widget renders a native `type="number"` input, so the browser decides what the box accepts. Two things can happen and **only one of them is announced**: * **Announced.** Text the browser cannot read at all — `1e`, a lone `-`, a lone `.` — leaves the box visibly showing what was typed while its value reads empty. The field is marked `aria-invalid` and draws *"Not saved: the text in this box is not a number."* * ⚠️ **Not announced.** Entries the browser silently **truncates**: pasting `1.2.3` stores `1.23`, and `0x10` stores `10`. No warning is possible here — the browser discards the extra characters as they arrive, so nothing reaches ObjectUI to check. ⛔ **"No warning" therefore does not mean "the value is right."** Full explanation and the reasoning: [What a number field silently rewrites](/docs/guide/fields#what-a-number-field-silently-rewrites). ## Use Cases [#use-cases] * **Quantities**: Order quantities, stock levels * **Measurements**: Dimensions, weights, distances * **Ratings**: Numeric ratings or scores * **Ages**: User age or duration values ## Cell Renderer [#cell-renderer] In tables/grids, numbers are displayed with tabular formatting: ```ts import { NumberCellRenderer } from '@object-ui/fields'; // Renders with precision and tabular-nums font // Example: 1,234.56 ``` # Object Field The Object Field component provides a JSON editor for storing and editing structured data objects. It validates JSON syntax and provides a textarea interface for complex data. ## Basic Usage [#basic-usage] ## With Schema [#with-schema] ## Nested Data [#nested-data] ## Read-Only [#read-only] ## Field Schema [#field-schema] An object field is authored as `ObjectFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with one key, an optional `schema` describing the JSON the field accepts. ```ts import type { ObjectFieldMetadata } from '@object-ui/types'; const settings: ObjectFieldMetadata = { type: 'object', name: 'settings', label: 'Settings', placeholder: '{ }', help: 'Stored as JSON.', schema: { theme: { type: 'string' }, notifications: { type: 'boolean' }, }, }; ``` The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## JSON Validation [#json-validation] The field validates JSON syntax in real-time: * **Valid JSON**: Updates the value immediately * **Invalid JSON**: Maintains current valid value, doesn't update * **Empty Input**: Sets value to `null` ## Data Format [#data-format] The object field stores data as parsed JSON: ```plaintext // Input (string) '{"name": "John", "age": 30}' // Stored (object) { name: "John", age: 30 } // Display (formatted) { "name": "John", "age": 30 } ``` ## Common Patterns [#common-patterns] ### Configuration Objects [#configuration-objects] ```plaintext { type: 'object', name: 'api_config', label: 'API Configuration', value: { endpoint: 'https://api.example.com', timeout: 5000, retries: 3, headers: { 'Content-Type': 'application/json' } } } ``` ### Metadata [#metadata] ```plaintext { type: 'object', name: 'custom_metadata', label: 'Custom Metadata', value: { tags: ['important', 'urgent'], priority: 'high', department: 'engineering' } } ``` ### Preferences [#preferences] ```plaintext { type: 'object', name: 'user_preferences', label: 'Preferences', value: { theme: 'dark', language: 'en', notifications: { email: true, push: false, sms: false } } } ``` ## Cell Renderer [#cell-renderer] In tables/grids, an `object` value is rendered by the JSON cell renderer — `getCellRenderer('object')` resolves to this same component, shared with `json`, `composite` and `record`: ```ts import { JsonCellRenderer } from '@object-ui/fields'; // renders single-line JSON, // truncated to the column width with the full text in the cell's `title`. // A null or empty value renders an em-dash instead. ``` ## Schema Validation [#schema-validation] For typed object fields, you can define a schema in two formats: **Simplified Format** (for documentation): ```plaintext { type: 'object', name: 'product_specs', label: 'Product Specifications', schema: { weight: 'number', dimensions: { width: 'number', height: 'number', depth: 'number' }, materials: 'array' } } ``` **JSON Schema Format** (for validation): ```plaintext { type: 'object', name: 'product_specs', label: 'Product Specifications', schema: { type: 'object', properties: { weight: { type: 'number' }, dimensions: { type: 'object', properties: { width: { type: 'number' }, height: { type: 'number' }, depth: { type: 'number' } } }, materials: { type: 'array' } } } } ``` ## Use Cases [#use-cases] * **Configuration**: Application settings, API configurations * **Metadata**: Custom properties, tags, attributes * **API Responses**: Storing API response data * **Preferences**: User preferences, feature flags * **Structured Data**: Any complex structured information * **JSON Storage**: Raw JSON data storage ## Best Practices [#best-practices] 1. **Use for Flexible Data**: When schema changes frequently 2. **Validate on Backend**: Always validate structure server-side 3. **Consider Alternatives**: Use typed fields when structure is fixed 4. **Document Schema**: Provide clear documentation for expected structure 5. **Size Limits**: Set reasonable size limits for JSON data ## Backend Validation [#backend-validation] ObjectUI does not enforce `schema`. `ObjectField` checks JSON **syntax** only — it accepts any value `JSON.parse` accepts, and simply declines to propagate a draft it cannot parse — so nothing on the client rejects a well-formed object whose shape is wrong. Structural validation belongs on the server. The `schema` you author is carried on the field metadata untouched, and in the JSON Schema Format above it is an ordinary JSON Schema document. That is the whole integration point: the server validates the incoming value against the very same object, using whichever JSON Schema validator it already has (Ajv, for instance — ObjectUI ships none and names none). ```ts import type { ObjectFieldMetadata } from '@object-ui/types'; const apiConfig: ObjectFieldMetadata = { type: 'object', name: 'api_config', label: 'API Configuration', schema: { type: 'object', properties: { api_key: { type: 'string', minLength: 1 }, timeout: { type: 'number', minimum: 0 }, enabled: { type: 'boolean' }, }, required: ['api_key'], }, }; ``` One caveat when you do: the Simplified Format above is documentation for a reader, not a validator input — only the JSON Schema Format can be handed to a JSON Schema validator as-is. # Password Field The Password Field component provides a secure text input for passwords with a toggle button to show/hide the password text. ## Basic Usage [#basic-usage] ## With Validation [#with-validation] ## Confirm Password [#confirm-password] ## Field Schema [#field-schema] A password field is authored as `PasswordFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the two length bounds. The reveal toggle and the masked read-only rendering are the widget's. ```ts import type { PasswordFieldMetadata } from '@object-ui/types'; const password: PasswordFieldMetadata = { type: 'password', name: 'password', label: 'Password', placeholder: 'Enter a password', required: true, min_length: 12, max_length: 128, }; ``` The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Features [#features] * **Masked Input**: Password is hidden by default with bullet points (•) * **Toggle Visibility**: Eye icon button to show/hide password * **Secure Display**: Read-only mode always shows •••••••• * **Validation**: Built-in support for length requirements ## Security Best Practices [#security-best-practices] When using password fields: 1. **Never log passwords**: Don't log form values containing passwords 2. **Use HTTPS**: Always transmit over secure connections 3. **Validation**: Enforce minimum length and complexity requirements 4. **Storage**: Never store passwords in plain text on the backend 5. **Auto-complete**: Consider disabling auto-complete for sensitive passwords ## Use Cases [#use-cases] * **User Registration**: New account password * **Login Forms**: User authentication * **Password Change**: Updating existing passwords * **Security Settings**: API keys, tokens (when masked input is appropriate) # Percent Field The Percent Field component provides a percentage input that automatically converts between stored decimal values (0-1) and displayed percentage values (0-100). ## Basic Usage [#basic-usage] ## With Precision [#with-precision] ## Required Field [#required-field] ## Read-Only [#read-only] ## Field Schema [#field-schema] A percent field is authored as `PercentFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the decimal precision and the two bounds. ```ts import type { PercentFieldMetadata } from '@object-ui/types'; const discountRate: PercentFieldMetadata = { type: 'percent', name: 'discount_rate', label: 'Discount Rate', placeholder: '0%', precision: 2, min: 0, max: 1, }; ``` The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## What the browser rewrites before this field sees it [#what-the-browser-rewrites-before-this-field-sees-it] This widget renders a native `type="number"` input, so the browser decides what the box accepts. Two things can happen and **only one of them is announced**: * **Announced.** Text the browser cannot read at all — `1e`, a lone `-`, a lone `.` — leaves the box visibly showing what was typed while its value reads empty. The field is marked `aria-invalid` and draws *"Not saved: the text in this box is not a number."* * ⚠️ **Not announced.** Entries the browser silently **truncates**: pasting `1.2.3` stores `0.0123`, and `0x10` stores `0.1`. No warning is possible here — the browser discards the extra characters as they arrive, so nothing reaches ObjectUI to check. ⛔ **"No warning" therefore does not mean "the value is right."** Full explanation and the reasoning: [What a number field silently rewrites](/docs/guide/fields#what-a-number-field-silently-rewrites). ## Value Conversion [#value-conversion] The percent field handles automatic conversion: * **Stored Value**: Decimal between 0 and 1 (e.g., `0.75`) * **Display Value**: Percentage between 0 and 100 (e.g., `75%`) Example: ```plaintext // Stored in database: 0.85 // Displayed to user: 85% // User enters: 90 // Stored as: 0.90 ``` ## Cell Renderer [#cell-renderer] In tables/grids, values are formatted with percentage symbol: ```ts import { PercentCellRenderer } from '@object-ui/fields'; // Renders: 85.50% ``` ## Use Cases [#use-cases] * **Progress Tracking**: Task completion, project progress * **Analytics**: Conversion rates, success metrics * **Financial Data**: Interest rates, discount percentages * **Statistics**: Performance scores, efficiency ratings # Phone Field The Phone Field component provides a text input optimized for phone number entry with proper formatting. ## Basic Usage [#basic-usage] ## Required Phone [#required-phone] ## Field Schema [#field-schema] A phone field is authored as `PhoneFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with a display format. ```ts import type { PhoneFieldMetadata } from '@object-ui/types'; const mobile: PhoneFieldMetadata = { type: 'phone', name: 'mobile', label: 'Mobile', placeholder: '(555) 000-0000', required: false, format: '(###) ###-####', }; ``` The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Cell Renderer [#cell-renderer] In tables/grids, phone numbers are clickable tel: links: ```ts import { PhoneCellRenderer } from '@object-ui/fields'; // Renders as: +1 (555) 123-4567 // Clicking initiates a phone call on mobile devices ``` ## Use Cases [#use-cases] * **Contact Information**: Customer phone numbers * **Support**: Support line numbers * **Emergency Contacts**: Emergency contact numbers * **Delivery**: Delivery contact numbers * **Appointments**: Callback numbers ## Features [#features] * **Mobile Optimized**: Opens phone dialer on mobile * **Clickable Links**: tel: links in read-only mode * **International Support**: Supports international formats * **Accessible**: Proper input type for mobile keyboards # Rich Text Field The Rich Text Field component edits formatted text content with markdown or HTML support — a plain-textarea editor today, with the stored markup rendered formatted on every read surface. ## Basic Usage [#basic-usage] ## HTML Editor [#html-editor] ## Field Schema [#field-schema] `markdown` and `html` are two field types served by one widget, and each has its own exported metadata type — `MarkdownFieldMetadata` and `HtmlFieldMetadata` (`@object-ui/types`). Both extend `BaseFieldMetadata` and add a length bound and an inline-editor height; there is no combined "rich text" metadata type. ```ts import type { HtmlFieldMetadata, MarkdownFieldMetadata } from '@object-ui/types'; const releaseNotes: MarkdownFieldMetadata = { type: 'markdown', name: 'release_notes', label: 'Release Notes', placeholder: 'Write the notes…', max_length: 20000, // Inline editor height in text rows (positive integer; default 8). The // fullscreen dialog sizes itself and ignores it. rows: 12, }; const emailBody: HtmlFieldMetadata = { type: 'html', name: 'email_body', label: 'Email Body', max_length: 50000, }; ``` `rows` sizes the inline editor, in text rows — declared on both types (and on `@objectstack/spec`'s `FieldSchema` for the multiline editor types), matching the `textarea` field's key of the same name ([objectui#6140](https://github.com/objectstack-ai/objectui/issues/6140)). The editor is a plain textarea today — there is no formatting toolbar, no preview pane and no pixel height to configure, so neither metadata type declares one: `toolbar`, `preview`, `minHeight` and `maxHeight` are **not** metadata keys, and writing them does nothing. The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Supported Formats [#supported-formats] ### Markdown [#markdown] * **Headings**: `# H1`, `## H2`, `### H3` * **Bold/Italic**: `**bold**`, `*italic*` * **Lists**: Ordered and unordered * **Links**: `[text](url)` * **Images**: `![alt](url)` * **Code**: Inline and blocks * **Tables**: Markdown tables ### HTML [#html] * Full HTML editing support * Sanitization for security * Style preservation * Embedded media support ## Cell Renderer [#cell-renderer] In tables/grids, rich text is shown as plain text: ```ts import { TextCellRenderer } from '@object-ui/fields'; // Strips formatting, shows plain text only // Long content is truncated ``` ## Use Cases [#use-cases] * **Blog Posts**: Article content, blog posts * **Documentation**: Technical documentation * **Descriptions**: Product or feature descriptions * **Comments**: Rich formatted comments * **Emails**: Email templates, rich email content * **Pages**: CMS page content ## Features [#features] * **Markdown Support**: For lightweight formatting * **HTML Support**: For advanced formatting * **Formatted read surfaces**: Grids, detail pages and the readonly form branch render the stored markup formatted (sanitized for HTML) * **Fullscreen editing**: An expand affordance when the form opts in (`mobile.fullscreenLongText`) The editing surface itself is a plain textarea with a format indicator — a formatting toolbar, an edit-time preview pane and WYSIWYG editing are **not yet implemented** (whatever the editor grows into, both the inline and fullscreen surfaces gain it at once, since they share one editor component). ## Editor Modes [#editor-modes] The rich text field can operate in different modes: 1. **Markdown Mode** (`type: 'markdown'`) * Lightweight markup * Easy to write and read * Great for documentation 2. **HTML Mode** (`type: 'html'`) * Full HTML editing * Advanced formatting * Embedded content support # Select Field The Select Field component provides a dropdown for selecting one or more options from a predefined list. ## Basic Usage [#basic-usage] ## With Colors [#with-colors] ## Multiple Selection [#multiple-selection] ## Cascading & Role-Gated Options [#cascading--role-gated-options] An option can carry a `visibleWhen` CEL predicate — it is offered only when the predicate is TRUE. The predicate is evaluated against the **live record** plus **`current_user`**, the same engine and binding environment as a field-level `visibleWhen`. This single mechanism covers two needs: * **Cascading / dependent options** — narrow a child list by a parent field (country → province → city). * **Role / context gating** — offer an option only to certain users. Declare the sibling field(s) a select reacts to with `dependsOn`. While any is empty the control is **gated** (a "Select country first" hint) instead of showing an unfiltered list; once the parent changes, the list re-evaluates and any now-invalid selection is **cleared automatically** (no stale "China + California" pair). ### Cascading (country → province) [#cascading-country--province] ```json { "type": "form", "fields": [ { "name": "country", "label": "Country", "type": "select", "options": [ { "label": "China", "value": "cn" }, { "label": "United States", "value": "us" } ]}, { "name": "province", "label": "Province", "type": "select", "dependsOn": "country", "options": [ { "label": "Zhejiang", "value": "zj", "visibleWhen": "record.country == 'cn'" }, { "label": "Guangdong", "value": "gd", "visibleWhen": "record.country == 'cn'" }, { "label": "California", "value": "ca", "visibleWhen": "record.country == 'us'" }, { "label": "Texas", "value": "tx", "visibleWhen": "record.country == 'us'" } ]} ] } ``` Chain a third level (`city`, `dependsOn: "province"`) the same way — the gate and cascade-clear propagate down the chain. ### Role-gated option [#role-gated-option] ```json { "name": "visibility", "type": "select", "options": [ { "label": "Private (only me)", "value": "private" }, { "label": "My team", "value": "team" }, { "label": "Whole organization", "value": "org" }, { "label": "Public — external", "value": "public", "visibleWhen": "'admin' in current_user.positions" } ]} ``` > **Security — hiding is UX, not authorization.** A `visibleWhen` on an option > only removes it from the dropdown on the client; a determined caller can still > submit the value. When an option is gated for **access-control** reasons the > **server must also reject** writes of that value (the rule-validator evaluates > the picked value's `visibleWhen`). Use option `visibleWhen` for convenience and > cascades freely; for real authorization, pair it with server-side enforcement. ### When to use options vs. a lookup [#when-to-use-options-vs-a-lookup] `visibleWhen` options are for **small, static dictionaries** (category → subcategory, a handful of provinces). When the data is large, changes over time, or is shared across forms (real country/province/city tables, org units, product catalogs), model each level as a **`lookup`** with `depends_on` instead — the candidate query is filtered server-side and paginated. See [Lookup Field](/docs/fields/lookup). ## Field Schema [#field-schema] A select field is authored as `SelectFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the option list and the multiple/searchable switches. Each option is a `SelectOptionMetadata`, so the options are checked by the same compiler that checks the field. ```ts import type { SelectFieldMetadata } from '@object-ui/types'; const status: SelectFieldMetadata = { type: 'select', name: 'status', label: 'Status', placeholder: 'Select a status', required: true, multiple: false, searchable: true, options: [ { label: 'Draft', value: 'draft', color: 'gray' }, { label: 'Active', value: 'active', color: 'blue' }, // Offered only when the predicate is true, evaluated against the live record. { label: 'Archived', value: 'archived', color: 'red', visibleWhen: "current_user.is_admin" }, { label: 'Locked', value: 'locked', disabled: true }, ], }; ``` Cascading option lists are driven by a sibling field's value. The widget reads a camelCase `dependsOn` off the metadata, but no exported metadata type declares it — `BaseFieldMetadata` declares the snake\_case `depends_on` instead — so the two spellings disagree and the gap is tracked as [objectui#6153](https://github.com/objectstack-ai/objectui/issues/6153). The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Available Colors [#available-colors] * `gray` - Default neutral color * `red` - For errors, urgent items * `orange` - For warnings, high priority * `yellow` - For pending, attention needed * `green` - For success, completed * `blue` - For info, in progress * `indigo` - For special items * `purple` - For creative, design * `pink` - For featured items ## Cell Renderer [#cell-renderer] In tables/grids, select values are displayed as colored badges: ```ts import { SelectCellRenderer } from '@object-ui/fields'; // Single value: Colored badge // Multiple values: Multiple badges in a row ``` ## Use Cases [#use-cases] * **Status Fields**: Order status, task status * **Categories**: Product categories, content types * **Tags**: Multi-tag selection * **Priorities**: Task or ticket priorities * **Roles**: User roles or permissions # Summary Field The Summary Field component displays aggregated values from related records. This is a read-only field where the value is automatically calculated by the backend through rollup aggregations. ## Count Summary [#count-summary] ## Sum Summary [#sum-summary] ## Average Summary [#average-summary] ## Field Schema [#field-schema] A summary field is authored as `SummaryFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the related object, the aggregated field, the aggregation and its filter. `summary_type` is a closed union — `'count' | 'sum' | 'avg' | 'min' | 'max' | 'first' | 'last'`. ```ts import type { SummaryFieldMetadata } from '@object-ui/types'; const totalRevenue: SummaryFieldMetadata = { type: 'summary', name: 'total_revenue', label: 'Total Revenue', readonly: true, summary_object: 'opportunities', summary_field: 'amount', summary_type: 'sum', summary_filter: { stage: 'closed_won' }, auto_update: true, }; ``` The computed value, and the `className` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Summary Types [#summary-types] * **count**: Count of related records * **sum**: Sum of numeric field values * **avg**: Average of numeric field values * **min**: Minimum value in the field * **max**: Maximum value in the field ## Examples [#examples] ### Count Related Records [#count-related-records] ```plaintext { type: 'summary', name: 'task_count', label: 'Open Tasks', summary_object: 'tasks', summary_field: 'id', summary_type: 'count' } ``` ### Sum Amounts [#sum-amounts] ```plaintext { type: 'summary', name: 'total_sales', label: 'Total Sales', summary_object: 'invoices', summary_field: 'amount', summary_type: 'sum' } ``` ### Average Value [#average-value] ```plaintext { type: 'summary', name: 'avg_response_time', label: 'Avg Response Time', summary_object: 'support_tickets', summary_field: 'response_time_hours', summary_type: 'avg' } ``` ### Min/Max Values [#minmax-values] ```plaintext { type: 'summary', name: 'highest_bid', label: 'Highest Bid', summary_object: 'bids', summary_field: 'amount', summary_type: 'max' } ``` ## Cell Renderer [#cell-renderer] In tables/grids, a `summary` value is rendered by the formula cell renderer — `getCellRenderer('summary')` resolves to this same component: ```ts import { FormulaCellRenderer } from '@object-ui/fields'; // renders the computed // value as monospace text: 15750.5 // A null or empty value renders an em-dash instead. ``` ## Backend Implementation [#backend-implementation] Summary fields are calculated through database aggregations: ```plaintext // Example backend aggregation const calculateSummary = async (config: SummaryConfig, parentId: string) => { const { summary_object, summary_field, summary_type } = config; switch (summary_type) { case 'count': return db.count(summary_object, { parent_id: parentId }); case 'sum': return db.sum(summary_object, summary_field, { parent_id: parentId }); case 'avg': return db.avg(summary_object, summary_field, { parent_id: parentId }); case 'min': return db.min(summary_object, summary_field, { parent_id: parentId }); case 'max': return db.max(summary_object, summary_field, { parent_id: parentId }); } }; ``` ## Use Cases [#use-cases] * **Order Management**: Total order value, order count * **CRM**: Number of contacts, total deal value * **Project Management**: Task count, total hours * **Analytics**: Average ratings, min/max values * **Financial**: Sum of expenses, average transaction size # Text Field The Text Field component provides a single-line text input for collecting basic text data from users. It's the most commonly used field type for names, titles, and short text entries. ## Basic Usage [#basic-usage] ## With Placeholder [#with-placeholder] ## Required Field [#required-field] ## Read-Only [#read-only] ## Field Schema [#field-schema] A text field is authored as `TextFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the text-specific validation keys. ```ts import type { TextFieldMetadata } from '@object-ui/types'; const productName: TextFieldMetadata = { type: 'text', name: 'product_name', label: 'Product Name', placeholder: 'Enter a product name', help: 'Shown on the storefront and in search results.', required: true, min_length: 2, max_length: 120, pattern: '^[A-Za-z0-9 -]+$', pattern_message: 'Letters, digits, spaces and hyphens only.', }; ``` The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Use Cases [#use-cases] * **User Names**: Collecting first names, last names, or usernames * **Titles**: Post titles, document names, or product names * **Short Text**: Any short text input that doesn't require multiple lines * **IDs**: Custom identifiers or reference numbers ## Cell Renderer [#cell-renderer] When used in data tables or grids, the text field is rendered as simple truncated text: ```ts import { TextCellRenderer } from '@object-ui/fields'; // Automatically used for type: 'text' in grids/tables // Displays value or '-' if empty ``` # TextArea Field The TextArea Field component provides a multi-line text input for collecting longer text content from users, such as descriptions, comments, or notes. ## Basic Usage [#basic-usage] ## Custom Rows [#custom-rows] ## Required Field [#required-field] ## Field Schema [#field-schema] A textarea field is authored as `TextareaFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the length bounds and the editor's visible row count. ```ts import type { TextareaFieldMetadata } from '@object-ui/types'; const description: TextareaFieldMetadata = { type: 'textarea', name: 'description', label: 'Description', placeholder: 'Describe this record…', rows: 6, required: false, min_length: 0, max_length: 2000, }; ``` The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Use Cases [#use-cases] * **Descriptions**: Product descriptions, bio information * **Comments**: User feedback, review comments * **Notes**: Meeting notes, task descriptions * **Messages**: Contact form messages, support tickets ## Cell Renderer [#cell-renderer] When displayed in tables or grids, long text is truncated: ```ts import { TextCellRenderer } from '@object-ui/fields'; // Same renderer as text field - truncates long content ``` # Time Field The Time Field component provides a time-only input for collecting hour and minute information without a date component. ## Basic Usage [#basic-usage] ## With Default Value [#with-default-value] ## Required Field [#required-field] ## Read-Only [#read-only] ## Field Schema [#field-schema] A time field is authored as `TimeFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with a display format. ```ts import type { TimeFieldMetadata } from '@object-ui/types'; const startTime: TimeFieldMetadata = { type: 'time', name: 'start_time', label: 'Start Time', placeholder: 'HH:mm', required: true, format: 'HH:mm', }; ``` The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Time Format [#time-format] The time field uses 24-hour format: `HH:mm` Examples: * `09:00` - 9:00 AM * `14:30` - 2:30 PM * `23:45` - 11:45 PM ## Use Cases [#use-cases] * **Operating Hours**: Business hours, shift times * **Scheduling**: Daily task times, recurring events * **Alarms**: Reminder times, notification schedules * **Time Tracking**: Work start/end times # URL Field The URL Field component provides a text input with URL validation and clickable link rendering. ## Basic Usage [#basic-usage] ## Required URL [#required-url] ## Field Schema [#field-schema] A URL field is authored as `UrlFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with a single length bound. ```ts import type { UrlFieldMetadata } from '@object-ui/types'; const website: UrlFieldMetadata = { type: 'url', name: 'website', label: 'Website', placeholder: 'https://example.com', required: false, max_length: 2048, }; ``` The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Validation [#validation] The URL field automatically validates: * Proper URL protocol (http\:// or https\://) * Valid domain structure * No invalid characters ## Cell Renderer [#cell-renderer] In tables/grids, URLs are clickable external links: ```ts import { UrlCellRenderer } from '@object-ui/fields'; // Renders as: https://example.com // Opens in new tab with security attributes ``` ## Use Cases [#use-cases] * **Websites**: Company or personal websites * **Social Media**: Social media profile links * **Documentation**: External documentation links * **Resources**: Reference materials, articles * **Portfolios**: Portfolio or project links ## Features [#features] * **Link Preview**: Clickable links in read-only mode * **New Tab**: External links open in new tab * **Security**: Proper rel="noopener noreferrer" attributes * **Validation**: Real-time URL format validation * **Accessible**: Proper ARIA labels for screen readers # User Field The User Field component provides a user selector for assigning users or owners to records. It displays user avatars and supports both single and multiple user selection. ## Basic Usage [#basic-usage] ## Multiple Users [#multiple-users] ## Read-Only Record Owner [#read-only-record-owner] A record-owner field is a plain `user` field whose NAME carries the ownership meaning — there is no separate owner type. Marking it `readonly` is what makes it display-only. ## Field Schema [#field-schema] A user field is authored as `UserFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the picker style, the subtitle fields, the avatar field and the candidate filters. `user` is a lookup specialised to the `sys_user` system object, so its filters are `LookupFilterDef`. ```ts import type { UserFieldMetadata } from '@object-ui/types'; const owner: UserFieldMetadata = { type: 'user', name: 'owner_id', label: 'Owner', required: true, multiple: false, picker: 'search', subtitle: ['primary_business_unit_id.name', 'email'], avatar_field: 'image', lookupFilters: [{ field: 'banned', operator: 'ne', value: true }], }; ``` The field stores the selected user's id (or an array of ids when `multiple` is set), not a user object; the picker resolves names and avatars from `sys_user` itself. The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## User vs Owner [#user-vs-owner] Both are the same field **type**. What differs is the field's name and whether it is writable: * **Assignment field**: a selectable `user` field for assignees, team members, etc. * **Owner field**: a `user` field named `owner`, typically `readonly` and defaulted to the record creator. There is no `owner` field type. It existed as a synonym until objectui#4814 retired it (it resolved to the very same widget, and it was never a member of `@objectstack/spec`'s `FieldType`). Authoring `type: 'owner'` now renders a visible refusal naming this migration rather than silently falling back to a text input. Write `{ type: 'user', name: 'owner' }` instead. ## Display Features [#display-features] * **Avatar Badges**: User avatars with initials if no image * **Color Coding**: Consistent colors for user avatars * **Multiple Display**: Shows up to 3 users, then "+N more" * **Hover Info**: User details on avatar hover ## Cell Renderer [#cell-renderer] In tables/grids, displays avatar with name: ```ts import { UserCellRenderer } from '@object-ui/fields'; // Single user: Avatar + name // Multiple users: Overlapping avatars + count ``` ## How it works [#how-it-works] `user` fields are a **lookup specialized to the framework's `sys_user` object** — there is no custom user API to wire up. The `UserField` widget delegates to the shared lookup picker with the reference fixed to `sys_user`, reusing the same debounced search, record-picker dialog and id resolution as any lookup field: * The field stores the selected user's **id** (a foreign key to `sys_user`); `multiple: true` stores an array of ids. * A `dataSource` (provided by `SchemaRenderer` / the app shell) supplies the candidate search — the picker queries `sys_user` by name/email as you type. * On read, `$expand` resolves the id(s) to the full user record so cells and read-only views show the name/avatar (via `UserCellRenderer`). No custom user-management integration is required when a `dataSource` is present. ## Permission Patterns [#permission-patterns] Common permission configurations: ```plaintext // Record owner only — a `user` field whose NAME carries the ownership meaning { type: 'user', name: 'owner', label: 'Owner', readonly: true, defaultValue: 'current_user' } // Assignable user { type: 'user', name: 'assigned_to', label: 'Assigned To', required: true } // Team members { type: 'user', name: 'collaborators', label: 'Collaborators', multiple: true } ``` ## Use Cases [#use-cases] * **Task Management**: Task assignee, reviewer * **CRM**: Account owner, opportunity owner * **Support**: Ticket assignee, support agent * **Project Management**: Project members, task owners * **Approval Workflows**: Approvers, reviewers * **Collaboration**: Document collaborators, editors ## Backend behavior [#backend-behavior] The platform handles the common cases natively — you generally don't write backend code for user fields: * **Owner stamping** — declare `defaultValue: 'current_user'` and the acting user's id is filled in at insert time (no create hook required). * **Display resolution** — request `expand=` and the stored id(s) resolve to the full `sys_user` record (name / avatar) for rendering. * **Ownership & permissions** — record ownership and row-level security continue to use the platform's `owner_id` convention and security rules. # Vector Field The Vector Field component displays vector embeddings used in AI/ML applications. This is a read-only field that shows a preview of the embedding values and dimensionality. ## Basic Usage [#basic-usage] ## Different Dimensions [#different-dimensions] ## Field Schema [#field-schema] A vector field is authored as `VectorFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the embedding dimensionality and whether the stored vector is normalised. ```ts import type { VectorFieldMetadata } from '@object-ui/types'; const embedding: VectorFieldMetadata = { type: 'vector', name: 'embedding', label: 'Embedding', readonly: true, dimensions: 1536, normalize: true, }; ``` The computed value, and the `className` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). ## Display Format [#display-format] Vectors are displayed with a preview: ``` [0.1234, -0.5678, 0.9012...] (768D) ``` Shows: * First 3 values (formatted to 4 decimal places) * Total dimensionality in parentheses ## Common Embedding Dimensions [#common-embedding-dimensions] Different AI models use different embedding sizes: * **OpenAI text-embedding-3-small**: 512, 1536, or 3072 dimensions (configurable) * **OpenAI text-embedding-3-large**: 256, 1024, or 3072 dimensions (configurable) * **OpenAI text-embedding-ada-002**: 1536 dimensions (fixed) * **Sentence Transformers (BERT)**: 768 dimensions * **OpenAI CLIP**: 512 dimensions * **Word2Vec**: 100-300 dimensions * **Custom Models**: Variable dimensions ## Data Format [#data-format] Vectors are stored as arrays of floating-point numbers: ```plaintext // Example embedding const embedding: number[] = [ 0.1234, -0.5678, 0.9012, 0.3456, -0.7890, // ... 763 more values for 768D embedding ]; ``` ## Cell Renderer [#cell-renderer] In tables/grids, a `vector` value is shown as a compact placeholder, not as its components. It has no named renderer export of its own — the component is resolved by field type, which is the supported path for every type: ```ts import { getCellRenderer } from '@object-ui/fields'; const VectorCell = getCellRenderer('vector'); // renders: [Vector] ``` ## Vector Operations [#vector-operations] Common operations with vector fields (performed on backend): ### Similarity Search [#similarity-search] ```plaintext // Find similar vectors using cosine similarity const findSimilar = async (queryVector: number[], limit: number = 10) => { return await db.raw(` SELECT id, 1 - (embedding <=> $1) as similarity FROM documents ORDER BY embedding <=> $1 LIMIT $2 `, [queryVector, limit]); }; ``` ### Distance Metrics [#distance-metrics] * **Cosine Similarity**: Measures angle between vectors * **Euclidean Distance**: Straight-line distance * **Dot Product**: Inner product of vectors ## Use Cases [#use-cases] * **Semantic Search**: Find similar documents, products, or content * **Recommendation Systems**: Product recommendations, content suggestions * **Text Analysis**: Document clustering, topic modeling * **Image Search**: Similar image finding, visual search * **Anomaly Detection**: Identify outliers in data * **AI Applications**: Any machine learning feature vectors ## Backend Implementation [#backend-implementation] Example vector storage and search: ```plaintext // PostgreSQL with pgvector extension import { Client } from 'pg'; // Create table with vector column const createTable = async () => { await db.raw(` CREATE TABLE IF NOT EXISTS documents ( id SERIAL PRIMARY KEY, content TEXT, embedding vector(768) ); CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops); `); }; // Generate embedding (using OpenAI) const generateEmbedding = async (text: string): Promise => { const response = await openai.embeddings.create({ model: 'text-embedding-3-small', input: text }); return response.data[0].embedding; }; // Store document with embedding const storeDocument = async (content: string) => { const embedding = await generateEmbedding(content); return db.insert('documents', { content, embedding }); }; // Search similar documents const searchSimilar = async (query: string, limit = 10) => { const queryEmbedding = await generateEmbedding(query); return db.raw(` SELECT id, content, 1 - (embedding <=> $1::vector) as similarity FROM documents ORDER BY embedding <=> $1::vector LIMIT $2 `, [queryEmbedding, limit]); }; ``` ## Database Support [#database-support] Vector storage is supported by: * **PostgreSQL**: pgvector extension * **Pinecone**: Specialized vector database * **Weaviate**: Vector search engine * **Milvus**: Open-source vector database * **Qdrant**: Vector similarity search engine * **Chroma**: AI-native embedding database ## Performance Considerations [#performance-considerations] 1. **Indexing**: Use approximate nearest neighbor (ANN) indices 2. **Quantization**: Consider dimension reduction for storage 3. **Batch Operations**: Generate embeddings in batches 4. **Caching**: Cache frequently used embeddings 5. **Async Generation**: Generate embeddings asynchronously ## Example: Semantic Search [#example-semantic-search] ```plaintext // 1. Generate embedding for user query const query = "Find documentation about authentication"; const queryEmbedding = await generateEmbedding(query); // 2. Search for similar documents const results = await db.raw(` SELECT id, title, content, 1 - (embedding <=> $1::vector) as similarity FROM documents WHERE 1 - (embedding <=> $1::vector) > 0.7 ORDER BY embedding <=> $1::vector LIMIT 10 `, [queryEmbedding]); // 3. Display results with similarity scores results.forEach(doc => { console.log(`${doc.title} (${(doc.similarity * 100).toFixed(1)}% match)`); }); ``` # Field Widget Props Every field reference page in this section documents one thing: the **metadata you author** for that field type. This page documents the other half — the props a field **widget** receives when it renders. The two are different shapes with different producers, and confusing them is the most common authoring mistake this section can cause. A key like the live value, the host-supplied `className`, or the form's `disabled` state belongs to the widget at runtime; writing it on an object's field definition publishes a key `@objectstack/spec`'s strict schemas reject. ## The type is the source of truth [#the-type-is-the-source-of-truth] `FieldWidgetComponentProps` is exported from `@object-ui/fields`. It is a **closed** type — there is no `[key: string]: any` in it (objectui#3221), so a misspelled prop is a compile error rather than a permanent `undefined`. That is what makes it usable as a reference: the compiler answers "is this a real prop", and this page does not have to. A widget implements it by taking it as its props type: ```tsx import { toDomProps, type FieldWidgetComponentProps } from '@object-ui/fields'; /** A custom single-line widget, registered for a field type of your own. */ export function SlugWidget(props: FieldWidgetComponentProps) { const { value, onChange, field, readonly, disabled, className, error } = props; return ( onChange(event.target.value)} placeholder={field.placeholder} readOnly={readonly} disabled={disabled} // The widget drives the a11y state; the message TEXT stays with the form // renderer, and the required MARKER is drawn by its label. aria-invalid={Boolean(error)} aria-required={Boolean(field.required)} /> ); } ``` Read the full member list from the type — in your editor, or from `packages/fields/src/widgets/types.ts`, where every key carries a doc comment naming its producer and its consumer. This page deliberately does not copy that list: a hand-maintained restatement of a declared surface is exactly the drift these pages are being fixed for. ## What the categories are [#what-the-categories-are] The type is assembled from five groups, and knowing which group a key is in tells you who supplies it: 1. **The controlled-input contract.** The current value, the callback that changes it, the field's metadata carrier, and the display-state flags every widget interprets. Every widget in the package implements this group; the rest are optional. 2. **Host plumbing.** What a rendering host forwards to widgets that need more than a value — a data source for widgets that query records, live sibling-field values for cascading and dependent options, resolved labels and hints for the copy those gates render, a compact mode for grid cells, and record-selection callbacks for pickers. A widget that needs none of it destructures none of it. 3. **DOM pass-through.** The identity, focus and event keys that may legitimately land on the element a widget renders — the field's `id`, the `aria-describedby` the form control minted, and so on. `toDomProps` is this group's runtime executor, bound to the declaration in both directions by compile-time assertions, so a key cannot be declared here and silently never delivered. 4. **The ARIA attribute family**, intersected in whole from React's `AriaAttributes`. This group is the bulk of the member count, which is why the count is not a useful thing to quote. 5. **`data-*` attributes**, open by design and expressed as a template-literal key so `keyof` stays finite — an undeclared prop still fails. ## Metadata and props are two shapes, not one [#metadata-and-props-are-two-shapes-not-one] The authored metadata arrives at the widget under a single carrier (objectui#3233 converged it at the producers; there is no second key to check). The live value never lives on metadata, and the metadata never lives on the DOM: ```ts import type { FieldWidgetComponentProps } from '@object-ui/fields'; import type { FieldMetadata, TextFieldMetadata } from '@object-ui/types'; // What you AUTHOR: object metadata, validated at publish. const slug: TextFieldMetadata = { type: 'text', name: 'slug', label: 'Slug', max_length: 80, }; // What the widget RECEIVES at runtime. declare const props: FieldWidgetComponentProps; const carrier: FieldMetadata = props.field; // the authored metadata, unchanged const live: string = props.value; // never a metadata key export { slug, carrier, live }; ``` Assigning `slug` into `props.field` type-checks; the reverse — writing `props.value` into `slug` — does not, and that asymmetry is the whole distinction. ## Where each half is documented [#where-each-half-is-documented] * **Metadata keys** — the `Field Schema` section of each field page in this section, as a literal annotated with that field type's exported `*FieldMetadata`. * **Runtime props** — this page, and the type it names. # Agent Skills # Agent Skills [#agent-skills] ObjectUI ships an official **Agent Skill** — a structured bundle of rules, guides, and evals that teaches AI coding agents how to build pages, plugins, and integrations the *right* way (schema-first, no-touch zones respected, English-only, expression-aware). The skill is published on [skills.sh](https://skills.sh/objectstack-ai/objectui) and lives in [`skills/objectui/`](https://github.com/objectstack-ai/objectui/tree/main/skills/objectui) inside this repository. [![skills.sh](https://skills.sh/b/objectstack-ai/objectui)](https://skills.sh/objectstack-ai/objectui) ## Install [#install] One command, in your project root: ```bash npx skills add objectstack-ai/objectui ``` The `skills` CLI auto-detects which AI agent you're using and writes the skill to the location that agent reads from — for example: | Agent | Install location | | ---------------------------------------------------- | ---------------------------------- | | Claude Code | `.claude/skills/objectui/` | | GitHub Copilot | `.github/copilot/skills/objectui/` | | Cursor | `.cursor/skills/objectui/` | | Codex | `.codex/skills/objectui/` | | Windsurf, Gemini, Cline, Goose, Kilo, Droid, Trae, … | per-agent path | See [skills.sh/docs](https://skills.sh/docs) for the full, up-to-date list of supported agents. ## What you get [#what-you-get] Once installed the skill activates automatically whenever you describe ObjectUI work in chat. Nothing to import, nothing to configure. The skill is structured as a single entry point plus deep-dive guides: ``` skills/objectui/ ├── SKILL.md # Entry point — core principles, tech stack, scope ├── rules/ # Non-negotiable global constraints │ ├── protocol.md │ ├── styling.md │ ├── composition.md │ └── no-touch-zones.md ├── guides/ # Domain-specific deep dives, loaded on demand │ ├── architecture.md │ ├── page-builder.md │ ├── plugin-development.md │ ├── schema-expressions.md │ ├── data-integration.md │ ├── project-setup.md │ ├── testing.md │ ├── i18n.md │ ├── mobile.md │ ├── auth-permissions.md │ └── console-development.md └── evals/ # Machine-checkable prompts (one per guide) ``` When the agent picks up a task it: 1. Reads `SKILL.md` for core principles, scope boundaries, and the package map. 2. Loads the relevant `rules/*.md` so non-negotiables (Shadcn purity, expression syntax, layout composition) are respected. 3. Pulls the matching `guides/*.md` for the task at hand. ## What the skill enforces [#what-the-skill-enforces] The skill bakes the ObjectUI worldview into every answer your agent gives you: * **Schema-first**: page output is JSON for ``, not bespoke React. * **Shadcn-native aesthetics**: components stay in Tailwind + `cn()` + `cva`; no inline styles, no CSS-in-JS. * **Protocol agnostic**: data goes through the `DataSource` interface, not raw `fetch`/`axios`. * **Expression-aware**: `visible`, `hidden`, `disabled` use `${data.*}` / `${props.*}` templates, never raw JS. * **No-touch zones**: upstream Shadcn primitives in `packages/components/src/ui/**` are never edited — extensions live in `custom/` wrappers. * **English-only**: all generated component text, labels, comments, and docs are English. * **Scope discipline**: the skill explicitly defers backend/data-modelling questions to the `objectstack-*` skills, so it stays focused on the UI engine. Each rule has a machine-checkable eval under `evals/`, so regressions in agent behaviour are caught the same way regressions in code are. ## When *not* to use it [#when-not-to-use-it] If your question is purely about data modelling, kernel plugins, ObjectQL queries, CEL formulas, or server-side automation, prefer the matching `objectstack-*` skill. The ObjectUI skill's frontmatter explicitly defers those topics so multiple skills can coexist cleanly in your agent. ## Updating [#updating] To pull the latest version of the skill after we ship improvements: ```bash npx skills add objectstack-ai/objectui ``` The CLI is idempotent — running it again refreshes the local copy in place. ## Contributing [#contributing] The skill source lives in [`skills/objectui/`](https://github.com/objectstack-ai/objectui/tree/main/skills/objectui). PRs are welcome — see [`skills/objectui/README.md`](https://github.com/objectstack-ai/objectui/blob/main/skills/objectui/README.md) for the maintenance rules (guide ↔ eval naming, English-only, eval assertion format). # Architecture Overview # Architecture Overview [#architecture-overview] ObjectUI is a **Server-Driven UI (SDUI) engine** that transforms JSON schemas into fully interactive React interfaces built on Shadcn/Tailwind. This document covers the internal architecture, data flow, and extension points. ## The 3-Layer Architecture [#the-3-layer-architecture] ObjectUI enforces a strict separation across three layers. Each layer has clear constraints on what it may import and what it may contain. ``` ┌─────────────────────────────────────────────────────────────────────┐ │ Layer 1: @objectstack/spec (The Protocol) │ │ Pure TypeScript type definitions — 12 export modules │ │ ❌ No runtime code. No React. No dependencies. │ └──────────────────────────────┬──────────────────────────────────────┘ │ imports (never redefines) ┌──────────────────────────────▼──────────────────────────────────────┐ │ Layer 2: @object-ui/types (The Bridge) │ │ Re-exports spec types + ObjectUI-specific schemas │ │ ❌ No runtime code. Zero runtime dependencies. │ └──────────────────────────────┬──────────────────────────────────────┘ │ consumed by ┌──────────────────────────────▼──────────────────────────────────────┐ │ Layer 3: Implementations (The Runtime) │ │ core, react, components (91+), fields (35+), plugins, etc. │ └─────────────────────────────────────────────────────────────────────┘ ``` ### Layer 1 — `@objectstack/spec` (The Protocol) [#layer-1--objectstackspec-the-protocol] The upstream JSON specification for all ObjectStack products. ObjectUI **imports** these types but never redefines them. Located externally and consumed as `@objectstack/spec`; the range each package installs is declared in that package's own `package.json`, under `dependencies`. ### Layer 2 — `@object-ui/types` (The Bridge) [#layer-2--object-uitypes-the-bridge] Lives in `packages/types/`. Re-exports spec types and adds ObjectUI-specific schemas (component schemas, widget props, field props). Has **zero** runtime dependencies — only type-level imports of `@objectstack/spec` and `zod` for validation schemas. Marked `sideEffects: false`. ### Layer 3 — Implementations (The Runtime) [#layer-3--implementations-the-runtime] All packages that ship runnable code: `core`, `react`, `components`, `fields`, `layout`, `i18n`, `auth`, `permissions`, and every `plugin-*` package. ## Package Dependency Graph [#package-dependency-graph] ``` @objectstack/spec │ ▼ @object-ui/types ◄─────────────────────────────────────┐ │ │ ▼ │ @object-ui/core │ │ (registry, evaluator, actions, validation) │ ▼ │ @object-ui/react ──────► @object-ui/i18n │ │ (SchemaRenderer, hooks, contexts) │ ├──────────────────────────────────────┐ │ ▼ ▼ │ @object-ui/components @object-ui/fields │ │ (91+ Shadcn atoms) (35+ inputs) │ ▼ ▼ │ @object-ui/layout @object-ui/plugin-* │ (AppShell, Header, (grid, kanban, charts, │ Sidebar, routing) dashboard, form, etc.) │ │ │ └─────────────────┘ (all packages import types) ``` **Strict rules:** | Package | May Import | Must NOT Import | | ------------ | -------------------------------- | ------------------------ | | `types` | `@objectstack/spec` | Any runtime package | | `core` | `types`, `lodash`, `zod` | React, any UI library | | `react` | `core`, `types`, `i18n` | Plugin packages directly | | `components` | `types` (for props) | `core`, `react` | | `fields` | `types` (for `FieldWidgetProps`) | `core` business logic | | `plugin-*` | Any package | Other plugins | ## Data Flow: Schema → Screen [#data-flow-schema--screen] The rendering pipeline transforms a JSON schema into a live React component tree: ``` JSON Schema (from API or static file) │ ▼ ┌─────────────┐ ┌──────────────────┐ │ SchemaRenderer │───►│ ExpressionEvaluator │ │ (packages/ │ │ Resolves ${...} │ │ react/src/) │ │ expressions │ └──────┬────────┘ └──────────────────┘ │ ▼ ┌─────────────────┐ │ ComponentRegistry │ ← registry.resolve(schema.type) │ (packages/core/ │ │ src/registry/) │ └──────┬──────────┘ │ ▼ ┌──────────────────┐ │ React Component │ ← Wrapped in ErrorBoundary │ (Button, Grid, │ with ARIA attributes │ Kanban, etc.) │ └──────────────────┘ ``` 1. **SchemaRenderer** (`packages/react/src/SchemaRenderer.tsx`) receives a JSON schema object. 2. It evaluates dynamic expressions via `ExpressionEvaluator` (`packages/core/src/evaluator/`). 3. It looks up the component type in the `ComponentRegistry` (`packages/core/src/registry/Registry.ts`). 4. The matched component renders with schema props, wrapped in a per-component `ErrorBoundary` with ARIA accessibility attributes (`aria-label`, `aria-describedby`, `role`). ## The Plugin System [#the-plugin-system] Plugins are self-contained packages that register heavy or complex views (grids, kanbans, charts). They follow a consistent pattern defined in `packages/core/src/registry/PluginSystem.ts`. ### Plugin Lifecycle [#plugin-lifecycle] ``` import 'plugin-kanban' │ ▼ PluginSystem.load(plugin) │ ├─ Check dependencies │ ├─ Prevent duplicate loading │ └─ Call plugin.register(scope) ▼ PluginScope.registerComponent(type, Component, meta) │ └─ Auto-prefixes with plugin namespace ▼ ComponentRegistry.register('kanban-ui', KanbanRenderer, { namespace: 'plugin-kanban', category: 'plugin' }) ``` ### Lazy Loading [#lazy-loading] Plugins use `React.lazy()` wrapped by `LazyPluginLoader` (`packages/react/src/LazyPluginLoader.tsx`): * **Retry logic**: 2 retries with 1-second delay by default * **Custom fallbacks**: Loading skeleton + error boundary * **Tree-shaking**: Plugins are code-split from the initial bundle ### Plugin Registration Pattern [#plugin-registration-pattern] Every plugin follows the same structure (see `packages/plugin-kanban/`, `packages/plugin-grid/`): ```typescript // 1. Lazy-load the heavy implementation const LazyKanban = React.lazy(() => import('./KanbanImpl')); // 2. Define a thin wrapper with Suspense const KanbanRenderer: React.FC = ({ schema }) => ( }> ); // 3. Register in the global registry ComponentRegistry.register('kanban-ui', KanbanRenderer, { namespace: 'plugin-kanban', label: 'Kanban Board', category: 'plugin', inputs: [/* schema config */] }); ``` Scaffold a new plugin with `npx @object-ui/create-plugin`. ## State Management [#state-management] ObjectUI uses **React Context** for all state management, with contexts scoped to specific concerns: ### Core Contexts (`packages/react/src/context/`) [#core-contexts-packagesreactsrccontext] | Context | Purpose | | ----------------------- | ----------------------------------------------------- | | `SchemaRendererContext` | Data scope + debug mode for the rendering tree | | `ActionContext` | Action runner instance for handling user interactions | | `ThemeContext` | Theme tokens and dark/light mode | | `NotificationContext` | Toast and notification system | | `DndContext` | Drag-and-drop state for sortable views | ### Domain Contexts (separate packages) [#domain-contexts-separate-packages] | Context | Package | Purpose | | ------------------- | ----------------------- | ----------------------- | | `AuthContext` | `packages/auth/` | Authentication state | | `PermissionContext` | `packages/permissions/` | RBAC/ABAC permissions | | `I18nProvider` | `packages/i18n/` | Locale and translations | Contexts are composed at the application root and consumed by plugins and components via hooks (e.g., `useActionRunner`, `useExpression`, `useViewData`). ## Expression Evaluation [#expression-evaluation] The expression engine lives in `packages/core/src/evaluator/` and powers dynamic schemas: ### Components [#components] * **`ExpressionEvaluator.ts`** — Main engine: parses and evaluates `${...}` template expressions. * **`ExpressionContext.ts`** — Variable scope: provides `data`, `user`, `params` to expressions. * **`ExpressionCache.ts`** — Caches parsed expressions for repeated evaluations. * **`FormulaFunctions.ts`** — Built-in functions available inside expressions. ### Expression Types [#expression-types] ```json { "visible": "${data.role === 'admin'}", "label": "Hello, ${data.user.name}!", "disabled": "${data.status !== 'draft'}", "className": "${data.priority === 'high' ? 'text-red-500' : 'text-gray-500'}" } ``` Expressions support: * **String interpolation**: `"Welcome, ${data.name}"` * **Conditionals**: `"${data.age > 18}"` * **Ternary operators**: `"${data.active ? 'Yes' : 'No'}"` * **Variable references**: `data.*`, `user.*`, `params.*` ## Action System [#action-system] The action system (`packages/core/src/actions/`) handles user interactions defined in schemas. ### ActionRunner (`ActionRunner.ts`) [#actionrunner-actionrunnerts] Executes action schemas and returns directives: ``` User Click → Schema Action │ ▼ ActionRunner.execute(action, context) │ ├─ Check confirmation dialog │ ├─ Evaluate conditions │ └─ Execute by action type ▼ ActionResult ├─ reload: boolean ├─ redirect: string ├─ modal: SchemaObject └─ toast: { message, type } ``` ### Supported Action Types [#supported-action-types] | Type | Description | | -------- | -------------------------------------------------------------------------------------------- | | `script` | Dispatch a named/registered script action — an `action.body` always executes **server-side** | | `url` | Navigate to a URL | | `api` | Make an API request (AJAX) | | `modal` | Open a modal with a nested schema | | `flow` | Execute a multi-step action sequence | ### Server Action Dispatch (`serverActionHandler.ts`) [#server-action-dispatch-serveractionhandlerts] `ActionSchema.body` (the spec's preferred binding for script actions) executes server-side via `POST /api/v1/actions/{object}/{action}` — the client dispatches, it never interprets a body (a browser cannot enforce the L2 sandbox's capabilities/timeout/memory contract, and L1 is formula-engine CEL, a different dialect from the `${...}` evaluator). `createServerActionHandler` is the core factory every consumer uses to build that dispatch. It owns the protocol (name-based action identity, record-id resolution, re-entrancy guard, the `/actions` response-envelope rule) and injects the three things core has no opinion about: ```typescript import { createServerActionHandler } from '@object-ui/core'; const script = createServerActionHandler({ fetch: myAuthenticatedFetch, // auth is yours baseUrl: 'https://api.example.com', // origin is yours ('' = same-origin) resolveObject: () => currentObject, // fallback object scope is yours onRefresh: () => notifyDataChanged(), // data invalidation is yours }); // Registered handlers beat the built-in executors: ``` The console builds on the same factory (`@object-ui/app-shell`'s `createConsoleServerActionHandler` adds the popup pre-open dance and the `redirectUrl` convention on top). ### TransactionManager (`TransactionManager.ts`) [#transactionmanager-transactionmanagerts] Wraps multi-step actions in transactions for consistency, supporting rollback on failure. ## Key Directories Reference [#key-directories-reference] ``` packages/ ├── types/src/ # Layer 2 — Pure type definitions ├── core/src/ │ ├── evaluator/ # Expression engine │ ├── registry/ # Component & plugin registries │ ├── actions/ # ActionRunner, TransactionManager │ ├── validation/ # Schema validation engine │ ├── adapters/ # Data source adapters (API, Value) │ ├── data-scope/ # DataScopeManager │ ├── query/ # Query AST (filtering/sorting) │ ├── theme/ # ThemeEngine │ └── builder/ # Schema builder utilities ├── react/src/ │ ├── SchemaRenderer.tsx │ ├── LazyPluginLoader.tsx │ ├── context/ # All React contexts │ └── hooks/ # 20+ hooks (useExpression, useActionRunner, etc.) ├── components/ # 91+ Shadcn UI atoms ├── fields/ # 35+ field renderers ├── layout/ # AppShell, Header, Sidebar ├── i18n/ # Internationalization ├── auth/ # AuthContext + providers ├── permissions/ # RBAC/ABAC └── plugin-*/ # 20 plugin packages ├── plugin-grid/ ├── plugin-kanban/ ├── plugin-charts/ ├── plugin-dashboard/ ├── plugin-form/ └── ... (15 more) ``` ## Further Reading [#further-reading] * [Schema Overview](/docs/guide/schema-overview) — JSON schema structure * [Schema Rendering](/docs/guide/schema-rendering) — How schemas become UI * [Component Registry](/docs/guide/component-registry) — Registering components * [Plugin Development](/docs/guide/plugin-development) — Building plugins * [Expressions](/docs/guide/expressions) — Expression syntax reference * [Theming](/docs/guide/theming) — Theme configuration # Architecture Overview # Architecture Overview [#architecture-overview] ObjectUI is a universal, server-driven UI (SDUI) engine built on React, Tailwind CSS, and Shadcn UI. This guide explains the core architecture and how all the pieces work together. ## Core Philosophy [#core-philosophy] ObjectUI follows three fundamental principles: 1. **JSON-First**: Every UI element is described as JSON metadata, not hardcoded React components 2. **Backend Agnostic**: Works with any backend system (ObjectStack, custom APIs, etc.) 3. **Component Library Quality**: Combines low-code speed with Shadcn/Tailwind design quality ## Architecture Layers [#architecture-layers] ``` ┌─────────────────────────────────────────────┐ │ JSON Schema (Protocol) │ ← Backend sends this ├─────────────────────────────────────────────┤ │ @object-ui/react (Renderer) │ ← Interprets schema ├─────────────────────────────────────────────┤ │ Component Registry + Field Registry │ ← Lookup system ├─────────────────────────────────────────────┤ │ @object-ui/components (UI Primitives) │ ← Buttons, Cards, etc. │ @object-ui/fields (Form Inputs) │ ← Text, Date, Select │ @object-ui/layout (Page Structure) │ ← AppShell, Sidebar │ @object-ui/plugin-* (Advanced Widgets) │ ← Grid, Charts, Kanban ├─────────────────────────────────────────────┤ │ Shadcn UI + Radix UI (Primitives) │ ← Accessible components │ Tailwind CSS (Styling) │ ← Utility-first CSS └─────────────────────────────────────────────┘ ``` ## Package Structure [#package-structure] ObjectUI is organized as a PNPM monorepo with clear separation of concerns: ### Core Packages [#core-packages] #### `@object-ui/types` [#object-uitypes] * **Role**: The Protocol * **Contains**: Pure TypeScript interfaces for JSON schemas * **Constraint**: ZERO dependencies, no React code * **Example**: `BaseSchema`, `ActionSchema`, `FieldSchema` #### `@object-ui/core` [#object-uicore] * **Role**: The Engine * **Contains**: Schema validation, expression evaluation, registries * **Constraint**: No UI library dependencies, logic only * **Features**: * Expression engine (`visible: "${data.age > 18}"`) * Schema registry and validation * Event system #### `@object-ui/react` [#object-uireact] * **Role**: The Runtime * **Contains**: `SchemaRenderer` and React integration * **Purpose**: Transforms JSON schemas into live React components ### UI Packages [#ui-packages] #### `@object-ui/components` [#object-uicomponents] * **Role**: The Atoms * **Contains**: Shadcn primitives (Button, Badge, Card, Dialog, etc.) * **Constraint**: Pure UI, no business logic * **Style**: Tailwind CSS with `class-variance-authority` #### `@object-ui/fields` [#object-uifields] * **Role**: The Inputs * **Contains**: Standard field renderers (Text, Number, Select, Date, etc.) * **Implements**: `FieldWidgetProps` interface * **Purpose**: Reusable form inputs with consistent API #### `@object-ui/layout` [#object-uilayout] * **Role**: The Shell * **Contains**: Page structure components (AppShell, Page, Sidebar, Header) * **Purpose**: Routing-aware composition and app scaffolding ### Plugin Packages [#plugin-packages] Each plugin provides specialized, complex widgets: * `@object-ui/plugin-grid` - Data tables with ObjectStack integration * `@object-ui/plugin-kanban` - Kanban board view * `@object-ui/plugin-charts` - Recharts-based visualizations * `@object-ui/plugin-calendar` - Calendar and event views * `@object-ui/plugin-map` - Map visualization * `@object-ui/plugin-form` - Advanced forms * `@object-ui/plugin-editor` - Code editor (Monaco) * `@object-ui/plugin-markdown` - Markdown renderer * `@object-ui/plugin-gantt` - Gantt chart timeline * `@object-ui/plugin-timeline` - Event timeline * `@object-ui/plugin-dashboard` - Dashboard layouts * `@object-ui/plugin-chatbot` - Chat interface **Important**: Heavy dependencies (like Monaco, Recharts) are only allowed in plugin packages to keep the core bundle small. ### Utility Packages [#utility-packages] Development tools and integration utilities: * `@object-ui/cli` - Command-line tool for building apps from schemas * `@object-ui/create-plugin` - Interactive plugin scaffolder * `@object-ui/runner` - Universal runtime for testing and demos * `@object-ui/data-objectstack` - ObjectStack data backend adapter * `vscode-extension` - VS Code extension for schema development [Learn more about utilities →](/docs/utilities) ## How Schema Rendering Works [#how-schema-rendering-works] ### 1. JSON Schema Input [#1-json-schema-input] A backend system sends a JSON schema: ```json { "type": "card", "title": "Welcome", "body": { "type": "text", "content": "Hello, ${user.name}!" } } ``` ### 2. SchemaRenderer Processing [#2-schemarenderer-processing] The `SchemaRenderer` component: 1. Receives the schema + data context 2. Evaluates expressions (`${user.name}`) 3. Looks up the component type in the registry 4. Recursively renders child schemas 5. Handles events and state updates ```tsx import { SchemaRenderer } from '@object-ui/react' import type { BaseSchema } from '@object-ui/types' // The schema from step 1, as the object the renderer receives. const schema: BaseSchema = { type: 'card', title: 'Welcome', body: { type: 'text', content: 'Hello, ${user.name}!', }, } function App() { const data = { user: { name: 'Alice' } } return } ``` ### 3. Component Registry Lookup [#3-component-registry-lookup] The registry maps type strings to React components: ```typescript // During app initialization ComponentRegistry.register('card', CardComponent) ComponentRegistry.register('text', TextComponent) // At runtime const Component = ComponentRegistry.get('card') // → CardComponent ``` ### 4. React Component Rendering [#4-react-component-rendering] The registered component renders with evaluated props: ```tsx ``` ## The Registry Pattern [#the-registry-pattern] ObjectUI uses two registry systems for extensibility: ### Component Registry [#component-registry] Maps schema types to React components: ```tsx import { ComponentRegistry } from '@object-ui/core' // Register a component ComponentRegistry.register('my-widget', MyWidgetComponent, { label: 'My Widget', category: 'Custom', icon: 'box', inputs: [ { name: 'title', type: 'string' } ] }) ``` ### Field Registry [#field-registry] Maps field types to input components: ```tsx import { registerFieldRenderer } from '@object-ui/fields' // Register a field renderer registerFieldRenderer('rating', RatingFieldComponent) ``` This allows: * ✅ Overriding standard components * ✅ Adding custom field types * ✅ Plugin system for complex widgets * ✅ Keeping bundles small (lazy loading) ## Expression System [#expression-system] ObjectUI includes a powerful expression engine for dynamic UIs: ### String Interpolation [#string-interpolation] ```json { "type": "text", "content": "Welcome, ${user.firstName} ${user.lastName}!" } ``` ### Conditional Rendering [#conditional-rendering] ```json { "type": "button", "label": "Submit", "visible": "${form.isValid && !form.isSubmitting}", "disabled": "${form.isSubmitting}" } ``` A button's text key is `label`, and `text` is not a `ButtonSchema` key at all. Nothing refuses the misspelling either: `BaseSchema` is `.passthrough()`, so the validator KEEPS the unknown key, and the renderer — which reads `schema.label` — never looks at it. Measured on the node above with `text`: the button renders with an empty `textContent`, so it appears on screen as a blank rectangle with no text. ### Data Transformations [#data-transformations] ```json { "type": "statistic", "label": "Orders", "value": "${orders.length}", "description": "${orders.length > 10 ? 'Above target' : 'On track'}" } ``` `statistic` rather than `badge`: an expression is evaluated only on a key the node's own type carries, and `expressionBindableTextKeysFor` gives `statistic` the rows `label`, `value` and `description` while giving `badge` none. A badge's text is its `label`, and it has to arrive already resolved. See the [Expressions Guide](/docs/guide/expressions) for complete details. ## Data Flow [#data-flow] ``` Backend API ↓ JSON Schema + Data ↓ SchemaRenderer (evaluates expressions) ↓ Component Registry (maps types) ↓ React Components (render UI) ↓ User Interactions (events) ↓ Event Handlers (update data) ↓ Re-render (React state updates) ``` ## Styling System [#styling-system] ObjectUI uses **Tailwind CSS** exclusively for styling: ### Class-Variance-Authority (CVA) [#class-variance-authority-cva] All component variants use `cva` for type-safe variants: ```tsx import { cva } from 'class-variance-authority' const buttonVariants = cva( 'inline-flex items-center justify-center rounded-md', { variants: { variant: { default: 'bg-primary text-primary-foreground', destructive: 'bg-destructive text-destructive-foreground', }, size: { default: 'h-10 px-4 py-2', sm: 'h-9 px-3', lg: 'h-11 px-8', } } } ) ``` ### Class Merging [#class-merging] Use `cn()` helper (tailwind-merge + clsx) for class overrides: ```tsx import { cn } from '@/lib/utils' // The grid re-resolves `view` whenever `activeView` changes. ``` Selecting **Active** re-queries with the `active` view's `filter` (`status != Done`) and `sort` (`priority asc`) applied — you never assemble `$filter` / `$orderby` by hand. A view name your backend does not publish is reported, not silently ignored: swap `activeView` for a name outside `listViews` and the grid renders a configuration-error panel in place of the table, the same way an unresolved `objectName` does (Step 5). A page that instead fell back to the object's full, unfiltered scope would look like it worked while returning every record regardless of which button was pressed — so this block does not offer that fallback. **Search needs no binding at all.** `object-grid` renders its own search box in the toolbar — on by default — and typing there drives `DataSource.find()`'s `$search` parameter directly; there is no separate query-param key to author. Add `searchableFields: ['title', 'description']` to the schema to narrow which fields the server matches; leave it out and the server decides. ## Step 8: Add a Detail View [#step-8-add-a-detail-view] Create a detail page that renders a single record with all its fields: ```tsx import { SchemaRenderer } from '@object-ui/react'; function TaskDetail({ taskId, onBack }: { taskId: string; onBack: () => void }) { return (
); } ``` Render `TaskDetail` inside the same `SchemaRendererProvider` as the grid — it reads the injected data source from context, exactly as `object-grid` and `object-form` do. Two keys differ from the form above, and both matter: * **`resourceId`, not `recordId`.** `detail-view` sources the record id from `resourceId`; `recordId` is `object-form`'s spelling. The two blocks are not interchangeable here. * **No `data`.** On `detail-view`, `data` means *"here is the record already, do not fetch"* — so handing it anything (including the object's metadata) makes the block skip `findOne` entirely and render that value as if it were the record. Omit it and the block loads the record for itself. Use this component in your main app with simple routing state, or integrate with a router like React Router or TanStack Router for URL-based navigation. ## Deployment Considerations [#deployment-considerations] **Environment config** — Keep your API URL configurable: ```ts const dataSource = new RestDataSource( import.meta.env.VITE_API_URL || 'http://localhost:3000/api' ); ``` **Performance** — Use server-side pagination via `$top`/`$skip` query params. ObjectUI plugins support lazy loading via `LazyPluginLoader` from `@object-ui/react`. Set `cache: { enabled: true, ttl: 300 }` on your schema for client-side caching. **Production build** — Run `pnpm build` and deploy the `dist/` folder to any static host (Vercel, Netlify, Cloudflare Pages). **Authentication** — Extend `RestDataSource` to inject auth headers: ```ts class AuthenticatedDataSource extends RestDataSource { constructor(baseUrl: string, private getToken: () => string) { super(baseUrl); } // Override fetch calls to include: Authorization: `Bearer ${this.getToken()}` } ``` ## Next Steps [#next-steps] * Explore the [Schema Overview](/docs/guide/schema-overview) for advanced schema features * Add a Kanban board view using `@object-ui/plugin-kanban` (see the [Kanban Plugin](/docs/plugins/plugin-kanban) reference for runnable board schemas) * Connect to a production backend with the [Data Connectivity](/docs/guide/data-source) guide * Build multi-object apps with relationships using `lookup` / `master_detail` fields (see the [Lookup Field](/docs/fields/lookup) reference) # CI/CD Pipeline # CI/CD Pipeline [#cicd-pipeline] ObjectUI automates testing, quality checks, releases, and repository maintenance with GitHub Actions. All workflow files live in `.github/workflows/`. This page deliberately states **no workflow count**. It used to open with "11 GitHub Actions workflows"; the directory held 12 when [#3212](https://github.com/objectstack-ai/objectui/issues/3212) was filed and 13 by the time it was fixed. A hand-maintained number drifts by construction, and a stale one still reads as authoritative. What is pinned instead is the *set*: `scripts/__tests__/ci-cd-pipeline-doc.test.ts` fails `pnpm test` when a file in `.github/workflows/` has no section on this page, **and** when this page names a `.yml` that is not in that directory. Adding a workflow without documenting it is a red test, not a silent omission. ## Workflow Inventory [#workflow-inventory] Every workflow, the name it appears under in the checks list (they are not the same string — `performance-budget.yml` shows up as **Bundle Analysis**), and whether it can block a merge. Each one has its own section below. | Workflow file | Appears as | Runs on | Blocks a PR? | | ------------------------------ | ------------------------------------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ci.yml` | CI | Push / PR to `main`, `develop`; merge-queue builds | **Yes** — every job but the two coverage-lane jobs (`test-coverage` and `coverage-report`, push only) runs on PRs and on queue builds | | `lint.yml` | Lint | Push / PR to `main`, `develop`; merge-queue builds; manual | **Yes** — ESLint **errors** only | | `changeset-guard.yml` | Changeset Bump Policy, Changeset Overwrite Report | PR / push touching `.changeset/**` or either gate itself | **Yes** — the bump policy job only; the overwrite job is report-only | | `changeset-presence.yml` | Changeset Declaration | PR to `main`, `develop` — **no path filter**; merge-queue builds | **Yes** — when a released package's `src/` changed and no changeset was added | | `control-bytes.yml` | Control Byte Scan | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** | | `docs-links.yml` | Internal Docs Link Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** | | `skills-paths.yml` | Skill Guide Path Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a path stated in a `skills/` guide does not exist | | `skill-examples.yml` | Skill Example Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a MARKED fenced example in a `skills/` or `.claude/skills/` guide no longer compiles against the packages' built types, no longer parses as JSON, uses a bare `any`, or carries a marker that opts nothing in | | `skill-eval-tokens.yml` | Skill Eval Token Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when an eval assertion's `must_contain` token is not taught as a whole token anywhere in its own `skills/` bundle | | `doc-component-types.yml` | Doc Component Type Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `content/docs/**.mdx` snippet teaches a `type` nothing registers | | `doc-snippet-types.yml` | Doc Snippet Type Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a covered documentation snippet no longer compiles against the packages' built types | | `doc-fence-languages.yml` | Doc Fence Language Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a TypeScript block sits under a fence the snippet gate does not read | | `pre-install-import-graph.yml` | Pre-Install Import Graph Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a gate a workflow runs *before* `pnpm install` reaches a package anywhere in its import graph | | `vi-mock-specifiers.yml` | Inert vi.mock Specifier Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `vi.mock` / `vi.doMock` relative specifier resolves to no file, or the scan's population collapses | | `shell-escape-residue.yml` | Shell Escape Residue Scan | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a fenced block in `AGENTS.md`, `CLAUDE.md`, `skills/**` or `content/docs/**` carries the enumerated machine-produced shell escape, or a scan root fails to resolve | | `readme-exports.yml` | README Export Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `packages/**/README.md` imports a name from its own package that the package does not export, or the scan's population collapses | | `docs-route-eager-closure.yml` | Docs Route Eager Closure Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a package named in `apps/site/app/components/registerCatalogBlocks.ts` is not already reachable from the docs route's module graph (exit 1), or when the gate's own gauge cannot be trusted (exit 2) | | `governed-surface-guard.yml` | Governed Surface Queue Guard | PR to `main`, `develop` (incl. `ready_for_review`) — **no path filter**; merge-queue builds | **Yes on a queue build only** — a governed-surface diff with no authorized approval record (on any commit) is refused there; on the pull request itself it is deliberately green and prints an early warning | | `performance-budget.yml` | Bundle Analysis | Push / PR touching `packages/**`, `apps/console/**`, `pnpm-lock.yaml` | **Yes** — the console entry gzip budget | | `live-e2e.yml` | Live E2E (informational) | PR to `main`, `develop` (code paths); nightly cron `30 6 * * *`; manual | No — informational lane, `continue-on-error` | | `labeler.yml` | Auto Label PRs | PR `opened`, `synchronize`, `reopened` | No | | `dependabot-auto-merge.yml` | Dependabot Auto-merge | PR to `main`/`develop` authored by `dependabot[bot]` | No — but it gates *its own* merge, and goes red instead of merging when the check set is not green | | `cross-repo-issue-closer.yml` | Cross-repo Issue Closer | PR `closed` (acts only when merged) | No — runs after merge | | `changeset-release.yml` | Changeset Release | Push to `main` (publish half); 6-hourly cron `0 */6 * * *`; manual (version-PR refresh half) | n/a | | `changelog.yml` | Auto Changelog | Manual dispatch only — nothing triggers it automatically | n/a | | `stale.yml` | Stale Issues & PRs | Daily cron `0 0 * * *`; manual | n/a | | `shadcn-check.yml` | Check Shadcn Components | Weekly cron `0 9 * * 1`; manual | n/a | | `check-links.yml` | Check Links | Weekly cron `17 4 * * 0`; manual | n/a — reports, never gates | | `published-dist-gate.yml` | Published Dist Tooling Scan | Nightly cron `41 3 * * *`; push to `main` touching the gate; manual | No — the blocking copy runs on the publish path, not here | | `spec-range-floors.yml` | Spec Range Floor Scan | Nightly cron `11 4 * * *`; push to `main` touching the gate; manual | No — the blocking copy runs on the publish path, not here | | `node-esm-load-gate.yml` | Node ESM Load Scan | Nightly cron `17 4 * * *`; push to `main` touching the gate; manual | No — the per-PR half is `pnpm check:esm-specifiers` in **Type Check** | | `half-state-patrol.yml` | Half-State Patrol | 6-hourly cron `37 1,7,13,19 * * *`; manual; PR touching the sweeper or the workflow | No — **report-only**; it fails only when the sweep could not run | | `merge-queue-head-patrol.yml` | Merge queue head patrol | Every 15 minutes (cron `7,22,37,52 * * * *`); manual | No — it gates no branch and blocks no queue, but it **goes red on a finding**: a merge-queue head with no `merge_group` build is a live repo-wide block | | `hook-selftests.yml` | Hook Self-Tests | PR / push touching `.claude/hooks/**` or the workflow | **Yes** | The path filters explain most "why did nothing run on my PR?" questions: * `ci.yml` and `lint.yml` both list `**/*.md`, `content/**`, `docs/**` and `.changeset/**` under `paths-ignore` (`ci.yml` also ignores `apps/site/**`) — but **only on their `push` trigger**. Their `pull_request` trigger carries no filter at all since [#3523](https://github.com/objectstack-ai/objectui/issues/3523): every pull request starts both workflows, and the same list decides *inside each job* whether the expensive steps run. A docs-only PR therefore still installs nothing and builds nothing, while **Lint**, **Type Check**, **Test (shard N/4)**, **Build & E2E** and **Changeset Fixed Group Check** all appear in the checks list and all report. That difference is the whole point: a check that is never *created* cannot be a required check — it leaves the PR pending rather than failing it — so while the filter sat on the trigger, none of these could be required at all. * `changeset-guard.yml` carries the inverse filter — it runs *only* when `.changeset/**` changes (plus its own YAML and `scripts/check-changeset-no-major.mjs`, so a change to the gate itself is exercised by the PR that makes it — objectui#6321), which is precisely why it is a separate workflow instead of a job inside `ci.yml`. * `changeset-presence.yml` is that guard's mirror image and the reason there are two: a PR which *forgot* its changeset does not touch `.changeset/**`, so the inverse filter guarantees the one check that could notice never runs. It therefore carries **no** filter and decides from the diff inside its script. * `control-bytes.yml` and `docs-links.yml` carry **no** filter of any kind, which is equally deliberate: both guard markdown, and a gate that a markdown-only PR cannot start is no gate on the change most likely to trip it. Both cost a checkout plus one `node` call. * `docs-route-eager-closure.yml` carries **no** filter for the opposite reason — not that its subject is invisible to a filter, but that a filter naming everything it reads would be indistinguishable from having none. Its inputs are the whole `/docs/[[...slug]]` module graph: `apps/site/**`, `content/docs/**` (the compiled MDX modules are most of that graph) and `packages/**` — a refactor dropping an import from `packages/plugin-view/src/ObjectView.tsx` is exactly what turns a free declaration into a new graph — plus the gate's own closure under `scripts/`. A filter that then *missed* one of those directories could not be exercised by the pull request that changed it, which is the defect [#6321](https://github.com/objectstack-ai/objectui/issues/6321) records. It too costs a checkout plus one `node` call. ## Merge Queue [#merge-queue] `main` sits behind an **enforced merge queue**: a direct push is rejected with 405 `Changes must be made through the merge queue`. The queue takes each approved pull request, rebuilds it on top of whatever `main` has become in the meantime, and merges it only if the checks it requires are green **on that rebuilt commit**. Those runs are a distinct event, `merge_group`, on a throwaway `gh-readonly-queue/**` branch — a workflow that does not subscribe to that event simply does not run there. Which workflows subscribe is deliberately not listed here, and is not maintained by hand anywhere either: `scripts/__tests__/merge-queue-reporting.test.ts` derives the floor from `REQUIRED_CONTEXTS` — every workflow producing a check that list declares blocking must subscribe, and an assertion fails the moment one of them does not. `MUST_SUBSCRIBE_MERGE_GROUP` in the same file records *why* particular members are requirable; a further assertion holds it to being a subset of the derived floor, so the two cannot drift apart. A copy of the list on this page would be right the day it was written and quietly wrong after the next subscriber landed, which is exactly what this paragraph used to do ([#4154](https://github.com/objectstack-ai/objectui/issues/4154)). What is worth knowing here is the rule that decides membership, not the instances: a gate that carries no path filter reports on every pull request and is therefore requirable — and a requirable context that skips the queue build does not fail it, it stalls it. That rule was learned the expensive way. `ci.yml`, `lint.yml`, `control-bytes.yml` and `docs-links.yml` did not subscribe at all until [#3523](https://github.com/objectstack-ai/objectui/issues/3523), and the consequence was not subtle. A queue whose required set is empty validates nothing: it rebuilds the PR, sees no failing required check because there are no required checks, and merges. On 2026-08-07 three pull requests ([#3503](https://github.com/objectstack-ai/objectui/issues/3503), [#3510](https://github.com/objectstack-ai/objectui/issues/3510), [#3516](https://github.com/objectstack-ai/objectui/issues/3516)) merged with **Type Check** at `conclusion=failure`, onto a `main` that [#3498](https://github.com/objectstack-ai/objectui/issues/3498) had left with a type error; [#3505](https://github.com/objectstack-ai/objectui/issues/3505) hot-fixed the result. **The three steps have to happen in this order**, and reversing them deadlocks the repository: 1. Subscribe the workflows to `merge_group`. Pure addition — nothing about pull requests changes. 2. Make the contexts report on *every* pull request, by moving path filtering out of `on.pull_request.paths-ignore` and into the jobs. 3. Only then may a maintainer add context names to the branch-protection and merge-queue required sets. This is a **repository-settings** change; nothing in this repository can do it, and nothing here can read the current state of it either. Step 3 before step 1 is the deadlock: a required context that never reports does not fail a queue build, it stalls it until the ruleset's 60-minute status-check timeout assumes failure — every queued PR burns an hour and fails, with nothing red to point at. Two things follow for anyone editing this directory: * **A workflow producing a context that could ever be required must subscribe `merge_group`.** Nothing has to be added to a list for that to be enforced: name the context in `REQUIRED_CONTEXTS` (`scripts/dependabot-merge-gate.mjs`), which is where this repository already writes down that a check is blocking and reports on every pull request, and the workflow is inside the derived floor from that moment. "May this context be required?" is still a property of the repository's settings that no test here can read — `REQUIRED_CONTEXTS` is a human's answer to it, and deriving from that answer beats writing it down a second time and watching the copies drift ([#6160](https://github.com/objectstack-ai/objectui/issues/6160)). A gate that carries no path filter *precisely so that it can be required* is the mirror image of the bullet below, and the sequence matters there too: name its context in `REQUIRED_CONTEXTS` and subscribe `merge_group` in the same commit that creates the workflow, rather than acquiring either afterwards ([#6316](https://github.com/objectstack-ai/objectui/issues/6316) is a worked example — see its own section for which gate that was). * **Some contexts can never be required, structurally**, and no amount of triggering changes that. Each line below is blocked by a *different* property, which is why they are all worth reading; they are examples rather than a census, so a further workflow carrying any of these shapes is just as unrequirable without appearing here. * **Changeset Bump Policy** (`changeset-guard.yml`) — an **inverse** path filter: its `pull_request` trigger declares `paths: ['.changeset/**', '.github/workflows/changeset-guard.yml', 'scripts/check-changeset-no-major.mjs', 'scripts/check-changeset-overwrite.mjs']`, so on a PR that touches none of those four neither of its contexts is created at all. * **Bundle Analysis** (`performance-budget.yml`) — an ordinary path filter on the same trigger, with the same consequence for every PR that matches none of its paths. * **Live E2E (informational)** (`live-e2e.yml`) — the job carries `continue-on-error: true`, so the run is green whatever the specs did; it cannot serve as a guarantee of anything. * **Close issues referenced in other repositories** (`cross-repo-issue-closer.yml`) — its only trigger is `pull_request_target` with `types: [closed]`, and the job additionally requires `github.event.pull_request.merged == true`, so it runs only *after* a merge. Each of those properties is pinned against the YAML in `scripts/__tests__/ci-cd-pipeline-doc.test.ts`: change one of them without editing its line here and that test fails, naming the workflow ([#4170](https://github.com/objectstack-ai/objectui/issues/4170)). The `live-e2e.yml` line is the one already scheduled to become false — that workflow's header says `continue-on-error` comes off once the lane has run clean long enough to trust, and the day it does, the lane becomes requirable and this line is wrong. Then delete the line and its entry in that test; do not soften it in place. ## Core CI Workflow (`ci.yml`) [#core-ci-workflow-ciyml] **Triggers:** **Every** PR to `main`/`develop` (no path filter), every merge-queue build (`merge_group`), and pushes to `main`/`develop` unless the change touches only `**/*.md`, `content/**`, `docs/**`, `apps/site/**` or `.changeset/**` (`paths-ignore`, kept on the push trigger only — see [#3523](https://github.com/objectstack-ai/objectui/issues/3523) and the **Merge Queue** section below). The path list did not go away, it moved. `type-check`, `test` and `e2e` each open with a `Decide whether this change needs a full run` step that diffs the PR against its merge base with exactly that list excluded, and every following step carries `if: steps.relevant.outputs.should_run == 'true'`. The job always runs and always reports; the paths decide only whether it does any work. The `docs` job has worked this way since [#3450](https://github.com/objectstack-ai/objectui/pull/3450) and is where the shape comes from. The gate fails **open** — if the diff cannot be computed the job runs everything, rather than reporting green having built nothing. Every job runs in parallel — there are no `needs:` edges between them. As with the workflow inventory above, this page states **no job count**: the table *is* the list, and `scripts/__tests__/ci-cd-pipeline-doc.test.ts` pins its first column against `ci.yml`'s `jobs:` keys in both directions, so a job added or removed without touching this table is a red test. (This section used to open with a hard-coded count and list a seventh job, `dev-server`, that had been deleted three months earlier — [#3451](https://github.com/objectstack-ai/objectui/issues/3451).) The **What it runs** column is pinned one level further down, by command ([#3653](https://github.com/objectstack-ai/objectui/issues/3653)): every first-party command a job runs — a `node scripts/*.mjs` invocation, a root `package.json` script, or a `turbo run` task — must be named in that job's row, and a row may not name one its job does not run. Until that pin landed this page was judged job by job only, so a `run:` step added to an existing job left every check on it green — which is how two of `type-check`'s gates came to be missing from this column. | Job key | Appears as | What it runs | When | | ----------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `changeset-check` | Changeset Fixed Group Check | `scripts/check-changeset-fixed.mjs` — every workspace package must be in the changeset `fixed` group or explicitly ignored. It checks group *membership*; it does **not** check whether the PR added a changeset. | Every run | | `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:phantom-deps`, then `pnpm check:self-import`, then `pnpm check:unreferenced-sources`, then `pnpm check:doc-example-readers`, then `pnpm check:handler-key-reads`, then `pnpm check:published-tsconfig-exclude`, then `pnpm check:side-effects-array`, then `pnpm check:element-data-source-declaration`, then `pnpm check:esm-specifiers`, then `pnpm check:spec-symbols`, then `pnpm check:action-forward-parity`, then `pnpm check:designer-field-key-parity`, then `pnpm check:icon-record-names`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm type-check:scripts`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). `pnpm check:phantom-deps` fails when a released package imports a bare specifier its own `package.json` does not declare — a *phantom dependency*, invisible locally because the workspace root's `devDependencies` sit on the upward resolution path from every package directory and on no consumer's, so `require.resolve('react', { paths: ['packages/core/src'] })` succeeds while `@object-ui/core` declares react in no field at all ([#4394](https://github.com/objectstack-ai/objectui/issues/4394)). `pnpm check:self-import` runs next because it reuses that gate's parser: it fails when a file inside a package names its OWN package, a specifier that resolves through the package's `exports` map to `dist/` while `type-check` waits on `^build` — the *dependencies'* builds, never the package's own — so on a cold cache the declarations do not exist yet and the file fails with `TS2307`. Locally it is always green, because every local workflow builds before it type-checks and leaves a `dist/` behind; PR #4789's first run was red on exactly one such line ([#4801](https://github.com/objectstack-ai/objectui/issues/4801)). `pnpm check:unreferenced-sources` runs next, reusing the same parser again: it fails when a covered package ships a source file that nothing reaches — not the package's declared entry, and not its build config. Until [#7515](https://github.com/objectstack-ai/objectui/issues/7515) no gate here could see one: `check-dist-completeness` asks whether `dist/` holds what `tsc` emits, `check-readme-exports` compares documented exports against shipped ones, and a file that is in the tarball while being reachable from nothing is outside both — so the detection mechanism was a human reading unrelated code, which is how both instances found in one week were found ([#7319](https://github.com/objectstack-ai/objectui/issues/7319), [#7397](https://github.com/objectstack-ai/objectui/issues/7397)). The hazard is not the bytes: the file #7319 removed carried the same export name as a live engine one package over and evaluated no predicate, so name-completion alone could have wired a silently wrong renderer into a published package. Reachability has TWO roots, and the second is the whole difficulty — `packages/components` reaches its two `use-sync-external-store` shims only through `vite.config.ts` `resolve.alias` entries whose importer is a bundled dependency no source file names, so a walk that skips that leg reports exactly those two live files as dead on its first run, and a gate that cries wolf gets switched off rather than fixed. Scope is DECLARED per package in `COVERED_PACKAGES` and the uncovered remainder is printed as a count derived from the workspace on every run, because the alias mechanisms differ per package and a gate that covers one package correctly beats one that covers forty with false positives. An alias expression it cannot evaluate is a FINDING rather than a skip, since skipping one would make it accuse whatever file that alias points at. `pnpm check:doc-example-readers` runs next, on the same parser again: it fails when an exported symbol's own JSDoc `@example` hand-spells a resolution that its REAL call sites obtain by calling a shared reader. A doc comment is what the next call site is copied from, so prose that outlives the ruling it encoded re-seeds every later copy — measured at two cards and three copied call sites ([#7627](https://github.com/objectstack-ai/objectui/issues/7627), [#7638](https://github.com/objectstack-ai/objectui/issues/7638)), both closed by pointing the prose at `resolveRecordSourceObjectName`. Nothing here could see either one, and `check-spec-symbol-derivation` was credited with the class twice — in #7638's card body and then in the dispatch that repeated it — while its rule 4 judges `@objectstack/spec` citations at member granularity and says nothing about prose prescribing a LOCAL spelling ([#7652](https://github.com/objectstack-ai/objectui/issues/7652)). It fires on four conditions at once — the example calls the symbol it documents, a real call site fills the same argument slot by calling an exported single-`return` reader, the example does not, and what the example writes there is that reader's own return expression or one of the rungs it resolves between — which is what keeps it off the literals and placeholders an example legitimately carries. It does NOT judge whether a prescribed spelling is correct: on the day either card was filed the prose and every copy of it agreed, and no gate reading only the tree can know a ruling. What it catches is the state right after, when the call sites move and the prose does not. `pnpm check:handler-key-reads` runs next, on the same parser again: it fails when an `on*` handler key that a REGISTERED renderer reads off the authored document is not a declared member of the zod arm for the type it is registered under. `BaseSchema` is `.passthrough()`, so an undeclared key is not refused — it stops being judged and the value is KEPT, then reaches the renderer that reads it; measured on the built dist, `{ type: 'kanban', columns: [], onCardClick: { action: 'toast' } }` went from REFUSED to ACCEPTED with the object surviving into the parsed output ([#7664](https://github.com/objectstack-ai/objectui/issues/7664)). Every gate stayed green, because the [#6124](https://github.com/objectstack-ai/objectui/issues/6124) ledger's population is two hand-written arrays of tuples and that change re-keyed the arm by SUBSTITUTION — so its length assertion held, and a count ratchet would have been green too, which is why [#7753](https://github.com/objectstack-ai/objectui/issues/7753) rejected that option on the instance itself. This gate derives BOTH populations: the arms from every `type: z.literal(…)` in `packages/types/src/zod`, and the read sites from every real `ComponentRegistry.register(…)` call — read off the AST, because one types file NAMES that call in prose eleven times and registers nothing. It follows the document one component at a time rather than every JSX child, because most children are handed a DIFFERENT document (a dashboard's widgets each get their own), and the chain it must reach is four hops long: `register('kanban', ObjectKanbanRenderer)` names a component, that component is an HOC, the document arrives at `ObjectKanban` through a render-prop parameter and at `KanbanRenderer` through an object spread. It says nothing about keys that reach a renderer only through a `{...props}` spread onto a Radix root or a DOM listener slot — there is no read site to derive from — nor about the ledger's `?: never` tombstones, which have no read site by construction; `KNOWN_UNDECLARED_READS` is an exemption list that only shrinks, each row naming the card that owns the fix, and a row whose read site the gate can no longer find fails it. It lives in `scripts/` because the read sites are spread across `@object-ui/plugin-*` and `packages/components`, which `@object-ui/types` may not import — `check:phantom-deps` rejects it and it would close a cycle. `pnpm check:published-tsconfig-exclude` follows, config reads only: it fails when a published package's build `tsconfig.json` excludes tooling by FILE NAME (`*.test.ts`) without also excluding the tooling DIRECTORIES (`**/__tests__/**` and its two siblings, derived from `TOOLING_FILE` rather than retyped). A name-only exclude stops the files that happen to be named that way and nothing else, so the first shared helper added to a `__tests__/` directory becomes a program input and an emitting program writes it into the published `dist` — three times so far, each found by a human and never by a gate ([#4006](https://github.com/objectstack-ai/objectui/issues/4006), [#4836](https://github.com/objectstack-ai/objectui/issues/4836), [#6943](https://github.com/objectstack-ai/objectui/issues/6943), the third in the same package as the first). [#7212](https://github.com/objectstack-ai/objectui/issues/7212) measured the standing exposure — 29 published packages carrying the name form with ZERO offending files, green because nobody had added such a helper yet — and the gate landed together with their conversion so `main` was green on merge. It reads `exclude` arrays and nothing else: no build, no artifact, no emit model, which is the narrower scope that keeps it clear of the modelling [#4846](https://github.com/objectstack-ai/objectui/issues/4846) declined for the artifact-level gate. Six published packages are named carve-outs, each re-proving its own reason on every run: `cli`, `create-plugin` and `data-objectstack` emit from a `tsup` entry graph, `plugin-charts` keeps its tooling exclude in the `dts()` options, and `console` and `runner` are Vite applications with `noEmit: true` and no `dts()` plugin. `pnpm check:side-effects-array` runs next, sources only and no build: it fails when a package's `sideEffects` ARRAY and its module bodies disagree in either direction — a module that registers something at load time and is not named (a bundler drops it, and the registration is gone from a *consumer's* app with no error, no warning and exit 0), or a name whose module no longer registers anything. `@object-ui/app-shell` declares such an array because both simpler answers are measurably wrong for it: omitting the field makes the whole package unshakeable, and `"sideEffects": false` silently drops three live SDUI widget registrations to zero chunks ([#6535](https://github.com/objectstack-ai/objectui/issues/6535), [#6683](https://github.com/objectstack-ai/objectui/issues/6683)). The enumeration is re-derived from the module bodies on every run rather than listed, so there is no second copy to rot. The artifact half of the same contract — do those registrations survive a real bundler — cannot run in this job at all: it needs a built console, so it lives in the SDUI registration pin step of `performance-budget.yml`. `pnpm check:element-data-source-declaration` runs next, sources only and no build: it fails when a source that consumes `ElementDataSourceGate` does not also pass through `elementDataSourceBlock()`, the seam that declares the `dataSource` key the gate reads. A block that wraps the gate off-seam publishes an authoring surface missing the one key its own runtime honours, and the html tier reports that key with the same `unknown-prop` warning it gives the spellings that do nothing ([#6678](https://github.com/objectstack-ai/objectui/issues/6678)). `pnpm check:esm-specifiers` follows it for the same reason — sources only, no build: it fails when a published package whose build preserves import specifiers (a bare emitting `tsc`, which never rewrites them) writes a relative specifier with no file extension. Node's ESM resolver does not extension-search relative specifiers, so such a specifier makes the published entry unloadable outside a bundler; `@object-ui/react`'s entry died with `ERR_MODULE_NOT_FOUND` while every bundler-based consumer, the whole test suite and CI stayed green ([#4538](https://github.com/objectstack-ai/objectui/issues/4538)). The half that actually *imports* each built entry needs a full build and runs in `node-esm-load-gate.yml`. `pnpm check:action-forward-parity` fails when an action renderer's forward whitelist drops a key the action runtime reads — the class that shipped six times one key at a time, each time green, because the key parses and publishes while the payload is dropped one hop before the runner ([#4050](https://github.com/objectstack-ai/objectui/issues/4050)). `pnpm check:designer-field-key-parity` fails when one of the field designers' statically declared payload shapes (`FieldMetadataPayload`, `ServerFieldSchema`, `DesignerFieldDefinition`) declares a key the installed `@objectstack/spec` `FieldSchema` refuses by NAME. Such a key makes `PUT /api/v1/meta/object/:name` return a hard 422 `INVALID_METADATA` that blocks *every subsequent save* of that object, and the author cannot tell from the designer UI which key did it — the class had been filed three times, each closed with a per-key tombstone written after the instance was found in production, with nothing detecting the next one ([#4644](https://github.com/objectstack-ai/objectui/issues/4644) `indexed`, [#4687](https://github.com/objectstack-ai/objectui/issues/4687) `distance_metric`, [#4676](https://github.com/objectstack-ai/objectui/issues/4676) `placeholder`, gated by [#5761](https://github.com/objectstack-ai/objectui/issues/5761)). It reads the accept set off the schema itself rather than from a list, and it covers a deliberately documented *subset* of the write path: a key that reaches the payload only through a `patchDef` spread or an index signature is outside its reach, and the boundary is stated in the script's own docblock. Its draft-I/O half — the `readFields`/`writeFields` round-trip, which has no declared shape to read — runs in the test suite as `object-fields-io.spec-keys.test.ts`. Same placement rationale as the gates around it: it parses the sources with `typescript` and imports the installed spec, so it needs the install and nothing built. `pnpm check:icon-record-names` fails when an authored icon NAME that reaches a resolver reading lucide's runtime `icons` record is not a live key of that record. lucide retires a spelling by dropping it from that record while keeping it as a deprecated named export, so the retired name still imports, still type-checks and still renders wherever it is used as a *component* — `Edit === SquarePen` is true — and resolves to nothing wherever it is used as a *string*: nothing goes red in either direction, which is why the class was repaired twice in two packages before anyone gated it ([#5586](https://github.com/objectstack-ai/objectui/issues/5586), [#5622](https://github.com/objectstack-ai/objectui/issues/5622), [#5633](https://github.com/objectstack-ai/objectui/issues/5633)). It carries no list of retired spellings — the record itself is the judgement — and it re-discovers the resolver population from source on every run, which is how its first pass found four record-reading resolvers nobody had catalogued. It sits here because it parses the sources with `typescript` and reads the installed lucide: the install, and nothing built. The two locale gates sit in the middle because both parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. | Every run; on a PR the steps short-circuit when only ignored paths changed | | `test` | Test (shard N/4) | `pnpm test --shard=N/4` across a 4-runner matrix with `fail-fast: false`, so every shard reports its own failures. No coverage instrumentation — v8 adds 40–100% overhead. Then, **on shard 1 only**, `pnpm test:dist` — the built-artifact lane ([#7183](https://github.com/objectstack-ai/objectui/issues/7183)). It delegates to a turbo task scoped to the one package that holds built-artifact pins; that task depends on the package's OWN build (`dependsOn: ["build"]`, not `^build`), so the bundle exists before the pins read it, and then runs the `dist` vitest project, whose pins import a package's BUILT bundle instead of its `src` — a claim the source-aliased suite above is structurally unable to make, since the root config aliases every workspace package to `src`. It is deliberately not sharded and not repeated on the other three runners: the lane is a handful of files, and running it on all four would pay for the same build four times. | Pull requests and merge-queue builds (everything but `push`); steps short-circuit on a PR that changed only ignored paths | | `test-coverage` | Test (coverage shard N/4) | `pnpm test:coverage --reporter=blob --shard=N/4` across a 4-runner matrix with `fail-fast: false`. Each shard writes `.vitest-reports/blob-N-4.json` — raw coverage and test results in one file — and uploads it as an artifact even when the shard is red, which is what makes a failing coverage run diagnosable at all (vitest deletes `coverage/` on a red run unless `coverage.reportOnFailure` is set, [#5402](https://github.com/objectstack-ai/objectui/issues/5402)). The configured coverage thresholds are neutralised on the shard legs, because a quarter of the suite judged against a whole-suite threshold is not a defect signal; they are enforced once, on the merged report, by the job below ([#5403](https://github.com/objectstack-ai/objectui/issues/5403)). | **Push only** | | `coverage-report` | Test (coverage) | Downloads the four blob reports, refuses to continue unless all four arrived, merges them with `pnpm test:coverage --merge-reports` into one complete report — which is where the configured coverage thresholds are enforced, over the whole merged map, the shard legs having overridden them to zero — and publishes that report as the `coverage-report` artifact (kept 7 days, the same as the blobs it is derived from). Its last step runs on every path and states the outcome: the job is **red, with an error annotation**, whenever the gate did not run for the commit — before [#5403](https://github.com/objectstack-ai/objectui/issues/5403) the final step carried the implicit `success()` and was silently skipped by 311 of 373 coverage jobs, which is how four days of a 100%-failing coverage job went unnoticed. A breach of the thresholds is reported *separately* from a lane that never delivered, because the two call for opposite actions. ⛔ It never merges a report from fewer than four shards: a wrong coverage number is worse than a missing one. The Codecov upload this job used to carry was retired by [#5436](https://github.com/objectstack-ai/objectui/issues/5436) — `CODECOV_TOKEN` was never set, so it failed on every push; the trend dashboard and PR coverage comments are gone with it, the gate is not. | **Push only** | | `e2e` | Build & E2E | Builds the console with `vite build` (`VITE_BASE_PATH=/console/`), verifies the artifact, then `pnpm test:e2e --project=chromium`. Uploads the Playwright report on failure. | Every run; on a PR the steps short-circuit when only ignored paths changed | | `docs` | Build Docs | `turbo run build --filter='@object-ui/site'`. On a PR it first diffs against the base and skips the build when nothing under `apps/site/` or `content/` changed. Then `scripts/check-doc-expression-carriage.mjs`, which is **report-only**: it censuses every `json` fence under `content/docs/**` for a `${…}` authored on a key `SchemaRenderer` never evaluates — the class that reached `main` four times under green gates, because `check:doc-types` judges the `type` literal only and `check:doc-snippets` compiles the ts/tsx blocks only ([#7851](https://github.com/objectstack-ai/objectui/issues/7851)). It prints its findings and **exits 0 regardless**, so it can block no merge; it exits 1 only when the instrument itself is broken — a derivation that matched nothing, a missing `@objectstack/spec` artifact, or a failed built-in control — because a check that runs, goes green and looked at nothing is worse than none. Report-only is a ruling, not an oversight: three cards of the class it reports ([#7440](https://github.com/objectstack-ai/objectui/issues/7440), [#7444](https://github.com/objectstack-ai/objectui/issues/7444), [#7838](https://github.com/objectstack-ai/objectui/issues/7838)) are open and each fixes its own sites. It does **not** check docs links any more — that moved to `docs-links.yml` (#3448), because this workflow's `paths-ignore` then hid exactly the docs-only PRs a link check needs to see. #3523 has since removed that filter from the `pull_request` trigger, but the check stays in its own home: `docs-links.yml` still runs where this workflow does not (a docs-only push to `main`), and one gate with one home was the point of #3448. | Every run (build itself conditional) | Uses: Node 22.x, pnpm via `corepack`, `actions/cache` over `.turbo/cache`. ### What is *not* in `ci.yml` [#what-is-not-in-ciyml] Three job names this page has carried at one time or another are absent from `ci.yml`, and looking for them there is a dead end: * **Lint** is not a `ci.yml` job, and never was. ESLint runs in its own workflow, `lint.yml` (next section), and shows up as a separate **Lint** check on the PR. * **Build Core** does not exist, and never did. `ci.yml` builds only the console SPA that Playwright consumes; building the packages and measuring their size belongs to the Bundle Analysis workflow (`performance-budget.yml`), as the comment on the `e2e` job states. * **Dev-server fixture build** (`dev-server`) is the one that *did* exist, and it is the cautionary tale behind the pin above. It was added on 2026-05-24 to run `pnpm --filter @object-ui/dev-server build` against an in-repo `apps/dev-server`. That app was removed two days later, on 2026-05-26 — after which the filter matched no package and the job exited 0 without building anything. It stayed green by vacuity for over two months; was then *documented in that state* by [#3253](https://github.com/objectstack-ai/objectui/pull/3253) on 2026-08-03, whose table row claimed a fixture-drift guard that had not run since May; and was finally deleted from `ci.yml` by [#3325](https://github.com/objectstack-ai/objectui/pull/3325) on 2026-08-04, which left the row behind ([#3451](https://github.com/objectstack-ai/objectui/issues/3451)). **Today there is no `apps/dev-server` and no such job** — nothing in the repository is being left unguarded by its absence. The intent it was meant to serve, proving this console still works against a real `@objectstack` backend, is carried by `live-e2e.yml`, informationally. Both halves of that history are the reason the job table is pinned. A row can be wrong because the job was deleted under it, and a row can be wrong the day it is written, because the job it describes was already doing nothing. Understating a gate is annoying; advertising a guardrail CI does not have is worse than no doc, because people trust it and stop checking. ## Lint (`lint.yml`) [#lint-lintyml] **Triggers:** **Every** PR to `main`/`develop` (no path filter), every merge-queue build, pushes to `main`/`develop` under the same `paths-ignore` as `ci.yml` minus `apps/site/**`, plus manual dispatch. As in `ci.yml`, the path list moved into the job ([#3523](https://github.com/objectstack-ai/objectui/issues/3523)): the `Lint` context now reports on every pull request, and short-circuits to no install and no lint when only ignored paths changed. This is a **real PR gate**, and it is easy to miss because it is not part of CI — it is its own **Lint** entry in the checks list. * `scripts/check-lint-coverage.mjs` runs first: every package must run ESLint or be declared a known gap. turbo skips scriptless packages silently, so without this guard a package reads as clean because nothing ever linted it. * Then `pnpm lint`. * Then `pnpm check` — this repository's own tree run through `objectui check`, the command the CLI ships. The step builds the CLI and its workspace dependency closure first, through pnpm rather than turbo: the root script executes `packages/cli/dist/`, this job installs without building, and a turbo cache hit can replay a build that writes no `dist/` at all — either way the step would then fail for a reason that has nothing to do with the tree being checked. Nothing ran this command until [#5246](https://github.com/objectstack-ai/objectui/issues/5246) — it had been exiting 1 on `main` for as long as any `tsconfig.json` carried a comment ([#5237](https://github.com/objectstack-ai/objectui/issues/5237)), and no gate ever asked. **It gates errors, not warnings.** `--max-warnings` is deliberately unset: the repository carries thousands of warnings (overwhelmingly `no-explicit-any`, plus React Compiler rules the config downgrades on purpose), and failing on those would make the gate unusable. What must stay clean are the rules [`eslint.config.js`](https://github.com/objectstack-ai/objectui/blob/main/eslint.config.js) sets to `error` — including the custom `object-ui/*` ratchets, each of which carries the ADR or issue it came from in a comment beside the rule itself. Until #2923 this workflow was `workflow_dispatch`-only, so every one of those `error` ratchets was inert: each was written specifically to fail CI, and nothing ran them. The `pnpm check` step splits the same way, and by the command's own behaviour rather than by a flag set here: `objectui check` exits non-zero on parse errors only, while its unknown-schema-type warnings print and leave the exit code alone. This gate neither promotes those warnings to failures nor suppresses them from the log — [#5127](https://github.com/objectstack-ai/objectui/issues/5127) owns that arm and is open. ## Control Bytes (`control-bytes.yml`) [#control-bytes-control-bytesyml] **Triggers:** Push and PR to `main`/`develop`, plus manual dispatch — with **no path filter at all**, which is the point of the workflow. It appears in the checks list as **Control Byte Scan**. Runs `scripts/check-control-bytes.mjs`, which reads `git ls-files` and rejects raw control characters in every tracked **text** file: the C0 range apart from tab, line feed and carriage return, plus U+007F. No install, no build — a checkout and one `node` call. **Why it blocks a merge.** A single raw U+0000 makes grep and ripgrep classify the *entire* file as binary: they print `binary file matches` and no matching line, so the file silently drops out of code search and out of every grep-based lint. Nothing else catches it — git decides binary-ness from the first 8000 bytes only, so a control byte past that offset keeps diffing as ordinary text, and review cannot see a character that renders as nothing. objectui had no such guard until objectstack#5425, by which time five files had accumulated the defect. The two byte classes carry different harms and the report says which: | Byte | Harm | Measured behaviour | | ------------------------ | ------------------------------- | ---------------------------------------------------------------- | | U+0000 | Code-search outage | GNU grep 3.11 and ripgrep 14 both refuse to print matching lines | | Every other control byte | Invisible, unreviewable literal | Both tools print the line normally | Covering only U+0000 would reproduce a known miss: objectstack#5140 shipped a NUL *and* a U+0001 fourteen bytes away, and the NUL-only scanner reported OK on the second one (objectstack#5157). **Why it is a separate workflow.** `ci.yml` and `lint.yml` used to list `'**/*.md'`, `content/**`, `docs/**` and `.changeset/**` under `paths-ignore` on *every* trigger, and GitHub has no per-job path filter. Markdown is exactly the carrier the worst instance of this bug used — objectstack#4890 was a raw NUL in a `.claude/` skill file, emitted by the PR that was writing the rule forbidding it, leaving the agent instructions unfindable by `grep -r` with no signal that anything was missing. A path-filtered gate could not have seen that PR. [#3523](https://github.com/objectstack-ai/objectui/issues/3523) has since taken that filter off their `pull_request` triggers, so a markdown-only PR does start them now — but their jobs short-circuit to nothing on such a change, and both keep the filter on `push`. This gate stays where it is, and its unfiltered trigger set is why it is one of only two contexts that audit found safe to make required today. `scripts/__tests__/check-control-bytes.test.ts` fails if a `paths` or `paths-ignore` key is ever added here. **If it fails:** write the escape sequence (backslash, lowercase `u`, four zeroes) instead of the byte — the resulting string is byte-identical at runtime. Better still, if the byte was only ever "a character the data cannot contain" (a join/split separator, a sentinel), use something a reader can verify: a newline, a comma, or `JSON.stringify`, which needs no impossible character at all. When writing *about* these bytes in prose or in a tool payload, name them as `U+0000` — a backslash escape typed into an agent's tool payload gets decoded into the real byte before it reaches disk, which is how two of the five incidents in this family happened. **Known pre-existing offenders.** `KNOWN_OFFENDERS` in the script baselines the files that already carried a control byte when the gate landed, so it could be switched on as a ratchet. It is not a skip-list: the scan fails on an entry whose file has been cleaned or deleted, so a fix that forgets to remove its entry is as red as a new offender. Entries carry the issue tracking their removal (objectstack#5450) and the map is expected to reach empty and stay there. ## Performance Budget (`performance-budget.yml`) [#performance-budget-performance-budgetyml] **Triggers:** Push and PR when changes touch `packages/`, `apps/console/`, or `pnpm-lock.yaml`. Its display name in the checks list is **Bundle Analysis**. ### Enforced limit [#enforced-limit] Exactly one bundle-size number in this repository is enforced — this one: | Bundle | Max gzip size | Enforced | | ---------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------- | | Console main entry (`apps/console/dist/assets/index-*.js`) | **350 KB** (`MAX_ENTRY_GZIP_KB`) | Yes — the step exits non-zero when the entry chunk exceeds it | > The 350 KB above is **pinned to the workflow**, not retyped from memory: > `scripts/__tests__/ci-cd-pipeline-doc.test.ts` reads `MAX_ENTRY_GZIP_KB` out of > `.github/workflows/performance-budget.yml` and fails `pnpm test` if this page > disagrees with it. Change one and you must change the other — the number cannot > drift silently again ([#3197](https://github.com/objectstack-ai/objectui/issues/3197)). * Builds the console app and measures bundle sizes. * Posts a PR comment with the budget report and pass/fail status — but **only when the bundle was actually measured**. A run that was cancelled (a second push supersedes the first via `cancel-in-progress`) posts nothing, and a run whose build never produced a bundle posts a neutral "not measured" note instead of a verdict. A `FAIL` verdict therefore always carries the measured size that exceeded the budget. * The comment is rendered by `scripts/render-budget-comment.mjs` (unit-tested), not by logic inlined in YAML. **If it fails:** the step prints `BUDGET EXCEEDED: Main entry is KB gzip (limit: 350 KB)` and the PR comment carries the same two numbers, so the log already tells you the size and the overshoot. Read the package size report appended to that comment next: the entry chunk is `apps/console`'s own code plus everything it imports eagerly, so a jump usually traces to one new eager import pulling a dependency in. Fix it at that import. Raising `MAX_ENTRY_GZIP_KB` is a deliberate decision, not a workaround for a red check — and it cannot be done quietly, because the pin above fails until this page states the new number too. ### Package size report — advisory, not a gate [#package-size-report--advisory-not-a-gate] The same workflow's `Generate package size report` step writes a markdown table of every `packages/*/dist/*.js` file with its raw and gzipped size, and that table is appended to the PR comment. The report is generated only from a **complete** package build, so it is never a truncated table that looks complete. The step **never compares a measured size against a limit and never exits non-zero** — it `echo`s the three tiers below into the report as explanatory text. They are guidance for reviewers; exceeding any of them turns no check red and blocks no merge: | Package category | Advisory target (gzip) | Enforced | | ------------------ | ---------------------- | ---------------------- | | Core packages | \< 50 KB | **No** — advisory only | | Component packages | \< 100 KB | **No** — advisory only | | Plugin packages | \< 150 KB | **No** — advisory only | > **There is no separate size-check workflow**, and there never has been one in this > repository — the package size report has always been a step inside > `performance-budget.yml`. This page used to document one as its own workflow file, > enforcing the three tiers above; both claims were false, which is worse than no > documentation because it advertises a guardrail that does not exist. If you want > these tiers enforced, add the comparison to the workflow — do not describe it as > enforced here. ## Live E2E (`live-e2e.yml`) [#live-e2e-live-e2eyml] **Trigger:** PRs to `main` / `develop` (same code-path filter as `ci.yml` — docs-only and changeset-only PRs skip it), a nightly cron (`30 6 * * *`) on `main`, and manual dispatch. **Blocks a merge: no.** The job runs with `continue-on-error: true` by construction — a red run is informational and never ejects a PR from the merge queue. Do not add it to required checks (and do not remove `continue-on-error`) until the nightly record proves the lane stable; see the header comment in the workflow file (#2835). What it does: runs an allowlist of the live specs (`pnpm test:e2e:live:ci`) against a real `objectstack dev` backend booted from **published** `@objectstack/*` packages serving the showcase app, catching the class of bug only a real browser against a real backend can see. Failures still surface as a red step plus an uploaded Playwright report and job summary. **Which specs are in the allowlist:** whatever the `test:e2e:live:ci` script in `package.json` names — that script is the single source of truth, and this page deliberately does not repeat the list. The lane grows the allowlist a few proven specs at a time (see the workflow's header comment), so every promotion would stale a hand-copied enumeration here; it already did (objectui#3488). Backend pins live in `e2e/live/ci/backend.env` and must match the `@objectstack/spec` version in `pnpm-lock.yaml` — bump both in the same PR, or the run proves nothing. ## Internal Docs Links (`docs-links.yml`) [#internal-docs-links-docs-linksyml] **Triggers:** Push and PR to `main`/`develop`, plus manual dispatch — with **no path filter at all**, which is the point of the workflow. It appears in the checks list as **Internal Docs Link Check**. Runs `scripts/check-doc-links.mjs`, which walks every `.md` / `.mdx` file in the surfaces listed in its `SCAN_ROOTS` (17 rows as of objectui#6280) — `content/docs/`, `examples/`, the internal `docs/` tree, every package and app `README.md`, the rest of each package's and app's directory tree (every file except `README.md` and `CHANGELOG.md`, the latter excluded everywhere as changesets output rather than authored prose), every nested `README.md` a package or app carries below its top level, and the root-level markdown files (`README.md`, `CONTRIBUTING.md`, `ROADMAP.md`, `AGENTS.md`, `CHANGELOG.md`, `CLAUDE.md`, `LICENSE-THIRD-PARTY.md`, `QUICK_REFERENCE.md`) — and asks of each internal markdown link whether its target is really there. **Two rules, because the two groups are read through different machinery** (objectui#3536). For `content/docs/` the question is the one a site reader cares about, **does the site serve this URL?** Four checks, by href shape: | Href shape | Resolved against | Rejected when | | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | relative (`../plugins/plugin-charts.mdx`) | the linking file's directory | the target file is missing… | | relative escaping the collection (`../../../packages/x/README.md`) | — | …**or** it resolves outside `content/docs/` (fumadocs can only resolve inside its page index, so the href reaches the browser verbatim — a 404 even though the file exists) | | absolute `/docs/...` | `content/docs/` as a **route** | no `foo.md`, `foo.mdx` or `foo/index.md*` backs it — a `.md`/`.mdx` suffix always fails, since that URL 404s whatever is on disk | | any other absolute (`/spec/...`, `/img/...`) | the **site itself**: route segments enumerated from `apps/site/app`, plus static files under `apps/site/public` | no route pattern or static file matches | **Two href shapes are checked on *every* surface, both rules included**, because they look external but are decidable offline: | Href shape | Resolved against | Rejected when | | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | this repo's own `https://github.com/objectstack-ai/objectui/(blob\|tree)/main/...` (objectui#3536) | the path in the working tree | that path is not in the checkout. Only `main` and only this repo — other refs and repos cannot be answered offline | | this site's own `https://[www.]objectui.org/...` (objectui#3603) | the origin is stripped, and what remains goes through the two absolute rows above, unchanged | the resulting route does not resolve — so `…/docs/guide/foo.md` fails for exactly the reason `/docs/guide/foo.md` does | The second one had been invisible since the beginning: `judgeHref()` skipped every href carrying a scheme, so a route written with the site's own origin was never checked while the identical origin-less route was checked strictly. That blind spot was never confined to package READMEs — `content/docs/` writes 6 such URLs itself — which is why the fix strips the origin in `judgeHref()` rather than special-casing any surface. Measured before landing: 11 across the scanned tree, zero dead. Prefer the origin-less form (`/docs/guide/plugins`) in new prose: it survives a domain change, and both spellings are now checked identically. Every other surface — `examples/`, `README.md`, `CONTRIBUTING.md`, `ROADMAP.md`, `docs/` and the package READMEs — is read on **GitHub** (and, for the package READMEs, on **npm**), not served by the site, so a relative href there names a path on disk and is checked for existence only: a directory (`./packages/core`) or a non-markdown file (`./vite.config.ts`) is a perfectly good target, and there is no collection to escape. A leading `/` is rejected outright: GitHub resolves it against `github.com`, not against this repository. Applying the `content/docs/` rules to these files instead would reject 186 links that render correctly today. `CONTRIBUTING.md`, `ROADMAP.md` and `docs/` are objectui#3572. They cost one `SCAN_ROOTS` row each and no new rule, because "read on GitHub" already had one; their own backlog — three dead links — was cleared first and separately (objectui#3545), so the rows landed on a green tree. The package READMEs are objectui#3622, and the same shape: **one row, its backlog paid first**. That backlog was 11 dead links in seven packages — three `/api/` routes the site has never served, four site URLs naming three `content/docs/` directories that have no index page (so fumadocs generates no route for them), a `/docs/types` tree that does not exist, an `/examples` route that does not either, and two disk paths that were simply absent. Each was repointed at a real page, or replaced with the repository URL that does exist, before the row went in. The row is also the table's only wildcard: `packages/*/README.md` stands for one file per package directory (38 of the 39 today), and only the README — a package's `CHANGELOG.md`, `TESTING.md` and its own `docs/` tree stay unscanned. **Package READMEs must keep the origin on site links.** Inside `content/docs/` the origin-less `/docs/guide/plugins` is preferred; in a README it would be wrong, because GitHub and npm both resolve a leading `/` against their own host, not against this site. Write `https://www.objectui.org/docs/guide/plugins` there — it is checked exactly as strictly. **One boundary, stated because it is easy to mistake for coverage:** links written inside a code fence are invisible to this check. `stripCode()` blanks fenced blocks and inline spans before scanning — required, since fenced code legitimately contains `[…](…)` that is not a link — so a dead route in an illustrative snippet is not reported. `CONTRIBUTING.md` carries 10 such links today against 15 outside fences, of which the gate judges one. Prose *about* links stays a human review item. One href shape is checked in **every** surface: a `https://github.com/objectstack-ai/objectui/(blob|tree)/main/` URL points back into this repository, so `` must exist in the working tree. Other repos' URLs, other refs, and `#fragments` are not resolvable offline and stay Lychee's job. Everything else — external `http(s)` and `mailto:` links, bare `#anchors` — is skipped. No install, no build, no network: a checkout and one `node` call. The last two rows are objectui#3490. Reading `apps/site` widens the script's responsibility, and that is the deliberate purchase: it is the only way to catch a link to a route that does not exist, and 18 such 404s had accumulated while the check waved every non-`/docs` absolute href through. The cost is that a docs PR can now go red because `apps/site` moved under it — correct, but real. The header of the script argues the trade-off in full. **Why it blocks a merge.** A broken internal link is a 404 on the published site, and nothing else in CI sees it: the site build succeeds with a dead link in it. The script itself is older than its gate — it existed, worked, and was wired to nothing under `.github/`, so it had never run in CI at all, and `main` sat with a broken link it would have caught (objectui#3213, objectui#3292). **Why it is a separate workflow.** This is the second instance of the lesson `control-bytes.yml` records, and it was found by the PR that first put this check into CI. That PR added it as a step in `ci.yml`'s `docs` job — where it could never see the PRs that matter. `ci.yml` *then* listed `'**/*.md'`, `content/**`, `docs/**` and `apps/site/**` under the `paths-ignore` of **both** its triggers, GitHub's `paths-ignore` skips the *whole workflow* when every changed file matches, and GitHub has no per-job path filter. So a **docs-only** PR — the likeliest way an internal link breaks — started no workflow at all, and the check only ever ran on PRs that touched docs alongside code, plus pushes to `main`. A bad link could merge through a pure-docs PR and turn `main` red later under an unrelated author (objectui#3448). The step was **removed** from `ci.yml` in the same change rather than left in place. This workflow's trigger set is a strict superset of that job's, so keeping both would only add a second red check for one broken link, and a second place to forget. `scripts/__tests__/docs-links-workflow.test.ts` pins all of it: the workflow must exist, must gate pull requests, must carry neither `paths` nor `paths-ignore`, and must remain the only workflow that runs the script. [#3523](https://github.com/objectstack-ai/objectui/issues/3523) removed `ci.yml`'s `pull_request` path filter, so the specific blindness above no longer exists there — but nothing moves back. `ci.yml` still filters its `push` lane, so it would miss a docs-only push to `main`; and this workflow is one of the two contexts that audit found safe to require today precisely because it has never had a filter to reason about. **If it fails:** it prints every offending `file -> href`. Either the link is misspelled, or the page it points at has moved or been renamed — fix the link, or restore the target. Links are checked as *routes*, so `/docs/guide/foo` is what belongs in the markdown, not `content/docs/guide/foo.md`. Run it locally with `pnpm docs:check-links`. ## Skill Guide Paths (`skills-paths.yml`) [#skill-guide-paths-skills-pathsyml] **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**, for the same reason as the two sections above: this gate's entire scan surface is markdown, and `ci.yml` still lists `'**/*.md'` under the `paths-ignore` of its `push` trigger. It appears in the checks list as **Skill Guide Path Check**. Runs `scripts/check-skills-paths.mjs`, which reads every markdown file under `skills/` and asks, of each in-repo path the prose states inside a backtick code span, whether it exists on disk. Those guides are a direct input to every agent that writes code in this repository, and their prose gives paths as coordinates. **Why a dead coordinate costs more than its size suggests:** the symbol named next to it is usually real and only the location is wrong, so nobody gets a compile error — an agent gets "file not found" from a `Read`, assumes its own search was clumsy, and spends a full lap re-locating something the guide claimed to have located for it. Two rounds were found by eye while reading: [#3713](https://github.com/objectstack-ai/objectui/issues/3713) (PR #3729) and [#3730](https://github.com/objectstack-ai/objectui/issues/3730) (PR #3734), the second one 13 real symbols at coordinates that did not exist. It also recurs by construction — the app-shell extraction commits moved code with nothing anywhere to say the guides had gone stale ([#3735](https://github.com/objectstack-ai/objectui/issues/3735)). **What counts as a stated path:** a backtick span that opens with one of five top-level directories (`apps/`, `packages/`, `examples/`, `scripts/`, `content/`) and contains no whitespace. Three exclusions, each a *rule* rather than an exemption, because none of them claims that a file exists: | Excluded | Example in the guides today | Why | | --------------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | whitespace inside the span | a `grep -rn … packages/app-shell/src` self-check command line | prose, a command line or a type — not a path | | glob or placeholder segment | the protected-primitive glob under `packages/components/src/ui`, a schema path with a placeholder domain segment | a shape, not a location; `existsSync` on it would mean nothing | | fenced code blocks | a `bash` block that creates a file | a worked example may legitimately name a file the reader is about to create | Measured on `main@6422aa891`: 18 guide files, 91 candidate spans, 5 of them patterns — **86 stated paths, of which 85 resolve**. **The one exemption, and why it cannot rot.** `scripts/skills-path-baseline.json` lists paths a guide states *deliberately as absent*. Today there is exactly one: the Key contexts section of `console-development.md` exists to correct a recurring wrong guess and says there is no `apps/console/src/context/` directory at all. That entry is a ratchet, red in **both** directions — if the path ever appears on disk the gate fails and names it (the sentence has become false), and if the scan stops meeting the entry the gate fails too (the prose was rewritten, so the entry is dead weight). Entries are keyed by file and token, never by line number, because guide prose moves constantly. **Scope, stated so it is not mistaken for an oversight.** `content/docs/**` carries backtick paths too and is **not** scanned here. Widening a scan surface arrives with its own batch of red to clear, which `check-doc-links.mjs` learned three times over (#3479, #3490, #3545) — measure it first, in its own change. The five-prefix list is the same kind of decision: adding this repository's other five top-level directories was measured at +2 candidates and 0 new red, so it is cheap, but it stays deliberate rather than assumed. **If it fails:** it prints every `file:line — token`. Fix the prose. Add a baseline entry only when the sentence's whole point is that the path does not exist. Run it locally with `pnpm check:skills-paths`, or `node scripts/check-skills-paths.mjs --list` to see every candidate and how it was classified. ## Skill Examples (`skill-examples.yml`) [#skill-examples-skill-examplesyml] **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**, for the same reason as the section above: the scan surface is markdown under `skills/` and `.claude/skills/`, and both `ci.yml` and `lint.yml` list `'**/*.md'` under the `paths-ignore` of their `push` trigger. It appears in the checks list as **Skill Example Check**. Runs `scripts/check-skill-examples.mjs`. Where the section above checks the *paths* a guide states in prose, this one checks the *worked examples* themselves: a marked `ts` / `tsx` / `typescript` fence must compile `--strict` against the packages' built `dist/*.d.ts` **and must not use a bare `any`**, and a marked `json` / `jsonc` fence must parse. **Why it was needed:** at the time it landed, `skills/objectui/` carried 112 TypeScript fences and 56 JSON fences and **not one gate in the repository read inside any of them** ([#7359](https://github.com/objectstack-ai/objectui/issues/7359)). Every gate that could have is scoped elsewhere by construction — `check-skills-paths.mjs` deliberately reads inline code spans in prose only, the three `content/docs` gates say so in their own headers. So a fence could import a symbol that does not exist and stay green forever, and two rounds of exactly that had already been cleaned by hand. **Opt-in, and why:** a fence is checked only when the line **immediately above** it is ``. Most skill fences are fragments by construction — a `columns: [...]` subtree, a block that continues the one above it — so compiling all of them at once would red on prose that is not wrong, and a gate that reds on correct code gets deleted by the first person who hits it. The marker is an inert HTML comment and leaves the fence info string bare, so the three gates that key on the info string still see the block. The convention is ported byte-for-byte from objectstack's `packages/spec/scripts/check-skill-examples.ts`. **No bare `any` in a marked block** ([#7463](https://github.com/objectstack-ai/objectui/issues/7463)): a marker is the author's claim that the block compiles, and every property access on an `any` is unchecked — so a marked block full of `any` is a green badge over a `tsc` run that proved nothing. The rule is ported from objectstack's runner with its scope intact: the annotation must **be** `any` in a position that erases checking (a parameter, a variable / property / return annotation, a type alias, an `as any` / `satisfies any` / angle-bracket assertion). An `any` **nested** inside a larger type — `Record`, `any[]`, `Promise` — is deliberately allowed; that boundary is what keeps a red meaning broken. The corpus had four such sites when the assertion landed, and each is declared verbatim in `KNOWN_BARE_ANY_EXAMPLES` in the script. That list is a **shrink-only** ratchet, not an allowlist: a row whose red goes away fails as **stale** and must be deleted. Fixing a row is a judgement about the guide (one of them faithfully restates a platform type that really is `any`), so the rows are declared debt rather than a mechanical unmark. **`MARKED_FLOOR` is a second shrink-only ratchet, per category** ([#7550](https://github.com/objectstack-ai/objectui/issues/7550)): the script also floors the *marked* population itself, one number per fence category, seeded at what `main` carried when it landed — `ts: 17`, `json: 39` — and printed beside its own count on every run: `Marked: 17 ts fence(s) (floor 17), 39 json fence(s) (floor 39) — the floor is ⛔ SHRINK-ONLY.` A category whose marked count falls below its floor exits 1 naming the category, the count, the floor and the two legal moves. It is a **floor**, not an exact pin, because the two directions are not symmetric: marking one more fence is the direction this gate exists to travel, and an equality that reds on that move is one people learn to route around — by unmarking, which is exactly the move this floor exists to catch. Only the downward move needs a witness — raise the number in the pull request that adds marks; lower it, in the *same* pull request that removes one, with the reason written beside the constant: which example stopped being one, and why unmarking it was the honest call rather than the cheap way out of a red. **It scans `.claude/skills/` too** ([#7463](https://github.com/objectstack-ai/objectui/issues/7463)): `SCAN_ROOTS` holds `skills` and `.claude/skills`, the same widening `check-skills-paths.mjs` took in [#7358](https://github.com/objectstack-ai/objectui/issues/7358). When #7251 moved the two contributor-only guides out of `skills/`, that gate stopped looking at them and nothing turned red. Widening a root is **not** arming it: opt-in is the design, so this added 9 candidate fences (18 → 20 guides, 112 → 121 `ts` fences) and **zero** marked ones. Adding a marker under `.claude/skills/` is the surface owner's step. **Adjacency is strict:** a marker that is not directly above a checkable fence — separated by a blank line, above a `bash` fence, or left behind when its example was deleted — is an **orphan** and fails the run. A lenient rule would opt in nothing while its author believed otherwise, which is this repository's recurring "looks like enforcement, isn't" class. A marker shown as *example text* inside another fence is neither an opt-in nor an orphan: it is not at top level, so it claims nothing. **It builds, unlike `skills-paths.yml`:** the criterion is the *published* type surface, so the packages the marked fences import must exist as `dist/*.d.ts` first. The build is filtered to exactly those packages, and the filter is emitted by the gate itself (`node scripts/check-skill-examples.mjs --build-filter`) rather than hand-maintained in the workflow, so it grows only as the marked population grows. **Three exit codes, not two.** `0` = every marked example held up. `1` = the gate ran and found errors — a verdict about a guide. `2` = the gate **could not run**: the packages are unbuilt or typed from source, a harness control failed, or nothing is marked at all. Nothing printed under exit 2 is a verdict about any guide, and it is never a pass — zero with nothing run reads as coverage, which is the failure shape this gate family exists to prevent ([#4846](https://github.com/objectstack-ai/objectui/issues/4846)). **If it fails:** it prints `file:line` for every failing fence with the compiler's own diagnostic, or the JSON parser's message, or the orphan marker's line, or — for a bare `any` — the position and the verbatim baseline row to declare if the fix belongs to a later card, or — for a floor breach — the category, the count, the floor and the two legal moves. Fix the example — or, if it was never meant to stand alone, remove its marker **and lower `MARKED_FLOOR` for that category in the same pull request**, with the reason written beside the constant: a marker deleted without that edit is a silent unmark, exactly the move this floor exists to catch. Run it locally with `pnpm check:skill-examples`, `node scripts/check-skill-examples.mjs --list` to see every candidate fence and its verdict, and `--measure` to judge every candidate whether marked or not. ## Skill Eval Tokens (`skill-eval-tokens.yml`) [#skill-eval-tokens-skill-eval-tokensyml] **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**, for the same reason as the two sections above: the whole input is the markdown and JSON under `skills/`, which `ci.yml` and `lint.yml` structurally cannot see. It appears in the checks list as **Skill Eval Token Check**. Runs `scripts/check-skill-eval-tokens.mjs`. The two sections above check the *paths* a guide states and the *worked examples* it ships; this one checks the **evals** — the `skills//evals/*.json` files that grade an answering agent. Every `must_contain` token must occur as a **whole token** in the markdown of its own skill bundle. Unlike its sibling it does **not** install or build: it reads text out of the checkout, so it is a checkout plus two `node` calls, the shape `skills-paths.yml` uses. **Why it was needed:** nothing checked that a token an eval grades on is something the skill actually teaches, so an eval could require a word the guides never say and grade nothing forever. The class had already been cleaned by hand twice — [#7360](https://github.com/objectstack-ai/objectui/issues/7360) (six assertions, one of them passing only by accidental substring) and [#7405](https://github.com/objectstack-ai/objectui/issues/7405) (three more) — both rounds found by a human reading, which is the economics this gate family exists to end ([#7461](https://github.com/objectstack-ai/objectui/issues/7461)). **The oracle is bundle-wide, and the losing option stays measurable.** A token counts as taught if it appears anywhere in its own bundle's markdown, not only in the guide whose basename matches the eval file. That is what the artefact supports: `SKILL.md` is a router that names every guide, and its rules block tells the reader to read `rules/` before writing schemas, so the corpus an eval is answered from is the whole bundle. Measured at the gate's branch point, per-guide would have started with 14 red rows against bundle-wide's 0 — and 12 of those 14 exist only because `evals/protocol.json` has no `guides/protocol.md` (its guide is `rules/protocol.md`), so they name no defect at all. `--measure` prints **both** red lists on every run, so that comparison stays re-derivable rather than a claim in a merged pull request. **Whole tokens, never substrings.** An identifier-shaped token is matched on word boundaries, so `view` does not match the tail of `// vite preview` and `FieldWidgetProps` does not match the head of `FieldWidgetPropsSchema` — both real rows that a substring grep had reported clean. A token that is not identifier-shaped at an end (a quoted JSON key, an operator) takes no boundary at that end and so degrades to an exact substring, which is what such a token means. Matching is case-sensitive. **`must_not_contain` is deliberately not scored against the guides** — it gets a shape check and nothing else. Those entries are tokens an answer must *avoid*, so "no guide says it" is the healthy case, and one of them is spelled as a quoted key fragment precisely so it does not fail an answer that names the rule while following it. A check that validated that array the way it validates `must_contain` would have the polarity backwards. **Three exit codes, not two.** `0` = every token is taught, and something was actually checked. `1` = the gate ran and found errors — an untaught token, a malformed assertion array, or a stale baseline row. `2` = the gate **could not run**: no eval population, no guide corpus, or an eval file that does not parse. Nothing printed under exit 2 is a verdict about any eval, and it is never a pass. **If it fails:** it prints `file eval N token` for every red row, and says whether the token is absent entirely or present only as a substring. Do **not** rewrite guide prose to teach the token and do **not** re-point the row mechanically — which of the two is right is a per-row skills decision. The declared list `KNOWN_UNTAUGHT_EVAL_TOKENS` is empty at landing and is **shrink-only**: a row parked in it stays visible, and a row whose red goes away fails as stale until its line is deleted. Run it locally with `pnpm check:skill-eval-tokens`, `--list` for every row under both oracles, and `--measure` for the two red lists side by side. ## Documented Component Types (`doc-component-types.yml`) [#documented-component-types-doc-component-typesyml] **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**, and here the reason is sharper than in the three sections above. `ci.yml`'s `type-check` job decides whether to run its gates with a `git diff` that *excludes* `content/**`, so a pull request editing only `content/docs/**.mdx` reports that context and runs nothing inside it — and a docs-only pull request is exactly the change that introduces the defect this gate exists for. It appears in the checks list as **Doc Component Type Check**. Runs `scripts/check-doc-component-types.mjs`, which reads every fenced code block under `content/docs/**` and asks, of each `type` string literal in one, whether the repository registers a component under that name. **Why the teaching surface needed its own ratchet.** The catalog side has had one since [#4616](https://github.com/objectstack-ai/objectui/issues/4616): `examples/schema-catalog/test/catalog-gallery-render.test.tsx` renders every catalog entry and fails if any paints the registry's `Unknown component type` panel (OBJUI-001). A snippet in the docs is rendered by nothing, parsed by nothing and compared against nothing, so it could name any string at all and every check stayed green — while a reader who copied it got the red panel. The same defect landed three times that way, each found by a human probe: [#4786](https://github.com/objectstack-ai/objectui/issues/4786) taught `stats-card`, and [#4796](https://github.com/objectstack-ai/objectui/issues/4796) taught `plugin:grid` and `plugin:map` (the registered names are `object-grid` and `object-map`). **Where the key list comes from.** Nowhere — it is derived from the `ComponentRegistry.register(…)` and `registerLazy(…)` calls themselves on every run, including the loop forms and two helpers that register from a collection, with `namespace` and `skipFallback` read out of each call's own balanced argument span. There is no hard-coded enumeration to drift, and no build step, which is what keeps the whole run to a checkout plus one `node` call. A registration whose key the derivation cannot resolve **fails the gate** rather than being skipped: a key silently missing from the universe turns *correct* documentation red, which is the failure mode that gets gates deleted. **How a snippet is judged.** `type` is not one vocabulary in these pages — measured across 143 files and 558 literals, the corpus spells action schemas, block schemas, theme and report schemas, field and JSON-Schema data types, validation rules and navigation items all under the same key. A structural discriminator was built and rejected on measurement (a TypeScript annotation reads exactly like an object key to a brace tracker, and `items` carries navigation entries on one page and renderable children on another, so any global rule is a silent false green somewhere). So the rule is flat: every literal is a candidate component key, and a value outside the derived universe must be **declared** in the script's `DOC_TYPE_EXEMPTIONS` — keyed by (file, value), with a written reason naming the vocabulary it really belongs to. A whole-file exemption is deliberately not offered: `api/schema-reference.md` carries `"type": "action"` (an ActionSchema discriminant) and `"type": "card"` (a registered component key) in the same document. Entries are re-derived per run, so one whose page stopped spelling that type fails as a stale exemption rather than quietly widening the hole. **If it fails:** it prints every `file:line — type ''` with the offending source line. Either spell the registered key (`grep -rn "ComponentRegistry.register(" packages/` for the real name), or — if the value belongs to another vocabulary — add the declaration with its reason. Run it locally with `pnpm check:doc-types`. ## Documented Snippet Types (`doc-snippet-types.yml`) [#documented-snippet-types-doc-snippet-typesyml] **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**, for the same reason as the section above: the change that breaks a documentation snippet is a docs-only change, and that is exactly the shape `ci.yml`'s expensive jobs short-circuit. It appears in the checks list as **Doc Snippet Type Check**. Runs `scripts/check-doc-snippet-types.mjs`, which extracts every fenced `ts` / `tsx` block from the documents it covers and compiles them `--strict` against each package's **built** `dist/*.d.ts` — the surface a reader who copies the snippet actually imports. **The second dimension, and why it is separate from the first.** `doc-component-types.yml` answers whether a `type` literal names a registered component. It says so in its own header, and [#5138](https://github.com/objectstack-ai/objectui/issues/5138) measured what the gap beside it allowed: both plugin-report documents taught the pre-9.0 report form for the whole interval after the ADR-0021 cutover, and every gate was green on that prose — because the `type` literals (`summary`, `matrix`, `joined`) were the one thing that was correct, while `objectName`, `groupingsDown`, an object-shaped `columns` and an import of a type the spec does not export sat beside them. The harness that catches those had by then been hand-rolled three times, privately, in [#5053](https://github.com/objectstack-ai/objectui/issues/5053), [#5060](https://github.com/objectstack-ai/objectui/issues/5060) and [#5047](https://github.com/objectstack-ai/objectui/issues/5047) — which is what made it consolidation rather than new capability. **Why this one builds.** Its criterion is the *published* type surface, so the packages the covered snippets import must exist as `dist/*.d.ts` first. The build is filtered to exactly those packages, and the filter is emitted by the gate itself (`node scripts/check-doc-snippet-types.mjs --build-filter`) rather than hand-maintained in the workflow — so it can never drift from what the documents import, and the cost grows only when coverage grows. Each emitted filter carries pnpm and turbo's dependency-closure suffix (`--filter=@object-ui/react...`), because the packages the documents import are not a buildable unit on their own: they depend on workspace packages no snippet names, and those have to exist first. Under `turbo run build` the suffix selects the same tasks `dependsOn: ["^build"]` already did; under `pnpm ... run build`, which selects exactly what it matches, it is the difference between a build that completes and one that dies on an import the reader never wrote ([#5911](https://github.com/objectstack-ai/objectui/issues/5911)). This is deliberately **not** the per-PR full-repo build the 2026-08-16 ruling on [#4846](https://github.com/objectstack-ai/objectui/issues/4846) rejected; see *Published Dist Gate* below. **And the filter is checked, twice.** The step that derives it fails the job if the gate exits non-zero, and the build step refuses a filter that names no package ([#6221](https://github.com/objectstack-ai/objectui/issues/6221)). Written the obvious way — `echo "args=$(node …)" >> "$GITHUB_OUTPUT"` — the step's status is `echo`'s, so a gate that failed would read as a gate that named nothing, and `turbo run build` with no filter is the whole-workspace build this section just said the job must never run. **Fragments are declared, never guessed.** Documentation legitimately carries partial snippets, so a block that is not meant to compile carries a marker line immediately above its fence with a written reason — `{/* doc-snippet: fragment - why */}` in `.mdx`, the HTML-comment form in `.md`. A block that merely fails to parse is **reported**, never skipped: a skip-on-failure rule turns every real defect into silence, and degrades exactly as the docs get worse. **Syntax and semantics are reported apart.** `tsc` reports syntactic diagnostics and, if there are any, never reports semantic ones — program-wide. #5047 measured a run that printed five parse errors, zero semantic diagnostics, and read as a meaningful red while proving nothing. So this gate parses blocks one at a time first, keeps unparseable ones out of the semantic program, tags every failure `[syntax]` or `[semantic]`, and always prints how many blocks the semantic phase actually judged. **It proves itself before it judges the docs.** Every run prints three controls: the resolved path for `@object-ui/types` (which must land in a `dist/*.d.ts` — the root `tsconfig.json` maps the workspace to *source*, so that substitution is one inherited config away), a planted `ThisNameIsDefinitelyNotExported` import that must produce TS2305 (a program silently resolving to `any` reports green forever), and a real import that must be clean (so a broken harness cannot read as "the docs are full of defects"). A failed control fails the run and says no verdict about the documents can be read from it. **Coverage is declared.** A document is covered unless the script's `UNGATED_DOCS` ledger names it with a reason, so a new page is gated from the day it lands and opting one out is a visible edit. The ledger is debt with names: those documents are **not** compiled and **not** counted, which the script's header states plainly rather than letting a green run imply otherwise. **If it fails:** each line is `file:line TS: `, addressed at the document rather than at the harness. Either fix what the snippet teaches, or — if the block is genuinely partial — declare it with a reason. Run it locally with `pnpm check:doc-snippets` (after building the packages it names: `pnpm exec turbo run build $(node scripts/check-doc-snippet-types.mjs --build-filter)`). ## Fence Languages (`doc-fence-languages.yml`) [#fence-languages-doc-fence-languagesyml] **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — **no path filter**, for the same reason as the two sections above. It appears in the checks list as **Doc Fence Language Check**. Runs `scripts/check-doc-fence-languages.mjs`. It answers the question the gate above cannot ask about itself: *is every TypeScript block in the documentation actually fenced as TypeScript?* `check-doc-snippet-types` reads `ts` / `tsx` / `typescript` fences and nothing else, so a TypeScript block fenced any other way is invisible to it — [#5867](https://github.com/objectstack-ai/objectui/issues/5867), whose remediation lane collected its population from \`\`\`plaintext fences only. **`plaintext` is not the only spelling of an unhighlighted fence.** [#6135](https://github.com/objectstack-ai/objectui/issues/6135) measured a \`\`\`text block opening `interface FileUploadSchema {` sitting outside the gate *and* outside the lane that exists to close it, for no reason but how its fence is spelled. Widening the lane's derivation once would fix that block; it would not stop a sixth spelling reopening the identical gap. **So it reads bodies, not a list of languages.** No enumeration of allowed fence languages is on the enforcement path — an enumeration is the thing that rots, and it rots silently. Every fence's body is put to #5867's own binding triage classifier (*a block whose first line starts with `import` / `export` / `interface` / `type X =` / `const x: T` is code*), quoted rather than extended. `txt`, `console`, `raw`, or a bare fence with no info string at all therefore cannot hide a block. **Two failure modes, because only one can be auto-classified.** A *known* spelling of an unhighlighted fence (`plaintext`, `text`, `plain`, `txt`, no info string) is #5867's population and its remedy is mechanical, so it is the only mode the baseline describes. Any *other* spelling might be a sixth synonym or a real highlighter language — that is a human's call, so it is reported separately and can **never** be baselined. **The baseline is #5867's remaining population.** `KNOWN_UNHIGHLIGHTED_TS_FENCES` maps a path to the number of hidden blocks it carries, ⛔ **shrink-only** in the shape [#6133](https://github.com/objectstack-ai/objectui/issues/6133) landed for `KNOWN_HAND_TYPED_GUARDS`: a file not in the map that carries one fails, a file carrying more than its number fails, and a file carrying fewer fails as *stale* and names itself. Every #5867 batch now lowers these numbers in the same pull request that re-fences the blocks, so the lane's arithmetic lives in the repository instead of being re-derived by hand in each handback. **`--self-test` runs first.** It drives the real scanner over fixture sources — including a `text`-fenced, a `txt`-fenced and an info-string-less TypeScript block — and pins the shrink-only baseline in every direction it can move. A scanner whose recogniser is broken reports a clean tree, which is why the probe runs before the verdict. **If it fails:** each line is `file:line ````. Re-fence the block `ts (or `tsx) and fix whatever `check-doc-snippets` then reports, then lower the file's number. Run it locally with `pnpm check:doc-fences`; it needs no install and no build. ## Pre-Install Import Graphs (`pre-install-import-graph.yml`) [#pre-install-import-graphs-pre-install-import-graphyml] **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**. What this gate judges is the arrangement of the workflows themselves, so its input is `.github/workflows/**` plus the `scripts/` files those workflows name, and the change most likely to break it is a workflow edit. It appears in the checks list as **Pre-Install Import Graph Check**. Runs `scripts/check-pre-install-import-graph.mjs`. Several gates in this repository deliberately run **before any `pnpm install`** — that is what lets them run unfiltered on every pull request shape for the price of a checkout plus one `node` call. The property that arrangement silently depends on is that each of those scripts' *whole static import graph* is node builtins plus repo-relative modules, with nothing in it needing `node_modules`. **Why it needed a gate.** A violation is invisible everywhere it could be caught cheaply: it is not a type error (`tsc` is happy with a package import), not a lint error (the package is a real dependency of the repo), not a local failure (locally `node_modules` exists), and — until [#6148](https://github.com/objectstack-ai/objectui/issues/6148) — not a test failure, because exactly one of the pre-install scripts had a test asserting it. It surfaces only as `ERR_MODULE_NOT_FOUND` inside one CI job, on whichever pull request happens to touch the file; and for the gates that carry no path filter *precisely so they see every PR shape*, that is a gate which **stops running** rather than one that fails loudly. **The population is derived, never listed.** On every run the gate parses every workflow and, per job, compares each step's index against the index of the first `pnpm install` step **in that same job**. Move a step above an install and the population grows on the next run; move one below and it shrinks. A hard-coded list would break silently the first time someone moved a step across an install, which is exactly the edit that needs catching. Two anchoring decisions the derivation depends on, each with a case in this repository: `pnpm exec playwright install chromium` installs a browser rather than the workspace, and `git config merge.pnpm-merge.driver "pnpm install …"` *configures* a driver — the `pnpm install` there is a quoted argument, not an install. That second case was formerly live in `changeset-release.yml`; it was removed with the dead CI half of the lockfile merge driver ([#6436](https://github.com/objectstack-ai/objectui/issues/6436)), so the repository no longer contains a real instance and the gate keeps it as a synthetic fixture instead. Reading either shape as an install would move a boundary and silently drop a script out of the population. **It walks the graph, not the entry file.** Requiring each of the entry's own imports to start with `node:` is too narrow in one direction (a relative import of a builtins-only local module is fine, and two of these scripts spell their builtins bare as `from "fs"`, which is equally install-free) and too weak in the other, because it cannot see a package pulled in **one hop away**. Since [#6092](https://github.com/objectstack-ai/objectui/issues/6092) every one of these scripts imports `scripts/invoked-as.mjs`, so one hop away is exactly where the next breach comes from. The check is static rather than a runtime resolver hook because a hook *executes* module top level, and these files are CI gates that spawn `git`, read the whole tree and call `process.exit`. **It is in its own population.** The step above runs a `scripts/` file before any install, in a job that never installs, so the gate walks its own import graph on every run. A floor that exempted its own enforcer would be the first thing to rot. **If it fails:** it prints the offending chain — `scripts/some-gate.mjs -> scripts/invoked-as.mjs -> typescript` — rather than a bare verdict, so the hop that introduced the package is named. Repairing the import is deliberately *not* this gate's job: either drop the package, or move the step below `pnpm install` in its workflow and accept the install cost. Run it locally with `pnpm check:pre-install-import-graph`, `node scripts/check-pre-install-import-graph.mjs --list` to see the derived population and every module walked, or `--self-test` to exercise the parser and the walk against fixtures. ## Inert vi.mock Specifiers (`vi-mock-specifiers.yml`) [#inert-vimock-specifiers-vi-mock-specifiersyml] **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**. A module mock can be written into any package in any shape of pull request, and the scan costs a checkout plus one `node` call, so there is nothing to gain by hiding it behind a filter. It appears in the checks list as **Inert vi.mock Specifier Check**. Runs `scripts/check-vi-mock-specifiers.mjs`. It walks every tracked JS/TS-family source file, finds each `vi.mock` / `vi.doMock` call site, and resolves the **relative** specifiers against the calling file's own directory. Any that resolves to no file fails the run. **Why it needed a gate.** A mock whose specifier names no file does **not** error. Vitest registers it against a module id nothing imports, the run proceeds with the *real* module everywhere, and the suite passes — with no warning and no smaller assertion count, identically to a correct one. In [#5646](https://github.com/objectstack-ai/objectui/issues/5646)'s one known instance (PR #5645) the suite passed even when the code under test was reverted to the exact broken shape it had been written to catch; only an ablation leg exposed it. Neighbouring mocks in that same file made it invisible to a reader: one stepped up a single level and one stepped up two, and **both were correct**, because their targets sat at different depths. This is [#4347](https://github.com/objectstack-ai/objectui/issues/4347) one layer down — a declaration pointing at nothing, reported as a pass. **It is green at rest, so its census is part of the verdict.** There are zero unresolvable specifiers in the tree and there should stay zero, which means the run's output alone cannot distinguish a working gate from one that matches nothing. Two things answer that. The verdict line prints the **population** it judged, not a bare `OK`. And the scan **fails when that population collapses**: no source files, no test files, or no relative specifiers is a broken walk, not a clean tree, and reporting `OK` for it would be this gate's own defect one level up. The evidence that the gate works lives in `scripts/__tests__/check-vi-mock-specifiers.test.ts`, which reconstructs the historical specifier on a fixture tree and pins that the two correct neighbours are *not* flagged. **Resolution matches how this repo spells specifiers**, which is more than an existence check: the bare path plus `.ts/.tsx/.js/.jsx/.mjs/.cjs`, the `/index.*` forms, and a trailing `.js` stripped and retried, because `src/` is NodeNext throughout. The judgement is `isFile` rather than "exists", so a directory with no index is correctly unresolved. Comments are masked and a call quoted inside a string literal is counted but not judged — an ESLint `RuleTester` code sample is source text, not a mock. **Scope:** relative specifiers only. A bare specifier (`@object-ui/…`, `lucide-react`) can be misspelled too, but resolving one needs the workspace map rather than the filesystem — a different check with a different failure mode. Bare specifiers are counted in the census and never judged. **If it fails:** it names the file, the line and the specifier, and the first path it tried. Fix the specifier, then confirm the mock is really installed by reverting the code under test and checking that the suite goes red. Run it locally with `pnpm check:vi-mock-specifiers`, or `node scripts/check-vi-mock-specifiers.mjs --list` to see every call site the walk found. It needs no install and no build. ## Shell Escape Residue (`shell-escape-residue.yml`) [#shell-escape-residue-shell-escape-residueyml] **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**. The scan surface is markdown that any shape of pull request can touch, and a markdown-only change is exactly the shape `ci.yml` and `lint.yml` skip their expensive steps on. It appears in the checks list as **Shell Escape Residue Scan**. Runs `scripts/check-shell-escape-residue.mjs`. It walks `AGENTS.md`, `CLAUDE.md`, every `.md`/`.mdx` under `skills/`, every one under `.claude/skills/` and every one under `content/docs/`, and fails when a **fenced code block** contains one of the enumerated machine-produced shell-quote escape runs. The contributor tree `.claude/skills/` joined that list in [#7403](https://github.com/objectstack-ai/objectui/issues/7403). It is not published, but it is agent-**written** and agent-**read**, which is both halves of the mechanism this gate exists for — and [#7251](https://github.com/objectstack-ai/objectui/issues/7251) had moved two contributor guides there out of `skills/objectui/`, taking 18 fenced blocks off the surface in one commit with nothing turning red. Nothing could have turned red: the `skills` row's file floor is a **collapse** detector, and 16 files stayed behind to satisfy it while the two that left went unmeasured. A floor measures the roots that are declared, never the tree that walked out of them, so a move and its `SCAN_ROOTS` row belong in one change. The sibling gate `check-skills-paths` lost 55 stated paths to the same move and was widened the same way in [#7358](https://github.com/objectstack-ai/objectui/issues/7358). **Why it needed a gate.** In [#5150](https://github.com/objectstack-ai/objectui/issues/5150) the `git commit -F -` example in `AGENTS.md` §9 shipped with its heredoc terminator wrapped in the single-quote-inside-single-quote shell escape. Copied verbatim, that example does not fail with a message — it **hangs**, on a terminator that never matches, and a reader does not attribute a hung terminal to the document. [#5151](https://github.com/objectstack-ai/objectui/issues/5151) then ran the full derived gate union against the replanted bytes: `check-control-bytes`, `check-doc-links`, `check-changeset-presence` and `check-changeset-no-major` **all exited 0**. None of them was negligent — the residue is printable ASCII inside a code block, and no scan surface in this repository reached it. The amplifier is that `AGENTS.md`, `CLAUDE.md` and `skills/**` are re-read **once per session** by every agent seat, so a bad example is not paid once; it is paid by every reader. **⛔ What this gate does not do.** It checks an **enumerated literal** — one entry today, the sequence \#5150 leaked. It does **not** make fenced shell examples executable-by-construction, and nothing in this repository does: a \`\`\`bash block may be syntactically invalid, may never terminate, or may name a flag that does not exist, and this gate is green on all of it. Running `bash -n` over every block is \#5151's **unbuilt** "direction 1"; it was ruled out of that card rather than rejected on the merits, and it carries a dependency worth recording — it is only as good as its **extraction convention**. In \#5150's own example the block sat inside a numbered list, so both lines carried a two-space indent, and a quoted heredoc terminator must reach **column 0**. Rendered markdown strips the container indent and the block looks fine; agents read these files by `cat`, not by rendering them, so a verbatim copy including the indent hangs exactly as the original defect did. The boundary is asserted as a *fact* in `scripts/__tests__/check-shell-escape-residue.test.ts` — broken shell is fed to the gate and a pass is required — rather than pinned as a sentence, so the claim cannot rot into a false one. **It is green at rest, so its census is part of the verdict.** There are zero occurrences in the tree and there should stay zero, which means the run's output alone cannot distinguish a working gate from one that matches nothing. The verdict line therefore prints the **per-root population** — files and fenced blocks for each of the five roots — rather than a bare `OK`, and the scan **fails when that population collapses**: a root that does not resolve, a root that walks to fewer documents than its floor, or a total fence count under the floor is a broken walk, not a clean tree. The document floors are **per root** and deliberately never a whole-surface total: on a day the four-file `.claude/skills` root reads zero, the other four still return 203 of today's 207 files, so a total floor stays green through the entire outage. A scan root that has moved or been mistyped is reported **by name**, because a mistyped root and a clean root produce identical output otherwise. The evidence that the gate works is the ablation in its test suite, which replants #5150's exact line in each root on a fixture tree. **Scope:** fenced blocks only. An occurrence in prose or an inline code span is **counted in the census and not judged**, because documentation about this defect class has to be able to name the literal. That is a known narrowing, and the census figure is what keeps it visible. **If it fails:** it names the file, line and column, the fence language and the line the fence opened on. Note that `AGENTS.md`, `CLAUDE.md`, `skills/**` and `.claude/**` are **governed surface** — a finding in one of those is reported for a human to fix in its own change, not folded into an unrelated pull request. A finding under `content/docs/**` is an ordinary docs fix. Run it locally with `pnpm check:shell-escape-residue`, or `node scripts/check-shell-escape-residue.mjs --list` to see the per-root census. It needs no install and no build. ## README Exports (`readme-exports.yml`) [#readme-exports-readme-exportsyml] **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**. It appears in the checks list as **README Export Check**. The absent filter is the point. The two edits that introduce this drift are a README change and a source change that renames or drops an export, and `ci.yml` structurally cannot see the first: every one of its jobs opens with the `id: relevant` short-circuit whose diff excludes `**/*.md`, so on a README-only pull request its expensive steps are skipped by design. A gate against fabricated README imports living behind that switch would rebuild the hole it exists to close. Runs `scripts/check-readme-exports.mjs` (`pnpm check:readme-exports`). For every **tracked** `README.md` under `packages/`, it extracts the fenced code blocks, parses each one with the TypeScript parser, walks the `ImportDeclaration` nodes, and for every binding that names the README's **own** package checks the name against that package's real export surface. **Why it needed a gate.** A README teaching `import { X } from '@object-ui/'` for an `X` the package does not export gave the reader a `TypeError` at runtime or a TS2305/TS2724 at build time, and these READMEs are listed in each package's `files`, so they ship in the npm tarball. Nothing checked them: `check-doc-links.mjs` parses links and never looks inside a code block, and `check-doc-component-types.mjs` scans `content/docs` and never enters `packages/`. One manual sweep ([#5043](https://github.com/objectstack-ai/objectui/issues/5043)) found drift in **seven** packages (#5010–#5016) and recorded that number as a *lower bound*, because the method it used could only see single-line import statements. **It parses, it does not match.** The card's first sketch was a cross-line regex; measured on `plugin-gantt` it reported five words of prose as fabricated import names and missed both real fabrications, because a **side-effect import** (`import '@object-ui/plugin-gantt';`, no `from`) lets a lazy quantifier run on to the next `from` twenty lines later. Parsing makes that unrepresentable: a multi-line block is one node, a trailing `//` comment is trivia that can never contribute a name, and `A as B` exposes the export name separately from the local alias — the gate judges **`A`**. **The export set is symbols, never a grep.** It comes from the TypeScript checker's `getExportsOfModule` over each package's *declared* type entry, with aliases resolved before the value/type flags are read. A text-level set is measurably wrong here: `GanttSchema` grepped in `packages/types/src` has six hits, every one of them a substring of `ObjectGanttSchema`. **Three verdicts, because two of them have different fixes.** `real`; `fabricated` (no package exports it — delete or rename); and `wrong-path` (the name is real but belongs to another package, so the *path* is what to change). #5010's `CalendarViewSchema` was the third kind, and the first run of this gate found one more: `packages/core/src/adapters/README.md` imported `ObjectStackAdapter` and `createObjectStackAdapter` from `@object-ui/core` when both live in `@object-ui/data-objectstack`. **It builds first, and refuses to guess when it cannot.** The declared type entry is a built `dist/index.d.ts` for almost every package, so the workflow installs and runs `turbo run build` before the check (measured cold, concurrency 2, on a contended container: 2m42s for all 39 packages). If a package's type entry is missing anyway, that is a **failure**, never a skip: counting it as "exports nothing" would mark every import in its README fabricated, and skipping it would shrink the judged population with nothing in the output to say so. **It is green at rest, so its census is part of the verdict** — READMEs scanned, blocks parsed, bindings judged, packages whose exports were read — and the census says **tracked** out loud, because the walk is `git ls-files`: a new README that has not been `git add`-ed is outside the population and a local run reports OK without opening it (objectui#6545). CI is unaffected — a committed tree has no untracked files — and the scan **fails when that population collapses**. The evidence that it can fail lives in `scripts/__tests__/check-readme-exports.test.ts`, which plants four mutations on a fixture tree: a fabricated name in a multi-line block, one in a trailing comment (which must **not** be reported), an `X as Y` with `X` fabricated, and one mid-block in a type import. **Out of scope, deliberately:** compiling the extracted blocks (a separate card — it has pre-existing reds that need a baseline decision first), and authorable-JSON *key* surfaces, which no type check can reject while `BaseSchema` carries an index signature and its Zod mirror is `.passthrough()`. **If it fails:** it names the README, the line of the offending specifier, and which package really exports the name. Run it locally with `pnpm check:readme-exports` after a build, or `node scripts/check-readme-exports.mjs --list` to see every self-import it judged. ## Docs Route Eager Closure (`docs-route-eager-closure.yml`) [#docs-route-eager-closure-docs-route-eager-closureyml] **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all** (the reason is in the [inventory](#workflow-inventory) bullets above). It appears in the checks list as **Docs Route Eager Closure Check**, and `REQUIRED_CONTEXTS` in `scripts/dependabot-merge-gate.mjs` declares that context blocking — which is also what puts this workflow inside the derived `merge_group` floor, because a required check that never reports on a queue build does not fail it, it stalls it for the ruleset's 60 minutes. Runs `scripts/check-docs-route-eager-closure.mjs` (`pnpm check:docs-route-closure`): a checkout plus one `node` call over the source tree, **no install and no build**, \~1.3 s. **What it weighs, and what was not weighing it.** `apps/site/app/components/registerCatalogBlocks.ts` is a list of side-effect imports, and each one pulls its package's module graph into the Next docs route `/docs/[[...slug]]` — a route **all 181 docs pages share**, not just the catalog gallery. The cards that added to that list said the cost was governed by `check:eager-closure`. It was not: `scripts/check-eager-closure-budget.mjs` reads `apps/console/dist/eager-closure.json` and `performance-budget.yml` builds `@object-ui/console`, so that budget weighs the **console**. The only measurement of the docs route that has ever existed was reconstructed by hand, once, from the `script src` set of the prerendered route on disk, and the `+50%` stop condition [#4616](https://github.com/objectstack-ai/objectui/issues/4616) set had no gauge behind it ([#6316](https://github.com/objectstack-ai/objectui/issues/6316)). **Structural, not byte-level — ruled that way on purpose.** A second byte budget would need a 556-page docs build in CI. This gate instead walks the route's **static** module graph from source — the route entries, plus every compiled `content/docs/**` MDX module, which the route pulls in through the generated `.source/server.ts` — and sorts every package the registrar names into one of three buckets: | Bucket | Meaning | | ------------- | ------------------------------------------------------------------------------------------------------- | | **Recorded** | listed in the gate's `MEASURED_PAYLOAD` — its eager cost was argued for and written down when it landed | | **Free** | already reachable without this file naming it, so the import adds a *declaration* and no payload | | **New graph** | neither, so the import pulls a graph this route has never carried — **fails** | The third bucket is the whole point: it turns an unmeasured hazard into a review event, which is what a cheap instrument can honestly do. `MEASURED_PAYLOAD` is a ledger and **not a ceiling** — it carries no bytes and no threshold, and every entry is re-measured on each run, so an entry the registrar stopped naming, or one that became reachable some other way, fails and has to shrink. **Exit 1 and exit 2 mean different things, and must not be read as one.** Exit **1** is a verdict about the registrar: a new graph, or a ledger that has drifted. Exit **2** says the **gauge** is not trustworthy — a specifier the walk must resolve did not, a route entry moved, the registrar is no longer reachable from the route at all, or every workspace package now reads as reachable (a traversal that reaches everything cannot tell a new graph from a free one). A reader who sees exit 2 must not conclude the registrar is wrong; nothing was validly measured. All three verdicts print before any of them decides the code, the way `check-eager-closure-budget.mjs` prints its four. **A structural gate that cannot fail is worse than none**, because it converts an unmeasured hazard into a false assurance — so the failing direction is verified rather than assumed. `scripts/__tests__/check-docs-route-eager-closure.test.ts` drives the real analysis over fixture trees for each way the walk could silently answer "everything is reachable": a fenced MDX code block counted as an import, an erased `import type`, a lazy `import()` (the distinction the gate exists to police — `PluginLoader` is built on it so those graphs stay *off* this route), a package named only in the registrar's own prose, an unresolved specifier, and the registrar falling off the route. **If it fails:** the message names the package, the line that declares it, and the two ways out — reach the code through a package the route already carries, or argue for the payload in review and record it in `MEASURED_PAYLOAD` with what it is for. Run it locally with `pnpm check:docs-route-closure`; a green run prints the full classification, including which file each *free* package is already imported by. ## Governed Surface Guard (`governed-surface-guard.yml`) [#governed-surface-guard-governed-surface-guardyml] **Trigger:** Pull request to `main` / `develop` (`opened`, `synchronize`, `reopened`, `ready_for_review`) and merge-queue builds — **no path filter** on either leg. **Appears as:** **Governed Surface Queue Guard**. **Blocks a PR?** Not on the pull request, by design. On a merge-queue build it refuses. The **governed surface** is a fixed list — `AGENTS.md`, `CLAUDE.md`, `.claude/**`, `skills/**`, `docs/adr/**` — and the rule about it is that a change to any of them is merged by a human, not by the queue. That rule used to live only in prose. On [#6183](https://github.com/objectstack-ai/objectui/pull/6183) an `AGENTS.md` change was correctly parked as a draft; a GitHub MCP `update_pull_request` call passing only `reviewers` silently also set `draft: false`; the pull request entered the merge queue and landed with no human approval, and converting it back to a draft did not dequeue it. Nothing in CI could have refused that. This workflow is the refusal ([#6596](https://github.com/objectstack-ai/objectui/issues/6596)). **The two legs mean different things, and that is the whole design.** On a **pull request** the check is deliberately green whatever it finds, and prints an early warning naming the governed paths and the sequence not to start. A governed pull request sitting as a draft for the maintainer to merge by hand is the *healthy* end state, so a check that reddened on it would be red on the healthy case forever — and a permanently red check is one everybody learns to ignore. On a **merge-queue build** the same finding is a refusal: that is a state a governed pull request should never be in at all, so red there is red on the anomaly. **What clears the queue leg** is a latest-decisive `APPROVED` review by an account in `GOVERNED_APPROVERS`, on whichever commit it was left. Dismissed and superseded approvals (a later `CHANGES_REQUESTED` by the same reviewer) never count; an `APPROVED` review by an account outside that set never counts; an empty or unreadable review list fails closed. The predicate is the existence of a human approval record, and says nothing about which bytes it was given for — the maintainer ruled the sha pin out on 2026-09-04, quoted verbatim and untranslated because rewriting a ruling is rewriting the ruling: 「你的门禁有问题,只需要有人工批准记录就行,不需要卡最新的提交。」 The pin is retired, not softened: no predicate reads `commit_id`, there is no stale bucket, and the pull-request head read that existed only to feed the pin is gone with it. **The accepted cost**, stated out loud rather than left to be discovered: a push after an approval is no longer re-reviewed by this gate, so an approved governed pull request can land carrying bytes its approver never read. The remedy the refusal prints **first** is not approval at all — convert the pull request back to a draft and leave the merge to the maintainer. **What it costs when nothing is governed:** nothing. The path test runs before any request is constructed, so an ordinary pull request produces a `CLEAR` verdict and **zero** GitHub API calls; an API outage cannot block a diff that touches no governed path. The mirrored requirement is that an API error on a diff that *is* governed is a refusal with its own exit code (4, distinct from 3 for "nobody approved"), never a pass — this gate exists because every other layer in the chain failed open. **What it deliberately does not do.** It does not govern its own workflow or CI configuration generally: that would be a larger rule than the one that was ruled. It cannot stop a maintainer merging a governed pull request by hand, and does not try — under this regime the human merge *is* the review record. And it does not make itself required: that is a branch-protection setting only the maintainer can flip. Until it is flipped, the queue leg reports without stopping anything. What this repository can write down, and has, is `REQUIRED_CONTEXTS` in `scripts/dependabot-merge-gate.mjs`. **If it fails:** read the verdict — it names every governed path that matched, the pull request each belongs to, and the two ways out. To ask the same question about a file list before pushing, run `pnpm governed -- AGENTS.md packages/core/src/index.ts` (or `node scripts/check-governed-queue-guard.mjs --test `); it exits 0 when nothing is governed. The predicates are covered by `node scripts/check-governed-queue-guard.mjs --self-test`, which the workflow runs as its own first step because a rotted predicate must redden rather than wave a governed diff through, and the wiring is pinned by `scripts/__tests__/check-governed-queue-guard.test.ts`. ## Link Checking (`check-links.yml`) [#link-checking-check-linksyml] **Trigger:** Weekly cron (`17 4 * * 0` — Sundays, off the top of the hour, when the scheduled-run queue is shortest) plus manual workflow dispatch. There are **two** link checkers, and they cover different things (objectui#3213): | | Covers | Network | Runs | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ----------------------------------------------------------------------- | | `scripts/check-doc-links.mjs` | **Internal** links in `content/docs/` (relative hrefs, `/docs/...` routes, every other site-absolute href against `apps/site`), and, as paths on disk: `examples/`, the internal `docs/` tree, every package and app `README.md`, the rest of each package's and app's directory tree (everything but `README.md`/`CHANGELOG.md`), every nested `README.md`, and the root-level markdown files (`README.md`, `CONTRIBUTING.md`, `ROADMAP.md`, `AGENTS.md`, `CHANGELOG.md`, `CLAUDE.md`, `LICENSE-THIRD-PARTY.md`, `QUICK_REFERENCE.md`) — plus this repo's own `blob/main/` and `tree/main/` GitHub URLs and this site's own `objectui.org` URLs everywhere — **except** anything inside a code fence | No | `docs-links.yml` — every push and PR, no path filter (previous section) | | Lychee (this workflow) | **External** URLs, plus **relative** in-repo file links, in `content/docs/`, `docs/` and `README.md` | Yes | Weekly cron and manual dispatch | Lychee sweeps **both** documentation trees plus `README.md`: the **published** tree `content/docs/**` — every `.md` and `.mdx` under the fumadocs content source `apps/site/source.config.ts` declares (`dir: '../../content/docs'`, baseUrl `/docs`) — and the repo-root `docs/**` of **internal** material (ADRs, audits, architecture notes). What gets swept is decided by that workflow's `args` glob list and by nothing else, and `scripts/__tests__/check-links-workflow.test.ts` derives the expected scope from the site config rather than restating it, so moving the content tree turns that test red instead of quietly blinding the sweep. ⛔ **Neither tree's size belongs on this page.** One hand-copied count per tree stood in the sentence above, and both had drifted by the time anyone read them, with nothing red over the whole distance — no gate anywhere reads either figure, which is exactly why a number written here rots (objectui#7448, objectui#7825, objectui#7886). State the population, as above, and point at the reading: in any checkout, `find content/docs docs -type f \( -name '*.md' -o -name '*.mdx' \) | wc -l`, and Lychee's own run summary, which reports what it actually scanned. This page opens by declining to count workflows, for the same reason. **Past tense on purpose — none of the following describes the scope today.** Until objectui#3449 the `args` list named only the repo-root tree, so not one published page had ever been link-checked: the workflow was green because of what it was not looking at. It is deliberately **not** a PR gate (objectui#3213). External link checking goes over the network, and one 502 or rate-limit from a third-party site would redden a pull request whose author can do nothing about it. The cron was added only once the scope was correct: a schedule pointed at the wrong tree just produces a false-green report on a timer. Uses [Lychee](https://github.com/lycheeverse/lychee) with configuration from `lychee.toml`: * Scans `content/docs/**/*.{md,mdx}`, `docs/**/*.{md,mdx}` and `README.md` * Max concurrency: 10, timeout: 20s, retries: 3 * Excludes: localhost, example.com, Twitter/X, GitHub compare/commit URLs * Skips **site-absolute** routes (`/docs/...`, `/api/...`): Lychee cannot resolve extensionless fumadocs routes, and without handling it fails them while building the URI — before `exclude` is even consulted. `root_dir` therefore resolves them into a sentinel namespace that is then excluded wholesale. Judging those routes is `check-doc-links.mjs`'s job, and duplicating its route-to-file mapping here would only create a second copy free to drift. ## Release Workflows [#release-workflows] ### Changeset Release (`changeset-release.yml`) [#changeset-release-changeset-releaseyml] **Trigger:** Push to `main` — the **publish** half. Cron `0 */6 * * *` and manual dispatch with `refresh_version_pr` — the **version-PR refresh** half. Uses [Changesets](https://github.com/changesets/changesets) for automated versioning and npm publishing, in **two lanes that cannot do each other's job**: | Event | Predicate | What runs | | ------------------------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | Push to `main` | declared version **not** on npm | **Publish to npm.** Normally the version-PR merge, which is the release act; also the retry for a release whose own run failed. | | Push to `main` | declared version already on npm | **Nothing.** Every ordinary landing — a merge that is not a release does not move the version. | | Cron `0 */6 * * *` | n/a | **Refresh the version PR** ([#5400](https://github.com/objectstack-ai/objectui/pull/5400)) — never publishes. | | Manual, `refresh_version_pr` checked | n/a | The same refresh, on demand. | | Manual, unchecked | n/a | Nothing; the run says so with a `::notice::`. | The refresh used to run on **every** push to `main`, which force-pushed the version PR \~18 times a working day while releases are weekly — so its branch CI never converged, and every refresh was a CI run spent on bookkeeping nobody reads until release day ([objectstack#10850](https://github.com/objectstack-ai/objectstack/issues/10850)). A cheap `lane` job answers the questions the split turns on from a sparse checkout of `.changeset/` and `packages/core/`, before anything installs or builds. #### The publish lane is keyed on npm, not on `.changeset/` [#the-publish-lane-is-keyed-on-npm-not-on-changeset] The publish half asks **"is the version this commit declares already on npm?"** — not "are changesets pending?" ([#5442](https://github.com/objectstack-ai/objectui/issues/5442)). The two read as interchangeable and come apart exactly where it costs a release: the version PR is cut from `main` at T and merged at T+n, `main` takes \~18 merges a working day, and the merge does not remove the changesets that landed in between. Those belong to the *next* version — but keyed on them, the version this commit just bumped to is skipped, and the next version PR bumps straight past it. Measured when #5442 was fixed: of the 90 versions `packages/core/CHANGELOG.md` declared, **16 had never reached npm**, and the repository said `17.6.0` while `dist-tags.latest` said `17.5.0`. The npm predicate is also **cheaper** than the one it replaced, rather than a trade against it. An ordinary landing does not move the manifest version, so it answers "already published" and the expensive job is skipped — where "no changesets pending" ran the job in full on every landing that happened to find `.changeset/` empty, only to publish nothing. `@object-ui/core` is the version anchor because every package in the `fixed` group of `.changeset/config.json` moves as one version, so any member answers for the whole release. The lane **asserts** that membership instead of assuming it, and it refuses to guess a lane if the registry cannot be read: 200 is published, 404 is not, and anything else fails the run. `changesets/action@v1` chooses publish-vs-version from repository state rather than from an input, so the predicate cannot reach it on its own — with changesets present it would take its version branch and publish nothing. The publish lane therefore clears the pending `.changeset/*.md` from the **runner's working tree** before invoking it. Nothing is committed and nothing is pushed (`runPublish` pushes tags and creates releases; it never commits), so `.changeset/` on `main` is untouched and those changesets are still owed to the next version PR. #### The loud check [#the-loud-check] \#5442's defect was never a red run — it was a green one: run 3370 on `cfeb378b5` completed `success` having published nothing, and only a CHANGELOG-against-registry audit noticed, 16 versions later. So the publish lane now reads the registry back afterwards and **fails** if the version it exists to ship is still absent. A repo/npm divergence is a failing run, not a finding. The refresh lane is invoked **without** a `publish:` script and **without** npm credentials, so it cannot publish by construction rather than by a condition — the release act in this repository stays the human merge of the version PR. The publish lane runs `pnpm changeset:publish`, and that script is `node scripts/check-published-dist-tooling.mjs && changeset publish` — the **blocking** copy of the Published Dist Gate above. A published package whose `dist/` carries tooling material stops the publish before a single tarball reaches npm, which is where that defect actually costs anything ([#4846](https://github.com/objectstack-ai/objectui/issues/4846)). #### The release PR runs no CI, so the refresh lane validates the tree itself [#the-release-pr-runs-no-ci-so-the-refresh-lane-validates-the-tree-itself] The version PR gets **no checks of its own, and cannot be given any**. Measured 2026-08-25: `ci.yml` has **849** runs on `changeset-release/main` and every recent one is `action_required` with `created_at == run_started_at == updated_at` — created and immediately parked, nothing executed. GitHub does not start workflow runs from events raised by `GITHUB_TOKEN`, and the refresh force-pushes that branch, so there is no stable head to re-run against either. On the 17.6.0 release PR the check-runs endpoint returned `total_count: 1`: one job, started **7 seconds after the merge**. The release commit is therefore the only commit that reaches `main` without passing the merge queue ([#5397](https://github.com/objectstack-ai/objectui/issues/5397)). So the refresh lane renders `pnpm changeset:version` into the runner's working tree, validates it, restores the tree, and only then invokes the action — which does its own versioning and owns the commit and the push. **What it validates, and why that is not `pnpm test`.** The version step cannot move a source byte. Measured against the real tree (328 pending changesets, 17.6.0 → 17.7.0) it touches 411 paths: 330 `.changeset/*.md` deleted, 40 `package.json` (the `"version"` key and nothing else), 40 generated `CHANGELOG.md`, and `QUICK_REFERENCE.md`. The source in the post-version tree is byte-identical to the `main` commit `ci.yml`'s push lane just tested under coverage across four shards, so a suite run here would re-test tested bytes at \~40 minutes a go, four times a day — \~2.7 h of daily runner time for a PR nobody reads until release day, which is the cost [objectstack#10850](https://github.com/objectstack-ai/objectstack/issues/10850) was closed to remove. The validation is scoped to the surfaces the diff can move instead: | Command | Covers | Measured | | ----------------------------- | ---------------------------------------------------------------------------------------------------------- | -------- | | `pnpm quick-reference:check` | `QUICK_REFERENCE.md` | \~1 s | | `pnpm check:control-bytes` | the 40 generated `CHANGELOG.md` | \~4 s | | `pnpm test scripts/__tests__` | 73 files / 1996 tests — every test that reads a manifest version, `QUICK_REFERENCE.md` or a `CHANGELOG.md` | \~50 s | `check:spec-floors` and `check:published-dist` are deliberately **not** here: they read dependency ranges and built `dist/`, neither of which the version step moves, and `pnpm changeset:publish` runs both first on the publish lane anyway. There is also **no "only when the PR content changed" condition**, for a measured reason: across the seven consecutive 6-hourly windows from 2026-08-23T06:08Z to 2026-08-25T00:10Z, six carried new changeset files (median 18). Such a predicate would skip about one refresh in seven while adding a local prediction of another project's state that can be wrong silently — the shape [#6081](https://github.com/objectstack-ai/objectui/issues/6081) just deleted from this file. **Failure semantics.** A red validation fails the job and the refresh never runs, so the standing PR keeps its last validated content rather than being force-pushed to a broken one. Nothing here can touch the publish lane: all three steps are scoped to `schedule` / `workflow_dispatch`. The restore step is load-bearing — with `.changeset/` left consumed the action would find nothing pending, take its no-op branch and return, and the PR would fossilise with nothing failing anywhere — so the restoration is asserted, not assumed. Neither lane configures a lockfile merge driver. This job performs no local merge — the version branch is updated by `reset --hard` plus a force-push inside `changesets/action` — so there is nothing for a driver to resolve. It configured one until [#6436](https://github.com/objectstack-ai/objectui/issues/6436); see **Lockfile Merge Driver** below for the half of that mechanism which is still live. ### Published Dist Gate (`published-dist-gate.yml`) [#published-dist-gate-published-dist-gateyml] **Trigger:** Nightly cron `41 3 * * *`; push to `main` that touches the gate script or this workflow; manual. **It carries no `pull_request` trigger, on purpose.** No published package's build output may contain tooling material — `__tests__/`, `__mocks__/`, `__benchmarks__/`, `*.test.*`, `*.spec.*`, `*.bench.*`, `*.stories.*`. The gate is `scripts/check-published-dist-tooling.mjs` (`pnpm check:published-dist`); it builds every published package itself, then reads each one's tarball file list from `npm pack --dry-run`. Three things about it are easy to get wrong and are written down in the script's own header ([#4846](https://github.com/objectstack-ai/objectui/issues/4846)): * **The criterion has to be artifact-level.** The cheap static version — "no build tsconfig program may contain a tooling file" — was measured and reds five packages that emit nothing wrong, because a tooling file in a *checking* program is correct and only a tooling file in an *emitting* program is a defect. Acting on it would mean moving tests out of type programs, which is the mirror of what [#4006](https://github.com/objectstack-ai/objectui/issues/4006) taught. * **It must never pass vacuously.** A published package that contributes no build output is a finding (`no-build-output`), not a skip, and a failed build is a failure rather than a green run with nothing to look at. * **It is deliberately not a PR gate.** The criterion needs a full-repo build and this repository has none per PR: `ci.yml`'s **Build & E2E** builds only `@object-ui/console`, and **Type Check** gets only the dependency closure from turbo's `dependsOn: ["^build"]`, so leaf packages are never built there. The blocking copy runs on the publish path instead — see below. * **A per-PR sibling now covers the CONFIG half.** `pnpm check:published-tsconfig-exclude` ([#7212](https://github.com/objectstack-ai/objectui/issues/7212)) runs in **Type Check** and fails when a published package's build tsconfig names tooling by file name without naming the tooling directories. It is not a replacement and does not weaken anything here: it checks config shape, never emit semantics, so a package can satisfy it and still ship tooling material for some other reason. This gate stays the second line of defence and the only criterion that cannot be wrong about what actually ships. ### Spec Range Floors (`spec-range-floors.yml`) [#spec-range-floors-spec-range-floorsyml] **Trigger:** Nightly cron `11 4 * * *`; push to `main` that touches the gate script or this workflow; manual. **It carries no `pull_request` trigger, on purpose.** A package's declared `@objectstack/spec` floor must carry every symbol that package's own build output references. The gate is `scripts/check-spec-range-floors.mjs` (`pnpm check:spec-floors`); the workflow builds every published package, then the gate compares each one's `dist` imports of `@objectstack/spec/*` against the export set of the version that package's own range admits at its lowest. The defect it closes ([#5793](https://github.com/objectstack-ai/objectui/issues/5793)): `@object-ui/plugin-detail` shipped `dist/renderers/record-reference-rail.d.ts` re-exporting `ReferenceRailEntry` from `@objectstack/spec/ui` — a symbol that arrived in spec 17.1.0 — while its own `dependencies` still named a floor a minor lower. A declared range is a public claim, and that one admitted a spec without the symbol. (No range literal is quoted here on purpose: the live answer is `packages/plugin-detail/package.json`, and this gate's output.) Normal installs resolve the newest 17.x and never see it, which is exactly why nothing found it: it is a floor-honesty defect, and the lockfile hides it from every other check in this repository. Two ways of building this check return a confident green, and both are avoided by construction rather than by care — the script's header carries the long version: * **Resolution answers the wrong question.** The root `package.json` declares `@objectstack/spec`, so pnpm hoists it to the workspace root and every resolution succeeds from every package directory regardless of that package's own manifest. A green type-check therefore proves nothing about a floor: it type-checks against the *installed* version. The gate resolves nothing through `node_modules` — it fetches the declared minimum from the registry and reads that tarball's own `exports` map. It is the same trap `check-phantom-dependencies.mjs` records for `react`. * **The spec is dual-package.** `require` reaches `dist//index.js` and `import` reaches `dist//index.mjs`, with separate type entries. Reading it through `createRequire` judges a build no bundler ever puts in an application. The gate walks the fetched manifest's `exports` map under the **`import`** condition and prints the entry it landed on, and `--cross-check` re-reads the `require` half and compares. Like the two gates above it, the criterion is artifact-level and therefore needs a full build, so the blocking copy runs on the publish path and this workflow is the nightly alarm. Reading `src/` instead would be cheaper and wrong in the expensive direction: an `import type` used only inside a function body is erased and never reaches `dist/`, so a source-level version would demand floor bumps nothing published justifies. ### Node ESM Load Gate (`node-esm-load-gate.yml`) [#node-esm-load-gate-node-esm-load-gateyml] **Trigger:** Nightly cron `17 4 * * *`; push to `main` that touches the gate script or this workflow; manual. **It carries no `pull_request` trigger, on purpose.** Every published ESM package must be importable by Node's own resolver — no bundler, no loader hooks. The gate is `scripts/check-node-esm-load.mjs` and it has two legs, only one of which runs here: * **The specifier leg** (`pnpm check:esm-specifiers`) reads *sources* and needs no build, so it runs per pull request in **Type Check**, not in this workflow. It is the ratchet: for a package whose build preserves import specifiers, the emitted specifier *is* the source specifier, because `tsc` never rewrites them. * **The load leg** (`pnpm check:node-esm-load`) builds every published package and then actually `import()`s each entry in a child `node`. That is what runs here, and it needs the full build this repository has no per-PR copy of — the same trade `published-dist-gate.yml` records above. Both legs exist because neither is honest alone ([#4538](https://github.com/objectstack-ai/objectui/issues/4538)): * **Resolving the entry is not enough.** The card was filed from `@object-ui/plugin-charts`, whose own emitted entry resolves perfectly; the failure appeared only once evaluation crossed into `@object-ui/react`. A check that stops at resolution passes while the tree is broken, so the load leg evaluates. * **The specifier leg cannot see a defect that arrives through a dependency.** In the first full run, four plugin packages failed on `packages/mobile/dist/useBreakpoint` — not their file. The gate therefore attributes a missing module to the package that *owns* it, so one cause produces one finding against one owner. * **It must never pass vacuously.** "Imported nothing, found nothing" is the verdict this gate may never give, so both legs assert a floor on how much they inspected, and a published entry that is missing after the build is a finding (`no-build-output`), not a skip. Packages that still carry the defect are named in the script's `SPECIFIER_DEBT` ledger with a reason each. The ledger is a ratchet, not a mute button: an entry whose package has become clean is itself a failure, so it cannot outlive the debt it records. Packages that are not importable by design — a `bin`-only CLI, or a built web app whose `dist` is `index.html` — are printed on every run rather than skipped silently. A separate list, `UNBUNDLED_NODE_UNSUPPORTED`, names the packages plain Node is **not expected to load at all**. Three style-carrying plugin packages sit there: `@object-ui/plugin-dashboard` and `@object-ui/plugin-map` import `react-grid-layout`'s and `maplibre-gl`'s stylesheets at module scope, and `@object-ui/app-shell` reaches the first of those through static imports. All three resolve fine and then die on `ERR_UNKNOWN_FILE_EXTENSION`, because Node has no loader for `.css`. That is **a stated product boundary, not debt** — unbundled Node consumption is not supported for style-carrying plugin packages, ruled on [#5384](https://github.com/objectstack-ai/objectui/issues/5384) after measuring that no unbundled-Node consumer exists: every consumer reaches these packages through a bundler (`vite` in the console and the examples, Next's `transpilePackages` in `apps/site`). The load leg's count therefore stops short of the total on purpose, and the run prints those three names on every run rather than quietly subtracting them. Each package says the same thing in its own README, so a consumer meets the boundary before a red import rather than after one. The boundary has a price, and the list states it rather than leaving it to be found later: it matches by **package name**, not by error code, so the load leg cannot speak for those three at all — a genuine extensionless-specifier regression in one of them would print as a boundary line instead of failing the run. What guards them instead is the specifier leg, which is a hard requirement now that `SPECIFIER_DEBT` is empty; [#5357](https://github.com/objectstack-ai/objectui/issues/5357)'s ablation reverted app-shell's specifiers and watched the specifier leg redden while the load leg went on printing its ledger line. The ratchet is kept in the other direction too: a named package whose entry starts loading is itself a failure, because from that moment the exemption costs coverage and buys nothing. ### Changeset Guard (`changeset-guard.yml`) [#changeset-guard-changeset-guardyml] **Trigger:** PR to `main`/`develop`, and push to `main`, **when `.changeset/**` changes** — the inverse of every other workflow's filter. It was carved out of `ci.yml` because `ci.yml` and `lint.yml` listed `'**/*.md'` and `.changeset/**` under `paths-ignore`, so a PR that added only a changeset started nothing at all. Since [#3523](https://github.com/objectstack-ai/objectui/issues/3523) such a PR does start both — and every job in them short-circuits, because `.changeset/**` is still on the in-job ignore list. The check that has to read the changeset therefore still lives here. The same `paths:` list also carries the gate's own YAML and both scripts it runs, `scripts/check-changeset-no-major.mjs` and `scripts/check-changeset-overwrite.mjs` ([#6321](https://github.com/objectstack-ai/objectui/issues/6321)) — self-coverage, not the inverse trigger above: without it, a PR that edits the gate is not the PR that runs it, and the first real execution lands on someone else's unrelated `.changeset/**` PR. Deliberately not listed: `scripts/check-changeset-presence.mjs` (the overwrite gate imports its base-ref resolver, `git diff` wrapper and frontmatter reader rather than growing a third copy — the second copy, in `check-i18n-en-drift.mjs`, inherited a real defect from that resolver's first draft and had to be fixed to match under [#3766](https://github.com/objectstack-ai/objectui/issues/3766); the root vitest suite exercises it on any PR touching `scripts/**`), `scripts/invoked-as.mjs` (a dependency the gate scripts import, but a widely shared one — 40+ importers under `scripts/` — that `published-dist-gate.yml`, `spec-range-floors.yml` and `node-esm-load-gate.yml` also import without listing; only `half-state-patrol.yml` lists it, as a documented one-off) and the script's own `__tests__/check-changeset-no-major.test.ts` (it already runs in the root vitest suite on any PR that touches `scripts/**`, the same `~ partial` reasoning `published-dist-gate.yml` and `spec-range-floors.yml` apply to their own gate scripts' `__tests__` files). Runs `scripts/check-changeset-no-major.mjs`, which fails if any pending changeset declares a `major` bump. Every publishable package is in one `fixed` group (39 packages), so a single `major` publishes all of them as the next major — and objectui's major is pinned to the `@objectstack` major it is compatible with, not to its own count of breaking changes. Score breaking changes of our own as `minor` and describe the break in the changeset body. The one release that legitimately bumps the major is the one following `@objectstack` across its major; it sets `OBJECTUI_ALLOW_MAJOR=1`. `pnpm test` asserts the same repository state, so the rule survives this workflow being skipped. #### Second job: Changeset Overwrite Report [#second-job-changeset-overwrite-report] Runs `scripts/check-changeset-overwrite.mjs`, which asks a different question of the same files: did this change **modify or delete a `.changeset/*.md` that already existed at its merge base** — a changeset it did not add? It is a separate job because it reads a diff and so needs `fetch-depth: 0`, which the bump-policy job does not want. [#6336](https://github.com/objectstack-ai/objectui/issues/6336) is why it exists. A dev run wrote its changeset to a hand-picked `changesets`-style name that already existed on `main`, and the heredoc overwrote an unrelated `@object-ui/plugin-charts: minor`. It was caught before any commit, but the property that makes it worth a gate is that **the cost lands on a third party and is invisible at the time it happens**: the agent that picks the colliding name loses nothing, and whichever earlier PR's release declaration vanishes only discovers it when a package silently fails to bump. Both signals that should catch it fail — `git status` shows ` M` rather than `??`, which reads as your own new file landing, and a deleted release declaration is not something any later gate flags. With 424 accumulated changesets against an `adjective-animal-verb` name space, the collision probability is not theoretical. **It is report-only, and that is measured rather than cautious.** Across all 5281 first-parent commits on `main`, 12 commits modified a pre-existing changeset (19 files) and **all 19 were legitimate** — bump levels corrected when the pending release line changed, a "eleven" corrected to "ten", a typo'd package name fixed, authors amending their own not-yet-released changeset. A blocking gate would have failed every one of those PRs. Deletions are dominated by the release itself (82 of 88 delete changesets alongside a package `CHANGELOG.md`, which is `changeset version` emptying the queue); the job recognizes that shape and says so instead of reporting it. `OS_CHANGESET_OVERWRITE_ENFORCE=1` flips the job to blocking for whoever revisits this with a new measurement. ⭐ **The convention that makes the hazard impossible**: name a changeset after the issue it settles — `.changeset/-.md`. The `adjective-animal-verb` names are safe when `pnpm changeset` allocates them, because it allocates against the files already present; picking one by hand is what removes that guarantee. > **A changeset IS now required, by `changeset-presence.yml` — but there is still no > `skip-changeset` mechanism.** Until [#3387](https://github.com/objectstack-ai/objectui/issues/3387) > nothing in CI asked whether a PR had added one, and this note said so at length, because the > opposite had been documented for months: a second workflow inventory at `.github/WORKFLOWS.md` > — unpinned, therefore free to drift — gave a "Changeset Check" workflow its own numbered > section, failing any PR touching `packages/` without a `.changeset/*.md` and skippable with a > `skip-changeset` or `dependencies` label. None of it existed; > [#3724](https://github.com/objectstack-ai/objectui/issues/3724) deleted the page. > > ⚠️ **The label object came back, and it still does nothing.** This note used to report a > point-in-time labels-API reading (2026-08-08: of the two names only `dependencies` existed). > That reading has since expired — a `skip-changeset` label now exists in this repository's > label set, because GitHub mints a label the first time one is applied by name, so a single > API call that applies it is enough to create it. By 2026-08-25 it sat on **seven** pull > requests, carrying the default grey `ededed` and an empty description that tell an > auto-minted label apart from a curated one. > [#4912](https://github.com/objectstack-ai/objectui/issues/4912) tracks deleting the object. > > ⛔ **Whether or not you can still see it in the picker, applying it declares nothing** — no > gate in this repository reads it. The real gate has no label escape hatch by design: its > exemption is a changeset with an **empty frontmatter**, which lives in the repository where > the next reader finds it, rather than a label that vanishes from history. The name is wired > in the `objectstack` sibling, not here, which is how it reaches agents that then look for it > in this repo. If you were told to apply it, the instruction is wrong; declare an empty > changeset instead. > > The three real things with adjacent names each do something different, and none of them > subsumes another. `changeset-guard.yml` reads pending changesets and rejects a `major` bump. > `ci.yml`'s `changeset-check` job (**Changeset Fixed Group Check**) checks `fixed`-group > *membership*. `changeset-presence.yml` asks whether this change declared anything at all. ### Changeset Presence (`changeset-presence.yml`) [#changeset-presence-changeset-presenceyml] **Trigger:** PR to `main`/`develop`, and merge-queue builds. **No path filter** — see below. **Blocks a PR:** yes, when a released package's `src/` changed and the PR added no changeset. Runs `scripts/check-changeset-presence.mjs`, which compares the change against its merge base with the target branch and asks one question: did anything under the `src/` of a package the release covers change, and if so, does this change **add** a `.changeset/*.md`? * **The exemption is an empty frontmatter.** What is demanded is a declaration, once, by the person who still knows what the change does — not a release. A changeset whose frontmatter names no package is a first-class pass: ```md --- --- Test-only change to the grid column resolver; no published behaviour changes. ``` * **The changeset must be ADDED by this change.** `.changeset/` accumulates until a release, so "a changeset exists in the tree" would be satisfied by somebody else's pending declaration and make the gate vacuous for every change that followed one. * **The guarded surface is derived, not written down.** Every workspace package named in the `fixed` group of `.changeset/config.json` contributes its `src/`; everything in `ignore` is skipped. That matters more than it sounds: `@object-ui/console` lives at `apps/console`, outside `packages/`, and is both the most-edited published package here and the one the platform's `bump-objectui.sh` writes a changeset for — a hand-written `packages/*/src/**` glob would have missed it. A changed source file whose package is in *neither* list fails the check rather than being assumed unreleased; `check-changeset-fixed.mjs` is the gate that owns that classification. * **Every missing input fails loudly.** An unresolvable base commit, a `git diff` that errors, a missing `.changeset/` directory: all red, none a silent pass. Note the direction is the *opposite* of the filter gates in `ci.yml` — those decide whether to run work, so "cannot tell" means run; here the work *is* the decision, so "cannot tell" means fail. Both refuse to report green having looked at nothing ([objectstack#4928](https://github.com/objectstack-ai/objectstack/issues/4928)). * **No path filter, deliberately**, and it is the point of the whole workflow. A `paths:` filter skips the entire workflow, so the context is never created on a PR that does not match — and a required context that is never created leaves the PR pending rather than failing it ([#3523](https://github.com/objectstack-ai/objectui/issues/3523)). It would also be a second copy of the guarded surface, free to drift from the config the script reads. **Why this is separate from `changeset-guard.yml`, which also polices changesets:** that workflow's trigger is `paths: ['.changeset/**']`, and the inversion is deliberate — on a PR that adds *only* a changeset, every gate inside `ci.yml` and `lint.yml` short-circuits, so nothing in either of them ever reads the changeset, and that guard exists to see exactly that PR. (It is *not*, as this paragraph said until [#4381](https://github.com/objectstack-ai/objectui/issues/4381), that such a PR "starts no other workflow": since [#3523](https://github.com/objectstack-ai/objectui/issues/3523) both workflows start and report on it — see **Changeset Guard** above, and the path-filter bullets at the top of this page.) A PR that **forgot** its changeset does not touch `.changeset/**` at all, so the one check able to notice was the one check guaranteed not to run. Widening those paths would have broken the case that guard was built for. Two workflows, opposite directions: one polices the *level* of a declaration that exists, the other the *existence* of a declaration at all. Why it exists: [objectstack#4731](https://github.com/objectstack-ai/objectstack/issues/4731) / [#4843](https://github.com/objectstack-ai/objectstack/issues/4843) made the declared changesets the single criterion for which frontend changes shipped, and the premise underneath — published source changed, so a changeset was written — was enforced by nothing. Replaying this gate over the 80 commits before it landed reports 10 that would have failed, two of them user-visible fixes (`918888a30` `fix(fields)`, `dcff16e06` `fix(cli,create-plugin)`) that reached a release with no CHANGELOG line anywhere. `scripts/__tests__/check-changeset-presence.test.ts` pins the verdicts, the derived surface, and every loud-failure path. ### Changelog Generation (`changelog.yml`) [#changelog-generation-changelogyml] **Trigger:** manual dispatch only. Nothing triggers this workflow automatically. Uses [git-cliff](https://git-cliff.org/) with `cliff.toml` configuration to regenerate the root `CHANGELOG.md` and commit it to the repository. It configured the lockfile merge driver until [#6358](https://github.com/objectstack-ai/objectui/issues/6358); it no longer does, because this job never merges. It checks out, runs git-cliff, stages exactly `CHANGELOG.md`, commits and pushes — and a driver fires only when git has to merge the attributed path. Committing back to a branch that may have moved does not reach one: such a push is *rejected*, not merged. **When to run it:** at release time, as part of cutting the release — that is the ritual it belongs to, and there is no other owner. The lane also declared `release: types: [published]` until [#5409](https://github.com/objectstack-ai/objectui/issues/5409), and that half never fired once. Every release here is authored by `github-actions[bot]`, created by the Changesets action in `changeset-release.yml` using `secrets.GITHUB_TOKEN`, and GitHub does not start workflow runs from events raised with that token — so the automated release path structurally cannot wake this workflow. Measured before the trigger came off: 0 runs across the repository's whole life, against `changeset-release.yml`'s 4049 through the identical API call. It was removed rather than left implying an automation that cannot happen. What follows for readers: the root `CHANGELOG.md` is a **periodically hand-curated summary**, not an auto-maintained full history. The per-package `CHANGELOG.md` files that Changesets writes on each release commit are the source of truth for granular and current history. ## Repository Maintenance [#repository-maintenance] ### Auto-Labeler (`labeler.yml`) [#auto-labeler-labeleryml] **Trigger:** PR opened, synchronized, or reopened. Automatically labels PRs based on file path patterns defined in `.github/labeler.yml`. Syncs labels on each push to the PR. The job holds `issues: write` in addition to `pull-requests: write`. That is load-bearing, not defensive: `pull-requests: write` alone lets the action attach labels that already exist in the repository, while creating one it has never seen requires `issues: write`. Because the action applies the whole label set in a single call, a config rule naming a label that does not yet exist would otherwise fail the call and leave the PR with **no** labels at all. `scripts/__tests__/labeler-package-coverage.test.ts` fails if either permission is dropped, and also if a directory under `packages/` draws no label (objectui#7746). ### Cross-repo Issue Closer (`cross-repo-issue-closer.yml`) [#cross-repo-issue-closer-cross-repo-issue-closeryml] **Trigger:** `pull_request_target` with type `closed`; the job acts only when the PR was actually merged. GitHub's closing keywords work **only within a repository**. A PR here whose body says `Fixes objectstack-ai/objectstack#4475` reads to a human exactly like a same-repo close, merges, and leaves that issue open forever — with no reference to the PR on the issue's page either. That is not hypothetical: during v17 verification it happened twice in one day, and both framework issues had to be closed by hand. This workflow scans the merged PR body for **qualified** `owner/repo#N` closing keywords (the bare `#N` form is left to GitHub) and takes one of two visible paths: | `CROSS_REPO_ISSUE_TOKEN` | Behaviour | | ------------------------ | --------------------------------------------------------------------------------- | | Configured | Comments on each foreign issue with the PR link, then closes it as `completed`. | | Absent | Comments **on this PR**, listing every issue that still has to be closed by hand. | The second path is the point. A workflow that quietly does nothing because a secret was never provisioned is the same "declared but never enforced" shape both repositories keep having to fix, so the missing credential announces itself — the run logs the token's presence before any early return, and the PR comment names the cost. It uses `pull_request_target` rather than `pull_request` because the latter withholds repository secrets from fork-originated runs. The usual hazard of `pull_request_target` does not apply here: the job never checks out the head ref and never executes anything from the PR — it reads the body and calls the issues API. ### Stale Issues (`stale.yml`) [#stale-issues-staleyml] **Trigger:** Daily at 00:00 UTC (cron), or manual dispatch. | Resource | Stale after | Close after | Exempt labels | | ------------- | ----------- | ----------- | ------------------------------------------------------ | | Issues | 60 days | 7 days | `pinned`, `security`, `critical`, `bug`, `enhancement` | | Pull Requests | 45 days | 14 days | `pinned`, `security`, `in-progress`, `blocked` | The two exemption lists are set separately (`exempt-issue-labels` and `exempt-pr-labels`) and neither is a subset of the other: `critical`, `bug` and `enhancement` exempt issues only, `in-progress` and `blocked` exempt pull requests only. This page used to state one merged list — `pinned`, `security`, `critical`, `in-progress` — which was wrong in both directions for both resources ([#3724](https://github.com/objectstack-ai/objectui/issues/3724)). ### Half-State Patrol (`half-state-patrol.yml`) [#half-state-patrol-half-state-patrolyml] **Trigger:** Four times a day at `:37` past the hour (cron `37 1,7,13,19 * * *`), manual dispatch, or a pull request touching `scripts/pm/check-half-states.mjs`, `scripts/invoked-as.mjs` or the workflow itself. Runs `scripts/pm/check-half-states.mjs` against **this** repository's issue board and rewrites one pinned anchor issue's body with what it found. The sweeper carries a family of predicates over the dispatch protocol's label/assignee/PR invariants — a `pm:dispatched` card with no assignee, a card carrying both `pm:queue` and `pm:dispatched`, a merged PR whose card still says it is in flight, a `Blocked-by:` block whose blocker already closed, and so on. **Report-only, and this is a rule rather than a description.** The job never writes a label, never closes a card, never fixes a state, and no finding fails anything: a completed sweep exits 0 whether it found 0 half-states or 40. Its one write is the anchor issue's body, and `permissions:` grants nothing beyond `contents: read` + `issues: write`. A pull-request run proves the sweep on a real runner but skips the anchor write entirely, publishing the rendered body to the run summary instead. The run *does* go red when the sweep could not run or its report could not be delivered — that is the patrol reporting its own death, not a gate on the board. A workflow that quietly does nothing because a credential lapsed would leave a stale anchor body that reads exactly like a clean board. For the same reason the `Swept` timestamp is refreshed even when the findings are unchanged: a timestamp that stops advancing is how a reader learns the standing caller died. **One manual setup step.** The anchor issue is named by the repository *variable* `HALF_STATE_ANCHOR_ISSUE` (Settings → Secrets and variables → Actions → Variables). Until it is set the job fails loudly *after* sweeping, with the findings preserved in the run summary — it will not guess an issue number and rewrite an unrelated card. **Ported from objectstack, with the divergences listed in the workflow header.** The pair (`scripts/pm/check-half-states.mjs` + this workflow) is adopted from `objectstack-ai/objectstack` and is meant to stay re-syncable, so this install keeps its differences in one place. The behavioural one: the sweeper's closed-card reader (`pm:*` labels left on cards that already closed) is switched **off** here via `PM_SWEEP_CLOSED_WINDOW_PAGES: '0'`. Stripping `pm:*` on close was never this repo's practice — 815 closed cards carry `pm:dispatched`, \~87% of the reader's window — so that predicate would report the convention rather than a defect and bury every other finding. The rendered summary says that surface is **UNREAD**, never that it is clean ([#5791](https://github.com/objectstack-ai/objectui/issues/5791)). ### Merge Queue Head Patrol (`merge-queue-head-patrol.yml`) [#merge-queue-head-patrol-merge-queue-head-patrolyml] **Trigger:** every 15 minutes (cron `7,22,37,52 * * * *`) and manual dispatch. No pull-request leg — see below. Runs `scripts/check-merge-queue-head.mjs`, which asks one question: **does the entry at the head of `main`'s merge queue have a `merge_group` build?** A head entry for which GitHub never dispatches `merge_group` blocks every lane in the repository — a merge queue is strictly ordered, so nothing behind a head that cannot merge can merge either — and it does so with *nothing red anywhere*, which is what made the four recorded occurrences so expensive to diagnose ([#7010](https://github.com/objectstack-ai/objectui/issues/7010): 2026-08-17, two on 08-31, one on 09-02; the worst ran four hours). **How the head is identified.** Each queue entry is a real branch, `gh-readonly-queue/main/pr--`, and the head is the one stacked on `main`'s current tip. The patrol lists those refs, picks the head, and counts `merge_group` runs on it. The ref is taken verbatim from the refs listing and never assembled: `GET /actions/runs?branch=` answers `total_count: 0` with HTTP 200 for a branch that does not exist, so a constructed ref one character off would report a wedge on a healthy queue. **Only the head is judged, and that is a correctness rule rather than a saving.** GitHub builds only the first few entries speculatively, so zero `merge_group` runs is the *normal* state of an entry deep in the queue — one healthy entry measured on 2026-09-05 waited 877 seconds for its first run simply because it was sixth in line. The head is always inside the build window, which is what makes zero runs anomalous there and nowhere else. **The threshold is five minutes**, one named constant (`WEDGE_THRESHOLD_MS`) carrying both of the boundary readings it sits between: a healthy head dispatches its runs 3–24 seconds after its queue commit (measured over 18 entries), and the branch ruleset's status-check timeout self-heals a wedge after about 60 minutes. A suspected wedge is confirmed with a second run count 60 seconds later, so "wedged for an hour" is never confused with the seconds after a head change. **Report-only in the queue's direction, red in its own.** The patrol never merges, dequeues, closes or comments, and it **never opens an issue** — a finding that fired four times an hour would produce one card per firing for one incident. Its only write is a PATCH of one pinned issue body, when the repository variable `MERGE_QUEUE_ANCHOR_ISSUE` names one; with the variable unset the run summary and the job status are the whole delivery, and that is not an error. The job *does* go red on a wedge (unlike `half-state-patrol.yml`, which is report-only throughout) because the remedy is a human removing that entry from the queue inside a 60-minute window, and an issue-body edit notifies nobody. An empty queue, a head too young to judge, and a head that could not be identified all exit 0. **A reading that could not be taken is never rendered as a healthy queue.** The script refuses its own verdict (`assertGrounded`) unless the evidence is there: `clear` is unreachable without an identified head *and* a positive run count. "I could not tell which entry is the head" is its own verdict with its own wording. **No `pull_request` leg, deliberately.** Every job of a `pull_request`-triggered workflow produces a check run, and `scripts/dependabot-merge-gate.mjs` requires every produced name to be classified as required, optional or not-a-gate. Adding a leg here would mean editing that declaration; instead the offline `--self-test` runs on every pull request through `scripts/__tests__/check-merge-queue-head.test.ts`, and the live transport is proven by `workflow_dispatch`. ⛔ **Why** GitHub declines to dispatch `merge_group` for such an entry is *unestablished*. It is a repository/Actions-settings reading no agent seat can take, and #7010's triage split it off so the detection could ship without it. All four recorded heads were Dependabot pull requests, but that is a correlation the patrol does not encode — Dependabot pull requests have merged through this queue (`1a4381083`, 2026-08-25), so the failure is conditional and nobody has established on what. ### Hook Self-Tests (`hook-selftests.yml`) [#hook-self-tests-hook-selftestsyml] **Trigger:** PR to `main`/`develop`, and push to `main`, **when `.claude/hooks/**` or this workflow file changes**. **Blocks a PR:** yes. Runs the hermetic self-test matrices for the PreToolUse guards behind the rules both `CLAUDE.md` files state as binding: worktree-first, and never `git stash` (AGENTS.md §9). Each self-test builds its own throwaway git fixture and needs only `jq` and `git`, both preinstalled on `ubuntu-latest` — no install, no build. **Discovered at run time, never enumerated** ([#6906](https://github.com/objectstack-ai/objectui/issues/6906)). The single step runs `find .claude/hooks -type f -name '*.selftest.sh'`, so a new matrix under `.claude/hooks/` is picked up with **no edit to this workflow** — including one in a subdirectory, which a flat glob would miss. It used to be one hand-written `run:` step per matrix, and that was this workflow's own defect one level up: it names itself the standing caller for all of them while a new matrix shipped uncalled until someone remembered to edit it. Two properties keep discovery honest, and both are load-bearing: an **empty** discovery is **red** (a step that verified nothing is not a pass, so a renamed or moved directory cannot degrade the gate into a silent no-op), and the loop **tolerates and collects** rather than sequencing — a `run:` block executes under `bash -e`, so a bare loop would abort at the first red matrix and leave the rest unrun, neither green nor red. The step still fails when any matrix does, and names every one that failed. Before this workflow ([#5754](https://github.com/objectstack-ai/objectui/issues/5754)), nothing ran either matrix automatically: a hook is not imported by any package, so no unit test, type check, or lint reaches it (`eslint.config.js` is scoped to `**/*.{ts,tsx}` throughout, and there is no `shellcheck` anywhere in this repo), and both self-tests' own headers only say to run them *after touching the hook* — an instruction with no gate behind it. The failure mode is asymmetric and both halves are bad: a fail-open regression silently stops guarding the shared checkout, and a fail-closed regression (a false block) trains an operator onto `OS_ALLOW_MAIN_EDITS=1` / `OS_ALLOW_STASH=1`, switching the guard off for the whole command. Neither shows up in a PR without a caller. **This job is a runner, not a rewrite.** It does not modify the hooks or their self-tests — `.claude/**` is governed surface. It only gives the existing matrices a caller that fails the build the moment any one of them goes red, the same way any other required check does. **Path-filtered, unlike `control-bytes.yml`.** That gate carries no `paths` filter because a raw control byte can land in a markdown-only PR just as easily as a TypeScript one. That reasoning does not transfer here: the self-tests assert the CURRENT hook script's behaviour against a fixture they build themselves, so nothing about a docs-only or dependency-bump PR can move the result. This workflow instead mirrors `changeset-guard.yml`'s inverse-filter shape, firing only on a PR that touches `.claude/hooks/**` — which is also why `scripts/dependabot-merge-gate.mjs` classifies **Hook Self-Tests** as `OPTIONAL_CONTEXTS` (present → must be `success`; absent → a Dependabot bump never touches `.claude/hooks/**`, so it is never waited for) rather than `REQUIRED_CONTEXTS`, following the same rule `Changeset Bump Policy` and `Bundle Analysis` do. ### Dependabot Auto-Merge (`dependabot-auto-merge.yml`) [#dependabot-auto-merge-dependabot-auto-mergeyml] **Trigger:** PRs on `main`/`develop` authored by `dependabot[bot]`. * **Patch/minor updates**: approved and enqueued — **but only after an explicit wait**, see below. * **Major updates**: commented for manual review; never approved, never enqueued. * Configures **no** lockfile merge driver ([#6369](https://github.com/objectstack-ai/objectui/issues/6369)): its only merge is `gh pr merge`, which GitHub runs server-side, where the runner's git config cannot reach. **The wait, and why it exists.** This workflow used to run `gh pr merge --auto --squash` unconditionally for every patch/minor bump. `--auto` lands the merge as soon as GitHub considers the PR mergeable — that is, as soon as the *branch-protection required set* is satisfied, which is a different set from "the checks this repository runs". On 2026-08-17 the difference put a red commit on `main`: [#4959](https://github.com/objectstack-ai/objectui/issues/4959) merged at 08:13:36Z with nine of its nineteen check runs still in flight, and shard 3/4 then reported `failure` at 08:21:01Z, shard 1/4 at 08:21:56Z. The four-way test shard matrix is the slowest job here **by construction** — it exists to cut a \~9 minute wall clock — so it is the check `--auto` systematically outruns, and the resulting red `main` blocked every parallel agent until [#4968](https://github.com/objectstack-ai/objectui/issues/4968) repaired it. It was the second time in seven days ([#4098](https://github.com/objectstack-ai/objectui/issues/4098)). So the wait is now explicit and this workflow owns it ([#4973](https://github.com/objectstack-ai/objectui/issues/4973)): `scripts/dependabot-merge-gate.mjs` polls the Checks API for the pull request's head SHA until every context it declares has reported `success`, and only then may the two mutations — approve, enqueue — run. The declared set is the unfiltered blocking contexts (all four shards, **Type Check**, **Lint**, **Build & E2E**, **Build Docs** and the five one-`node`-call gates); the path-filtered ones (**Bundle Analysis**, **Changeset Bump Policy**, **Hook Self-Tests**) must be green *if they reported*; everything else is listed with the reason it cannot gate. A context that is missing, still running at the deadline, or anything other than `success` is **not** green: nothing merges, the job goes red, and a comment on the PR names what refused. Two properties are worth keeping in mind when editing it: * The gate does **not** ask GitHub which checks are required, because that set is a repository-settings surface nothing here can read (see the three ordered steps under [Merge Queue](#merge-queue)) — and it provably does not contain the shards today, since a merge happened while all four were `in_progress`. Reading it would reproduce the hole. * It does **not** replace `--auto` with a direct merge. `main` is behind an enforced merge queue, where a direct merge is rejected with 405; enabling auto-merge *is* the enqueue action. What changed is that it happens after the check set is green on that SHA, not 29 seconds after the shards started. `scripts/__tests__/dependabot-merge-gate.test.ts` holds both halves: it replays #4959's measured check-run timeline and asserts the gate says `pending` at the instant of the old merge and `red` once the shards report, and it asserts the declared buckets partition exactly the set of check names that `pull_request`-triggered workflows produce — so a renamed or added job fails that test instead of quietly dropping out of the wait. ### Shadcn Component Check (`shadcn-check.yml`) [#shadcn-component-check-shadcn-checkyml] **Trigger:** Weekly on Monday at 9:00 AM UTC, or manual dispatch. * Runs offline and online analysis of shadcn/ui components. * Creates or updates a GitHub issue if components need review or updating. * Uploads analysis artifacts for reference. ## Lockfile Merge Driver [#lockfile-merge-driver] ⭐ **Contributor-facing, with zero CI consumers.** It had exactly one CI consumer, `changeset-release.yml`, and that one was measured dead before it was removed ([#6436](https://github.com/objectstack-ai/objectui/issues/6436), ruled 2026-08-27). What is left is live, repository-wide, and takes place entirely on contributors' machines — CI has no part in it. `pnpm-lock.yaml` is never merged line by line — it is regenerated. `.gitattributes` asks for that: ``` pnpm-lock.yaml merge=pnpm-merge ``` ⚠️ **An attribute names a driver; it does not define one.** Git ships no `pnpm-merge` driver, and an attribute naming a driver nothing defines falls back to an ordinary text merge. The definition lives in each contributor's own git config, and `CONTRIBUTING.md` is where they are told to add it, under **Configure Git Merge Driver for pnpm-lock.yaml**: ```bash git config merge.pnpm-merge.name "pnpm-lock.yaml merge driver" git config merge.pnpm-merge.driver "pnpm install" ``` That is the entire live mechanism — the attribute in the repository, the definition on the machine — and it fires on the `git merge upstream/main` that the same page tells contributors to run. Measured in a scratch repository with one variable changed between two runs: **with** the attribute the driver fires and the lockfile is regenerated; **without** it, nothing else altered, the identical merge ends in `CONFLICT (content)` with conflict markers left inside `pnpm-lock.yaml`. ⭐ **Why the live half is worth more than the CI half ever was.** A lockfile carrying conflict markers is a high-blast-radius artifact that fails far from its cause, and hand-resolving one — by a person or by an agent — is precisely the edit nobody can review line by line. Letting the package manager regenerate the file instead makes that whole class of error structurally unreachable. **No workflow configures the driver**, and none should unless it gains a merge that git carries out **on the runner**: | Workflow | Why it needs the driver | | -------- | -------------------------------------------------------------------------------- | | *(none)* | No workflow performs a local merge, so none gives the driver an occasion to fire | `scripts/__tests__/ci-cd-pipeline-doc.test.ts` pins that table against the workflows that actually configure the driver, in both directions — a workflow that gains the step without a row is as red as a row whose workflow lost it. It is pinned because the claim had already drifted two ways at once, and neither copy was checked by anything: this page named `changeset-release.yml` and `dependabot-auto-merge.yml`, while the deleted `.github/WORKFLOWS.md` named `changeset-release.yml` and `changelog.yml`. Each was missing a different one ([#3724](https://github.com/objectstack-ai/objectui/issues/3724)). ⚠️ **That pin no longer proves the mechanism is alive, so something else has to.** While a workflow still configured the driver, "at least one workflow configures it" doubled as the guard that the table's grep had actually matched something. Zero is now the correct answer, so the same test instead asserts the two halves that remain load-bearing: that `.gitattributes` still routes `pnpm-lock.yaml` through the driver, and that `CONTRIBUTING.md` still tells contributors to define it. If either of those goes, the mechanism really has no consumers and this section should go with it — but that is then a measured conclusion rather than a silent one. Adding a workflow that merges **locally** — a merge git performs on the runner? Add the configuration step after checkout and before the merge, and add its row to the table above — the pin fails otherwise. It would need `--no-frozen-lockfile` in the driver command, because Actions sets `CI=true`, under which pnpm refuses to modify the lockfile, and a driver that cannot write the file it exists to rewrite would fail the merge it was installed to resolve. That default is off locally, which is why contributors configure the same driver with a plain `pnpm install`. The "does this workflow merge?" question has been read too widely three times, and each reading left a dead copy behind: ⛔ **Pushing is not merging.** This sentence said "merges or pushes" until [#6358](https://github.com/objectstack-ai/objectui/issues/6358): `changelog.yml` carried the step on the strength of that word, having no merge to resolve. A workflow that only commits and pushes needs no driver, and giving it one buys nothing while implying a lockfile hazard it does not have. ⛔ **A server-side merge is not a local one.** `dependabot-auto-merge.yml` carried the step until [#6369](https://github.com/objectstack-ai/objectui/issues/6369) for a merge it genuinely performs — `gh pr merge`. But GitHub executes that one itself, not on the runner, so no local git config takes part in it. ⛔ **A rewrite is not a merge, and a force-push resolves none.** `changeset-release.yml` carried the step on the strength of "version bumps rewrite the lockfile" ([#6391](https://github.com/objectstack-ai/objectui/issues/6391)), and was the last workflow to carry it. Regenerating a file is not reconciling two versions of it, and the version branch is updated by `reset --hard` plus a force-push inside `changesets/action`, which merges nothing on the runner. Removed by [#6436](https://github.com/objectstack-ai/objectui/issues/6436). ## Adding a New Workflow [#adding-a-new-workflow] > **Give it a section on this page in the same PR.** Not a convention — a test. > `scripts/__tests__/ci-cd-pipeline-doc.test.ts` reads `.github/workflows/` and fails when a > workflow has no heading here naming its file. Three workflows (`lint.yml`, > `cross-repo-issue-closer.yml`, `changeset-guard.yml`) went undocumented for months precisely > because nothing checked, and one of them is a PR gate. 1. Create a new `.yml` file in `.github/workflows/`. 2. Copy the pnpm + Turbo setup from a workflow that runs today, not from a snippet on this page. A copied YAML block is a fossil the moment it is pasted — this page used to keep one here, and every line of it had drifted: `actions/setup-node@v4` where every workflow now uses `@v7`, a hardcoded `node-version: 20` where every workflow declares 22 (and 20 sat below the floor the root `package.json`'s `engines` field now declares), and `pnpm/action-setup@v4`, which no workflow in this repository has ever used — pnpm comes from `corepack enable` plus the root `packageManager` field instead. `readme-exports.yml` (see the **README Exports** section above) is a good one to read: it is short, runs on every pull request, and its setup is the complete pattern most new build/test/lint workflows need — checkout, enable Corepack, `actions/setup-node` with pnpm's own cache, `pnpm install --frozen-lockfile`, then a `turbo run build` step for whatever it needs built. Two of its steps hold for any workflow no matter which Node or pnpm version the repository is on when you read this: ```yaml - uses: actions/checkout@v7 - run: corepack enable ``` Copy everything else — the Node version, the cache key, the install command — from the workflow itself, not from this page. 3. Use Turbo for any build/test/lint steps to leverage caching: ```yaml - run: pnpm turbo run build --filter=@object-ui/core ``` 4. For PR workflows, consider adding path filters to avoid unnecessary runs: ```yaml on: pull_request: paths: - 'packages/**' - 'pnpm-lock.yaml' ``` 5. Add a section for it under the right heading on this page, and a row to the [inventory table](#workflow-inventory). State the display name if it differs from the file name, and say plainly whether it can block a merge. ## Environment Variables and Secrets [#environment-variables-and-secrets] | Secret / Variable | Used By | Purpose | | ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GITHUB_TOKEN` | All workflows | GitHub API access (automatic) | | `NPM_TOKEN` | `changeset-release.yml` | npm package publishing | | `CROSS_REPO_ISSUE_TOKEN` | `cross-repo-issue-closer.yml` | Closing issues in sibling repositories. `GITHUB_TOKEN` cannot do this — it is scoped to the repository running the workflow. When absent the workflow reports instead of closing. | | `TURBO_TOKEN` | Build workflows | Turbo remote cache authentication | | `TURBO_TEAM` | Build workflows | Turbo remote cache team identifier | Secrets are configured in the repository settings under **Settings → Secrets and variables → Actions**. # Component Registry The Component Registry is Object UI's system for mapping schema types to React components. Understanding the registry is key to extending Object UI with custom components. ## Overview [#overview] The registry acts as a lookup table that the `SchemaRenderer` uses to determine which React component to render for each schema type: ``` Schema Type → Component Registry → React Component ``` ## Getting the Registry [#getting-the-registry] `ComponentRegistry` is a process-level singleton exported by `@object-ui/core`. Import it directly — there is no accessor function and nothing to construct: ```tsx import { ComponentRegistry } from '@object-ui/core' ``` ## Registering Components [#registering-components] ### Using Default Components [#using-default-components] The easiest way to get started is to register all default components: ```tsx import { initializeComponents } from '@object-ui/components' // Side-effect import: loading the package runs its own field registration. import '@object-ui/fields' // Call once at app initialization initializeComponents() ``` Loading each package registers what it owns — the components and the field widgets both land in the one `ComponentRegistry`; `initializeComponents()` exists so a bundler cannot tree-shake the side-effect import away. The individual renderers are not exported for hand-registration: registration is what loading the package does. This registers all built-in components like: * Forms: `input`, `textarea`, `select`, `checkbox`, etc. * Data: `table`, `list`, `card`, `tree`, etc. * Layout: `page`, `grid`, `flex`, `container`, etc. * Feedback: `alert`, `dialog`, `toast`, etc. ### Registering Custom Components [#registering-custom-components] Create and register your own components: ```tsx import { ComponentRegistry } from '@object-ui/core' import type { BaseSchema } from '@object-ui/types' interface MyComponentSchema extends BaseSchema { type: 'my-component' title: string content: string } function MyComponent(props: MyComponentSchema) { return (

{props.title}

{props.content}

) } ComponentRegistry.register('my-component', MyComponent) ``` Now you can use it in schemas: ```json { "type": "my-component", "title": "Hello", "content": "This is my custom component!" } ``` ## Component Interface [#component-interface] All registered components receive the schema as props: ```tsx interface ComponentProps { // The complete schema object schema: T // Data context (optional) data?: Record // Event handlers (optional) onAction?: (action: any, context: any) => void onChange?: (value: any) => void onSubmit?: (data: any) => void } function MyRenderer(props: ComponentProps) { const { schema, data, onChange } = props return (
{/* Your component implementation */}
) } ``` ## Advanced Registration [#advanced-registration] ### With Metadata [#with-metadata] Register components with additional metadata: ```tsx ComponentRegistry.register('my-component', MyComponent, { label: 'My Custom Component', category: 'Custom', icon: 'component-icon', inputs: [ { name: 'title', type: 'string' }, { name: 'content', type: 'string' } ] }) ``` This metadata is used by the Visual Designer to provide better editing experience. ### Lazy Loading [#lazy-loading] Register components that load on demand: ```tsx // The loader runs the first time a schema asks for `heavy-component`. ComponentRegistry.registerLazy('heavy-component', () => import('./HeavyComponent')) ``` ### Overriding Built-in Components [#overriding-built-in-components] Override default components with your own: ```tsx import { ComponentRegistry } from '@object-ui/core' import { initializeComponents } from '@object-ui/components' import '@object-ui/fields' // Register defaults first initializeComponents() // Override specific component ComponentRegistry.register('button', MyCustomButton) ``` ## Component Categories [#component-categories] Default components are organized by category: ### Form Components [#form-components] * `input` * `textarea` * `select` * `checkbox` * `radio` * `switch` * `slider` * `date-picker` * `time-picker` * `file-upload` * `color-picker` ### Data Display [#data-display] * `table` * `list` * `card` * `tree` * `timeline` * `calendar` * `kanban` ### Layout [#layout] * `page` * `container` * `grid` * `flex` * `tabs` * `accordion` * `divider` * `spacer` ### Feedback [#feedback] * `alert` * `toast` * `dialog` * `drawer` * `popover` * `tooltip` * `progress` * `skeleton` * `spinner` ### Navigation [#navigation] * `menu` * `breadcrumb` * `pagination` * `steps` ### Other [#other] * `button` * `link` * `text` * `icon` * `image` * `video` * `badge` * `avatar` ## Checking Registered Components [#checking-registered-components] ### Get All Registered Types [#get-all-registered-types] ```tsx import { ComponentRegistry } from '@object-ui/core' const types = ComponentRegistry.getAllTypes() console.log(types) // ['input', 'button', 'form', ...] ``` ### Check if Type is Registered [#check-if-type-is-registered] ```tsx import { ComponentRegistry } from '@object-ui/core' if (ComponentRegistry.has('my-component')) { console.log('Component is registered') } ``` ### Get Component Metadata [#get-component-metadata] ```tsx import { ComponentRegistry } from '@object-ui/core' const metadata = ComponentRegistry.getMeta('input') console.log(metadata) // { // label: 'Input', // category: 'Form', // icon: 'input-icon', // ... // } ``` ## Best Practices [#best-practices] ### 1. Register Once at App Initialization [#1-register-once-at-app-initialization] ```tsx // main.tsx or App.tsx import { initializeComponents } from '@object-ui/components' import '@object-ui/fields' initializeComponents() function App() { // Your app code } ``` ### 2. Use TypeScript for Custom Components [#2-use-typescript-for-custom-components] ```tsx import type { BaseSchema } from '@object-ui/types' interface CustomSchema extends BaseSchema { type: 'custom' customProp: string } function CustomComponent(props: { schema: CustomSchema }) { // TypeScript ensures type safety } ``` ### 3. Follow Naming Conventions [#3-follow-naming-conventions] Use kebab-case for component types: * ✅ `my-component`, `custom-button`, `data-table` * ❌ `MyComponent`, `customButton`, `DataTable` ### 4. Provide Meaningful Metadata [#4-provide-meaningful-metadata] ```tsx ComponentRegistry.register('rating', RatingComponent, { label: 'Star Rating', category: 'Form', icon: 'star', labelling: 'group' }) ``` ### 5. Handle Missing Props Gracefully [#5-handle-missing-props-gracefully] ```tsx function MyComponent(props: ComponentProps) { const { schema } = props const title = schema.title || 'Default Title' const content = schema.content || '' return (

{title}

{content}

) } ``` ## Creating Plugin Packages [#creating-plugin-packages] Group related components into plugin packages: ```tsx // @my-org/objectui-plugin-charts import { ComponentRegistry } from '@object-ui/core' import { BarChart } from './BarChart' import { LineChart } from './LineChart' import { PieChart } from './PieChart' export function registerChartComponents() { ComponentRegistry.register('bar-chart', BarChart) ComponentRegistry.register('line-chart', LineChart) ComponentRegistry.register('pie-chart', PieChart) } ``` Usage: ```tsx import { initializeComponents } from '@object-ui/components' import '@object-ui/fields' import { registerChartComponents } from '@my-org/objectui-plugin-charts' initializeComponents() registerChartComponents() ``` ## Example: Custom Form Component [#example-custom-form-component] Here's a complete example of a custom form component: ```tsx import { forwardRef, useState } from 'react' import { ComponentRegistry } from '@object-ui/core' import type { BaseSchema } from '@object-ui/types' import { cn } from '@object-ui/components' interface RatingSchema extends BaseSchema { type: 'rating' name: string label?: string maxStars?: number required?: boolean disabled?: boolean onChange?: (value: number) => void } const RatingComponent = forwardRef( ({ schema }, ref) => { const [value, setValue] = useState(0) const maxStars = schema.maxStars || 5 const handleClick = (rating: number) => { if (schema.disabled) return setValue(rating) schema.onChange?.(rating) } return (
{schema.label && ( )}
{Array.from({ length: maxStars }).map((_, index) => ( ))}
) } ) RatingComponent.displayName = 'Rating' // Register the component ComponentRegistry.register('rating', RatingComponent, { label: 'Star Rating', category: 'Form', labelling: 'group', inputs: [ { name: 'name', type: 'string', required: true }, { name: 'label', type: 'string' }, { name: 'maxStars', type: 'number', description: 'Defaults to 5 — the renderer\'s own fallback' }, { name: 'required', type: 'boolean' }, { name: 'disabled', type: 'boolean' } ] }) export { RatingComponent } ``` ## Next Steps [#next-steps] * [Expression System](./expressions.md) - Learn about dynamic expressions * [Schema Rendering](./schema-rendering.md) - Understand the rendering engine * [Custom Plugin Development](/docs/guide/plugin-development) - Deep dive into component creation ## Related Documentation [#related-documentation] * [`@object-ui/core` README](https://github.com/objectstack-ai/objectui/tree/main/packages/core) - Component registry API * [`@object-ui/react` README](https://github.com/objectstack-ai/objectui/tree/main/packages/react) - React integration * [Schema Type Reference](/docs/api/schema-reference) - Component metadata reference # Console Architecture # Console Architecture [#console-architecture] This document describes the internal architecture of the Console SPA. `apps/console` is a thin host: it owns the Vite build, the outermost route tree, and plugin registration. Almost everything named below is a **component exported by a package**, not a file under `apps/console` — the shell (providers, layout, object and record views) ships from `@object-ui/app-shell`, view rendering from `@object-ui/plugin-view`, and the app wizard from `@object-ui/plugin-designer`. Packages are named where it matters; import from the package, never from a path. ## Data Flow [#data-flow] ``` ┌─────────────────────────────────────────────────────────┐ │ ObjectStack server (owns apps, objects, views) │ │ • metadata is authored and stored server-side │ │ • the console reads it over HTTP — it has no local │ │ metadata file of its own │ └────────────────────┬────────────────────────────────────┘ │ HTTP (base URL from VITE_SERVER_URL) ▼ ┌─────────────────────────────────────────────────────────┐ │ ObjectStackAdapter (@object-ui/data-objectstack) │ │ • discovery() → apps[], objects[] │ │ • find / findOne / create / update / delete │ │ • getView / getApp (optional metadata cache) │ └────────────────────┬────────────────────────────────────┘ │ DataSource interface ▼ ┌─────────────────────────────────────────────────────────┐ │ SchemaRendererProvider (@object-ui/react) │ │ • provides dataSource + registry to all children │ └────────────────────┬────────────────────────────────────┘ │ React Context ▼ ┌─────────────────────────────────────────────────────────┐ │ Console shell (@object-ui/app-shell) │ │ ├── ExpressionProvider (user, app, evaluator) │ │ ├── ConsoleLayout │ │ │ ├── AppShell (@object-ui/layout) │ │ │ │ └── useAppShellBranding (CSS vars) │ │ │ ├── sidebar nav (app switcher + nav tree) │ │ │ └── AppHeader (breadcrumbs, status) │ │ └── Routes (mounted by apps/console at /apps/:appName) │ │ ├── /apps/:appName/:objectName → ObjectView │ │ ├── /apps/:appName/:objectName/record/:id → Detail │ │ └── /apps/:appName → Home Page │ └─────────────────────────────────────────────────────────┘ ``` ### What the console boots from [#what-the-console-boots-from] The console has **no metadata file of its own** — nothing in `apps/console` declares apps, objects or views. Everything above the adapter is fetched. Its entire local configuration is one build-time Vite variable: * **`VITE_SERVER_URL`** — the only setting that picks a backend. It seeds both the adapter's `baseUrl` and the runtime-config fetch. Empty means same origin, which is what a server that serves the console itself wants. * **Server-pushed runtime config** — before React mounts, the entry point resolves `/api/v1/runtime/config` (branding, feature flags, cloud URL) through `@object-ui/app-shell`, so first paint already shows operator branding instead of the static defaults. * **Discovery + metadata** — `AdapterProvider` (`@object-ui/app-shell`) constructs the `ObjectStackAdapter`, `connect()`s it (one `/api/v1/discovery` probe, cached per base URL), and the metadata provider pulls apps, objects and views from the server's metadata API on demand. Apps and objects **are** authored declaratively — but in the ObjectStack **server** project (`objectstack.config.ts` there, or through Studio), not in this repo. The console is a pure consumer of whatever that server publishes; see [ObjectOS Integration](/docs/guide/objectos-integration) for the server-side shape. ## Routing [#routing] Routing is React Router DOM v7. `apps/console` mounts the per-app subtree at `/apps/:appName/*`; the routes below are declared inside it by the console shell (`@object-ui/app-shell`). Every component in the table is exported by `@object-ui/app-shell`, except `CreateAppPage` / `EditAppPage`, which are lazy-loaded from `@object-ui/plugin-designer`. **The table below is a curated subset, not an inventory.** It covers the object-facing routes — the ones you need to understand how metadata becomes a page. The real tree is several times larger and is declared in exactly two places, which are the source of truth when you need the full list: * the **console's own route tree** (`apps/console`) — the unauthenticated auth surfaces (login, register, password reset, verify-email, setup, OAuth consent, invitations), plus home, Studio, AI, organizations, docs and the shared/public record pages; * the **shell's app-content route tree** (`@object-ui/app-shell`) — everything under `/apps/:appName/*`: record create and edit, dashboards, pages, reports, search, the marketplace, and the whole metadata-admin subtree. Read those two route trees in the source rather than trusting a hand-copied table to stay current. | Route Pattern | Component | Purpose | | --------------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------- | | `/apps/:appName` | Home redirect | Redirects to the first object in navigation | | `/apps/:appName/:objectName` | `ObjectView` | Object list with view switcher | | `/apps/:appName/:objectName/view/:viewId` | `ObjectView` | Specific view for an object | | `/apps/:appName/:objectName/data` | `ObjectDataPage` | Bare data surface — URL `filter[]=` conditions, not bound to any saved view (ADR-0055) | | `/apps/:appName/:objectName/record/:recordId` | `RecordDetailView` | Single-record detail | | `/apps/:appName/create-app` | `CreateAppPage` | App creation wizard (4-step) | | `/apps/:appName/edit-app/:editAppName` | `EditAppPage` | Edit existing app configuration | ## Key Patterns [#key-patterns] ### 1. Expression-Based Visibility [#1-expression-based-visibility] Navigation items can be conditionally hidden using expressions: ```json { "type": "object", "objectName": "admin_settings", "visible": "${user.role === 'admin'}" } ``` `ExpressionProvider` (`@object-ui/app-shell`) wraps the layout and provides an `ExpressionEvaluator` that resolves `${}` templates against context variables (`user`, `app`, `data`). ### 2. Action System [#2-action-system] Actions are typed with `ActionDef` from `@object-ui/core`: ```ts import { useActionRunner } from '@object-ui/react'; const { execute } = useActionRunner({ context: { objectName: 'contacts' }, }); await execute({ type: 'delete', confirmText: 'Are you sure?', params: { recordId: '123' }, }); ``` The `ActionRunner` supports: * **Confirmation** — async `ConfirmationHandler` (default: `window.confirm`, override with Shadcn AlertDialog) * **Toast notifications** — `ToastHandler` for success/error messages * **Custom handlers** — register domain-specific action types (e.g., `'create'`, `'delete'`, `'refresh'`) ### 3. Plugin ObjectView Delegation [#3-plugin-objectview-delegation] The shell's `ObjectView` — the one exported by `@object-ui/app-shell` and bound to the routes above — is a **thin wrapper** around `@object-ui/plugin-view`'s `ObjectView`: * Resolves views from the object definition's `listViews` * Passes a `renderListView` callback for multi-view rendering (kanban, calendar, chart) * Handles shell-level concerns: URL routing, MetadataInspector, record detail overlay ### 4. App Creation & Editing [#4-app-creation--editing] App creation and editing are owned end to end by `@object-ui/plugin-designer`: it exports both route pages and the `AppCreationWizard` they render. The console shell only lazy-loads them onto routes. * **Create App** — `CreateAppPage` at `/apps/:appName/create-app`. Passes metadata objects as `availableObjects`, handles `onComplete` (converts draft via `wizardDraftToAppSchema()`, navigates to new app), `onCancel` (navigate back), and `onSaveDraft` (localStorage persistence). * **Edit App** — `EditAppPage` at `/apps/:appName/edit-app/:editAppName`. Loads existing app config as `initialDraft` and updates on completion. **How users get there.** Manual app creation is **deprecated in favour of the AI-first builder**, and the menu entries that used to launch it are gone. Today: * **Build with AI** — the primary path. The console home (`/home`) offers it whenever the server reports a deployed build agent, and it opens the AI build surface rather than the 4-step wizard. * **Studio** — the authoring surface for a package and the apps inside it (`/studio`, and the design surface per package). This is where app structure is edited by hand now. * **The wizard routes themselves** — `create-app` / `edit-app/:editAppName` stay mounted and reachable as direct (legacy) deep links, which is why the pages above still ship. Do not re-document the old sidebar / command-palette entries: the "Add App" and "Edit App" items exist only in `AppSidebar`, which the console no longer mounts (`ConsoleLayout` renders `UnifiedSidebar`), and the command palette never registered a create-app command. `AppSidebar` is `@deprecated` as of objectui#5720 — it stays published (for any external consumer of the `@object-ui/app-shell` npm package) but is scheduled for removal once its deprecation window closes (objectui#5817). New work should target `UnifiedSidebar`. ### 5. Branding [#5-branding] Per-app branding is applied via `AppShell`'s `branding` prop: ```tsx ``` This writes CSS custom properties on the document root (`html`), and re-applies them whenever the `.dark` class on that element changes, so the brand stays readable across the theme toggle. **Branding acts through the Shadcn theme tokens.** `primaryColor` sets `--primary`, `--primary-foreground`, `--ring`, `--sidebar-primary` and `--sidebar-ring`; `accentColor` sets `--accent` and `--accent-foreground`. They reach rendered output through the Tailwind 4 `@theme` block in `packages/components/src/index.css`, which maps each one to a colour token (`--color-primary: hsl(var(--primary))`, `--color-accent: hsl(var(--accent))`, and so on) — and that is what generates the `bg-primary`, `text-primary-foreground`, `bg-accent` and `ring-ring` utilities the components already use. Overriding the token is therefore the whole mechanism: every existing consumer picks up the brand colour without a single component change. `AppShell` also writes `--brand-primary`, `--brand-primary-hsl`, `--brand-accent` and `--brand-accent-hsl`. These are **backward-compatibility aliases, not the branding surface** — no utility is wired to them, and nothing in this repository reads them. They are not interchangeable with the tokens above either: `--brand-*` carries the authored hex and its light-mode HSL triple and does not follow the light/dark toggle, whereas `--primary` / `--accent` carry the mode-adjusted value. Theme against the Shadcn tokens; treat the `--brand-*` names as an alias kept for whatever already consumes it. ## Development Mode [#development-mode] There is **no bundled mock backend** — offline development is not a thing here. In dev exactly as in production, `ObjectStackAdapter` talks over HTTP to a live ObjectStack server at `VITE_SERVER_URL`, and everything above the adapter in the data flow depends on that call succeeding: no server, no discovery, no apps in the sidebar. See [Console App → Quick Start](/docs/guide/console#quick-start) for the dev server port, the default `VITE_SERVER_URL`, and how to point the console at a different backend. # Console App # ObjectStack Console [#objectstack-console] The **Console** is the reference application for [ObjectUI](/docs/guide). It renders a full-featured admin interface from JSON metadata — objects, views, dashboards, and actions — with zero custom pages required. ## Quick Start [#quick-start] ```bash # From the repository root pnpm install pnpm dev # starts the console dev server (Vite) ``` The console opens at **[http://localhost:5180](http://localhost:5180)** (the port is fixed in `apps/console/vite.config.ts`). There is no bundled mock backend — `apps/console/.env.development` ships `VITE_SERVER_URL` **empty** (same origin), and the Vite dev server proxies `/api/*` to `http://localhost:3000` by default, so an ObjectStack server has to be listening there. See [Running with a Real Backend](#running-with-a-real-backend) to point dev at a different one. ## Key Features [#key-features] | Feature | Description | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Multi-App Switcher** | Switch between the apps discovered from the connected server. | | **Dynamic Navigation** | Sidebar renders from the app's `navigation` tree (objects, groups, URLs, pages). | | **Object Views** | List / Grid / Kanban / Calendar — backed by `@object-ui/plugin-view`. | | **CRUD Dialogs** | Create & edit records via schema-driven forms. | | **Expression Visibility** | Show/hide navigation items using `visible: "${data.role === 'admin'}"`. | | **Branding** | Per-app colors, favicons, and logos via `AppShell` branding. | | **Command Palette** | `⌘+K` opens a searchable command bar for quick navigation. | | **Studio Package Scope** | Studio home, metadata counts, quick-create links, and diagnostics follow the selected package. | | **Design in Studio** | Workspace admins get a top-bar entry inside a running app that opens its owning package on the Studio design surface. On an interface route — a dashboard, page, or report — it deep-links straight to that surface's design page in the Interfaces pillar (`/studio/:packageId/interfaces?surface=:`, e.g. `surface=page:showcase_crm_workbench`); elsewhere (objects, the app root) it opens the package's Data tab (`/studio/:packageId/data`). These interfaces are authored in Studio — there is no in-page edit panel. | | **App Creation Wizard** | 4-step wizard (Basic Info → Objects → Navigation → Branding) to create or edit apps. | | **Record Approvals Tab** | A record with approval requests grows an Approvals tab on its detail page (peer of Details/Related, with a request-count badge) — current step, decision progress, resolved "waiting on" approvers, the merged decision timeline, and a submitter remind button — visible to every viewer who can read the record, not just approvers. | | **Error Boundary** | Graceful error handling with a retry button. | ### Object design (Studio Data tab) [#object-design-studio-data-tab] Selecting an object in Studio's **Data** pillar (`/studio/:packageId/data`) opens a tab strip over that object — **Records · Form · Validations · Hooks · Actions · API · Settings**. Each of Validations, Hooks and Actions is a no-code **config panel driven by the corresponding metadata**, and each supports **adding** new entries — no code round-trip required: | Tab | Edits | Panel | | --------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Validations** | the object's inline `validations[]` (spec `ValidationRuleSchema`) | Master-detail covering **every** rule type — `script`, `cross_field`, `state_machine`, `format`, `json_schema`, `conditional`. The **New** menu adds any type (seeded with a valid, never-firing skeleton); a rule's type can be switched in place. CEL predicates reuse the shared `ConditionBuilder`, fed the object's draft fields. | | **Hooks** | the separate `hook` metadata type targeting this object | Master-detail whose editor is the platform `SchemaForm` **driven by the live `hook` JSONSchema from `/meta/types`**, so its fields and enums always match the running server's contract. | | **Actions** | the object's inline `actions[]` (spec `ActionSchema`) | Master-detail using the type-aware `ActionDefaultInspector`; anything not curated falls through to a **"More fields"** form fed the live `action` JSONSchema, so no spec property is un-editable. | Validations and Actions persist with the object's own **Save draft**; Hooks (a distinct metadata type) save per-hook. Nothing goes live until the package is published from the top-bar **Publish** flow. ## Configuration [#configuration] **The console has no configuration file.** It declares no apps, objects or views of its own — it renders whatever the server it is pointed at publishes. There are only two inputs: **1. `VITE_SERVER_URL` — which backend to talk to.** A build-time Vite variable, and the only setting the console itself owns. It seeds the data adapter's base URL and the runtime-config fetch; `apps/console/.env.development` ships it **empty**, which means same origin — the Vite dev server proxies `/api/*` to the backend. See [Running with a Real Backend](#running-with-a-real-backend). **2. Server-pushed runtime config — everything else.** Before React mounts, the console resolves `/api/v1/runtime/config` from that server and applies it: product branding, feature flags (marketplace, AI Studio, SSO, custom domain), and the cloud URL. Operators configure these on the **server**, not in the SPA, which is why changing them needs no console rebuild. Apps, objects and views themselves are metadata fetched over HTTP — discovered at connect time and loaded on demand. To change what the console shows, change the metadata on the server: author it in the ObjectStack server project (`objectstack.config.ts` lives **there**, not here) or edit and publish it from Studio. See [ObjectOS Integration](/docs/guide/objectos-integration) for the server-side configuration shape. ## Running with a Real Backend [#running-with-a-real-backend] `VITE_SERVER_URL` is the setting that decides which backend the console talks to — the data adapter, auth, i18n and action endpoints all hang off it. 1. In dev, leave `VITE_SERVER_URL` empty and point the dev proxy at your server instead. Only `/api/*` is proxied, to `DEV_PROXY_TARGET` when set and `http://localhost:3000` otherwise: ```bash DEV_PROXY_TARGET=https://demo.objectstack.ai pnpm dev ``` This keeps the page and the API on one origin. Setting `VITE_SERVER_URL` to an absolute origin still works, but it opts dev out of same-origin and into CORS — an absolute `VITE_SERVER_URL` is the right setting for a *built* console deployed apart from its backend. 2. The console will use the ObjectStack client to discover metadata and perform CRUD operations against the server. ## Where the Code Lives [#where-the-code-lives] Most of what you see in the console does not live in `apps/console`. The shell and layout, sidebar, header, command palette, object list and record detail views all ship from **`packages/app-shell`** (`@object-ui/app-shell`), so any host application can mount the same experience; the heavier view surfaces (grid, kanban, calendar, charts, designer) come from the `@object-ui/plugin-*` packages. `apps/console` is the assembly layer on top: it owns the route tree, registers the plugin set, wires the backend connection, and adds the surfaces specific to this app (auth pages, the docs portal, system and settings pages). So when you want to change something you *see* in the console, look in `packages/app-shell` first. ## See Also [#see-also] * [Console Architecture](/docs/guide/console-architecture) — data flow, routing, and plugin integration * [Schema Overview](/docs/guide/schema-overview) — the JSON protocol that drives the console * [Data Source](/docs/guide/data-source) — how the adapter fetches and caches data # Dashboard-Level Filters A dashboard often needs one top-level filter — a date range, a region select — that drives **several charts at once**. ObjectUI models this as a **dashboard-level parameter**, not a shared dataset: * The **filter control and its value live on the dashboard** — hosted as dashboard-level variables (the page/dashboard variables primitive). * Each widget declares which of **its own** fields a filter binds to via `filterBindings` — a small mapping, not a copied query. * At render time the dashboard **broadcasts** the active values into every bound widget's inline query, `AND`-combined with the widget's own `filter`. Charts stay inline and self-contained; one place owns the filter; each chart edit stays local. > **Working examples**: the schema catalog ships a > `plugin-dashboard/filtered-dashboard` example plus variants for dynamic > options, text/number/lookup filter types, dataset widgets, the > `targetWidgets` allow-list, and date presets with a custom range. They are > **presentation** examples — the filter declarations are what they teach, so > their widgets carry inline demo data (the dataset variant additionally binds > two widgets to a `dataset`), which is what lets the docs gallery draw them > with no application behind it. Inline static data is never filtered; see > Known limitations at the end of this page. ## Tutorial: from zero to a filtered dashboard [#tutorial-from-zero-to-a-filtered-dashboard] ### Step 1 — a plain dashboard [#step-1--a-plain-dashboard] Start from two charts over **different** datasets. Without filters they always show everything: ```json { "type": "dashboard", "columns": 2, "widgets": [ { "id": "invoices_by_status", "title": "Invoices by Status", "type": "bar", "dataset": "invoices", "dimensions": ["status"], "values": ["count"] }, { "id": "accounts_signed", "title": "Accounts Signed", "type": "line", "dataset": "accounts", "dimensions": ["signed_month"], "values": ["count"] } ] } ``` #### Where a widget's data comes from [#where-a-widgets-data-comes-from] Filters scope a widget's **query**, so which data surface a widget uses decides whether it can respond at all: | Surface | Shape | Filtered? | | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | Semantic-layer dataset (ADR-0021) | `"dataset": "invoices"` + `dimensions` + `values` | yes — merged into the dataset query as `runtimeFilter` | | Inline object query | `"options": { "data": { "provider": "object", "object": "invoices", "aggregate": { "function": "count", "groupBy": "status" } } }` | yes — `AND`-merged into that query | | Inline static data | `"options": { "data": [ … ], "xField": "status", "yField": "count" }` | no — there is no query to scope | > **Retired: the top-level inline analytics shape.** `object` + > `categoryField` / `valueField` / `aggregate` on the widget itself (and the > pivot `rowField` / `columnField` pair) was **removed** — the renderer no > longer reads those keys, and a stored widget still carrying them renders a > visible *"This widget uses a retired data format. Edit it to bind a dataset."* > prompt instead of a chart. Rebind such a widget to a `dataset` (select its > `dimensions` and `values` by name), or — for a renderer-internal query with > no semantic layer behind it — move the query under > `options.data` with `"provider": "object"`. `@objectstack/spec` refuses the > retired shape at publish, so this is not a soft deprecation. ### Step 2 — add the built-in date range [#step-2--add-the-built-in-date-range] Declare `dateRange` at the dashboard level. A preset/custom date-range control appears in the filter bar above the widgets: ```json { "dateRange": { "field": "created_at", "defaultRange": "last_30_days", "allowCustomRange": true } } ``` * `field` — the **default** field the range applies to on every bound widget (falls back to `created_at` when omitted). * `defaultRange` — the initially selected preset: `today`, `yesterday`, `this_week`, `last_week`, `this_month`, `last_month`, `this_quarter`, `last_quarter`, `this_year`, `last_year`, `last_7_days`, `last_30_days`, `last_90_days`, or `custom` (starts empty and lets the user pick). * `allowCustomRange` — offer a "Custom…" item that opens a from/to calendar (default `true`). Presets stay **symbolic** until query time: they compile to date-macro tokens (`{30_days_ago}`, `{current_month_start}`, …) that each widget resolves exactly like hand-authored widget filters — so a dashboard saved today still means "last 30 days" tomorrow. ### Step 3 — add a global filter [#step-3--add-a-global-filter] Add a `globalFilters` entry. Each entry renders one control in the filter bar: ```json { "globalFilters": [ { "name": "region", "field": "region", "label": "Region", "type": "select", "options": [ { "value": "EMEA", "label": "EMEA" }, { "value": "APAC", "label": "APAC" }, { "value": "AMER", "label": "AMER" } ] } ] } ``` * `name` — the **stable filter name**: the variable key the value is published under, and the key widgets reference in `filterBindings`. Defaults to `field`. (`"dateRange"` is reserved for the built-in date range.) * `field` — the default field the filter applies to on bound widgets. * `type` — the control type: `text`, `number`, `select`, `lookup`, or `date`. | Type | Control | Generated condition | | ------------------- | ------------------- | ----------------------------------------- | | `text` | input | `{ field: { "$contains": value } }` | | `number` | numeric input | `{ field: value }` (equality) | | `select` / `lookup` | dropdown | `{ field: value }` (or `$in` for arrays) | | `date` | preset/custom range | `{ field: { "$gte": from, "$lte": to } }` | A `date` filter's `defaultValue` is a **string**, and exactly three spellings are accepted: * a **preset name** from the `defaultRange` list above (`"last_7_days"`) — it is lifted to that preset's range, the same as picking it in the control; * an **ISO date** (`"2026-01-15"`) — equality on that day; * a **date-macro token** (`"{today}"`, `"{7_days_ago}"`) — resolved at query time like any other filter token. Anything else — a misspelled preset such as `"last_7_dayz"` — is **skipped**, and the runtime logs a `console.warn` naming the filter and the value. It is deliberately not compared as-is: `field = "last_7_dayz"` matches no row, and the widget would render a perfectly healthy-looking `0`. Static `options` are `@objectstack/spec` object pairs — `{ "value": "amer", "label": "AMER" }`. This is the only form the platform accepts: a dashboard is validated against `GlobalFilterSchema` when it is published, and anything else is refused there. > **Deprecated: the bare-string shorthand.** `"options": ["EMEA", "APAC"]` is > still lifted by the runtime to `{ "value": "EMEA", "label": "EMEA" }` pairs so > that already-stored dashboards keep rendering, but it now logs a deprecation > warning naming the filter, and it is scheduled for removal > ([objectui#4356](https://github.com/objectstack-ai/objectui/issues/4356)). > Write the object form. The lift is mechanically lossless, so migrating a > stored dashboard is a direct rewrite of each string `X` to > `{ "value": "X", "label": "X" }`. Options can also be fetched from an object at runtime: ```json { "name": "industry", "field": "industry", "label": "Industry", "type": "select", "optionsFrom": { "object": "accounts", "valueField": "industry", "labelField": "industry" } } ``` With a dataset-capable data source, `optionsFrom` resolves distinct values **server-side** (a GROUP BY over the source object), so the option list is complete regardless of row count. Data sources without dataset queries fall back to a best-effort client-side dedupe over the first 200 records. ### Step 4 — bind each widget's own fields [#step-4--bind-each-widgets-own-fields] By default every filter applies to its own `field` on every widget. When a widget stores the concept under a different field — or should ignore a filter — declare `filterBindings` on the widget: ```json { "widgets": [ { "id": "invoices_by_status", "type": "bar", "dataset": "invoices", "dimensions": ["status"], "values": ["count"] }, { "id": "accounts_signed", "type": "line", "dataset": "accounts", "dimensions": ["signed_month"], "values": ["count"], "filterBindings": { "dateRange": "signed_at", "region": "sales_region" } }, { "id": "total_invoices", "title": "Total Invoices (all regions)", "type": "metric", "dataset": "invoices", "values": ["count"], "filterBindings": { "region": false } } ] } ``` Binding rules, in precedence order: 1. `filterBindings[name]` as a **string** — apply the filter to that field. 2. `filterBindings[name]: false` — opt this widget out of that filter. 3. Legacy `targetWidgets` on the filter — when set, only listed widget ids get the default binding (an explicit `filterBindings` entry still wins). 4. Otherwise the filter applies to its own `field` (the built-in date range defaults to `dateRange.field ?? 'created_at'`). That's the whole feature: changing any filter live re-scopes every bound widget, each against **its own** field. Here it is running in the showcase app's *Revenue Pulse* dashboard — the date range's default field is the invoice `issued_on`, account widgets re-map it to `signed_on`, and the "Accounts (all time)" KPI opts out of both filters: Revenue Pulse — dashboard-level date + region filters over two objects Selecting **EMEA** re-scopes every bound widget live (invoices via their own `region`, accounts via `sales_region`), while the opted-out KPI holds steady — and a Reset button appears once any filter deviates from its default: Revenue Pulse re-scoped to EMEA — bound widgets update, the opted-out KPI holds Bindings can also be edited visually: the Studio dashboard widget inspector shows a **Dashboard filter bindings** section (one row per declared filter) with an Apply toggle (opt-out) and a field picker for the override — no JSON editing required. The widget inspector's Dashboard filter bindings section ## Reading filter values in expressions [#reading-filter-values-in-expressions] Filter values are hosted as dashboard variables, so any widget expression can read them under the `page.` scope, keyed by the filter's `name`: ```json { "type": "text", "content": "Region: ${page.region || 'All'}" } ``` ```json { "id": "emea_playbook", "component": { "type": "card", "title": "EMEA Playbook", "hidden": "${page.region !== 'EMEA'}" } } ``` The built-in date range is an object under `page.dateRange` — a preset selection is `{ "preset": "last_30_days" }`, a custom range is `{ "from": "2026-01-01", "to": "2026-03-31" }` (either bound may be absent). ## Dataset widgets [#dataset-widgets] Widgets bound to a semantic-layer `dataset` participate the same way: the dashboard merges the scoped filter into the widget's `filter`, which the dataset widget forwards to the dataset query as `runtimeFilter`. Dataset-bound and inline widgets mix freely on one filtered dashboard — the `plugin-dashboard/filtered-dashboard-dataset-widgets` catalog entry is exactly that, two dataset-bound widgets beside an inline one. What differs is only what each surface can answer: an inline **object query** (`options.data` with `"provider": "object"`) is scoped like a dataset widget, while an inline **static array** carries no query and is left untouched. ## Nested variable scopes [#nested-variable-scopes] When a filtered dashboard is embedded inside a Page that declares its own `variables`, the two scopes **merge**: inside the dashboard subtree, `page.*` resolves the outer Page's variables plus the dashboard's filter values, and a dashboard filter only shadows an outer variable that has the **same name**. Writes route to the scope that defines the variable — setting an outer-page variable from inside the dashboard updates the outer scope, so both subtrees stay in sync. ## Known limitations [#known-limitations] * **Static-data widgets are not filtered** — a widget whose `options.data` is an inline array has no query to scope, so dashboard filters do not apply to it. Bind the widget to a `dataset` (or give it an `options.data` object query) if it should respond to filters. * **A binding is applied as written** — the dashboard does not know a dataset's fields, so it cannot check a binding target for you. A default binding whose field the widget's data does not have produces an empty widget rather than a silent no-op, which is the visible, fixable failure: map the filter explicitly with `filterBindings: { "": "" }`, or opt out with `false`. (`buildWidgetScopedFilter` can skip an unknown default field with a console warning when a host passes it the widget's known field names; the dashboard renderer does not.) ## i18n [#i18n] The filter bar's strings resolve from the `dashboard.filters.*` keys (`@object-ui/i18n` ships `en` and `zh` entries — control labels come from each filter's `label`, so translate those in your schema metadata). ## Spec alignment [#spec-alignment] `DashboardSchema.dateRange`, `GlobalFilterSchema` (including `name`) and `DashboardWidgetSchema.filterBindings` are part of `@objectstack/spec` (framework#2501). Author dashboards against the spec shapes; ObjectUI renders them. # Data Connectivity ObjectUI follows the **Universal Adapter Pattern**. UI components do not hardcode transport details. They receive a `DataSource` implementation from `SchemaRendererProvider` and call a stable CRUD/query contract. This keeps the renderer backend-agnostic: ObjectStack, REST, GraphQL, and proprietary backends can all be adapted behind the same interface. ## The Interface [#the-interface] The canonical interface lives in `@object-ui/types`: ```typescript import type { BatchTransactionOperation, QueryParams, QueryResult } from '@object-ui/types'; export interface DataSource { find(resource: string, params?: QueryParams): Promise>; findOne(resource: string, id: string | number, params?: QueryParams): Promise; create(resource: string, data: Partial): Promise; update( resource: string, id: string | number, data: Partial, opts?: { ifMatch?: string }, ): Promise; delete( resource: string, id: string | number, opts?: { ifMatch?: string }, ): Promise; // Optional: atomically persist an ordered set of cross-object operations // (master-detail save). `{ $ref: }` links a child to a parent // created earlier in the same batch. Adapters without server-side atomicity // may emulate it — see below. batchTransaction?( operations: BatchTransactionOperation[], ): Promise<{ results: any[] }>; getObjectSchema(objectName: string): Promise; } ``` `find()` returns a `QueryResult` so components can receive both rows and pagination metadata: ```typescript interface QueryResult { data: T[]; total?: number; page?: number; pageSize?: number; hasMore?: boolean; cursor?: string; metadata?: Record; } ``` ## Available Adapters [#available-adapters] ### ObjectStack Adapter (Official) [#objectstack-adapter-official] Use `@object-ui/data-objectstack` for ObjectStack-compatible backends. ```bash pnpm add @object-ui/data-objectstack ``` ```typescript import { createObjectStackAdapter } from '@object-ui/data-objectstack'; const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.your-instance.com' }); ``` ## Usage [#usage] Inject the data source at the renderer boundary: ```tsx import '@object-ui/components'; import '@object-ui/fields'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; import { createObjectStackAdapter } from '@object-ui/data-objectstack'; import type { BaseSchema } from '@object-ui/types'; const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' }); const mySchema: BaseSchema = { type: 'table', objectName: 'users' }; function App() { return ( ); } ``` ## Creating a Custom Adapter [#creating-a-custom-adapter] If you have a proprietary backend, wrap its SDK or client in a `DataSource` implementation. Keep transport details in the adapter, not in renderers. ```typescript import type { DataSource, QueryParams, QueryResult } from '@object-ui/types'; type User = { id: string; name: string; email: string; }; type BackendClient = { listUsers(params?: QueryParams): Promise<{ rows: User[]; total?: number }>; getUser(id: string | number): Promise; createUser(data: Partial): Promise; updateUser(id: string | number, data: Partial): Promise; deleteUser(id: string | number): Promise; describeObject(name: string): Promise; }; class UserDataSource implements DataSource { constructor(private readonly client: BackendClient) {} async find(resource: string, params?: QueryParams): Promise> { if (resource !== 'users') { return { data: [], total: 0 }; } const result = await this.client.listUsers(params); return { data: result.rows, total: result.total, }; } findOne(_resource: string, id: string | number): Promise { return this.client.getUser(id); } create(_resource: string, data: Partial): Promise { return this.client.createUser(data); } update(_resource: string, id: string | number, data: Partial): Promise { return this.client.updateUser(id, data); } delete(_resource: string, id: string | number): Promise { return this.client.deleteUser(id); } getObjectSchema(objectName: string): Promise { return this.client.describeObject(objectName); } } ``` ## Query Parameters [#query-parameters] ObjectUI uses OData-style query keys for broad compatibility: ```typescript await dataSource.find('users', { $select: ['id', 'name', 'email'], $filter: { status: 'active' }, $orderby: { name: 'asc' }, $skip: 0, $top: 25, $count: true, }); ``` Data-aware plugins may also use optional methods such as `batchTransaction`, `bulkUpdate`, `bulkDelete`, `getView`, or `listViewOverrides` when an adapter supports them. Keep the required CRUD methods implemented first, then add optional capabilities as your UI needs them. ### Cross-object atomic writes (`batchTransaction`) [#cross-object-atomic-writes-batchtransaction] Master-detail saves (a parent record plus its child line items) go through `dataSource.batchTransaction(operations)` — one ordered list of cross-object create/update/delete operations, where a child's foreign key can be `{ $ref: }` to point at a parent created in the same batch. The `@object-ui/data-objectstack` adapter maps this to the published `@objectstack/client` `data.batchTransaction` SDK method, which drives the server's atomic `POST /api/v1/batch` endpoint (commit-all-or-roll-back-all). Adapters without a transactional endpoint don't need to hand-write orchestration: call `emulateBatchTransaction(dataSource, operations)` from `@object-ui/core`, which executes the operations sequentially (resolving `$ref`s) with best-effort compensation on failure. UI components never branch on atomicity — they call `runBatchTransaction(dataSource, operations)` (also from `@object-ui/core`), which uses the adapter's method when present and emulates otherwise. The `@object-ui/data-objectstack` adapter decides whether it can trust server atomicity **declaratively**, at connect time: it reads the `capabilities.transactionalBatch` flag from `GET /api/v1/discovery` (framework #3298). When the backend advertises `true`, the adapter treats any `/batch` failure as a real error — no non-atomic client-side compensation. When the flag is `false` or absent (a backend predating #3298), it keeps the legacy behaviour: probe `/batch` and fall back to the non-atomic emulation on `404`/`405`/`501`. Atomic cross-object saves are therefore guaranteed only against backends that advertise the capability; older ones still save, but best-effort. See the [adapter README](https://github.com/objectstack-ai/objectui/blob/main/packages/data-objectstack/README.md#cross-object-atomic-batch-batchtransaction) for the full capability table and minimum-backend note. ## Per-element data binding on a page (`dataSource`) [#per-element-data-binding-on-a-page-datasource] A metadata page component carries its own data binding — `PageComponentSchema.dataSource`, the spec's `ElementDataSourceSchema` — so one page can show several objects without a page-level object context: ```json { "type": "list-view", "dataSource": { "object": "account", "view": "hot", "limit": 10 } } ``` This is metadata, **not** the data-source adapter. The two share a name and are different things: the adapter is injected by the host (`SchemaRendererProvider`), while `dataSource` on a schema node is JSON describing *what to query*. A renderer therefore reads the binding off `schema.dataSource` and gets its adapter from context — never from a prop the schema could occupy. `SchemaRenderer` strips the binding from the props it spreads for exactly this reason. `view` names a **saved view** of that object; its columns, filter, sort and page size are applied to the render, so a page never has to keep a second copy of a view's configuration. `filter` is *additional* criteria — it AND-combines with the view's filter rather than replacing it — while `sort` and `limit` override the view's. A `view` name that does not resolve is reported as a configuration error; it never degrades into an unfiltered query for the object. `@object-ui/react` exposes `useElementDataSource(schema, dataSource?)` for renderers that need the same resolution, and `@object-ui/core` exposes the pure parts (`isElementDataSourceConfig`, `resolveSavedView`, `composeElementDataSource`). ### Which blocks consume it, and which keys each one honours [#which-blocks-consume-it-and-which-keys-each-one-honours] The binding is declared on every page component, but a component can only honour the keys it has a read site for — a calendar has no page to cap, a metric is one aggregated number, a form edits one record. Each block therefore maps the keys it reads and leaves the rest alone; a key written onto a schema slot the block ignores would be accepted and dropped, which is the defect this binding removes. | block | `object` | `view` | `filter` | `sort` | `limit` | | --------------------------- | ----------------- | ------------------------------- | --------------------- | ----------------- | ---------------------- | | `list-view` | ✅ | ✅ | ✅ | ✅ | ✅ | | `object-grid` | ✅ | ✅ | ✅ | ✅ | ✅ | | `element:record_picker` | ✅ | ✅ | ✅ | ✅ | ✅ | | `record:related_list` | ✅ | columns / filter / sort / limit | ✅ | ✅ | ✅ | | `object-calendar` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling | | `object-kanban` | ✅ | filter / limit | ✅ | — no ordering | ✅ (`limit`) | | `object-chart` | ✅ | filter | ✅ | — engine orders | — no page | | `object-metric` | ✅ | filter | ✅ | — single value | — single value | | `object-gantt` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling | | `object-map` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling | | `object-pivot` | ✅ | filter | ✅ | — grouping orders | — totals need all rows | | `object-timeline` | ✅ | filter / sort / limit | ✅ | ✅ | ✅ (`limit`) | | `object-form` | ✅ | error-checked only | — no collection query | — | — | | `embeddable-form` | ✅ | error-checked only | — no collection query | — | — | | `object-master-detail-form` | ✅ | error-checked only | — no collection query | — | — | | `record:line_items` | ✅ (`childObject`) | filter / sort / limit | ✅ (AND parent scope) | ✅ | ✅ (`limit`) | Reading the `view` column: it lists what a named saved view actually contributes on that block. A view name that does not resolve is reported as a configuration error on **every** block in the table, including the ones that take nothing else from the view — so a typo never passes silently, whatever the block. Reading the `object` column: it lands on the block's own object key, which is `objectName` everywhere except `record:line_items`, where the collection the panel lists, fetches and writes is `childObject`. Its `relationshipField` is *not* part of the binding and stays the author's — it has to name a field on the bound child object, so rebinding `object` without updating it is an authoring error the panel cannot paper over. On `record:related_list` and `record:line_items` the composed filter is AND-combined with the parent relationship condition, never substituted for it: a child panel is always scoped to the record it appears on, and an *additional* criterion can only narrow that set further. (Until objectstack#7118 `record:related_list` declared `filter` without reading it, so a named view contributed its columns / sort / limit while its filter was dropped — the list could be wider than the view it named. That gap is closed; the `filter` cell above is what closed it.) `object-timeline` and `record:line_items` were the two residual gaps in this table until objectstack#7137. Neither had a `filter` / `sort` read site at all — the timeline's whole fetch was `find(objectName, { options: { $top: 100 } })` and the line-items panel's was the parent FK plus a fixed `$top: 500` — so a `view` named on either resolved (a typo reported) and then contributed nothing: the rendered rows could be **wider than the view they named**, with no error anywhere. Both now read `filter`, `sort` and `limit`, so the cells above are ✅. Two notes on what came with that: * The timeline's default window is `limit ?? 100` and it is now a real `$top`. The old `{ options: { $top: 100 } }` nested the cap under a key that is not a `QueryParams` field and that no adapter in this repo reads, so the intended cap never reached the wire; a timeline over a large object fetched whatever the server chose to return. Authoring `limit` (or a view's `pagination.pageSize`) now sets it. * `record:line_items` still does **not** take a view's `columns`: they are editable `GridColumn` objects (`{ field, type, … }`) rather than a field-name projection, so a view's column list would be the wrong *shape*, not merely a wider answer. `object-kanban` carried the same nesting, and until objectui#4025 this table read `— fixed window` in its `limit` cell. There was no window: the board's cap was written `{ options: { $top: 100 } }` — `$filter` at the top level where the adapters read it, the cap one level down under a key that is not a `QueryParams` field — so a board over a large object fetched every row the server would return and grouped all of it into lanes, client-side. The cap is now a real `$top`, defaulting to 100, and the `limit` cell is ✅ because the read site exists: a `limit` authored on the block, the binding's `limit`, or the named view's `pagination.pageSize` all set it. The board's `sort` cell still reads `— no ordering` — lanes come from `groupBy` and the fetch declares no `$orderby`, so mapping `sort` would be the accepted-and-dropped defect one block over. Note the two `limit`s on a board are different keys at different levels: the row cap above is `limit` on the **board**, while `limit` on a **column** is that lane's WIP limit (how many cards it may hold before it warns), which is display behaviour and never touches the query. Reading `— platform ceiling` on `object-calendar` / `object-gantt` / `object-map` (and on `object-tree`, which predates this table): those four are the **non-grid** visualisations, and objectui#7210's maintainer ruling settled what their row behaviour is. They fetch the whole **filtered** result set — a gantt cannot compute a truthful `min(start) → max(end)` from one page, a map fits its camera to every marker, and a tree assembled from a page loses every node whose parent fell outside it — but the fetch is **bounded** by `NON_GRID_ROW_CEILING` (`@object-ui/react`, currently 2,000). Past it the view draws the first N rows and shows a footnote naming both N and the total. Silent truncation is the failure that ruling exists to prevent: a cut-off schedule still looks like a schedule. The `limit` cell stays "not ✅" for all four because the ceiling is **not authorable and must not become one** — a named constant in the renderer, by the same ruling. An authored `limit`, a binding's `limit` and a named view's `pagination.pageSize` all still fail to reach these queries, which is what the cell has always meant. What changed is only that "no cap at all" is no longer true. Remaining gap, recorded rather than papered over: * `object-form` / `embeddable-form` / `object-master-detail-form` resolve `view` only to report an unresolvable name; a view that does resolve contributes nothing, because a list view's columns are not a form layout. On the master-detail form the bound object is the **parent**; child collections come from `details[]`, by FK. Blocks not in the table (`dashboard`, the other `record:*` panels) do not consume the binding yet. # Deployment # Deployment [#deployment] ObjectUI apps are standard Vite + React applications, so they can be deployed anywhere that serves static files or runs Node.js containers. This guide provides copy-paste-ready configurations for the most popular platforms. ## Prerequisites [#prerequisites] * A production build: `pnpm build` (runs `turbo run build` across all packages) * The build output lives in `apps/console/dist/` (or your app's `dist/` folder) * Environment variables configured for your target environment ## Docker [#docker] Create a multi-stage `Dockerfile` at the project root: ```dockerfile # Stage 1: Build FROM node:22-alpine AS builder RUN corepack enable && corepack prepare pnpm@10 --activate WORKDIR /app COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY packages/ packages/ COPY apps/ apps/ RUN pnpm install --frozen-lockfile RUN pnpm build # Stage 2: Serve FROM nginx:alpine AS runner COPY --from=builder /app/apps/console/dist /usr/share/nginx/html COPY <<'EOF' /etc/nginx/conf.d/default.conf server { listen 80; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { expires 1y; add_header Cache-Control "public, immutable"; } gzip on; gzip_types text/plain text/css application/json application/javascript text/xml; } EOF EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] ``` Build and run: ```bash docker build -t objectui-app . docker run -p 3000:80 objectui-app ``` ## Vercel [#vercel] Create `vercel.json` in the project root: ```json { "buildCommand": "pnpm build", "outputDirectory": "apps/console/dist", "installCommand": "pnpm install --frozen-lockfile", "framework": "vite", "rewrites": [ { "source": "/(.*)", "destination": "/index.html" } ], "headers": [ { "source": "/assets/(.*)", "headers": [ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] } ] } ``` Deploy with the Vercel CLI: ```bash npx vercel --prod ``` > **Note:** Set the **Root Directory** to the repository root so the monorepo workspace resolves correctly. ## Railway [#railway] Create `railway.json` in the project root: ```json { "$schema": "https://railway.com/railway.schema.json", "build": { "builder": "NIXPACKS", "buildCommand": "corepack enable && pnpm install --frozen-lockfile && pnpm build" }, "deploy": { "startCommand": "npx serve apps/console/dist -s -l tcp://0.0.0.0:$PORT", "healthcheckPath": "/", "restartPolicyType": "ON_FAILURE", "restartPolicyMaxRetries": 3 } } ``` Push to your linked Railway project: ```bash railway up ``` ## Netlify [#netlify] Create `netlify.toml` in the project root: ```toml [build] command = "pnpm install --frozen-lockfile && pnpm build" publish = "apps/console/dist" [build.environment] NODE_VERSION = "22" PNPM_VERSION = "10" # SPA fallback — redirect all routes to index.html [[redirects]] from = "/*" to = "/index.html" status = 200 [[headers]] for = "/assets/*" [headers.values] Cache-Control = "public, max-age=31536000, immutable" ``` Deploy with the Netlify CLI: ```bash npx netlify deploy --prod ``` ## Environment Variables [#environment-variables] ObjectUI uses Vite's `import.meta.env` for build-time configuration. Prefix all custom variables with `VITE_`. | Variable | Default | Description | | ----------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `VITE_SERVER_URL` | `""` (same origin) | Absolute origin of the ObjectStack backend, e.g. `https://demo.objectstack.ai`. The one setting that matters — the data adapter, auth, i18n and action endpoints all hang off it. An empty value means same-origin, which is correct when the ObjectStack server serves the console itself; on a static host with no backend behind it, every `/api/v1/*` request then 404s. | | `NODE_ENV` | `"development"` | Set automatically to `"production"` by `vite build`. | Because Vite inlines `import.meta.env` at **build time**, `VITE_SERVER_URL` has to be present when the build runs. Setting it only in a static host's runtime environment changes nothing — the value is already baked into the bundle. Create a `.env.production` file for production defaults: ```bash # Leave empty for same-origin; set an absolute origin for a split-origin deploy. VITE_SERVER_URL=https://demo.objectstack.ai ``` For platform-specific configuration, set environment variables in each platform's dashboard or CLI: ```bash # Vercel vercel env add VITE_SERVER_URL production # Railway railway variables set VITE_SERVER_URL=https://demo.objectstack.ai # Netlify netlify env:set VITE_SERVER_URL https://demo.objectstack.ai ``` > **Tip:** A split-origin deployment (console and backend on different origins) needs two things from the backend: CORS for the SPA origin (`Access-Control-Allow-Origin: `, `Access-Control-Allow-Credentials: true`), and auth cookies marked `SameSite=None; Secure` so they survive cross-site requests. ## Build Optimization [#build-optimization] ### Gzip and Brotli Compression [#gzip-and-brotli-compression] Add the `vite-plugin-compression` plugin for pre-compressed assets: ```bash pnpm add -D vite-plugin-compression ``` ```ts // vite.config.ts import compression from 'vite-plugin-compression'; export default defineConfig({ plugins: [ react(), tailwindcss(), compression({ algorithm: 'gzip' }), // .gz files compression({ algorithm: 'brotliCompress', ext: '.br' }), // .br files ], }); ``` ### Code Splitting [#code-splitting] Vite splits chunks automatically. For ObjectUI plugins, use dynamic imports to keep the initial bundle small: ```tsx import { createLazyPlugin } from '@object-ui/react'; const ObjectGrid = createLazyPlugin( // The plugin package has no default export — name the component you want. async () => ({ default: (await import('@object-ui/plugin-grid')).ObjectGrid }), { fallback:
Loading grid...
} ); ``` ### Bundle Analysis [#bundle-analysis] Visualize your bundle to find optimization opportunities: ```bash pnpm add -D rollup-plugin-visualizer ``` ```ts // vite.config.ts import { visualizer } from 'rollup-plugin-visualizer'; export default defineConfig({ plugins: [ react(), tailwindcss(), visualizer({ open: true, gzipSize: true }), ], }); ``` ### Production Build Command [#production-build-command] Bake the backend origin into the bundle and verify the output: ```bash VITE_SERVER_URL=https://demo.objectstack.ai pnpm build ``` `pnpm build` runs `turbo run build` across the workspace. Turbo runs in strict env mode, but it detects the console as a Vite package and passes `VITE_*` through automatically, so an inline `VITE_SERVER_URL` does reach the build. To build the console alone, use `pnpm build:console`. ## Health Checks [#health-checks] For containerized deployments, add a lightweight health check endpoint. Create `public/health.json` in your app: ```json { "status": "ok" } ``` This file is copied to the build output as-is by Vite. Point your health check to `/health.json`: ```dockerfile # Docker HEALTHCHECK HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ CMD wget -qO- http://localhost:80/health.json || exit 1 ``` ```json // railway.json (excerpt) { "deploy": { "healthcheckPath": "/health.json" } } ``` For platforms that expect an HTTP 200 on `/`, the SPA `index.html` fallback already handles this. ## Next Steps [#next-steps] * [CI/CD Pipeline](/docs/guide/ci-cd-pipeline) — Understand the automated build and release workflows * [Architecture Overview](/docs/guide/architecture) — How ObjectUI packages fit together * [Quick Start](/docs/guide/quick-start) — Set up a new ObjectUI project from scratch * [Theming](/docs/guide/theming) — Customize the look-and-feel before deploying # Designing App Navigation # Designing App Navigation [#designing-app-navigation] When you compose an app, the same requirement can often be expressed three ways: a plain object menu entry, an object entry pinned to a named view, or a custom page that embeds views. They are **not** interchangeable — this guide gives you the decision rules. (The canonical, agent-facing version lives in `skills/objectui/guides/app-composition.md`; keep the two in sync.) ## The Three Ways to Reach a List [#the-three-ways-to-reach-a-list] Say your app has a `project` object: | You write | User lands on | You get for free | | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `{ "type": "object", "objectName": "project" }` | `/apps/my_app/project` — the object's **default view** | The full object shell: view switcher, object actions, a create button, record detail routing, search and recent-items integration | | `{ "type": "object", "objectName": "project", "viewName": "project.by_status" }` | `/apps/my_app/project/view/project.by_status` | The same shell, with the entry **anchored** to a named view — users can still switch | | `{ "type": "object", "objectName": "project", "filters": { "status": "open" } }` | `/apps/my_app/project/data?filter[status]=open` — the **bare data surface** | URL-defined conditions over everything permissions allow, bound to **no saved view**. Conditions show as removable chips; the full filter/sort/group toolbar is available; "Save as view" turns the slice into a named view | | `{ "type": "page", "pageName": "project_overview" }` | `/apps/my_app/page/project_overview` | Nothing but your page schema. View switching, actions, and record links must be assembled by hand | The key asymmetry: a page can imitate the other two, but it **loses the object shell** — and every future improvement to that shell (new actions, better view switching, permission trimming) will skip your page. > **Creating a view at runtime ("Add View" / "Save as view").** Both entry > points stage the new view as a per-item **draft** (ADR-0034) — invisible to > other users until you Publish. Its identity is the canonical qualified name > `.` (e.g. `project.by_status`), used as the metadata row key, > the `body.name`, and the ViewTabBar tab id alike, so the draft → preview → > publish loop resolves to a single row. After creating, the console navigates > you to the new view in **draft-preview mode** (`?preview=draft`) so you can > verify it and Publish from the DraftPreviewBar in one click. The create > dialog asks for a display label **and** a machine key; the key auto-fills > from the label for Latin text, and must be typed for non-Latin (CJK, …) > labels rather than falling back to a random name. ## The Rule of Least Power [#the-rule-of-least-power] Use the least powerful construct that expresses the requirement: 1. **Default: one plain `object` entry per core business object.** No `viewName`. The default view is defined by view metadata, so the navigation layer stays decoupled from presentation. 2. **Add `viewName` when the menu item is a named slice.** If the label is a *perspective* — "By Status", "Due This Week", "My Tasks" — create a named view (convention: `.`, e.g. `project.by_status`) and anchor the entry to it. `viewName` sets the entry point; it does not lock the user in. 3. **Use `filters` for one-off or parameterized slices.** A dashboard drill-through, a shared link, "records assigned to me" — put the condition in the URL (`filters: { "owner_id": "{current_user_id}" }`) and let it land on the bare data surface instead of authoring a view. Promote the slice to a named view only when it's curated and reused. Note the surface is not a security feature: it shows exactly what row-level permissions already allow. 4. **Create a page only for composition a single object view cannot express.** Multiple objects side by side or in tabs, KPI cards mixed with lists, onboarding or static content, parameterized pages. A page that wraps a single object's single view is an anti-pattern. 5. **Use `dashboard` for metric/chart aggregation and `report` for tabular analysis** — don't rebuild them as pages of chart blocks. 6. **One entry per target.** Don't offer the same object through both a plain entry and a page that wraps its default view. If one object needs several menu entries, make all of them named-view entries — mixing styles breaks active-state highlighting in the sidebar. As a rule of thumb, in a typical business app about 80% of navigation entries should be `object` entries (with or without `viewName`); pages are for the home screen, onboarding, and cross-object workbenches. ## Write Spec-Shaped Items [#write-spec-shaped-items] Navigation is a discriminated union on `type`. Each type has its own target field — there is no generic `path` and no `kind`: ```json { "navigation": [ { "id": "nav_projects", "type": "object", "objectName": "project", "label": "Projects" }, { "id": "nav_by_status", "type": "object", "objectName": "project", "viewName": "project.by_status", "label": "By Status" }, { "id": "nav_my_open", "type": "object", "objectName": "project", "filters": { "owner_id": "{current_user_id}", "status": "open" }, "label": "My Open Projects" }, { "id": "nav_kpis", "type": "dashboard", "dashboardName": "company_kpis", "label": "KPIs" }, { "id": "nav_workbench", "type": "page", "pageName": "cross_object_workbench", "label": "Workbench" }, { "id": "nav_docs", "type": "url", "url": "https://docs.example.com", "target": "_blank", "label": "Docs" } ] } ``` Requirements: * `id` (snake\_case), `type`, and `label` are mandatory on every item. * The target field must match the type: `objectName`, `pageName`, `dashboardName`, `reportName`, or `url`. Keys like `path` or `kind` are ignored at runtime and rejected at save. * Put items under the `navigation` key. `menu` is deprecated legacy and only kept for backward compatibility. Object entries also support record deep-links — `recordId` (with template variables like `{current_user_id}`) opens a specific record, which is how "My Profile"-style entries are built. ## Quick Checklist [#quick-checklist] Before publishing an app, scan the navigation for: * Pages that merely wrap a single object view → replace with an object entry. * Items carrying `path`/`kind` → rewrite as typed items. * The same object reachable twice (plain entry + wrapper page) → keep one. * View names not following `.`, ids not snake\_case → rename. # Expression System Object UI includes a powerful expression system that enables dynamic, data-driven UIs. Expressions allow you to reference data, compute values, and create conditional logic directly in your JSON schemas. ## Overview [#overview] Expressions are JavaScript-like code snippets embedded in schemas using the `${}` syntax: ```json { "type": "text", "content": "Hello, ${user.name}!" } ``` With data: ```tsx const data = { user: { name: "Alice" } } ``` This renders: **"Hello, Alice!"** ## Basic Syntax [#basic-syntax] ### Simple Property Access [#simple-property-access] Access data properties using dot notation: ```json { "type": "text", "content": "${user.firstName}" } ``` ### Nested Properties [#nested-properties] Access nested objects: ```json { "type": "text", "content": "${user.address.city}" } ``` ### Array Access [#array-access] Access array elements: ```json { "type": "text", "content": "${users[0].name}" } ``` ### String Interpolation [#string-interpolation] Mix expressions with static text: ```json { "type": "text", "content": "Welcome, ${user.firstName} ${user.lastName}!" } ``` ## Operators [#operators] ### Arithmetic Operators [#arithmetic-operators] ```json { "type": "text", "content": "Total: ${price * quantity}" } ``` Supported: `+`, `-`, `*`, `/`, `%` ### Comparison Operators [#comparison-operators] ```json { "type": "text", "content": "${score >= 90 ? 'Top grade' : 'Keep going'}" } ``` Supported: `>`, `<`, `>=`, `<=`, `==`, `===`, `!=`, `!==` ### Logical Operators [#logical-operators] ```json { "type": "button", "visibleOn": "${user.isAdmin && user.isActive}" } ``` Supported: `&&`, `||`, `!` ### Ternary Operator [#ternary-operator] ```json { "type": "text", "content": "${count > 0 ? count + ' items' : 'No items'}" } ``` ## Conditional Properties [#conditional-properties] ### visibleOn [#visibleon] Show component when expression is true: ```json { "type": "button", "label": "Admin Panel", "visibleOn": "${user.role === 'admin'}" } ``` ### hiddenOn [#hiddenon] Hide component when expression is true: ```json { "type": "section", "hiddenOn": "${user.settings.hideSection}" } ``` ### disabledOn [#disabledon] Disable component when expression is true: ```json { "type": "button", "label": "Submit", "disabledOn": "${form.submitting || !form.isValid}" } ``` ## Data Context [#data-context] ### Accessing Root Data [#accessing-root-data] The root data object is available directly: ```tsx const data = { user: { name: "Alice" }, settings: { theme: "dark" } } ``` ```json { "type": "text", "content": "Theme: ${settings.theme}" } ``` ### Scoped Data [#scoped-data] Some components provide scoped data: ```json { "type": "list", "items": "${users}", "itemTemplate": { "type": "card", "title": "${item.name}", // 'item' is scoped data "description": "${item.email}" } } ``` ### Index in Loops [#index-in-loops] Access the current index in loops: ```json { "type": "list", "items": "${users}", "itemTemplate": { "type": "text", "content": "#${index + 1}: ${item.name}" } } ``` ## Built-in Functions [#built-in-functions] ### String Functions [#string-functions] ```json { "type": "text", "content": "${user.name.toUpperCase()}" } ``` Available: * `toUpperCase()`, `toLowerCase()` * `trim()`, `trimStart()`, `trimEnd()` * `substring(start, end)` * `replace(search, replace)` * `split(separator)` * `includes(substring)` * `startsWith(prefix)`, `endsWith(suffix)` ### Array Functions [#array-functions] ```json { "type": "text", "content": "Total users: ${users.length}" } ``` ```json { "type": "text", "content": "${users.map(u => u.name).join(', ')}" } ``` Available: * `length` * `map(fn)`, `filter(fn)`, `reduce(fn, initial)` * `join(separator)` * `slice(start, end)` * `includes(item)` * `find(fn)`, `findIndex(fn)` * `some(fn)`, `every(fn)` ### Number Functions [#number-functions] ```json { "type": "text", "content": "Price: ${price.toFixed(2)}" } ``` Available: * `toFixed(decimals)` * `toPrecision(digits)` * `toString()` ### Math Functions [#math-functions] ```json { "type": "text", "content": "${Math.round(average)}" } ``` Available: All standard `Math` functions * `Math.round()`, `Math.floor()`, `Math.ceil()` * `Math.min()`, `Math.max()` * `Math.abs()` * `Math.random()` ### Date Functions [#date-functions] ```json { "type": "text", "content": "${new Date().toLocaleDateString()}" } ``` ## Complex Expressions [#complex-expressions] ### Nested Ternary [#nested-ternary] ```json { "type": "text", "content": "${ status === 'active' ? 'Active' : status === 'pending' ? 'Pending review' : status === 'error' ? 'Failed' : 'Unknown' }" } ``` ### Combining Operators [#combining-operators] ```json { "type": "alert", "visibleOn": "${ (user.role === 'admin' || user.role === 'moderator') && user.isActive && !user.isSuspended }" } ``` ### Array Methods [#array-methods] ```json { "type": "text", "content": "${ users .filter(u => u.isActive) .map(u => u.name) .join(', ') }" } ``` ## Practical Examples [#practical-examples] ### User Greeting [#user-greeting] ```json { "type": "text", "content": "${ new Date().getHours() < 12 ? 'Good morning' : new Date().getHours() < 18 ? 'Good afternoon' : 'Good evening' }, ${user.firstName}!" } ``` ### Status Badge [#status-badge] `badge` has no row in the expression carriage map, so its `label` and `variant` are read off the node exactly as written — a `${…}` in either reaches the screen as those characters. Resolve both in the data you hand the renderer and author the node with the resolved values; the condition keys are evaluated on every type and stay expressions: ```json { "type": "badge", "label": "Completed", "variant": "secondary", "visibleOn": "${status !== 'draft'}" } ``` Two neighbouring traps: `text` is not a `badge` key at all — the badge's text is `label` — and `variant` is a closed set: `default`, `secondary`, `destructive`, `outline`. ### Price Formatting [#price-formatting] ```json { "type": "text", "content": "$${(price * quantity).toFixed(2)}" } ``` ### Empty State [#empty-state] ```json { "type": "empty", "visibleOn": "${items.length === 0}", "message": "No items to display", "description": "Start by adding your first item" } ``` ### Percentage Bar [#percentage-bar] `progress` has no row in the expression carriage map, so its `value` and `label` are read off the node exactly as written — a `${…}` in either reaches the screen as those characters. Compute the percentage in the data you hand the renderer; the condition keys are evaluated on every type and stay expressions: ```json { "type": "progress", "value": 75, "label": "75% complete", "visibleOn": "${total > 0}" } ``` ### Conditional Styling [#conditional-styling] `className` has no row in the carriage map either — on `card` or on any other type — so an expression written there lands in the rendered `class` attribute as its own source text. Resolve the class list in the data you hand the renderer, or author each variant and gate it with a condition key: ```json { "type": "card", "title": "${task.name}", "className": "border-red-500 border-2", "visibleOn": "${task.isPriority}" } ``` ## Form Expressions [#form-expressions] ### Dependent Fields [#dependent-fields] ```json { "type": "form", "body": [ { "type": "select", "name": "country", "label": "Country", "options": ["USA", "Canada", "Mexico"] }, { "type": "select", "name": "state", "label": "State/Province", "visibleOn": "${form.country === 'USA'}", "options": ["CA", "NY", "TX"] } ] } ``` ### Dynamic Validation [#dynamic-validation] ```json { "type": "input", "name": "email", "label": "Email", "required": true, "validations": { "isEmail": true, "errorMessage": "Please enter a valid email" } } ``` ### Computed Fields [#computed-fields] `input` has no row in the expression carriage map either, so a computed total cannot be carried by its `value`. Show it with a `text` node, whose `content` is evaluated on every component type: ```json { "type": "text", "content": "Total: ${form.price * form.quantity}" } ``` ## Performance Considerations [#performance-considerations] ### Expensive Computations [#expensive-computations] Expressions are re-evaluated when data changes. Avoid expensive operations: ```json // ❌ Bad: Complex computation in expression { "type": "text", "content": "${users.map(u => expensiveOperation(u)).join(', ')}" } // ✅ Good: Pre-compute in data ``` ```tsx const data = { processedUsers: users.map(u => expensiveOperation(u)) } ``` ### Caching [#caching] The expression engine automatically caches results when data doesn't change. ## Security [#security] ### Sandboxed Execution [#sandboxed-execution] Expressions run in a sandboxed environment and can only access: * The data context you provide * Built-in JavaScript functions (Math, Date, String, Array methods) They **cannot** access: * Browser APIs (window, document, localStorage) * Node.js APIs (fs, path, etc.) * Global variables * Function constructors ### Sanitization [#sanitization] All expression outputs are automatically sanitized to prevent XSS attacks. ## Debugging Expressions [#debugging-expressions] ### Expression Errors [#expression-errors] Invalid expressions show helpful error messages: ```json { "type": "text", "content": "${user.invalidProperty}" } ``` Error: "Cannot read property 'invalidProperty' of undefined" ### Debug Mode [#debug-mode] Enable debug mode to see expression evaluation: ```tsx ``` This logs all expression evaluations to the console. ## Advanced Usage [#advanced-usage] ### Custom Functions [#custom-functions] There is no global evaluator to extend: `SchemaRenderer` builds a fresh `ExpressionEvaluator` for each evaluation, so a function has to reach it through the evaluation context. Anything callable you put in the context is callable in an expression, under exactly the name you gave it: ```tsx import { evaluateExpression } from '@object-ui/core' const formatCurrency = (value: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value) // => '$1,234.50' evaluateExpression('${formatCurrency(price)}', { formatCurrency, price: 1234.5 }) ``` That is the direct-evaluation path. A component expression rendered by `SchemaRenderer` resolves against the scope the renderer itself builds — the provider's data source (as `data`), the host scope (`user` / `current_user`) and page variables — so a function you registered elsewhere is not reachable from a schema expression. Compute the value before it reaches the schema, and bind the result. Hold an evaluator when you want one context reused — construct it, then call `evaluate`: ```tsx import { ExpressionEvaluator } from '@object-ui/core' const evaluator = new ExpressionEvaluator({ user: { role: 'admin' } }) evaluator.evaluate('${user.role === "admin"}') ``` ### Custom Operators [#custom-operators] Expressions are JavaScript, evaluated against the context — operators are the language's own. A membership test is written with the array method: ```json { "type": "button", "visibleOn": "${user.permissions.includes('admin')}" } ``` ## Best Practices [#best-practices] ### 1. Keep Expressions Simple [#1-keep-expressions-simple] ```json // ❌ Bad: Too complex { "content": "${users.filter(u => u.age > 18).map(u => ({...u, isAdult: true})).reduce((acc, u) => acc + u.score, 0)}" } // ✅ Good: Pre-compute complex logic { "content": "${adultUsersScore}" } ``` ### 2. Use Meaningful Variable Names [#2-use-meaningful-variable-names] ```json // ❌ Bad { "visibleOn": "${x && y || z}" } // ✅ Good { "visibleOn": "${isAdmin && isActive || isSuperUser}" } ``` ### 3. Handle Null/Undefined [#3-handle-nullundefined] ```json // ❌ Bad: Might throw error { "content": "${user.address.city}" } // ✅ Good: Safe access { "content": "${user.address?.city || 'N/A'}" } ``` ### 4. Use TypeScript [#4-use-typescript] Define your data types: ```tsx interface UserData { user: { name: string role: 'admin' | 'user' isActive: boolean } } const data: UserData = { /* ... */ } ``` ## Next Steps [#next-steps] * [Schema Rendering](./schema-rendering.md) - Learn the rendering engine * [Component Registry](./component-registry.md) - Understand components * [Schema Overview](/docs/guide/schema-overview) - Explore schema specifications ## Related Documentation [#related-documentation] * [`@object-ui/core` README](https://github.com/objectstack-ai/objectui/tree/main/packages/core) - Expression evaluator API * [Form Plugin](/docs/plugins/plugin-form) - Form-specific expressions * [View Plugin](/docs/plugins/plugin-view) - Data view expressions # Field Registry Object UI uses a **Field Registry** system to decouple the core engine from specific UI implementations of fields. This allows for rich extensibility and plugin support. ## Concept [#concept] The `@object-ui/fields` package serves as the "Universal Language" for rendering values. When a component like `` needs to render a `date` field, it doesn't import a DatePicker directly. Instead, it asks the registry: > *"Hey, give me the component responsible for rendering type 'date'."* This architecture allows you to: 1. **Override standard fields** (e.g. replace the native date picker with a fancy one). 2. **Add new field types** (e.g. add a `rating` or `signature` field). 3. **Keep bundles small** (heavy components like Code Editors are loaded only if their plugin is registered). ## Usage [#usage] ### 1. Registering a Custom Field [#1-registering-a-custom-field] You can register a custom renderer globally, typically at your app's entry point. ```tsx // src/setup.tsx import { registerFieldRenderer, type CellRendererProps } from '@object-ui/fields'; const MyRatingField = ({ value, onChange }: CellRendererProps) => { return (
{[1, 2, 3, 4, 5].map(star => ( onChange?.(star)} style={{ color: star <= value ? 'gold' : 'grey' }} > ★ ))}
); }; // Register it registerFieldRenderer('rating', MyRatingField); ``` ### 2. Using in Schema [#2-using-in-schema] Once registered, you can simply use the new type in your JSON schema. ```json { "type": "form", "fields": [ { "name": "customer_satisfaction", "type": "rating", "label": "Satisfaction" } ] } ``` ## Standard Fields [#standard-fields] Object UI comes with built-in support for the standard [ObjectStack Protocol](https://github.com/objectstack-ai/objectstack/tree/main/packages/spec) types: | Type | Description | | --------------- | ------------------------------------------------------------------------------------ | | `text` | Single line text | | `textarea` | Multi-line text | | `number` | Numeric input | | `currency` | Currency formatting | | `percent` | Percentage values | | `date` | Date picker | | `datetime` | Date & Time picker | | `boolean` | Checkbox / Switch | | `select` | Dropdown | | `lookup` | Reference to another object | | `master_detail` | Parent-child relationship | | `user` | Person picker — searches the `sys_user` object (a lookup specialized to users) | | `owner` | Record owner — a `user` field, typically read-only and stamped with the current user | ## What a number field silently rewrites [#what-a-number-field-silently-rewrites] `number`, `currency`, `percent` and `geolocation` render a native `type="number"` input. The browser — not ObjectUI — decides what that box will accept, and it rewrites some entries **before any widget code runs**. Two different things can happen, and only one of them is announced. ### Announced: text the browser cannot read [#announced-text-the-browser-cannot-read] If the box is left holding something that is not a complete number, the browser reports `validity.badInput` and these widgets now say so: the control is marked `aria-invalid="true"` and a message is drawn under it — > Not saved: the text in this box is not a number. Enter a plain decimal (example: 1234.56). Measured in Chromium 141, typing any of `1e`, `1e-`, `1e+`, `5e`, `-`, `.`, `+`, `-.` or `e` leaves the box **visibly displaying** what was typed while its value reads empty. Before this was announced, the field simply stored nothing and said nothing. ### ⚠️ NOT announced: entries the browser silently truncates [#️-not-announced-entries-the-browser-silently-truncates] This is the important limitation, and it is deliberate rather than an oversight. | you paste / type | the field stores | | ---------------- | ---------------- | | `1.2.3` | `1.23` | | `0x10` | `10` | | `12abc` | `12` | **No warning is shown for these, and no widget-side check can add one.** The browser filters the keystrokes or the pasted text as it arrives, so by the time ObjectUI sees the field the discarded characters are already gone — there is nothing left to detect. This is native `type="number"` behaviour; recovering it would mean giving up the numeric keyboard on mobile and the `min`/`max`/`step` spinner on every numeric field in the product. ⛔ **So do not read "no warning" as "the value is correct."** A warning means the browser could not read the box at all. Silence means the browser read *something* — which may be less than you typed. When exact input matters (reference codes, serial numbers, anything where `1.2.3` is meaningful), declare a `text` field, not a numeric one. ## Using Renderers in Custom Components [#using-renderers-in-custom-components] If you are building your own custom component (like a Kanban board card), you can leverage the registry to render fields without reinventing the wheel. ```tsx import { getCellRenderer } from '@object-ui/fields'; export const KanbanCard = ({ task }: { task: { name: string; assignee: string } }) => { // Get the standard renderer for a 'user' type field const UserRenderer = getCellRenderer('user'); return (

{task.name}

); }; ``` # Flow Designer # Flow Designer [#flow-designer] The **Flow Designer** is the visual editor for `flow` metadata in the console's metadata admin. It renders a flow's nodes and edges on a pan/zoom canvas so you can assemble automation — create/update records, branch on conditions, wait for events, call APIs, route for approval — without hand-editing JSON. The designer is a thin, **spec-driven** view over `flow` metadata: everything you drop on the canvas is a node in the flow's schema, so the same document runs unchanged on the automation engine. Open it from a `flow` record's **Design** tab. The **Form** tab holds the same flow as an editable tree; the two stay in sync. ## The canvas [#the-canvas] Each node is a card showing its icon, label, `type`, and a one-line summary of its config. Edges are the arrows between them. | Gesture | Result | | ------------------------------------------ | ------------------------------------ | | Click a card | Select it (opens the node inspector) | | Drag a card | Reposition it | | Hover a card → click the bottom **+** | Append a connected child node | | Zoom controls / **Fit** | Scale and re-center the graph | Structural problems (an undeclared cycle, an unreachable node) surface three ways at once: a red ring on the offending card, a badge in its corner, and an inline banner at the top-left of the canvas. Clicking a banner row selects and pans to the element it refers to. ## Adding nodes — the palette [#adding-nodes--the-palette] The **Add node** button (top-right, edit mode only) opens the node palette: a searchable, grouped list of every node type the flow can use. ### Search and keyboard [#search-and-keyboard] Type in the box at the top to filter across **all** categories at once — the match is a case-insensitive substring test over each node's **label**, **hint**, and **type**, so `scr` finds *Screen*, `http` finds *HTTP request*, and a word that only appears in a hint (`concurrently` → *Parallel*) still surfaces the node. Clearing the box restores the full grouped list. The palette is keyboard-navigable end to end: | Key | Action | | --------- | --------------------------------- | | `↑` / `↓` | Move the highlight (wraps around) | | `Enter` | Insert the highlighted node | | `Esc` | Close the palette | The search box autofocuses when the palette opens, so you can open, type a few letters, and press `Enter` without touching the mouse. ### Categories [#categories] Nodes are grouped into five sections, in this order: | Category | Node types | | --------------- | ---------------------------------------------------- | | **Data** | Create / Update / Get / Delete record | | **Logic** | Decision, Loop, Set variables, Parallel, Try / Catch | | **Human** | Approval, Screen | | **Integration** | HTTP request, Connector, Script | | **Flow** | Subflow, Wait, End | Section headings are localized to the active console language (e.g. *数据 / 逻辑 / 人工 / 集成 / 流程* in Chinese); the underlying node types are unchanged. ### Recently used [#recently-used] When the search box is empty, a **Recently used** group tops the list with the node types you inserted most recently (up to five, most-recent first) — so the nodes you reach for repeatedly stop needing a scroll or a search. The list is per-user and, when the console is connected to a backend, syncs across devices (see [User-Scoped State Persistence](/docs/guide/user-state-persistence)); it falls back to browser-local storage when offline. Types from a plugin that was since uninstalled drop out of the list automatically. ### Server-merged, plugin-extensible [#server-merged-plugin-extensible] The palette is **server-driven**. Beyond the built-in node types, the running engine publishes its registered actions at `GET /api/v1/automation/actions`, and plugins contribute their own node types there (for example an `approval` node from an approvals plugin, or third-party `connector_action` providers). The designer overlays those descriptors onto the built-in list — adopting the engine's labels and descriptions and appending engine-only types — so the palette always matches what the connected backend actually supports. Plugin nodes are searchable exactly like built-ins, including by their registered `type`. When the endpoint is unreachable the designer falls back to its built-in defaults, so authoring still works offline. ## The node inspector [#the-node-inspector] Selecting a node opens its inspector: **ID**, **Label**, **Node Type**, an optional **Description**, and a **Configuration** section. New nodes start with spec-valid defaults (a *Wait* node already carries a timer config, an *HTTP* node defaults to `GET`) so a freshly dropped block is never in a broken intermediate state. For node types whose engine executor publishes a `configSchema` (ADR-0018), the inspector renders a **server-driven property form** from that schema — so a plugin's node gets a real config UI without the designer hardcoding its fields. The mapping covers scalars, enums (→ select), typed references (→ picker), free-form maps (→ key/value), and array/object shapes: an array of objects becomes a **repeater**, and a repeater column that is itself an array (of strings, numbers, or objects) becomes a **nested repeater** — so an engine-published nested-array config is editable inline rather than dropping to the Advanced JSON block. A **Decision** node's Branches editor defines each branch's label, CEL expression, **and target node** in one table: the **Target** column picks the downstream node, wiring (creating, retargeting, or detaching) the branch's outgoing edge with its condition, label, and default flag. The same binding can also be edited from the edge side — select a connector and use its **Branch** picker — and the two stay in sync, because the routing always lives on the edges. ## Validate, simulate, inspect runs [#validate-simulate-inspect-runs] The toolbar toggles four side panels: | Panel | What it shows | | ------------- | ------------------------------------------------------------------------------------------- | | **Variables** | The flow's declared variables | | **Problems** | Structural + server validation issues, each clickable to reveal on canvas | | **Debug** | A step-through **simulator** that walks the graph and highlights the active / visited nodes | | **Runs** | Execution history for the published flow, fetched from the engine | ## See also [#see-also] * [User-Scoped State Persistence](/docs/guide/user-state-persistence) — how the "Recently used" list is stored and synced. * [Console App](/docs/guide/console) — the reference app that hosts the metadata admin and its designers. # Guide # ObjectUI Guide [#objectui-guide] Welcome to the ObjectUI Guide! This comprehensive guide covers everything you need to know to build powerful server-driven UIs with ObjectUI. # Layout System # Layout System [#layout-system] ObjectUI provides a comprehensive layout system through the `@object-ui/layout` package. This guide explains how to use layout components to build professional application structures. ## Overview [#overview] The layout system provides: * **AppShell** - Full application container with a top navbar, sidebar, and content areas * **Page** - Individual page wrapper with header and body * **PageHeader** - Consistent page headers with title, breadcrumbs, and actions * **SidebarNav** - Navigation sidebar with menu items ## Installation [#installation] The layout package is included in the core ObjectUI installation: ```bash npm install @object-ui/react ``` Layout components are automatically registered when you import ObjectUI. ## AppShell Component [#appshell-component] The `AppShell` provides a complete application structure with a top navbar, a sidebar, and a main content area. **`AppShell` is a React component, not an authorable JSON node.** Four of its seven props are `React.ReactNode` slots — `sidebar`, `navbar`, `children` and `rightRail` — and a JSON document has no way to put a node into any of them. Compose the shell in React, and render your JSON pages *inside* it. `app-shell` is not a component key either — what a `{ "type": "app-shell" }` node does now is measured under [There is no `app-shell` node](#there-is-no-app-shell-node) below. ### Basic Usage [#basic-usage] ```tsx import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; import { SchemaRenderer } from '@object-ui/react'; import { LayoutDashboard, Settings, Users } from 'lucide-react'; const navItems: NavItem[] = [ { title: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, { title: 'Users', href: '/users', icon: Users }, { title: 'Settings', href: '/settings', icon: Settings }, ]; My Application} sidebar={} > ``` Top-bar content goes in `navbar`. `AppShell` renders the sticky `
` element itself and `{navbar}` is the only thing that fills it — there is no `header` prop. Main content is the component's `children`; there is no `body` prop either. ### Props [#props] `AppShellProps` (`packages/layout/src/AppShell.tsx`) declares exactly these seven. The component destructures that fixed key list with **no rest element**, so anything else you pass is built and then dropped on the floor. These are React props, passed in JSX. None of them is authorable in JSON — there is no `app-shell` node to write them on. | Prop | Type | Required | Description | | ------------- | ------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sidebar` | `React.ReactNode` | no | Left sidebar node, a flex sibling of the content. Pass `SidebarNav`, or your own node. | | `navbar` | `React.ReactNode` | no | Top-bar content. `AppShell` supplies the sticky `
` around it, so pass only what goes inside. | | `children` | `React.ReactNode` | yes | Main content, rendered inside the `
` element. | | `className` | `string` | no | Tailwind overrides for the `
` content element — **not** for the outer container. | | `defaultOpen` | `boolean` | no | Initial open state of the underlying Shadcn `SidebarProvider`. Defaults to `true`. | | `branding` | `AppShellBranding` | no | App branding, applied by `useAppShellBranding`: `primaryColor` / `accentColor` become CSS custom properties on the document root, `favicon` sets the icon link's `href`, and `title` sets `document.title`. | | `rightRail` | `React.ReactNode` | no | Optional right-side rail. It reflows the content beside it rather than overlaying it; absent → unchanged single-pane layout. | ### There is no `app-shell` node [#there-is-no-app-shell-node] `app-shell` is not a component key. `registerLayout()` (`packages/layout/src/index.ts`) does not register it, and nothing else in this repo does either, so a `{ "type": "app-shell" }` node resolves to nothing and says so. Measured on this tree: * `SchemaRenderer` replaces the node with its error panel — `Unknown component type: app-shell`, error code `OBJUI-001`. * `sdui-parser` reports it before render, as an `error`-severity diagnostic with code `unknown-component` and the message ` is not a known component`. It **was** registered until objectui#4841, and the registration could never produce a shell. Four of the seven props are `React.ReactNode` slots that a JSON document cannot fill, so a node had exactly two outcomes: `children` was stripped by `SchemaRenderer` before a node's keys were spread as props — `AppShell` reads its `children` prop, never `schema.children` — so the `
` element rendered **empty with nothing logged**; and a schema written into `sidebar` / `navbar` / `rightRail` arrived as a plain object, which React refuses to render, replacing the node with an error box. Only `className`, `defaultOpen` and `branding` ever survived the JSON path, i.e. the best result JSON could reach was a shell with no navigation, no top bar and an empty content area. The key was retired under ADR-0049 (enforce-or-remove) so that this comes out as a named refusal rather than a page that renders nothing. Two doors remain, one per capability: * **Compose in React** — `` as shown above, with your JSON pages rendered *inside* it through `SchemaRenderer`. * **The whole shell from metadata** — `AppSchemaRenderer`, registered as `app-schema-renderer` and declaring its `inputs`, which builds branding and sidebar navigation from an `AppSchema` JSON document and takes the page content as its `children`. ### Features [#features] * Responsive layout that adapts to mobile/tablet/desktop * Collapsible sidebar with state management * Sticky header * Scroll management for content area * Consistent spacing and structure ## Page Component [#page-component] The `Page` component provides a consistent wrapper for individual pages with optional headers. ### Basic Usage [#basic-usage-1] ```json { "type": "page", "title": "User Management", "description": "Manage users and permissions", "body": { "type": "container", "children": [ { "type": "text", "content": "User list goes here" } ] } } ``` ### With Action Buttons [#with-action-buttons] ```json { "type": "page", "title": "Products", "actions": [ { "type": "button", "label": "Add Product", "variant": "default", "icon": "plus" }, { "type": "button", "label": "Export", "variant": "outline", "icon": "download" } ], "body": { "type": "object-grid", "object": "products" } } ``` `label` is the button's text key — `text` is not a `ButtonSchema` key, and because `BaseSchema` is `.passthrough()` nothing refuses it: the validator keeps the unknown key and `button.tsx`, which reads `schema.label`, renders a button with no text. ### Schema API [#schema-api] ```typescript { type: 'page', // Header title?: string, // Page title description?: string, // Page description/subtitle icon?: string, // Optional icon breadcrumbs?: Array<{ // Breadcrumb navigation label: string, href?: string }>, actions?: SchemaNode[], // Action buttons // Content body: SchemaNode, // Main page content // Layout options maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full', padding?: boolean, // Add padding (default: true) // Styling className?: string, headerClassName?: string, bodyClassName?: string } ``` ### Max Width Options [#max-width-options] Control page content width: ```json { "type": "page", "title": "Settings", "maxWidth": "lg", // Centered content with max width "body": { "type": "form", "fields": [...] } } ``` Available values: * `sm` - 640px * `md` - 768px * `lg` - 1024px * `xl` - 1280px * `2xl` - 1536px * `full` - No maximum width (default) ## PageHeader Component [#pageheader-component] The `PageHeader` provides consistent page headers with a title, an optional subtitle, an icon chip, and an action row. > **The canonical author key is `page:header`; `page-header` is a legacy alias.** The > snippets in this section are the `@object-ui/layout` component, which `registerLayout()` > registers as `page-header` (plus its namespaced form `layout:page-header`) — that node > still renders, so metadata already written this way is not stranded. The contract knows > only `page:header`, though: that is the `PageComponentType` value and the > `ComponentPropsMap` row binding `PageHeaderProps`, and it resolves to a different, > record-aware renderer in `@object-ui/components`. Props written under the alias have no > `ComponentPropsMap` row to dispatch, so nothing validates them — a misspelling there is > neither rejected nor reported. Author metadata pages against `page:header` > ([Slotted pages](/docs/guide/slotted-pages)); its props are not the ones below — see the > [PageHeader reference](/docs/layout/page-header). ### Usage [#usage] ```json { "type": "page-header", "title": "Customer Details", "subtitle": "View and edit customer information", "icon": "users", "actions": ["edit", "delete"] } ``` `title` and `subtitle` both interpolate `{field.path}` tokens against the surrounding record context, so `"title": "{first_name} {last_name}"` resolves on a record page. Unresolvable tokens collapse to an empty string rather than leaking the raw template. ### Schema API [#schema-api-1] ```typescript { type: 'page-header', title: string, // required; {field.path} tokens interpolated subtitle?: string, // secondary line; {field.path} tokens interpolated icon?: string, // Lucide icon name, rendered in a chip left of the title actions?: Array, // action ids, or inline ActionDef objects showBack?: boolean, // back arrow; inferred from record context when omitted children?: SchemaNode[], // rendered into the right-aligned slot; `actions` takes precedence className?: string, } ``` `showBack` defaults to `true` when a record context carrying a `recordId` is in scope and the header is not rendered inside embedded chrome (drawer / modal, which already provide their own Close control), and `false` otherwise. Pass it explicitly to override. `actions` is handed to the `record:quick_actions` widget with `location: 'record_header'`. Its entries are **action ids** — resolved from the object's own `actions` metadata, which keeps the definitions in one place — or inline `ActionDef` objects. They are **not** `SchemaNode` nodes: a `{ "type": "button", … }` entry renders nothing here. > **Write `subtitle`. `description` is retired.** `@objectstack/spec/ui`'s > `PageHeaderProps` — the contract for the canonical `page:header` node — declares > `title / subtitle / breadcrumb / actions / recordChrome / showStar / showCopyId / > maxVisible / mobileMaxVisible / aria` and has **no** `description`, and > `page-header`'s registration declares four authorable inputs — `title`, `subtitle`, > `icon` and `actions`. `icon` sits on exactly one of those two lists on purpose: it is > an ADR-0087 D2 tombstone on the spec shape, which rejects it by name — "`page:header` > property `icon` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — no > renderer ever read it … Delete the key." — while remaining a live input of *this* > component, whose `` does draw an icon beside the title (objectui#3829). On > a canonical `page:header` node the key is gone; as a prop of this component it is > live. The renderer used to read `description` as well, as a legacy alias; objectui#3789 > removed that read, so `subtitle` is now the only spelling this component draws. Stored > metadata written the old way is not stranded: protocol 17's ADR-0087 D2 conversion > `page-header-subtitle-alias` rewrites `description` to `subtitle` on header nodes as the > stack loads — at every position a header can occupy, regions and slots and containers > nested to any depth (objectstack#6775 / #6776) — and `os migrate meta` rewrites it at > rest. See the [PageHeader reference](/docs/layout/page-header) for the per-key > reference face. > **There is no `breadcrumbs` array.** The component reads no breadcrumb property of any > kind, in either spelling. The spec's `breadcrumb` is singular and a **boolean** — a > display toggle on the canonical `page:header` node (see > [Slotted pages](/docs/guide/slotted-pages)), not a list of links. ## SidebarNav Component [#sidebarnav-component] The `SidebarNav` provides a collapsible navigation sidebar with menu items. **`SidebarNav` is a React component, and `sidebar-nav` is not a component key at all.** `registerLayout()` (`packages/layout/src/index.ts`) registers five keys — `page-header`, `page:card`, `responsive-grid`, `navigation-renderer` and `app-schema-renderer` — and nothing in this repo registers `sidebar-nav`. What a `{ "type": "sidebar-nav" }` node actually does is measured under [There is no `sidebar-nav` node](#there-is-no-sidebar-nav-node) below. Compose the nav in React, or use `navigation-renderer` when the tree has to come from metadata. ### Basic Usage [#basic-usage-2] `SidebarNav` renders a Shadcn `Sidebar`, so it must be inside a `SidebarProvider` — `AppShell` supplies one. Its rows are `NavLink`s, so it also needs a router above it. ```tsx import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; import { SchemaRenderer } from '@object-ui/react'; import { BarChart3, LayoutDashboard, Users } from 'lucide-react'; const navItems: NavItem[] = [ { title: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, { title: 'Users', href: '/users', icon: Users, badge: '12' }, { title: 'Reports', href: '/reports', icon: BarChart3, children: [ { title: 'Sales', href: '/reports/sales' }, { title: 'Analytics', href: '/reports/analytics' }, ], }, ]; }> ; ``` Three things that block a copy-paste: `icon` is a **component**, not an icon name — `SidebarNav` renders it as ``. Nested rows go in `children`; `items` is a key on `NavGroup`, never on a `NavItem`. And `href` is **required** on every item, including one that only expands children — it is the row's React key as well as its link target. ### Props [#props-1] `SidebarNavProps` (`packages/layout/src/SidebarNav.tsx`) declares exactly these six. | Prop | Type | Required | Description | | ------------------- | --------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- | | `items` | `NavItem[] \| NavGroup[]` | yes | The rows. A flat `NavItem[]`, or `NavGroup[]` for labelled sections — the array's first element decides which. | | `title` | `string` | no | Group label shown above a flat, ungrouped `items` list. Defaults to `"Application"`. | | `className` | `string` | no | Tailwind overrides for the `Sidebar` root. | | `collapsible` | `"offcanvas" \| "icon" \| "none"` | no | How the sidebar collapses at or above 768px. Defaults to `"icon"`. Not a boolean. | | `searchEnabled` | `boolean` | no | Show a search input that filters rows by title, children included. Defaults to `false`. | | `searchPlaceholder` | `string` | no | Placeholder for that input. Defaults to `"Search…"`. | #### `NavItem` [#navitem] | Key | Type | Required | Description | | -------------- | --------------------------------------------- | -------- | ------------------------------------------------------------------------------------- | | `title` | `string` | yes | The row's label. | | `href` | `string` | yes | Link target, and the row's React key. | | `icon` | `React.ComponentType<{ className?: string }>` | no | Rendered as `` — pass the Lucide component, not its name. | | `badge` | `string \| number` | no | Trailing badge content. Rendered whenever it is not `null`/`undefined`, so `0` shows. | | `badgeVariant` | `'default' \| 'destructive' \| 'outline'` | no | Badge styling. Defaults to `'default'`. | | `children` | `NavItem[]` | no | One level of nested rows; the parent becomes a collapsible trigger. | #### `NavGroup` [#navgroup] | Key | Type | Required | Description | | ------- | ----------- | -------- | ------------------------------------------- | | `label` | `string` | yes | Section heading, shown in place of `title`. | | `items` | `NavItem[]` | yes | The section's rows. | There is no `active` key — the active row is derived from the router (`pathname === item.href`), never declared. There is no `disabled` key either, and no `defaultOpen`: that one is `AppShell`'s prop, not this component's. ### There is no `sidebar-nav` node [#there-is-no-sidebar-nav-node] `sidebar-nav` was never registered, so the node does not resolve to anything. Rendering `{ "type": "sidebar-nav", "items": [...] }` produces the renderer's red error box instead of a sidebar: ``` Unknown component type: sidebar-nav 💡 Ensure the component is registered via registry.register() before rendering. Check for typos in the component type name. (OBJUI-001) ``` This is louder than the `app-shell` case above — nothing is silently dropped, because nothing is parsed as props at all. The whole sidebar is replaced by the error panel. When the navigation tree genuinely has to come from JSON, that path exists and is a different component: `navigation-renderer` (`NavigationRenderer`) renders a `NavigationItem[]` tree from AppSchema JSON, and it declares its `inputs`, so an unknown key there is diagnosed rather than ignored. Its items are JSON-shaped — `icon` really is a string name there, resolved by `resolveIcon`. `app-schema-renderer` wraps that up with branding for a whole-shell-from-metadata setup. ### Features [#features-1] * Nested menu items (2 levels) with collapsible expand/collapse * Active state highlighting via React Router * Icon support (Lucide icons) * Badge/counter support with variant styling (`default`, `destructive`, `outline`) * NavGroup support for grouped navigation sections * Built-in search filtering (`searchEnabled`) across all items and children * Collapse/expand animation ## Common Layout Patterns [#common-layout-patterns] ### Full Application Layout [#full-application-layout] The shell is React; the page inside it is your JSON. ```tsx import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; import { SchemaRenderer } from '@object-ui/react'; import { SidebarTrigger } from '@object-ui/components'; import { Home, Package, ShoppingCart } from 'lucide-react'; const navItems: NavItem[] = [ { title: 'Home', href: '/', icon: Home }, { title: 'Products', href: '/products', icon: Package }, { title: 'Orders', href: '/orders', icon: ShoppingCart }, ]; My App } sidebar={} defaultOpen > ``` `AppShell` adds no controls of its own to the top bar — the sidebar toggle above is one you render yourself, in `navbar`. There is no `sidebarCollapsible` prop: the sidebar's collapse behaviour belongs to the sidebar node you pass (`SidebarNav`'s `collapsible`), and `defaultOpen` is the shell's own initial-state prop. ### Landing Page (No Sidebar) [#landing-page-no-sidebar] Omit `sidebar` and the content fills the width under the top bar. ```tsx Welcome}> ``` ### Settings Page with Tabs [#settings-page-with-tabs] ```json { "type": "page", "title": "Settings", "maxWidth": "2xl", "body": { "type": "tabs", "tabs": [ { "label": "General", "value": "general", "content": { "type": "form", "fields": [...] } }, { "label": "Security", "value": "security", "content": { "type": "form", "fields": [...] } }, { "label": "Notifications", "value": "notifications", "content": { "type": "form", "fields": [...] } } ] } } ``` ### Detail Page with Actions [#detail-page-with-actions] ```json { "type": "page", "title": "Acme Corporation", "breadcrumbs": [ { "label": "Home", "href": "/" }, { "label": "Customers", "href": "/customers" }, { "label": "Acme Corporation" } ], "actions": [ { "type": "action:button", "name": "edit_record", "label": "Edit", "variant": "default", "icon": "pencil", "actionType": "editRecord" }, { "type": "action:button", "name": "delete_record", "label": "Delete", "variant": "destructive", "icon": "trash", "actionType": "deleteRecord" } ], "body": { "type": "card", "children": [ { "type": "text", "content": "Record details..." } ] } } ``` A button that RUNS something is an `action:button` node, not a `button` carrying an `onClick`. `ButtonSchema.onClick` is declared as a runtime slot for a host-supplied function and the zod mirror refuses it BY NAME — JSON has no function value, and no handler key consumes a declarative action object. The refusal is not the whole cost: `onClick` is on `SDUI_DOM_PASS_THROUGH_KEYS`, so an authored string or object is forwarded to the real DOM listener slot, and React throws the moment anyone clicks. Measured, React's own error: "Expected `onClick` listener to be a function, instead got a value of `object` type." The handler name goes in `actionType`, which `action:button` forwards to the action runner as the action's type; the runner dispatches to the handler registered under it. Same spelling as [Record Edit Modes](./record-edit-modes.md). ## Responsive Behavior [#responsive-behavior] The shell has exactly **one** layout breakpoint, at **768px** — Tailwind's `md`, and `MOBILE_BREAKPOINT` in `packages/components/src/hooks/use-mobile.tsx`. There is no separate tablet tier: nothing in `AppShell`, `SidebarNav` or the Shadcn sidebar underneath them reads `lg` (1024px), so 800px and 1400px get the same layout. ### Sidebar, at or above 768px [#sidebar-at-or-above-768px] * Rendered inline, as a flex sibling of the content — not an overlay. * How it collapses is the sidebar node's own `collapsible` prop: `"icon"` (the `SidebarNav` default) leaves an icon rail, `"offcanvas"` slides it fully out, `"none"` pins it open. * `AppShell`'s `defaultOpen` picks the initial state, and defaults to `true`. ### Sidebar, below 768px [#sidebar-below-768px] * It leaves the layout entirely and becomes a `Sheet` overlay (18rem) above the content, which is why the content is full-width there. * **Nothing opens it for you.** `AppShell`'s header renders `{navbar}` and nothing else — it adds no controls of its own, in particular **no sidebar toggle**. Render a `SidebarTrigger` inside `navbar` yourself; otherwise the only way in is the `SidebarProvider` keyboard shortcut, `Cmd/Ctrl + B`, which no touch device has. ### Header and content [#header-and-content] * The header is `h-14` (3.5rem / 56px) at **every** breakpoint — there is no compact variant — and spans the full viewport width at every size. The only thing about it that responds is horizontal padding, and it turns at `sm` (640px), not 768: `px-2 sm:px-4`. * Content padding steps three ways — `p-3`, `sm:p-4` (640px), `md:p-6` (768px) — with a taller `pb-20` below `sm` only. ## Styling and Customization [#styling-and-customization] ### Custom Classes [#custom-classes] Add Tailwind classes to layout components: ```tsx My App } sidebar={} > ``` `className` is the only class hook `AppShell` itself takes, and it lands on the `
` content element — not on the outer container. There is **no** per-slot className: `headerClassName`, `sidebarClassName` and `contentClassName` do not exist. The top bar and the sidebar are nodes you build, so style them where you build them, as above. ### Page Padding [#page-padding] Control page content padding: ```json { "type": "page", "padding": false, // Remove default padding "body": { "type": "container", "className": "p-8", // Custom padding "children": [...] } } ``` ## Best Practices [#best-practices] ### 1. Consistent Structure [#1-consistent-structure] Compose the shell once and let the page JSON change per route: ```tsx // One shell for the whole app; `pageSchema` is whatever the route resolves to. }> ``` ### 2. Breadcrumbs for Deep Navigation [#2-breadcrumbs-for-deep-navigation] Add breadcrumbs to help users navigate: ```json { "breadcrumbs": [ { "label": "Home", "href": "/" }, { "label": "Products", "href": "/products" }, { "label": "Electronics", "href": "/products/electronics" }, { "label": "Laptops" } ] } ``` ### 3. Action Buttons in Headers [#3-action-buttons-in-headers] Place primary actions in page headers: ```json { "type": "page", "title": "Orders", "actions": [ { "type": "button", "label": "New Order", "variant": "default" } ] } ``` ### 4. Max Width for Forms [#4-max-width-for-forms] Use constrained width for forms and reading content: ```json { "type": "page", "maxWidth": "lg", // Better for forms "body": { "type": "form", "fields": [...] } } ``` ### 5. Sidebar Organization [#5-sidebar-organization] Group related items with `NavGroup`. Pass groups instead of a flat list and `SidebarNav` labels each section and draws the separator between them itself — there is no `divider` item, and a row is either a link or a group, never both: ```tsx import { SidebarNav, type NavGroup } from '@object-ui/layout'; import { DollarSign, Home, Settings } from 'lucide-react'; const navGroups: NavGroup[] = [ { label: 'Overview', items: [{ title: 'Dashboard', href: '/', icon: Home }], }, { label: 'Sales', items: [ { title: 'Sales', href: '/sales', icon: DollarSign, children: [ { title: 'Orders', href: '/orders' }, { title: 'Invoices', href: '/invoices' }, ], }, ], }, { label: 'System', items: [{ title: 'Settings', href: '/settings', icon: Settings }], }, ]; ; ``` ## Related Documentation [#related-documentation] * [Components Overview](/docs/components) - All available components * [Schema Rendering](/docs/guide/schema-rendering) - How schemas work * [Architecture Overview](/docs/guide/architecture) - System architecture # Metadata Diagnostics # Metadata Diagnostics [#metadata-diagnostics] Every metadata item shipped by a package — `object`, `view`, `report`, `dashboard`, `flow`, `app`, … — is validated against its Zod schema when the framework loads it. The validation result travels alongside the item as a `_diagnostics` envelope, and Studio surfaces it at four levels so authors and operators can fix problems without grepping logs. > **Backend agnostic.** The shape and the REST endpoint described below > are part of the ObjectStack protocol. Studio is one consumer; any > custom UI built on `@object-ui/data-objectstack` can render the same > envelope. ## The `_diagnostics` envelope [#the-_diagnostics-envelope] ```ts interface MetadataDiagnostics { valid: boolean; errors?: Array<{ path: string; message: string; code?: string }>; warnings?: Array<{ path: string; message: string; code?: string }>; } ``` * `valid === false` means **at least one error** — features that depend on the item (rendering, queries, automation) are unsafe to use. * `warnings[]` is advisory — items remain `valid: true` but operators should review (deprecations, performance hints, missing-but-defaultable fields). * `path` is dot-delimited, matching the same convention Zod uses (`fields.email.type`, `columns.0.bind`). The envelope is attached to: | Endpoint | Where the envelope lives | | :------------------------------------------------ | :------------------------------------ | | `GET /api/v1/meta/items/:type` | Each list entry (`item._diagnostics`) | | `GET /api/v1/meta/items/:type/:name` | Top-level (`item._diagnostics`) | | `GET /api/v1/meta/items/:type/:name?layered=true` | `effective._diagnostics` | | `GET /api/v1/meta/diagnostics` | Sweep — see next section | ## The diagnostics sweep endpoint [#the-diagnostics-sweep-endpoint] `GET /api/v1/meta/diagnostics` runs validation across **every metadata type and item** in one round-trip. It powers the governance overview page and the per-type tile badges. ```http GET /api/v1/meta/diagnostics?severity=error ``` | Query | Default | Effect | | :--------- | :------ | :----------------------------------------------------------------------------------- | | `severity` | `error` | `error` returns invalid items only; `warning` also returns items with only warnings. | | `type` | — | Limit to a single metadata type. | | `package` | — | Limit to one package id. | Response: ```ts import type { MetadataDiagnostics } from '@object-ui/data-objectstack'; interface MetadataDiagnosticsSummary { entries: Array<{ type: string; name: string; diagnostics: MetadataDiagnostics; }>; total: number; // entries.length scannedTypes: number; // how many metadata types were checked scannedItems: number; // how many items were checked in total /** * Per-type aggregate stats — count of items and the list of * packages contributing to each type. Computed in the same sweep so * directory tiles render counts and a package filter without * additional round-trips. Empty `{}` on framework versions older * than the 7.x line. */ stats: Record; } ``` Use this as a CI gate too — `total === 0` is the green-build condition. ## Studio UI surfaces [#studio-ui-surfaces] ### 1. Directory page badges [#1-directory-page-badges] `/apps//metadata` — the directory is **scoped to the active project software package** (the sidebar `active_package` selector, published as `?package=`). Only metadata types that the selected project package contributes are listed — system/cloud types never appear, and there is no in-page "All packages" dropdown. If the URL holds no valid project package the page repairs it to the first available one. Each visible type tile shows: * A neutral count badge with the total items of that type. (Note: this total spans all packages — the per-type *list* page it links to is strictly scoped to the active project package.) * A red ⚠ + count when any items fail validation (errors). * An amber ⚠ + count when items have warnings but no errors. Tiles deep-link into the list page carrying the active `?package=`, so the scope survives navigation. The "View all issues (N)" link in the filter row jumps straight to the governance page. ### 2. Resource list rows [#2-resource-list-rows] `/apps//metadata/` — invalid rows get a red ⚠ icon next to the name and a destructive-tinted background; warning-only rows get an amber ⚠ and amber tint. The list header shows aggregate "Invalid N" and "Warnings N" chips. Hover the ⚠ for the first three messages. The list page is **always scoped to a single project software package**. Studio's sidebar exposes a mandatory **Package** scope selector (the app's `active_package` context selector) whose options are the installed *project* packages — system/cloud packages are never offered and there is no "All" choice. The selection is published as the `?package=` URL parameter, which every metadata list reads to filter rows by their `_packageId`. If the URL holds no valid project package the list repairs it to the first available one, so system metadata never leaks into the view. (The page no longer renders its own per-type package dropdown — scope is owned solely by the sidebar selector.) ### 3. Resource edit banners [#3-resource-edit-banners] `/apps//metadata//` — a destructive banner at the top of the edit page lists the first three errors with their paths; the same errors are also threaded into the form so the offending fields get inline messages **without** the user having to click Save first. Warnings, when present, render as a parallel amber banner. Edits clear the matching diagnostic immediately — the inline error on a field disappears as soon as you start typing in it, then re-validates on save. For **object** drafts the live validation goes beyond the Zod shape check: every field conditional rule (`visibleWhen` / `readonlyWhen` / `requiredWhen`) is linted as a CEL predicate with the same `@objectstack/formula` validators the server uses. A predicate that parses but references an unknown field, or references a field bare instead of as `record.`, surfaces under its `fields..` path in the banner. The field inspector's *Conditional rules* editors give the same verdict inline as you type — with autocomplete for the object's fields (after `record.` / `previous.`), the runtime-bound scope roots (`record`, `previous`, `parent`), and the CEL stdlib. Formula fields get the same treatment for their value `expression`: the inline editor lints it in `role: 'value'` mode and shows the **inferred result type** (only a proven-Number formula is offered as a dataset measure), while the draft-wide pass surfaces a broken formula on any field under its `fields..expression` path. A predicate that slips past authoring is still not silent at **runtime**: when a conditional rule (`visibleWhen` / `readonlyWhen` / `requiredWhen`, view-level `visibleOn`, per-option `visibleWhen`, list conditional formatting) fails to evaluate, the renderer applies the rule's safe default (fail-open — a broken predicate never hides a field or blocks a submit) and logs **one `console.warn` per predicate** with the predicate source, the engine's failure reason, and the field it was attached to. A rule that never fires while its field stays visible is the classic symptom — open the browser console and the broken predicate identifies itself (most often a bare field name where `record.` was meant). The same is now true of a **component node's own gate** — `visibleWhen` on a page component (and its `visible` / `visibleOn` / `visibility` / `hidden` / `hiddenOn` siblings), plus a `page:tabs` item's `visibleWhen`. These used to report in a development build only, so a gate that stopped biting in production left nothing on the console at all. They now warn in **both** builds, with the node type, the node id, the gate key, the predicate source and the engine's reason: ```text [ObjectUI] A visibility predicate could not be evaluated - node "record:alert" (id: "a1") visibleWhen: "nosuchroot.status == 'draft'" Reason: Failed to evaluate expression "nosuchroot.status == 'draft'": nosuchroot is not defined The node was treated as its safe default, which on this surface means the gate did NOT bite - a predicate that cannot be evaluated reads on screen exactly like one that said yes. ``` The line is **rate limited to one per distinct predicate source**, so a broken predicate rendered down two hundred rows of a list is one line, not two hundred — while a second, differently-broken predicate still gets its own. The verdict is unchanged in every case: this is a diagnostic about a predicate, not a change to what the gate decides. A node gate that fails open renders exactly as it always did; the difference is that it now says so. The **app shell's own `visible` gate** joins the same reporter and the same rate limit: a `visible` predicate on a navigation item, on an area's navigation, or on an object field rendered by the record form page. This one was silent in **both** builds before — including the bare-string dialect, which printed nothing at all — so a menu entry whose role gate had stopped working rendered for everyone, silently, with nothing to grep for. It now reports under the surface label `app-shell:visible`: ```text [ObjectUI] A visibility predicate could not be evaluated - node "app-shell:visible" visible: "'org_admin' in current_user.postions" Reason: ... ``` The dedupe key is the predicate **source**, not the menu entry — one broken role gate copy-pasted across eight entries is one authoring mistake and prints one line, while a second, differently-broken predicate still gets its own. Fail-open is unchanged here too: the item still renders for everyone, including the role the predicate was written to exclude. That is what the line exists to tell you. ### 4. Governance overview page [#4-governance-overview-page] `/apps//metadata/_diagnostics` — a single sortable table of every invalid item across every type, grouped by type, with deep-links to the offending edit page. Toggle the severity tab to include warning-only items. This is the page to open during a release readiness review. ## Authoring metadata that validates cleanly [#authoring-metadata-that-validates-cleanly] Validation rules are defined by the Zod schemas in `@objectstack/spec`. A few high-leverage patterns: * **Use `defineObject`, `defineView`, … helpers** from `@objectstack/spec` — TypeScript catches most shape issues at compile time before they ever reach the diagnostics path. * **Run `os check`** locally before publishing. It calls the same validators the server uses on load. * **Treat warnings like errors in CI.** Pass `severity=warning` to the sweep endpoint and assert `total === 0`. * **Layered overlays merge first, then validate.** If only your runtime overlay fails, the source artifact is fine — the bad value is in the overlay. The edit banner reflects the *effective* item, so what you see is what features will get. ## Client SDK [#client-sdk] ```ts import { MetadataClient } from '@object-ui/data-objectstack'; const client = new MetadataClient({ baseUrl: 'https://api.example.com' }); const summary = await client.diagnostics({ severity: 'error' }); console.log(summary.total, 'invalid item(s)'); ``` The hook used by the Studio surfaces: ```ts import { useGlobalDiagnostics, useMetadataClient } from '@object-ui/app-shell'; const client = useMetadataClient(); const { loading, error, summary, byType, // Record warnByType, // Record (severity='warning' only) countsByType, // Record packagesByType, // Record allPackages, // packageId[] — deduped union for filter dropdowns reload, } = useGlobalDiagnostics(client, 'warning'); ``` Pass `severity: 'warning'` when you need `warnByType` populated — the server omits warning-only entries when the default `'error'` severity is in effect. # Notifications # Notifications [#notifications] ObjectUI's notification system implements the spec `NotificationSchema` (`@objectstack/spec` → `ui/notification.zod.ts`). A notification carries two independent axes: * **`severity`** — `info` / `success` / `warning` / `error`. Picks the icon and tone. * **`displayType`** — `toast` / `snackbar` / `banner` / `alert` / `inline`. Picks the **surface**: where and how it appears. `displayType` used to be stored and never read, so every type surfaced as a toast — an author asking for a `banner` got a transient overlay. Each type now has a presentation of its own. ## The five presentations [#the-five-presentations] | `displayType` | Presentation | Rendered by | Persists | | ------------- | ------------------------------------------------------ | ----------------------------------------------------- | ------------------------ | | `toast` | Transient overlay | the host's `onToast` delegate (sonner in the console) | no — auto-dismiss | | `snackbar` | Bottom-anchored bar, one at a time, at most one action | `` | no — auto-dismiss | | `banner` | Page-width strip **in the content flow** | `` | yes — until dismissed | | `alert` | Blocking acknowledgement dialog, FIFO queue | `` | yes — until acknowledged | | `inline` | In place, at the surface that raised it | `` | yes — until dismissed | Auto-dismiss follows the presentation, not a single global timer: `toast` and `snackbar` are transient (`config.defaultDuration`, 5s by default), the other three stay until dismissed. An explicit `duration` always wins — including `duration: 0`, which makes a toast persistent. ## Mounting the surfaces [#mounting-the-surfaces] `toast` is delegated to the host; the other four are React components that subscribe to the provider. Placement is deliberately yours: a banner needs a slot in the content area and an inline notification belongs next to the thing that raised it, so neither can be positioned by a global overlay. ```tsx import { NotificationAlerts, NotificationBanners, NotificationInline, NotificationSnackbar, } from '@object-ui/components'; import { NotificationProvider } from '@object-ui/react'; import { toast } from 'sonner'; toast[n.severity](n.title, { description: n.message })} >
{/* top of the content area */}
``` A provider with **no** `onToast` is a supported "notification centre" mode: items are collected in `notifications` / `unreadCount` for a bell or list, and nothing is overlaid. Raising one of the other four types with its surface unmounted is a mistake, though — dev builds warn, naming the component to mount. ### In the console [#in-the-console] `@object-ui/app-shell` already does this wiring — a console route can call `useNotifications()` and every display type presents correctly with no setup: | Surface | Mounted by | | ------------------- | -------------------------------------------------------------------------------------------- | | `toast` | `ConsoleShell`, via `presentNotificationToast` → sonner (`ConsoleToaster`) | | `snackbar`, `alert` | `ConsoleShell` — both have a single global home | | `banner` | `ConsoleLayout`, at the top of the content area, beside the draft / unpublished bars | | `inline` | nothing, by contract — the raising surface mounts its own `` | Assembling a shell by hand? Both pieces are exported: `presentNotificationToast` for the `onToast` delegate, and `ConsoleNotificationBanners` — `NotificationBanners` guarded by `useHasNotificationProvider()`, so a layout rendered without the provider above it renders no banners instead of throwing. ## Raising notifications [#raising-notifications] ```tsx import { useNotifications } from '@object-ui/react'; declare const undo: () => void; const { notify } = useNotifications(); notify({ title: 'Saved', severity: 'success' }); // toast (spec default) notify({ title: 'Row deleted', severity: 'info', displayType: 'snackbar', actions: [{ label: 'Undo', onClick: undo }] }); // one action notify({ title: 'Viewing a draft', severity: 'warning', displayType: 'banner' }); notify({ title: 'Session expired', severity: 'error', displayType: 'alert' }); ``` ### `actions` [#actions] An action's `variant` is the spec vocabulary — `primary` (default) / `secondary` / `link` — describing the action's **role**. Each surface maps it onto its own button styling; it is not the shadcn Button vocabulary, which is a look. ```tsx notify({ title: 'Storage almost full', severity: 'warning', displayType: 'banner', actions: [ { label: 'Upgrade', onClick: upgrade }, // primary by default { label: 'Learn more', onClick: openDocs, variant: 'link' }, ], }); ``` A `snackbar` and a `toast` render only the **first** action — both have one action slot by nature. A `banner` and an `inline` render all of them; an `alert` renders them beside its acknowledge button. ### `inline` and `scope` [#inline-and-scope] An inline notification is rendered by the surface that raised it. `scope` is the routing key that pairs the two, so two forms on one page don't show each other's messages: ```tsx notify({ title: 'Fix 2 fields', severity: 'error', displayType: 'inline', scope: 'contact-form', }); ``` Omit `scope` on both ends for a page-level inline outlet. `scope` is renderer-local routing metadata, not a spec field — the spec describes what a notification *is*, not which React subtree hosts it. ### `icon` [#icon] Every surface — including the console's sonner toast — resolves `icon` through the same rule: a declared Lucide name (kebab-case or PascalCase) replaces the severity icon; anything else falls back to it. ```tsx notify({ title: 'Deploy finished', severity: 'success', displayType: 'banner', icon: 'rocket' }); ``` A name Lucide doesn't have costs the author their override and nothing more — deliberately *not* the generic `Database` glyph `getLazyIcon` returns for data-shaped schema slots, which on an error notification would replace a meaningful icon with a meaningless one. ### `position` [#position] Honored by the **floating** presentations — `toast` and `snackbar`. `banner`, `inline` and `alert` are anchored by what they are (content top / in place / centred modal) and ignore it. Resolution is `notification.position ?? config.defaultPosition ?? nothing`, and "nothing" is a real answer rather than a missing one: * **declared** → the surface pins itself there, always; * **undeclared** → the surface keeps its own anchor (a snackbar's bottom edge) or defers to the host's toast chrome. That asymmetry is deliberate. The host's toast container is shared with toasts that are *not* spec notifications (in the console, the action runtime's own `toast.*` calls), so it stays the fallback authority for placement — but never a competing one. A declared position that a component prop could silently override would be the same "validates, then does nothing" shape this whole area is about. ## Configuring the system [#configuring-the-system] `NotificationProvider`'s `config` is `NotificationSystemConfig`, declared by `@object-ui/react` (`packages/react/src/context/NotificationContext.tsx`) and normalized by `resolveNotificationConfig`: | Key | Default | Effect | | ----------------- | ------- | -------------------------------------------------------------------------------- | | `defaultPosition` | — | Fallback position for the floating presentations. Deliberately unset: see above. | | `defaultDuration` | `5000` | Auto-dismiss for the transient presentations. | | `maxVisible` | `5` | Cap on a stacking surface (banner, inline); the newest survive. | | `stackDirection` | `down` | Which way a stack grows — `down` puts the newest below. | | `pauseOnHover` | `true` | Hold a transient notification's timer while it is hovered. | The legacy spellings `position` and `stacking` are still accepted: `defaultPosition` wins over `position`, and `stacking: false` reads as `maxVisible: 1` ("show only the newest") rather than being ignored. ### `dismissible` [#dismissible] Defaults to `true`. On the persistent presentations, `dismissible: false` removes the dismiss control (a banner you must resolve rather than wave away). An `alert` always keeps its acknowledge button — `dismissible: false` only closes the Escape route, never the way out. ## Notes [#notes] * **`alert` is not the action system's modal.** `ModalHandler` resolves a page or object, renders it, and reports an `ActionResult` back to the `ActionRunner`. A notification alert has no schema, no target and no result — it is a message and an acknowledgement, so it renders through the `AlertDialog` primitive instead. * **`snackbar` is not a toast variant.** It supersedes rather than stacks, anchors to the bottom regardless of the toast position config, and carries at most one action. * Adding a member to the spec `NotificationTypeSchema` fails type-check in `NOTIFICATION_PRESENTATIONS` (`@object-ui/react`) until its presentation is decided — new types cannot silently fall back to a toast. ## Server-side notifications [#server-side-notifications] `useClientNotifications` bridges the `@objectstack/client` notifications API into the same provider (ADR-0030). Fetched items are persistent and default to `toast`, so a host that renders a bell from `notifications` needs no surface at all. # ObjectOS Integration Guide # ObjectOS Integration Guide [#objectos-integration-guide] ObjectUI is designed to be the official frontend renderer for the **ObjectOS** ecosystem. This guide provides comprehensive instructions for integrating ObjectUI components with ObjectOS, ObjectStack, and building enterprise applications. ## Overview [#overview] ObjectUI serves as the **UI Layer** in the ObjectOS architecture: ``` ┌─────────────────────────────────────────────────┐ │ ObjectUI (UI Layer) │ │ React Components + Tailwind + Schema Rendering │ └──────────────────┬──────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────┐ │ ObjectStack (Runtime Layer) │ │ Kernel + Plugins + ObjectQL + Data Drivers │ └──────────────────┬──────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────┐ │ ObjectOS (Platform Layer) │ │ Multi-tenant + RBAC + System Objects + APIs │ └─────────────────────────────────────────────────┘ ``` ## Quick Start Integration [#quick-start-integration] ### 1. Install Dependencies [#1-install-dependencies] ```bash # Core ObjectUI packages pnpm add @object-ui/react @object-ui/components @object-ui/fields # ObjectStack runtime pnpm add @objectstack/core @objectstack/runtime @objectstack/objectql # Data adapter pnpm add @object-ui/data-objectstack # Optional: Plugins as needed pnpm add @object-ui/plugin-form @object-ui/plugin-grid ``` ### 2. Set Up ObjectStack Kernel [#2-set-up-objectstack-kernel] {/* doc-snippet: fragment — this file belongs to the ObjectStack SERVER project, not to an ObjectUI app. `@objectstack/runtime`, `@objectstack/objectql`, `@objectstack/plugin-app` and `@objectstack/plugin-hono-server` are dependencies of that project and are not declared by any package in this repository, so they do not resolve in this gate's program (measured: TS2307 x4). Correct documentation the gate cannot reach — the authoritative reference is the ObjectStack side, linked in the section below */} ```typescript // src/kernel.ts import { Kernel } from '@objectstack/runtime'; import { ObjectQLPlugin } from '@objectstack/objectql'; import { AppPlugin } from '@objectstack/plugin-app'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; export async function createKernel() { const kernel = new Kernel(); // Register essential plugins kernel.registerPlugin(new ObjectQLPlugin()); kernel.registerPlugin(new AppPlugin()); kernel.registerPlugin(new HonoServerPlugin({ port: 3000, cors: true })); await kernel.start(); return kernel; } ``` ### 3. Create ObjectStack Configuration [#3-create-objectstack-configuration] `objectstack.config.ts` belongs to the **ObjectStack server project**, not to an ObjectUI app. It is authored with `defineStack()` and compiled by the `os` CLI; the packages it imports (`@objectstack/spec`, `@objectstack/runtime`, plugins) are dependencies of that project, not of this one. ObjectUI is a pure consumer of whatever the server publishes over the metadata API, so the authoritative reference for this file lives on the ObjectStack side: * [Your First Project](https://objectstack.ai/docs/getting-started/your-first-project) — the generated `objectstack.config.ts` walked through key by key: `manifest`, `plugins`, `objects` * [Command Line Interface](https://objectstack.ai/docs/deployment/cli) — how the file is discovered, validated against `ObjectStackDefinitionSchema`, and compiled to a deployable `dist/objectstack.json` * [Data Flow Diagrams](https://objectstack.ai/docs/api/data-flow) — how `defineStack()` becomes a running application The rest of this guide is ObjectUI's half of the integration: rendering the objects, views and apps that the server exposes. ### 4. Set Up Frontend with Console [#4-set-up-frontend-with-console] ```typescript // src/index.tsx import React from 'react'; import ReactDOM from 'react-dom/client'; import { SchemaRenderer } from '@object-ui/react'; import { ObjectStackAdapter } from '@object-ui/data-objectstack'; // Import required plugins import '@object-ui/components'; import '@object-ui/fields'; import '@object-ui/plugin-form'; import '@object-ui/plugin-grid'; // Initialize ObjectStack adapter const dataSource = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000/api' }); function App() { return (
); } ReactDOM.createRoot(document.getElementById('root')!).render( ); ``` ## ObjectOS-Specific Features [#objectos-specific-features] ### Multi-Tenancy Support [#multi-tenancy-support] The adapter's constructor takes no `headers` option. Per-request headers go through its `fetch` hook, which it uses for every call it makes: ```typescript // Configure tenant isolation import { ObjectStackAdapter } from '@object-ui/data-objectstack'; const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000/api', fetch: (input, init) => { const headers = new Headers(init?.headers); headers.set('X-Tenant-ID', 'tenant-123'); headers.set('X-Workspace-ID', 'workspace-456'); return globalThis.fetch(input, { ...init, headers }); } }); ``` ### Role-Based Access Control (RBAC) [#role-based-access-control-rbac] {/* doc-snippet: fragment — a SHAPE excerpt of the ObjectStack SERVER's object metadata (the `objects` map of `defineStack()`), not an expression: a bare object literal at statement position parses as a block with labels (measured: TS1005 x4, TS1109 x1, TS1128 x1). This repo declares no type for it — ObjectUI is a pure consumer of what the server publishes */} ```typescript // Define permissions in object schema { objects: { contact: { name: 'contact', label: 'Contact', permissions: { create: ['admin', 'sales'], read: ['admin', 'sales', 'support'], update: ['admin', 'sales'], delete: ['admin'] }, fields: { salary: { name: 'salary', label: 'Salary', type: 'currency', permissions: { read: ['admin', 'hr'], update: ['admin', 'hr'] } } } } } } ``` ### System Objects Integration [#system-objects-integration] ObjectOS provides system objects like `sys_user`, `sys_organization`, `sys_role`, etc. Integrate them in your UI: ```typescript import { ObjectStackAdapter } from '@object-ui/data-objectstack'; import type { ObjectViewSchema } from '@object-ui/types'; const objectStackAdapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000/api' }); const sysUserView: ObjectViewSchema = { type: 'object-view', objectName: 'sys_user', dataSource: objectStackAdapter, defaultViewType: 'grid', columns: ['username', 'email', 'role', 'status', 'last_login'] }; ``` ### Workflow Integration [#workflow-integration] {/* doc-snippet: fragment — a SHAPE excerpt of the ObjectStack SERVER's object metadata, not an expression: a bare object literal at statement position parses as a block with labels (measured: TS1005 x4, TS1128 x1). `workflow` is a server-side object concern; this repo declares no type for it */} ```typescript // Define workflow-enabled object { objects: { opportunity: { name: 'opportunity', label: 'Opportunity', workflow: { enabled: true, states: ['lead', 'qualified', 'proposal', 'negotiation', 'closed_won', 'closed_lost'], transitions: [ { from: 'lead', to: 'qualified', label: 'Qualify', role: ['sales'] }, { from: 'qualified', to: 'proposal', label: 'Create Proposal', role: ['sales'] }, { from: 'proposal', to: 'negotiation', label: 'Negotiate', role: ['sales', 'manager'] }, { from: 'negotiation', to: 'closed_won', label: 'Close Won', role: ['manager'] }, { from: 'negotiation', to: 'closed_lost', label: 'Close Lost', role: ['manager'] } ] }, fields: { // ... field definitions } } } } ``` ## Data Layer Integration [#data-layer-integration] ### Using ObjectQL for Queries [#using-objectql-for-queries] ```typescript import { ObjectStackAdapter } from '@object-ui/data-objectstack'; const dataSource = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000/api' }); // ObjectQL queries are automatically handled const schema = { type: 'object-grid', objectName: 'contact', dataSource, // Grid filter, JSON-rules form: an array of `{ field, operator, value }` // entries, AND-ed together. `operator` takes the canonical view-filter // vocabulary (`equals`, `greater_than_or_equal`, ...). filter: [ { field: 'status', operator: 'equals', value: 'active' }, { field: 'created_date', operator: 'greater_than_or_equal', value: '2024-01-01' } ], // ObjectQL sorting sort: [ { field: 'created_date', order: 'desc' } ] }; ``` ### Custom Data Hooks [#custom-data-hooks] `@object-ui/data-objectstack` ships the *adapter*, not hooks. Reads and writes go through `useViewData` from `@object-ui/react`, which resolves the adapter from context and hands back both the rows and the `DataSource` to write with. ```typescript import { SchemaRenderer, useViewData } from '@object-ui/react'; function ContactList() { const { data, loading, error, dataSource, refresh } = useViewData({ resource: 'contact', params: { $filter: { status: 'active' }, $orderby: [{ field: 'name', order: 'asc' }], $top: 20, }, }); const handleCreate = async (formData: Record) => { await dataSource?.create('contact', formData); await refresh(); }; if (loading) return
Loading...
; if (error) return
Error: {error.message}
; return ( ); } ``` ## Deployment Strategies [#deployment-strategies] ### Strategy 1: Monolithic Deployment [#strategy-1-monolithic-deployment] Deploy ObjectUI and ObjectStack together in a single Node.js process: {/* doc-snippet: fragment — `./kernel` and `./console-plugin` are the READER's own project files (the kernel module is the one built in step 2 above), so the relative specifiers resolve nowhere in this gate's program (measured: TS2307 x2) */} ```typescript // server.ts import { createKernel } from './kernel'; import { ConsolePlugin } from './console-plugin'; async function start() { const kernel = await createKernel(); // Register console UI plugin kernel.registerPlugin(new ConsolePlugin()); console.log('🚀 Server started at http://localhost:3000'); console.log('📊 Console UI at http://localhost:3000/console'); } start(); ``` ### Strategy 2: Microservices Deployment [#strategy-2-microservices-deployment] Deploy ObjectUI (frontend) and ObjectStack (backend) separately: **Backend (ObjectStack API):** {/* doc-snippet: fragment — `./kernel` is the READER's own project file (the kernel module built in step 2 above), so the relative specifier resolves nowhere in this gate's program (measured: TS2307 x1) */} ```typescript // backend/server.ts import { createKernel } from './kernel'; async function start() { const kernel = await createKernel(); console.log('🚀 API Server started at http://localhost:3000'); } start(); ``` **Frontend (ObjectUI):** {/* doc-snippet: fragment — two files in one block, and it is `import.meta.env` that cannot compile here: the `env` member is a Vite ambient declaration carried by the READER's `vite/client` types, which this repository's gate program does not load (measured: TS2339 x1, plus TS2304 x1 for the adapter the second file imports in the reader's own entry point) */} ```typescript // frontend/src/config.ts export const config = { apiBaseUrl: import.meta.env.VITE_API_URL || 'http://localhost:3000/api' }; // frontend/src/index.tsx const dataSource = new ObjectStackAdapter({ baseUrl: config.apiBaseUrl }); ``` ### Strategy 3: Cloud-Native Deployment [#strategy-3-cloud-native-deployment] Deploy on Kubernetes with separate services: ```yaml # k8s/deployment.yaml apiVersion: v1 kind: Service metadata: name: objectstack-api spec: selector: app: objectstack-api ports: - port: 3000 --- apiVersion: v1 kind: Service metadata: name: objectui-frontend spec: selector: app: objectui-frontend ports: - port: 80 --- apiVersion: apps/v1 kind: Deployment metadata: name: objectstack-api spec: replicas: 3 template: spec: containers: - name: api image: myregistry/objectstack-api:latest env: - name: DATABASE_URL value: postgresql://... --- apiVersion: apps/v1 kind: Deployment metadata: name: objectui-frontend spec: replicas: 2 template: spec: containers: - name: frontend image: myregistry/objectui-frontend:latest env: - name: API_URL value: http://objectstack-api:3000 ``` ## Advanced Integration Patterns [#advanced-integration-patterns] ### Custom Component Registration [#custom-component-registration] {/* doc-snippet: fragment — `MyCustomWidget` is the READER's own component, and the block closes with a bare `{ type: 'my-custom-widget' }` SHAPE excerpt showing where the registered name is then used, which at statement position parses as a block with labels (measured: TS1005 x1) */} ```typescript // Register custom components with ObjectUI import { ComponentRegistry } from '@object-ui/core'; ComponentRegistry.register('my-custom-widget', MyCustomWidget, { namespace: 'custom', lazy: true }); // Use in schema { type: 'my-custom-widget', config: { // custom props } } ``` ### Event Handling & Callbacks [#event-handling--callbacks] {/* doc-snippet: fragment — a SHAPE excerpt continuing the "Using ObjectQL for Queries" block above, which is where `dataSource` is constructed: a bare object literal at statement position parses as a block with labels (measured: TS1005 x3) */} ```typescript { type: 'object-grid', objectName: 'contact', dataSource, callbacks: { onRowClicked: (event) => { // Navigate to detail page window.location.href = `/contact/${event.data.id}`; }, onCellValueChanged: async (event) => { // Auto-save on edit await dataSource.update('contact', event.data.id, { [event.column.field]: event.newValue }); } } } ``` ### Reacting to Data Changes [#reacting-to-data-changes] ⚠️ `@object-ui/data-objectstack` has **no WebSocket transport and no server-push subscription**. What it offers is `onMutation`: a notification of the writes *this adapter instance* performed, which is what a view needs to refresh itself after its own create/update/delete. It returns its own unsubscribe function. ```typescript import { ObjectStackAdapter } from '@object-ui/data-objectstack'; const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000/api' }); // Fires for writes this adapter performed. Filter by `resource` for one object. const unsubscribe = adapter.onMutation((event) => { if (event.resource !== 'contact') return; if (event.type === 'create' || event.type === 'update') { // Refresh the view holding this object's rows } }); // Later, when the view unmounts: unsubscribe(); ``` ## Migration from Other Platforms [#migration-from-other-platforms] ### From Retool [#from-retool] {/* doc-snippet: fragment — a SHAPE excerpt showing the ObjectUI half of a side-by-side migration comparison; `dataSource` continues the "Using ObjectQL for Queries" block above, and a bare object literal at statement position parses as a block with labels (measured: TS1005 x4) */} ```typescript // Retool table → ObjectUI Grid { type: 'object-grid', objectName: 'users', dataSource, editable: true, rowSelection: 'multiple', exportConfig: { enabled: true } } ``` ### From Appsmith [#from-appsmith] {/* doc-snippet: fragment — a SHAPE excerpt showing the ObjectUI half of a side-by-side migration comparison; `dataSource` continues the "Using ObjectQL for Queries" block above, and a bare object literal at statement position parses as a block with labels (measured: TS1005 x4) */} ```typescript // Appsmith form → ObjectUI form { type: 'object-form', objectName: 'contact', dataSource, mode: 'create', fieldNames: ['name', 'email', 'phone', 'company'], onSubmit: async (data) => { await dataSource.create('contact', data); } } ``` ### From Mendix [#from-mendix] {/* doc-snippet: fragment — a SHAPE excerpt showing the ObjectUI half of a side-by-side migration comparison, with every slot body elided as a prose comment, so the object literal cannot parse as TypeScript (measured: TS1005 x2, TS1128 x2) */} ```typescript // Mendix page → ObjectUI page { type: 'page', template: 'header-sidebar-main', header: { /* ... */ }, sidebar: { /* ... */ }, main: { type: 'tabs', items: [ { label: 'Overview', content: { /* ... */ } }, { label: 'Details', content: { /* ... */ } } ] } } ``` ## Performance Optimization [#performance-optimization] ### Bundle Optimization [#bundle-optimization] ```typescript // Lazy load plugins const plugins = { grid: () => import('@object-ui/plugin-grid'), charts: () => import('@object-ui/plugin-charts'), kanban: () => import('@object-ui/plugin-kanban') }; // Load on demand await plugins.grid(); ``` ### Caching Strategy [#caching-strategy] ```typescript import { ObjectStackAdapter } from '@object-ui/data-objectstack'; const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000/api', cache: { ttl: 60000, // 1 minute maxSize: 500 // entries retained } }); ``` ## Testing & Quality Assurance [#testing--quality-assurance] ### Unit Tests [#unit-tests] {/* doc-snippet: fragment — a test body: `test` / `expect` are the RUNNER's globals (Vitest or Jest, injected by the reader's own test config, not imported here) and `mockDataSource` is the reader's own fixture, so none of the three resolves in this gate's program (measured: TS2593 x1, TS2304 x2) */} ```typescript import { render } from '@testing-library/react'; import { SchemaRenderer } from '@object-ui/react'; test('renders contact grid', () => { const { getByText } = render( ); expect(getByText('Contact')).toBeInTheDocument(); }); ``` ### Integration Tests [#integration-tests] End-to-end tests drive the running app, so they need a browser runner the app itself does not depend on. Install it in your project first — `npm install -D @playwright/test` (then `npx playwright install` once, for the browsers) — and run these specs with `npx playwright test`. {/* doc-snippet: fragment — an end-to-end spec for the READER's own Playwright installation: `@playwright/test` is a test runner the reader installs themselves (the sentence above says so), and no package documented here declares it, so it does not resolve in this gate's program (measured: TS2307 x1). It used to compile only because THIS repository happens to carry `@playwright/test` as a root devDependency, which is what objectui#7463 item 2 bounded */} ```typescript import { test, expect } from '@playwright/test'; test('create contact workflow', async ({ page }) => { await page.goto('http://localhost:3000/console/contacts'); await page.click('button:has-text("New Contact")'); await page.fill('[name="name"]', 'John Doe'); await page.fill('[name="email"]', 'john@example.com'); await page.click('button:has-text("Save")'); await expect(page.locator('text=John Doe')).toBeVisible(); }); ``` ## Resources [#resources] * [ObjectStack Documentation](https://docs.objectstack.ai) * [ObjectUI Components Reference](/docs/components) * [ObjectQL Schemas](/docs/api/schema-reference#objectql-schemas) * [Example: ObjectStack Console Starter](https://github.com/objectstack-ai/objectui/tree/main/examples/console-starter) * [Schema Catalog](/docs/guide/schema-catalog) - every schema rendered in these docs ## Support [#support] * GitHub Issues: [https://github.com/objectstack-ai/objectui/issues](https://github.com/objectstack-ai/objectui/issues) * Discord Community: [https://discord.gg/objectui](https://discord.gg/objectui) * Email: [hello@objectui.org](mailto:hello@objectui.org) # Custom Plugin Development This guide walks you through creating custom ObjectUI plugins — from scaffolding to publishing. Plugins extend ObjectUI with new view types, field widgets, or complex interactive components while keeping your application bundle lean through lazy loading. ## What Is an ObjectUI Plugin? [#what-is-an-objectui-plugin] A plugin is a self-contained package that registers one or more components into the [Component Registry](./component-registry.md). When a JSON schema references a plugin's component type, the renderer resolves and renders it automatically. Plugins differ from regular components in two ways: * **Lazy-loaded** — heavy dependencies are code-split and fetched on demand. * **Self-registering** — importing the package is enough; no manual wiring required. Official plugins (`@object-ui/plugin-grid`, `@object-ui/plugin-kanban`, `@object-ui/plugin-charts`, etc.) all follow this pattern, and your custom plugins should too. ## Plugin Anatomy [#plugin-anatomy] Every plugin has three key parts: ``` packages/plugin-board/ ├── src/ │ ├── index.tsx # Entry point: lazy wrapper + ComponentRegistry.register() │ ├── BoardImpl.tsx # Heavy implementation (imported lazily) │ ├── BoardImpl.test.tsx # Tests │ └── types.ts # TypeScript interfaces & schema types ├── package.json ├── vite.config.ts ├── tsconfig.json └── README.md ``` | File | Role | | --------------- | ---------------------------------------------------------------------------------------------------------- | | `index.tsx` | Lightweight entry — sets up `React.lazy()`, `Suspense` fallback, and calls `ComponentRegistry.register()`. | | `BoardImpl.tsx` | The actual renderer. All heavy dependencies live here so they are tree-shaken from the initial bundle. | | `types.ts` | Schema interfaces extending `BaseSchema` from `@object-ui/types`. | ## Scaffolding With the CLI [#scaffolding-with-the-cli] The fastest way to start is the `create-plugin` generator: ```bash npx @object-ui/create-plugin board --description "Kanban-style board view" # Or with pnpm / npm create aliases: pnpm create @object-ui/plugin board npm create @object-ui/plugin board ``` This produces a ready-to-build plugin under `packages/plugin-board/` with the correct `package.json`, Vite config, test file, and registry call already in place. That directory — and the anatomy shown above — is what the generator writes into **your** workspace (`/packages/plugin-`, see `packages/create-plugin/src/index.ts`); it is not a package that ships in this repository, so do not expect to find it in a fresh ObjectUI checkout. After scaffolding, install dependencies: ```bash pnpm install ``` ## Implementing a Custom View Plugin [#implementing-a-custom-view-plugin] Let's build a **board** view plugin that renders items in columns (similar to a Kanban but simplified). ### 1. Define the Schema Types [#1-define-the-schema-types] ```typescript // src/types.ts import type { BaseSchema } from '@object-ui/types'; export interface BoardColumn { id: string; title: string; } export interface BoardItem { id: string; columnId: string; title: string; description?: string; } export interface BoardSchema extends BaseSchema { type: 'board'; columns: BoardColumn[]; items: BoardItem[]; onItemMove?: (itemId: string, toColumnId: string) => void; } export interface BoardProps { schema: BoardSchema; className?: string; } ``` ### 2. Build the Implementation [#2-build-the-implementation] ```tsx // src/BoardImpl.tsx import React from 'react'; import { Card, CardHeader, CardTitle, CardContent } from '@object-ui/components'; import { cn } from '@object-ui/components'; import type { BoardProps } from './types'; export default function BoardImpl({ schema, className }: BoardProps) { const { columns, items } = schema; return (
{columns.map((col) => (

{col.title}

{items .filter((item) => item.columnId === col.id) .map((item) => ( {item.title} {item.description && ( {item.description} )} ))}
))}
); } ``` ### 3. Create the Entry Point [#3-create-the-entry-point] ```tsx // src/index.tsx import React, { Suspense } from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { Skeleton } from '@object-ui/components'; const LazyBoard = React.lazy(() => import('./BoardImpl')); export const BoardRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ schema, ...props }) => ( }> ); // Auto-register on import ComponentRegistry.register('board', BoardRenderer, { namespace: 'plugin-board', label: 'Board View', category: 'plugin', inputs: [ { name: 'columns', type: 'array', required: true }, { name: 'items', type: 'array', required: true }, ], defaultProps: { columns: [ { id: 'todo', title: 'To Do' }, { id: 'done', title: 'Done' }, ], items: [], }, }); export { default as BoardImpl } from './BoardImpl'; export type { BoardSchema, BoardProps, BoardColumn, BoardItem } from './types'; ``` Now any schema with `"type": "board"` will resolve to your component. ## Implementing a Custom Field Widget [#implementing-a-custom-field-widget] Field widgets follow the `FieldWidgetComponentProps` interface from `@object-ui/fields`. ```typescript // FieldWidgetComponentProps shape (from packages/fields/src/widgets/types.ts) import type { FieldMetadata } from '@object-ui/types'; type FieldWidgetComponentProps = { value: T; onChange: (val: T) => void; field: FieldMetadata; readonly?: boolean; disabled?: boolean; className?: string; error?: string; }; ``` The validation slot is named `error`, matching `FieldWidgetPropsSchema` in `@objectstack/spec/ui` — the published contract a widget is written against. The form renderer supplies it from the active validation message. ### `field` is the only metadata carrier (v17, breaking) [#field-is-the-only-metadata-carrier-v17-breaking] Before v17 a widget could receive its metadata under **either** `field` or `schema`, depending on which host rendered it, so widgets written in that era resolve their config as `field || schema`. `schema` has been removed from `FieldWidgetComponentProps` in v17: **read `props.field`, full stop.** Reading `props.schema` now yields `undefined`. `schema` itself is not going anywhere — it is the universal SDUI node `SchemaRenderer` hands to *every* registered component (`element:*`, `page:*`, grids, reports). That is precisely why a **field widget** needs an adapter when it is rendered from a schema node instead of from a form. Wrap it once, at registration, and the widget only ever implements one contract: ```tsx import { ComponentRegistry } from '@object-ui/core'; import { withFieldCarrier } from '@object-ui/fields'; ComponentRegistry.register('color', withFieldCarrier(ColorPickerField), { namespace: 'field', }); ``` `withFieldCarrier` forwards the node by reference (nothing is copied or dropped) and consumes `schema` so it never reaches the DOM. Every built-in field widget is registered through it. ### Who renders what [#who-renders-what] The widget and the form renderer split validation display, and the split is not optional: | Concern | Owner | | --------------------------- | -------------------------------------------------- | | `aria-invalid` on the input | **the widget** — only it renders the input element | | the required marker (`*`) | **the form renderer** (``) | | the message TEXT | **the form renderer** (``) | So consume `error` as a **boolean signal** — `aria-invalid={!!error}` — and do not render the message yourself. The form already prints it below the control; a widget that prints it too shows the user the same sentence twice. For the same reason `required` is not in the props: the marker has one author. ### Example: Color Picker Field [#example-color-picker-field] ```tsx // src/ColorPickerField.tsx import React from 'react'; import { Input } from '@object-ui/components'; import type { FieldWidgetComponentProps } from '@object-ui/fields'; export function ColorPickerField({ value, onChange, field, readonly, disabled, error, }: FieldWidgetComponentProps) { if (readonly) { return (
{value || '—'}
); } return (
onChange(e.target.value)} disabled={disabled} className="h-8 w-8 cursor-pointer rounded border-0 p-0" /> onChange(e.target.value)} placeholder={field?.placeholder || '#000000'} disabled={disabled} className="font-mono text-sm" // The whole job of `error` here: tell assistive tech the field failed. // The message text is rendered by the form, not by this widget. aria-invalid={!!error} />
); } ``` Register it as a field widget: ```tsx // src/index.tsx import { ComponentRegistry } from '@object-ui/core'; import { withFieldCarrier } from '@object-ui/fields'; import { ColorPickerField } from './ColorPickerField'; ComponentRegistry.register('field-color', withFieldCarrier(ColorPickerField), { namespace: 'plugin-board', label: 'Color Picker', category: 'field', inputs: [ { name: 'value', type: 'string', label: 'Value' }, { name: 'placeholder', type: 'string', label: 'Placeholder' }, ], }); export { ColorPickerField }; ``` ## Using the ComponentRegistry [#using-the-componentregistry] ### Namespaced Registration [#namespaced-registration] Namespaces prevent type collisions between plugins: ```tsx import { ComponentRegistry } from '@object-ui/core'; // Register with a namespace — accessible as 'plugin-board:board' AND 'board' ComponentRegistry.register('board', BoardRenderer, { namespace: 'plugin-board', }); // Explicit lookup by namespace ComponentRegistry.get('board', 'plugin-board'); // Fallback lookup (works when the type is unambiguous) ComponentRegistry.get('board'); ``` Use `skipFallback: true` in the metadata if you do **not** want the component to be available without a namespace prefix. ### Querying Registered Components [#querying-registered-components] ```tsx import { ComponentRegistry } from '@object-ui/core'; ComponentRegistry.has('board'); // boolean ComponentRegistry.getAllTypes(); // string[] ComponentRegistry.getNamespaceComponents('plugin-board'); // RegistryComponentConfig[] ``` ## Plugin Configuration & Schema Types [#plugin-configuration--schema-types] Define your schema interface in `types.ts` and extend `BaseSchema`: ```typescript import type { BaseSchema } from '@object-ui/types'; export interface BoardSchema extends BaseSchema { type: 'board'; columns: BoardColumn[]; items: BoardItem[]; } ``` Declare `ComponentInput` entries when registering: they are what the published manifest (`sdui.manifest.json`) and the JSX-page compiler's diagnostics read. Each entry carries the six keys the manifest forwards — `name`, `type`, `required`, `enum`, `binding`, `description`; a default belongs in the renderer's own fallback read and, for the author, in `description` (`label`, `defaultValue` and `advanced` are retired keys — nothing ever read them): ```tsx ComponentRegistry.register('board', BoardRenderer, { inputs: [ { name: 'columns', type: 'array', required: true }, { name: 'items', type: 'array', required: true }, { name: 'layout', type: 'enum', enum: ['horizontal', 'vertical'], description: 'Defaults to "horizontal" — the renderer\'s own fallback', }, ], }); ``` ## Testing Plugins [#testing-plugins] ObjectUI uses **Vitest + React Testing Library**. Place tests next to the implementation. ```tsx // src/BoardImpl.test.tsx import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; // `toBeInTheDocument` is a jest-dom matcher, not a Vitest one — without this // import the assertions below do not type-check and do not run. import '@testing-library/jest-dom'; import BoardImpl from './BoardImpl'; const schema = { type: 'board' as const, columns: [ { id: 'todo', title: 'To Do' }, { id: 'done', title: 'Done' }, ], items: [ { id: '1', columnId: 'todo', title: 'Write tests' }, { id: '2', columnId: 'done', title: 'Ship plugin' }, ], }; describe('BoardImpl', () => { it('renders all columns', () => { render(); expect(screen.getByText('To Do')).toBeInTheDocument(); expect(screen.getByText('Done')).toBeInTheDocument(); }); it('renders items in correct columns', () => { render(); expect(screen.getByText('Write tests')).toBeInTheDocument(); expect(screen.getByText('Ship plugin')).toBeInTheDocument(); }); it('handles empty items gracefully', () => { render(); expect(screen.getByText('To Do')).toBeInTheDocument(); }); }); ``` Run tests: ```bash pnpm vitest run packages/plugin-board ``` ## Publishing Guidelines [#publishing-guidelines] ### Package Checklist [#package-checklist] Before publishing, verify: * [ ] `package.json` has correct `name`, `version`, `exports`, and `peerDependencies`. * [ ] `react` and `react-dom` are **peer** dependencies, not direct dependencies. * [ ] `@object-ui/core` and `@object-ui/components` are in `devDependencies` (or `peerDependencies`). * [ ] `vite.config.ts` marks React and ObjectUI packages as **external**. * [ ] Types are exported via `"types"` field in `package.json`. * [ ] All tests pass (`pnpm vitest run`). * [ ] The entry point is lightweight — heavy code lives in `*Impl.tsx` files. ### Build & Verify [#build--verify] ```bash pnpm build --filter @object-ui/plugin-board ls -lh packages/plugin-board/dist/ ``` The entry chunk should be under 1 KB; the lazy chunk carries the bulk. ### Publish [#publish] ```bash cd packages/plugin-board npm publish --access public ``` ### Consumers Install & Use [#consumers-install--use] ```bash pnpm add @object-ui/plugin-board ``` ```tsx // app/main.tsx — import once, auto-registers import '@object-ui/plugin-board'; ``` ```json { "type": "board", "columns": [ { "id": "todo", "title": "To Do" }, { "id": "done", "title": "Done" } ], "items": [ { "id": "1", "columnId": "todo", "title": "Write docs" } ] } ``` ## Related Documentation [#related-documentation] * [Component Registry](./component-registry.md) — registry internals and advanced usage * [Plugins Overview](./plugins.md) — official plugin catalog * [Schema Rendering](./schema-rendering.md) — how schemas become UI * [Fields Guide](./fields.md) — built-in field widgets and `FieldWidgetComponentProps` # Plugins Object UI supports a powerful plugin system that allows you to extend the framework with additional components. Plugins are separate packages that load on-demand, keeping your main application bundle small while providing rich functionality. ## Overview [#overview] Plugins are lazy-loaded component packages that: * **Auto-register** components when imported * **Lazy-load** heavy dependencies on-demand * **Keep bundles small** - only load when needed * **Are type-safe** with full TypeScript support * **Follow best practices** with built-in loading states ## Official Plugins [#official-plugins] Object UI provides 14+ official plugins for common use cases: ### Data Visualization & Dashboards [#data-visualization--dashboards] #### [@object-ui/plugin-charts](../plugins/plugin-charts.mdx) [#object-uiplugin-charts] Data visualization components powered by Recharts. * Bar, line, area, and pie charts * Responsive design * Customizable colors * Lazy-loaded (\~80 KB) [Read full documentation →](../plugins/plugin-charts.mdx) *** #### [@object-ui/plugin-dashboard](../plugins/plugin-dashboard.mdx) [#object-uiplugin-dashboard] Dashboard layouts with metric cards and widgets. * Dashboard grid layouts * Metric/KPI cards with trends * Widget system * Lazy-loaded (\~22 KB) [Read full documentation →](../plugins/plugin-dashboard.mdx) *** #### [@object-ui/plugin-timeline](../plugins/plugin-timeline.mdx) [#object-uiplugin-timeline] Timeline component with multiple layout variants. * Vertical, horizontal layouts * Customizable markers * Date formatting * Lazy-loaded (\~20 KB) [Read full documentation →](../plugins/plugin-timeline.mdx) *** #### [@object-ui/plugin-gantt](../plugins/plugin-gantt.mdx) [#object-uiplugin-gantt] Gantt chart for project visualization. * Task dependencies * Progress tracking * ObjectQL integration * Lazy-loaded (\~40 KB) [Read full documentation →](../plugins/plugin-gantt.mdx) *** #### [@object-ui/plugin-calendar](../plugins/plugin-calendar.mdx) [#object-uiplugin-calendar] Calendar visualization for events. * Month/week/day views * Event management * ObjectQL integration * Lazy-loaded (\~25 KB) [Read full documentation →](../plugins/plugin-calendar.mdx) *** #### [@object-ui/plugin-map](../plugins/plugin-map.mdx) [#object-uiplugin-map] Map visualization with markers. * Interactive maps * Location markers * ObjectQL integration * Lazy-loaded (\~60 KB) [Read full documentation →](../plugins/plugin-map.mdx) *** ### Data Management [#data-management] #### [@object-ui/plugin-grid](../plugins/plugin-grid.mdx) [#object-uiplugin-grid] Advanced data grid with sorting, filtering, and pagination. * Column sorting and filtering * Pagination controls * Row selection * Lazy-loaded (\~45 KB) [Read full documentation →](../plugins/plugin-grid.mdx) *** #### [@object-ui/plugin-form](../plugins/plugin-form.mdx) [#object-uiplugin-form] Advanced form builder with validation. * Multi-step forms * Field validation * Custom field types * Lazy-loaded (\~28 KB) [Read full documentation →](../plugins/plugin-form.mdx) *** #### [@object-ui/plugin-view](../plugins/plugin-view.mdx) [#object-uiplugin-view] ObjectQL-integrated views for automatic CRUD. * Auto-generated forms and grids * CRUD operations * Field mapping * Lazy-loaded (\~35 KB) [Read full documentation →](../plugins/plugin-view.mdx) *** ### Content & Editing [#content--editing] #### [@object-ui/plugin-editor](../plugins/plugin-editor.mdx) [#object-uiplugin-editor] Code editor component powered by Monaco Editor. * Syntax highlighting for 100+ languages * IntelliSense and code completion * Multiple themes * Lazy-loaded (\~120 KB) [Read full documentation →](../plugins/plugin-editor.mdx) *** #### [@object-ui/plugin-markdown](../plugins/plugin-markdown.mdx) [#object-uiplugin-markdown] Markdown renderer with GitHub Flavored Markdown support. * GitHub Flavored Markdown * XSS protection * Code syntax highlighting * Lazy-loaded (\~30 KB) [Read full documentation →](../plugins/plugin-markdown.mdx) *** #### [@object-ui/plugin-chatbot](../plugins/plugin-chatbot.mdx) [#object-uiplugin-chatbot] Chat interface component. * Message history * User and assistant roles * Timestamps and avatars * Responsive floating panel for console assistants * Inline responding, stop, and retry states * Lazy-loaded (\~35 KB) [Read full documentation →](../plugins/plugin-chatbot.mdx) *** ### Workflows & Tasks [#workflows--tasks] #### [@object-ui/plugin-kanban](../plugins/plugin-kanban.mdx) [#object-uiplugin-kanban] Kanban board component with drag-and-drop powered by @dnd-kit. * Drag and drop cards between columns * Column limits (WIP limits) * Card badges for status/priority * Lazy-loaded (\~100 KB) [Read full documentation →](../plugins/plugin-kanban.mdx) *** ## How Plugins Work [#how-plugins-work] ### Stylesheets [#stylesheets] A plugin's JavaScript is only half of what it renders with. `@object-ui/plugin-grid` and `@object-ui/plugin-kanban` publish a `style.css` of their own, and an app that installs one must import it after the base sheets: ```css /* src/index.css */ @import "tailwindcss"; @import "@object-ui/components/style.css"; @import "@object-ui/fields/style.css"; @import "@object-ui/plugin-grid/style.css"; @import "@object-ui/plugin-kanban/style.css"; ``` Each plugin sheet is compiled against the components theme and then has every rule that sheet already ships subtracted from it, so it carries only what the plugin adds. That includes the themed utilities (`bg-muted/10`, `bg-card/60`, `ring-primary/40`) which **no consumer-side configuration can produce** — the `@theme` block declaring their tokens lives in package source that is not published, so scanning `node_modules` cannot reach it ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)). Skip the import and the view renders unstyled. Add a line only for the plugins you install. The other `@object-ui/plugin-*` packages do not publish a stylesheet yet; the build step above is the pattern each of them will adopt when it needs one. ### Lazy Loading Architecture [#lazy-loading-architecture] Plugins use React's `lazy()` and `Suspense` to load heavy dependencies on-demand: ```typescript // The plugin structure import React, { Suspense } from 'react' import { Skeleton } from '@object-ui/components' // Lazy load the heavy implementation const LazyEditor = React.lazy(() => import('./MonacoImpl')) export const CodeEditorRenderer = (props) => ( }> ) ``` **Benefits:** * **Smaller initial bundle**: Main app loads faster * **Progressive loading**: Components load when needed * **Better UX**: Loading skeletons while chunks download * **Automatic code splitting**: Vite handles chunking ### Bundle Impact [#bundle-impact] | Plugin | Initial Load | Lazy Load | Description | | ---------------- | ------------ | --------- | ---------------------- | | plugin-editor | \~0.2 KB | \~120 KB | Monaco editor | | plugin-charts | \~0.2 KB | \~80 KB | Recharts visualization | | plugin-kanban | \~0.2 KB | \~100 KB | Drag-and-drop board | | plugin-markdown | \~0.2 KB | \~30 KB | Markdown rendering | | plugin-dashboard | \~0.2 KB | \~22 KB | Dashboard layouts | | plugin-form | \~0.2 KB | \~28 KB | Form builder | | plugin-grid | \~0.2 KB | \~45 KB | Data grid | | plugin-view | \~0.2 KB | \~35 KB | ObjectQL views | | plugin-timeline | \~0.2 KB | \~20 KB | Timeline layouts | | plugin-chatbot | \~0.2 KB | \~35 KB | Chat interface | | plugin-calendar | \~0.2 KB | \~25 KB | Calendar views | | plugin-gantt | \~0.2 KB | \~40 KB | Gantt charts | | plugin-map | \~0.2 KB | \~60 KB | Map visualization | Without lazy loading, all this code would be in your main bundle! ### Auto-Registration [#auto-registration] Plugins automatically register their components when imported: ```typescript // In the plugin's index.tsx import { ComponentRegistry } from '@object-ui/core' ComponentRegistry.register('code-editor', CodeEditorRenderer) ``` You just need to import the plugin once: ```typescript // In your App.tsx or main.tsx import '@object-ui/plugin-editor' import '@object-ui/plugin-charts' import '@object-ui/plugin-kanban' import '@object-ui/plugin-markdown' import '@object-ui/plugin-dashboard' import '@object-ui/plugin-form' import '@object-ui/plugin-grid' // ... import other plugins as needed ``` Now all plugin components are available in your schemas! ## Creating Custom Plugins [#creating-custom-plugins] You can create your own plugins following the same pattern: ### 1. Create Package Structure [#1-create-package-structure] ```bash mkdir -p packages/plugin-myfeature/src cd packages/plugin-myfeature ``` ### 2. Create Heavy Implementation [#2-create-heavy-implementation] ```typescript // src/MyFeatureImpl.tsx import HeavyLibrary from 'heavy-library' export default function MyFeatureImpl(props) { return } ``` ### 3. Create Lazy Wrapper [#3-create-lazy-wrapper] ```typescript // src/index.tsx import React, { Suspense } from 'react' import { ComponentRegistry } from '@object-ui/core' import { Skeleton } from '@object-ui/components' // Lazy load implementation const LazyFeature = React.lazy(() => import('./MyFeatureImpl')) // Create renderer with Suspense export const MyFeatureRenderer = (props) => ( }> ) // Auto-register ComponentRegistry.register('my-feature', MyFeatureRenderer) // Export for manual use export const myFeatureComponents = { 'my-feature': MyFeatureRenderer } ``` ### 4. Add TypeScript Types [#4-add-typescript-types] ```typescript // src/types.ts import type { BaseSchema } from '@object-ui/types' export interface MyFeatureSchema extends BaseSchema { type: 'my-feature' customProp?: string } ``` ### 5. Configure Build [#5-configure-build] ```typescript // vite.config.ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { resolve } from 'path' export default defineConfig({ plugins: [react()], build: { lib: { entry: resolve(__dirname, 'src/index.tsx'), name: 'ObjectUIPluginMyFeature', fileName: (format) => `index.${format}.js` }, rollupOptions: { external: [ 'react', 'react-dom', '@object-ui/components', '@object-ui/core' ], output: { globals: { react: 'React', 'react-dom': 'ReactDOM' } } } } }) ``` ### 6. Add Package.json [#6-add-packagejson] ```json { "name": "@object-ui/plugin-myfeature", "version": "1.0.0", "type": "module", "main": "./dist/index.umd.js", "module": "./dist/index.es.js", "types": "./dist/index.d.ts", "exports": { ".": { "import": "./dist/index.es.js", "require": "./dist/index.umd.js", "types": "./dist/index.d.ts" } }, "files": ["dist"], "scripts": { "build": "vite build && tsc --emitDeclarationOnly" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "dependencies": { "heavy-library": "^1.0.0" }, "devDependencies": { "@object-ui/components": "workspace:*", "@object-ui/core": "workspace:*", "@object-ui/types": "workspace:*", "@vitejs/plugin-react": "^6.0.5", "typescript": "^6.0.3", "vite": "^8.2.1" } } ``` ## Best Practices [#best-practices] ### 1. Keep Entry Point Light [#1-keep-entry-point-light] The main index file should only contain: * Lazy loading wrapper * Component registration * Type exports Heavy imports go in the `*Impl.tsx` file. ### 2. Provide Good Loading States [#2-provide-good-loading-states] Always show a meaningful skeleton while loading: ```typescript }> ``` ### 3. Export Types [#3-export-types] Make your plugin type-safe: ```typescript export type { MyFeatureSchema } from './types' ``` ### 4. Document Your Plugin [#4-document-your-plugin] Include a README with: * Installation instructions * Usage examples * Schema API reference * Bundle size information ### 5. Test Lazy Loading [#5-test-lazy-loading] Verify that: * The main bundle is small (\~200 bytes) * The lazy chunk is separate * Components load correctly when rendered ```bash pnpm build ls -lh dist/ ``` ## Plugin vs Component Package [#plugin-vs-component-package] **Use a Plugin when:** * The component depends on large libraries (>50 KB) * Not all apps will use this component * You want on-demand loading **Use regular Components when:** * The component is lightweight * Most apps will use it * It's part of core functionality ## Troubleshooting [#troubleshooting] ### Plugin not loading [#plugin-not-loading] Check that you imported it in your app: ```typescript import '@object-ui/plugin-myfeature' ``` ### TypeScript errors [#typescript-errors] Make sure types are exported: ```typescript export type { MyFeatureSchema } from '@object-ui/plugin-myfeature' ``` ### Bundle size too large [#bundle-size-too-large] Check that the implementation is in a separate file: ``` ✅ src/index.tsx (light, uses React.lazy) ✅ src/MyFeatureImpl.tsx (heavy, imported lazily) ``` ### Component not registering [#component-not-registering] Check that ComponentRegistry.register() is called at the module level: ```typescript // ✅ Good - runs on import ComponentRegistry.register('my-feature', MyFeatureRenderer) // ❌ Bad - never runs export function registerComponents() { ComponentRegistry.register('my-feature', MyFeatureRenderer) } ``` ## Related Documentation [#related-documentation] * [Component Registry](./component-registry.md) - Understanding the registry * [Schema Rendering](./schema-rendering.md) - How schemas become UI * [Custom Plugin Development](/docs/guide/plugin-development) - Component development * **[Create Plugin Utility](/docs/utilities/create-plugin)** - Scaffold new plugins quickly * **[CLI Tool](/docs/utilities/cli)** - Test plugins with the CLI * **[All Utilities](/docs/utilities)** - Complete toolkit for development ## Next Steps [#next-steps] 1. Install official plugins you need 2. Try creating a custom plugin 3. Share your plugins with the community 4. Contribute new plugins to Object UI # Public Forms # Public Forms [#public-forms] `@object-ui/plugin-form` ships an `EmbeddableForm` component that renders a public-facing form (contact us, lead capture, signup, RSVP) with the security defaults you'd expect from Airtable Forms, Typeform or HubSpot Forms — without asking app authors to bolt them on themselves. The console exposes a turnkey route at `/f/:slug` (`FormPage`) that loads a `FormView` spec from the server's `GET /api/v1/forms/:slug` resolver and renders the merged form. The same component also serves authed internal forms at `/forms/:name` (`mode="internal"`), reading the FormView spec from `/api/v1/meta/view/:name` and posting to `/api/v1/data/:object`. ## Quick start [#quick-start] ```tsx import { EmbeddableForm } from '@object-ui/plugin-form'; import { createObjectStackAdapter } from '@object-ui/data-objectstack'; const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' }); ``` ## Security defaults (at a glance) [#security-defaults-at-a-glance] | Defence | Default | Config key | | ------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------ | | **Honeypot field** (silent fake-success on bots) | On | `honeypot` (string to rename, `false` to disable) | | **Min-fill-time guard** | 1500 ms | `minFillTime` (ms, `0` to disable) | | **URL prefill whitelist** | *none* — prefill is fully off | `allowedPrefillFields: string[]` | | **Open-redirect guard** | Same-origin only | `allowedRedirectHosts: string[]` (supports `*.example.com`) | | **Default `maxLength`** | text 200 · email 254 · url 2048 · phone 32 · textarea/markdown/html 5000 | Per-field `maxLength` overrides | | **GDPR consent gate** | Off (opt-in) | `consent: { required, label }` + `privacyPolicyUrl` | | **CAPTCHA token** | Off (opt-in) | `captchaToken` (string sent as `_captcha`) | | **Demo mode (`?demo=1`)** | DEV only | gated by `import.meta.env.DEV` in `EmbeddableForm` consumers | All gates run **before** the network call. If the consent gate or min-fill timer trips, the backend is never contacted. The honeypot silently shows the thank-you page so bots can't tell they failed. ## Configuration reference [#configuration-reference] ```ts import type { FormField } from '@object-ui/types'; import type { EmbeddableFormTexts } from '@object-ui/plugin-form'; interface EmbeddableFormConfig { formId: string; objectName: string; title?: string; description?: string; // Fields — either from a registered schema or inline fields?: string[]; customFields?: FormField[]; // Anti-spam honeypot?: string | false; // field name (default '_company_website_2'), false disables minFillTime?: number; // ms before submit allowed (default 1500) captchaToken?: string; // forwarded as payload._captcha // Prefill & redirect hardening allowedPrefillFields?: string[]; // empty/undefined → no URL prefill allowedRedirectHosts?: string[]; // supports '*.example.com' // GDPR consent?: { required?: boolean; label?: string }; privacyPolicyUrl?: string; // UI branding?: { logo?: string; primaryColor?: string; coverImage?: string }; thankYouPage?: { title?: string; message?: string; redirectUrl?: string; redirectDelay?: number }; texts?: EmbeddableFormTexts; // i18n-friendly string overrides } ``` ### URL prefill (safe-by-default) [#url-prefill-safe-by-default] Public form URLs are user-controlled, so prefill is **off** unless you explicitly opt fields in: ```tsx ``` With the snippet above, visiting `/f/contact?email=alice@x.com&secret=foo` fills the `email` field and silently ignores `secret`. The `prefillParams` prop (used by trusted hosts such as the console) bypasses this whitelist. ### Open-redirect guard [#open-redirect-guard] `thankYouPage.redirectUrl` is validated against the current origin **plus** `allowedRedirectHosts` before navigation. Wildcards like `*.example.com` match subdomains; the apex itself must be listed explicitly. Dangerous schemes (`javascript:`, `data:`) are always rejected. The thank-you panel follows that verdict rather than the declaration: the `Redirecting in N seconds…` line appears only for a destination that was **accepted**, and reads its countdown from the delay that destination was accepted with. When the destination is refused the line is omitted, and `texts.redirectBlocked` — if you declared it — is shown in the panel instead; leave it undeclared and the panel stays silent. Either way the refusal is logged with `console.warn`, which is where to look if a redirect you expected never happens. ### Who performs the redirect (mounted hosts) [#who-performs-the-redirect-mounted-hosts] The guard decides **whether** a destination is followed; who travels to it depends on the shape of the destination: | Destination | Travelled by | | -------------------------------------------------------- | ------------------------------------------------------------------------- | | App-relative — `/thanks`, `thanks`, `?ok=1`, `#done` | the host's navigate, when one is supplied; otherwise a browser navigation | | External — a host you listed in `allowedRedirectHosts` | a browser navigation, always | | Same-origin but **absolute** — `https://your.app/thanks` | a browser navigation, always | This matters when your application is mounted at a sub-path (the console runs at basename `/_console`). A browser navigation resolves `/thanks` against the **origin root**, which leaves the application — so an in-app thank-you page needs the host to place the path. Supply one with `HostNavigationProvider` from `@object-ui/react` and the form hands app-relative destinations to it: ```tsx import { HostNavigationProvider } from '@object-ui/react'; import { EmbeddableForm, type EmbeddableFormConfig } from '@object-ui/plugin-form'; import type { DataSource } from '@object-ui/types'; export function MountedPublicForm(props: { /** Your own router's navigate — it already knows the basename. */ navigate: (to: string) => void; config: EmbeddableFormConfig; dataSource: DataSource; }) { return ( ); } ``` Supplying it is optional and changes nothing for an unmounted host: with no provider — or with no router at all, where there is no basename to miss — every destination keeps the browser navigation it always had. An external destination is never handed to your navigate. A host navigate is a client-side router transition, so it takes an application-relative path, and a form holding a cross-origin address must not route it through your router. A same-origin **absolute** URL is treated the same way for a different reason: you spelled out the whole address, so that is the address the submitter gets — write the destination relatively if you want it placed inside your mount. ### GDPR consent [#gdpr-consent] ```tsx consent: { required: true, label: 'I agree to the privacy policy.' } privacyPolicyUrl: '/legal/privacy' ``` When `required: true`, submitting before the box is ticked shows `texts.consentRequired` and the network call is suppressed. ### Honeypot [#honeypot] The hidden input is rendered off-screen with `tabIndex={-1}` and `autocomplete="off"`. Bots that blindly fill every field trigger a **silent fake-success** — the visitor sees the thank-you screen, but `dataSource.create()` is never called. Honeypot data is also stripped from the payload defensively. ### Min-fill-time [#min-fill-time] Genuine users take more than \~1.5 s to read and fill a form. Faster submissions are soft-rejected with `texts.rateLimited` rather than a hard error, so legit speed-typers can simply retry. ### CAPTCHA hook [#captcha-hook] `EmbeddableForm` doesn't bundle a specific provider. Mount your preferred widget (hCaptcha, Turnstile, reCAPTCHA) and feed the token in: ```tsx const [token, setToken] = useState(); ``` The token is forwarded as `payload._captcha` for server-side validation. ## i18n [#i18n] `EmbeddableForm` is i18n-agnostic — every user-visible string is overridable via `config.texts: EmbeddableFormTexts`. The console wires this up through `@object-ui/i18n`: ```tsx const { t } = useObjectTranslation(); ``` ## Console route — `/f/:slug` and `/forms/:name` [#console-route--fslug-and-formsname] The console's `FormPage` component renders both modes from the same spec-merging code path: * **`/f/:slug`** (public, anonymous) — loads `GET /api/v1/forms/:slug` which resolves the `FormView` whose `sharing.publicLink` matches the slug, then submits to `POST /api/v1/forms/:slug/submit`. * **`/forms/:name`** (internal, authed) — loads `GET /api/v1/meta/view/:name` for the FormView spec plus `GET /api/v1/meta/object/:object` for field metadata, then submits to `POST /api/v1/data/:object` with the authenticated session cookie. URL parameters of the form `?prefill_=` populate the matching fields on mount; the rest of the chrome (label, section columns, post-submit behaviour) comes from the `FormView` spec itself. For richer public forms with anti-spam, GDPR consent, prefill whitelisting and open-redirect protection, host `EmbeddableForm` directly inside your own route — see the Quick start above. ## Testing [#testing] Pure helpers (`isRedirectUrlSafe`, `applyDefaultMaxLengths`) are exported from `@object-ui/plugin-form` for unit testing. See `packages/plugin-form/src/__tests__/EmbeddableForm.test.tsx` for the reference test suite covering all gates. # Quick Start # Quick Start [#quick-start] Get up and running with ObjectUI in a small Vite app. This guide installs the core renderer, registers the built-in component packages, and renders a first JSON schema. ## Prerequisites [#prerequisites] * **Node.js** and **pnpm** (or npm/yarn) — ObjectUI is tested on Node 22.x with pnpm 10.x. * Basic knowledge of **React** and **TypeScript** ## Step 1: Create a React Project [#step-1-create-a-react-project] If you don't have an existing React project, create one with Vite: ```bash pnpm create vite my-app --template react-ts cd my-app ``` ## Step 2: Install ObjectUI [#step-2-install-objectui] Install the core ObjectUI packages: ```bash pnpm add @object-ui/react @object-ui/core @object-ui/types @object-ui/components @object-ui/fields ``` Install Tailwind CSS for styling: ```bash pnpm add -D tailwindcss @tailwindcss/vite ``` ## Step 3: Configure Tailwind CSS [#step-3-configure-tailwind-css] Add Tailwind to your `vite.config.ts`: ```ts import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [react(), tailwindcss()], }); ``` Add to your `src/index.css`: ```css @import "tailwindcss"; @import "@object-ui/components/style.css"; @import "@object-ui/fields/style.css"; ``` Each `style.css` is a stylesheet the package compiles from its own sources at build time, and between them they carry every utility ObjectUI renders with — the themed ones (`bg-primary`, `border-input`) included. **Import them in that order.** `@object-ui/components/style.css` is the complete sheet: Tailwind's base layer, the `@theme` tokens and the utilities its components use. `@object-ui/fields/style.css` is a small supplement on top of it — only the \~155 utilities the field widgets add and the components sheet does not already carry, which is why it is a few kB rather than another 170. It is not a standalone stylesheet, and on its own it will not style anything. **Plugin packages that publish a stylesheet need one line each.** `@object-ui/plugin-grid` and `@object-ui/plugin-kanban` ship the same kind of supplement, built the same way, so add whichever of them you install: ```css @import "@object-ui/plugin-grid/style.css"; @import "@object-ui/plugin-kanban/style.css"; ``` Without that line the plugin renders with no themed styling at all — its `bg-muted/10`, `bg-card/60` and `text-muted-foreground/60` have no other source in a published app, because the `@theme` block they resolve lives in package source that is never published ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)). The remaining `@object-ui/plugin-*` packages ship no stylesheet yet; importing one that does not exist breaks the build, so add only the lines above. That is the whole styling setup: you do not add `@source` lines for the ObjectUI packages, and pointing Tailwind at them inside `node_modules` only regenerates utilities these imports already gave you. ## Step 4: Render Your First Schema [#step-4-render-your-first-schema] Replace `src/App.tsx` with: ```tsx import '@object-ui/components'; import '@object-ui/fields'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; const schema = { type: 'card', title: 'Team Directory', description: 'Rendered from JSON metadata', className: 'mx-auto max-w-3xl', body: { type: 'data-table', caption: 'Users', columns: [ { header: 'Name', accessorKey: 'name', sortable: true }, { header: 'Email', accessorKey: 'email' }, { header: 'Role', accessorKey: 'role' }, ], data: [ { name: 'Ada Lovelace', email: 'ada@example.com', role: 'Admin' }, { name: 'Grace Hopper', email: 'grace@example.com', role: 'Editor' }, { name: 'Katherine Johnson', email: 'katherine@example.com', role: 'Viewer' }, ], pagination: false, searchable: false, }, } as const; function App() { return (
); } export default App; ``` Importing `@object-ui/components` and `@object-ui/fields` registers their renderers with the shared `ComponentRegistry`. `SchemaRendererProvider` supplies the data scope used by expressions, smart fields, and data-aware plugins. ## Step 5: Run the App [#step-5-run-the-app] ```bash pnpm dev ``` Open [http://localhost:5173](http://localhost:5173). You should see a card and data table rendered from JSON. ## What Just Happened? [#what-just-happened] 1. **Schema** - the UI was described as JSON with `type`, visual props, and nested `body`. 2. **Registry** - importing the component packages registered renderers for `card` and `data-table`. 3. **Renderer** - `SchemaRenderer` resolved each `type` and rendered React components. 4. **Provider** - `SchemaRendererProvider` made a data scope available for expressions and plugins. ## Next Steps [#next-steps] ### Add Actions [#add-actions] Actions are data, not inline functions. Declare one as an `action:button` node: `actionType` names the built-in executor the action runner dispatches to, and the action's own keys carry that executor's arguments — `target` is the location a `url` action navigates to: ```json { "type": "action:button", "label": "Open details", "actionType": "url", "target": "/users/ada" } ``` Learn the full action model in [Enhanced Actions](/docs/core/enhanced-actions). ### Connect a Data Source [#connect-a-data-source] ```bash pnpm add @object-ui/data-objectstack ``` ```tsx import { createObjectStackAdapter } from '@object-ui/data-objectstack'; const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' }); ``` Pass the adapter to `SchemaRendererProvider` and let data-aware renderers call the `DataSource` interface. See [Data Connectivity](/docs/guide/data-source). ### Learn More [#learn-more] * [Architecture Overview](/docs/guide/architecture) — Understand how ObjectUI works * [Schema Rendering](/docs/guide/schema-rendering) — Deep dive into schema rendering * [Component Registry](/docs/guide/component-registry) — Customize and extend components * [Plugins](/docs/guide/plugins) — Add views like Grid, Kanban, Charts * [Fields Guide](/docs/guide/fields) — Field widgets and cell renderers # React Pages # React Pages [#react-pages] Most pages in ObjectUI are a **schema tree** — `regions[].components[]` of JSON nodes. Two page kinds let you write the body as **source** instead, for layouts that are awkward to express as nested JSON: | `kind` | Source is | Executed? | Author trust | | --------- | ------------------------------------------ | ---------------------------------- | ------------ | | `"html"` | Constrained JSX/HTML | **No** — parsed into a schema tree | Untrusted OK | | `"react"` | Real React (hooks, handlers, arbitrary JS) | **Yes** — in the main React tree | Trusted only | Both set `source` and leave `regions` unused. `"jsx"` is a deprecated alias for `"html"` and is still accepted. > Page `kind` also carries the record-page override values `"full"` (default) > and `"slotted"` — a different axis, covered in [Slotted Pages](./slotted-pages.md). ## Choosing between them [#choosing-between-them] Reach for **`kind:'html'`** by default. It is parsed, whitelisted against the public block manifest, and never executed, so it is safe for AI-generated and customer-authored pages. It covers layout, blocks, and styling — styling through the blocks' own structured props plus a JSON `style` object, **not** Tailwind (see *Styling*, below; the rule holds on both tiers). Reach for **`kind:'react'`** only when you need real behaviour the schema tree cannot express — local state, computed lists, event handlers wiring one block to another, custom data fetching. It runs **without a sandbox**. ## `kind:'react'` [#kindreact] ```json { "type": "home", "name": "project_console", "kind": "react", "source": "function Page() {\n const [selected, setSelected] = React.useState(null);\n return (\n
\n setSelected(r._id)} />\n {selected && }\n
\n );\n}" } ``` Written out, that `source` is: ```jsx function Page() { const [selected, setSelected] = React.useState(null); return (
setSelected(r._id)} /> {selected && }
); } ``` ### The security gate [#the-security-gate] A react page's source is transpiled and evaluated directly in the application — no isolation, full access to the page's React tree. The platform assumes page authors are reviewed and draft-gated, so the host capability `react-pages` defaults **ON**. A deployment that does not trust its authors turns it off server-side with `OS_PAGE_REACT=off` (or `disableCapability('react-pages')` in the host). Pages then render an explanatory notice instead of executing. Existing `kind:'html'` pages are unaffected. ### What is in scope [#what-is-in-scope] Nothing is imported. These identifiers are injected as closure variables: | In scope | What it is | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `React` | The host's React — call hooks with it (`React.useState`). | | The public data blocks | Every public non-container block, as a PascalCase tag *on this tier* — but *what resolves* and *what you author against* are two different sets, below. | | `Block` | Escape hatch for anything not injected. | | `useAdapter` | The live data source — query/create/update. | | `data`, `variables`, `page` | The page's own data, local variables, and schema. | #### Two tiers: what resolves, and what you author against [#two-tiers-what-resolves-and-what-you-author-against] **The runtime scope** is every block in the curated public contract (`PUBLIC_BLOCKS`) that is not a layout container. **On this tier** tags are derived by splitting the registry type on `-`, `_` and `:` and PascalCasing each part: `object-grid` → ``, `record:details` → ``. A `kind:'html'` page writes the registry type itself instead — ``, ``. Blocks registered lazily are in scope too — you never wait on a plugin chunk to reference one. **The authored contract** is the much smaller set that has *published props* — checked by `os validate` and generated into the reference an author, human or AI, writes against: **``, ``, ``, ``**. That set is `REACT_BLOCKS` in `@objectstack/spec`, and the generated per-prop table is `skills/objectstack-ui/references/react-blocks.md` in the framework repo. **Treat that table as the prop authority, not this page.** Everything in the runtime scope but outside the contract still resolves and renders — its props simply are not part of the react-tier contract. Reach those through the contract instead: a kanban / calendar / gantt / timeline / map of an object is ``, or ``. #### The `record:*` family is excluded from this tier [#the-record-family-is-excluded-from-this-tier] The tag derivation above is real — `` and `` *are* defined in the scope — but every `record:*` block reads its record from the record context a **record page** mounts, and a `kind:'react'` page never mounts one. The block renders empty however you bind it: its `objectName`/`recordId` are not read by the renderer. `os validate` rejects them at publish time: ``` ✗ Author-time rules failed (1 issue) • page "showcase_renewals_pipeline" › RecordHighlights: RecordHighlights renders "record:highlights", which reads its record from the record context a record page mounts — a kind:'react' page never mounts one, so the block renders empty no matter how it is bound (its objectName/recordId are not read by the renderer). rule: react-block-needs-record-context ``` The rule matches by **type**, so `` is rejected the same way. On a react page, bind the record yourself: | Instead of | Write | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `` | `` — it binds by its own props. | | `` | ``, or read the record with `useAdapter().findOne` and lay the strip out in JSX. | | `` | ``, ``, `` and friends have no injected wrapper. In react mode you compose layout with real HTML, which React is better at than a schema-children renderer — styled inline, not with Tailwind: `
`. ### Styling — page source is metadata, not build input [#styling--page-source-is-metadata-not-build-input] **Do not author Tailwind utility classes in page source** — on either tier. A page's `source` is *runtime metadata*. The console's Tailwind is compiled at **build** time by scanning the console's own `src`, and there is no safelist, so it never sees your page. A utility class in page source produces CSS only if that exact class happens to already appear in objectui's own source, and otherwise produces **nothing, with no error anywhere**. This is the most expensive mistake on this tier: the page still renders — correct structure, correct data, no styling — and nothing reports it. It is recorded as a 2026-06-30 amendment to ADR-0080 under ADR-0065, after a modal's `bg-black/50` backdrop rendered fully transparent in production. `os validate` reports it as `page-source-className-tailwind` (a warning, on both tiers). Each tier has its own styling primitive: | `kind` | Style with | | --------- | ------------------------------------------------------------------------------------------------------- | | `"react"` | Inline `style={{ … }}`, with `hsl(var(--token))` for colour. | | `"html"` | The blocks' own structured props (``, ``) plus a JSON `style` object. | Colours come from the active theme, so the page follows light/dark and whatever theme the deployment installs: ```jsx
``` Common tokens: `--background`, `--foreground`, `--card`, `--muted`, `--muted-foreground`, `--border`, `--primary`, `--destructive`, plus the spacing/radius tokens `--space-*` and `--radius`. For overlays, do not hand-roll a `position: fixed; inset: 0` backdrop — render the form in its built-in Sheet or Dialog, which arrives already styled: ``. ### Blocks take flat props [#blocks-take-flat-props] An injected block folds its JSX props into the block's schema, so you write flat props rather than a nested `schema` object: ```jsx ``` Use the **canonical** spelling of each prop — the one the contract publishes. Several blocks still read older flat spellings as back-compat fallbacks but do not declare them, so they are not authoring surface: on ``, for instance, `pageSize` and `fields` are deprecated aliases of `pagination` and `columns`. Function props (`onRowClick`, `onSelect`) are passed through as real callbacks — that is how you wire one block to another. One collision to know about: `type` is both the schema's component discriminator and a legitimate prop name on some blocks (a chart's family, for instance). The discriminator wins the `type` slot, and your value is preserved next to it as `specType` for the block to read. ### `Block` — the escape hatch [#block--the-escape-hatch] Any registered component, including ones outside the public contract: ```jsx ``` ### Live data [#live-data] ```jsx function Page() { const adapter = useAdapter(); const [rows, setRows] = React.useState([]); React.useEffect(() => { adapter .find('showcase_project', { $filter: ['status', '=', 'open'] }) .then((res) => setRows(res.data ?? [])); }, [adapter]); return
    {rows.map((r) =>
  • {r.name}
  • )}
; } ``` Two things in that call are easy to get wrong, and neither one errors: **The `$` prefixes are load-bearing.** Every query key starts with `$` — `$select`, `$filter`, `$orderby`, `$skip`, `$top`, `$expand`, `$search`, `$searchFields`, `$count`. An unprefixed `filters:` or `top:` is not a query option — the adapter reads only the `$`-prefixed keys, so anything else is dropped and the call comes back **unfiltered**, or with the default page size, with no error. `$filter` takes an ObjectQL filter array — `['field', 'op', value]`, with `and`/`or` compounds spelled `['and', [...], [...]]`. **`find` resolves to a `QueryResult`, not an array.** It is `{ data, total, page, pageSize, hasMore }`; the rows are `res.data`. Passing the result straight to `setRows` and then calling `.map` on it throws. ### Source shapes [#source-shapes] The page renders the source's **default export**. An implicit `export default` is added when the source *starts with* JSX, a `function` declaration, `()`, or `class`: ```jsx function Page() { return

; } // ✅

hi

// ✅ () =>

hi

// ✅ const Page = () =>

; // ❌ exports nothing const Page = () =>

; export default Page; // ✅ ``` The `const Page = …` form does **not** get the implicit export — export it explicitly. Getting this wrong reports an error in the page error panel; it does not silently render blank. ### When something throws [#when-something-throws] Transpile errors, evaluation errors, and errors thrown while rendering all surface in a **React page error** panel with the message. The error is held until the page source or its data changes, so it does not flicker or escape to the generic renderer error. Referencing an identifier that is not in scope is the common case, and reads as `ReferenceError: is not defined` — usually a layout container (not injected — use HTML) or a block outside the public contract (use `Block`). ### Page state [#page-state] A react page keeps its own `useState` across re-renders and across lazy plugin loads. Three things reset it, all intentional: a change to `source`, a change to the page's data/variables, and a **new data source** — the page is genuinely a different page then. That last one is a requirement on the **host**, not the author. The page is recompiled when the adapter's *identity* changes, because recompiling is the only way the new adapter reaches the blocks inside the page. So a host that constructs a new adapter on every render resets every react page on every render. Provide it from state or a module constant: ```tsx // ❌ new adapter object every render — every react page below loses its state // ✅ const [adapter, setAdapter] = useState(null); ``` `@object-ui/app-shell`'s `AdapterProvider` already does this correctly; the rule matters for custom hosts and preview surfaces. ## `kind:'html'` [#kindhtml] The constrained tier. Same JSX-looking syntax, but the source is **parsed** into a schema tree and rendered through the normal renderer — never executed. Only tags in the public block manifest are allowed, props are validated against each block's declared inputs, and unknown tags are a hard error at save time. Those tags are the **registered type names, written verbatim** — whatever the registry spells, character for character, including a `record:` / `page:` / `element:` / `action:` namespace prefix and any underscore inside the name: ``, ``, ``, ``. The whitelist is an exact string comparison, so nothing is normalised for you: the **PascalCase** tags this page shows for `kind:'react'` are the other tier's convention and are not registered names (`` is rejected with ` is not an allowed component`), and neither is a name re-spelled to look uniform — `` is not registered, only `` is. Use it for anything author- or AI-generated. Expressions are limited to what the schema supports (`${data.x}`), and there is no local state or event handling beyond the action system. Styling works the same way as on the react tier — *page source is never scanned by the build* — but with this tier's own primitive: lay out with the blocks' structured props (``, ``) and add CSS as a JSON `style` object. See *Styling*, above. ## Related [#related] * [Slotted Pages](./slotted-pages.md) — `kind:'full'` / `kind:'slotted'` record pages. * [Schema Rendering](./schema-rendering.md) — the schema tree the other kinds compile to. * [Component Registry](./component-registry.md) — how blocks are registered and what makes one public. # Record Edit Modes # Record Edit Modes [#record-edit-modes] ObjectUI's default console shell (`@object-ui/app-shell`) supports two ways to render the create/edit form for a record: * **Modal** (default) — the form opens in an overlay dialog above the current view. Best for short forms, quick edits, and contextual data entry. * **Page** — the form takes over a full route. Best for long forms, multi-tab or wizard layouts, or anywhere you need a deep-linkable URL that survives a refresh and integrates with the browser back button. Both modes use the same `` pipeline under the hood, so all field types, sections, validations, and visibility expressions work identically in either mode. ## Choosing a mode [#choosing-a-mode] Set `editMode` on the object metadata: ```jsonc // metadata/objects/account.json { "name": "account", "label": "Account", "editMode": "page", // "modal" (default) | "page" "fields": { "name": { "type": "text", "label": "Name", "required": true }, "industry": { "type": "picklist", "label": "Industry" }, "owner": { "type": "lookup", "label": "Owner", "reference_to": "user" } } } ``` Omitting `editMode` (or setting it to `"modal"`) keeps the existing behavior — clicking **Create** or **Edit** opens the global `ModalForm` overlay. ## URL patterns [#url-patterns] When `editMode: "page"` is set, the console renders the form on a dedicated route under the active app: | Action | URL | | ------ | -------------------------------------------------- | | Create | `/apps/:appName/:objectName/new` | | Edit | `/apps/:appName/:objectName/record/:recordId/edit` | Examples (for an app `sales` and an object `account`): * Create: `https://your-console.example/apps/sales/account/new` * Edit: `https://your-console.example/apps/sales/account/record/0015e000abcd/edit` These URLs are stable. Users can bookmark them, share them in chat, or refresh the page mid-edit (the form rehydrates from the URL `:recordId`). ## Triggering the routes from JSON [#triggering-the-routes-from-json] In addition to the implicit "click create/edit on a list" entry point, two declarative actions let you open the page-mode routes from an `action:button` in metadata. The handler name goes in `actionType`: that is the key the button renderer forwards to the action runner as the action's type, and the runner dispatches to the handler registered under it. Arguments go in a top-level `params` object: ```jsonc { "type": "action:button", "label": "New Account", "icon": "plus", "actionType": "navigate_create", "params": { "objectName": "account" } } ``` `navigate_edit` additionally needs the record to open. `params` reaches the handler verbatim: template expressions such as `${record.id}` are not evaluated inside `params`, and `action:button` does not inject the surrounding row, so a declared `navigate_edit` button carries a literal `recordId`: ```jsonc { "type": "action:button", "label": "Edit", "icon": "pencil", "actionType": "navigate_edit", "params": { "objectName": "account", "recordId": "0015e000abcd" } } ``` For a per-row **Edit** that follows the record under the cursor, use the list or detail view's built-in **Edit** entry point instead: under `editMode: "page"` it already routes to the same URL (see *Migrating an existing object* below). When invoked from inside an `ObjectView`, the action context already carries the active `objectName`, so `params` may be omitted entirely: ```jsonc { "type": "action:button", "label": "New", "actionType": "navigate_create" } ``` ## Behavior summary [#behavior-summary] | Aspect | Modal | Page | | ----------------------- | ----------- | -------------------------- | | Default | ✅ | — | | Deep-linkable URL | ❌ | ✅ | | Survives refresh | ❌ | ✅ | | Back button closes form | n/a | ✅ | | Best for | quick edits | long / multi-section forms | ## Migrating an existing object [#migrating-an-existing-object] The change is additive — existing apps continue to work unchanged. To migrate a single object to page mode: 1. Add `"editMode": "page"` to the object metadata. 2. (Optional) Adjust the form layout — page mode pairs well with `formType: "tabbed"` or `formType: "wizard"` for long forms. 3. Reload the console. Existing **Create** / **Edit** entry points automatically route to the new pages; no UI code changes required. ## See also [#see-also] * [`@object-ui/app-shell` README](https://www.objectui.org/docs/layout/app-shell) * [`ObjectForm` API](../plugins/plugin-form.mdx) * [Schema rendering](./schema-rendering.md) # Release Notes # Release Notes [#release-notes] ObjectUI does not keep a hand-written release history on this page. Two sources carry it, both written as part of the release itself: * **Each package's own `CHANGELOG.md`** — the source of truth for granular history. Changesets writes an entry into every affected `@object-ui/*` package on each release commit, so the changelog beside the package you depend on states exactly what changed in it, including breaking changes and migration notes. Read it in the installed package (`node_modules/@object-ui//CHANGELOG.md`), on that package's npm page, or in this repository under [`packages//CHANGELOG.md`](https://github.com/objectstack-ai/objectui/tree/main/packages). * **[GitHub Releases](https://github.com/objectstack-ai/objectui/releases)** — every published version, newest first, with its tag and publication date. Start here to see which version is current. The monorepo [CHANGELOG.md](https://github.com/objectstack-ai/objectui/blob/main/CHANGELOG.md) is a periodically hand-curated summary, not an auto-maintained record, and can lag the latest releases. Treat each package's own `CHANGELOG.md` as authoritative where the two disagree. # Schema Catalog # Schema Catalog [#schema-catalog] Every interactive demo on this site renders a JSON schema that lives in [`@object-ui/example-schema-catalog`](https://github.com/objectstack-ai/objectui/tree/main/examples/schema-catalog). This page is the visual gallery over that catalog — live thumbnails for every entry, grouped by domain. Click a card to open a full interactive preview with the JSON, copy a stable id, or grab a ready-to-paste `` snippet for any MDX page. ## How the catalog works [#how-the-catalog-works] * **Source of truth.** Each schema lives as a single `.json` file under `examples/schema-catalog/src/schemas//.json` and is imported into the registry by `examples/schema-catalog/src/index.ts`. * **One id, one schema.** Ids are stable (e.g. `auth/login-simple`). MDX pages reference schemas with ``, which looks the schema up via `getExample(id)` from the catalog package. * **Smoke-tested.** The catalog ships a Vitest suite that resolves every id and instantiates every schema; CI fails if any entry breaks. Adding a schema is a one-file change; renaming or deleting one fails CI loudly. * **Not published.** The catalog package is `private: true` — it is a test/docs fixture, not a runtime dependency for consuming apps. ## Browse [#browse] ## Adding a new example [#adding-a-new-example] 1. Drop the JSON under `examples/schema-catalog/src/schemas//.json`. 2. Run `pnpm -F @object-ui/example-schema-catalog regenerate` to update `src/index.ts` (the import map is generated). 3. Reference it from any MDX page: ```mdx ``` 4. `pnpm -F @object-ui/example-schema-catalog test` smoke-tests the new entry; the same test runs in CI on every push. # Schema Overview # Schema Overview [#schema-overview] ObjectUI provides powerful schemas that enable you to build sophisticated enterprise applications with advanced features like theming, reporting, and complex workflows. This guide provides an overview of all available schemas and helps you get started quickly. ## Key Capabilities [#key-capabilities] ObjectUI includes enterprise-grade capabilities to build production-ready applications: * **Application Structure** - Define complete multi-page applications with navigation * **Dynamic Theming** - Brand your applications with custom themes and light/dark modes * **Advanced Actions** - Build complex workflows with API calls, chaining, and conditions * **Enterprise Reporting** - Generate, schedule, and export comprehensive reports ## Core Schemas [#core-schemas] ### Application Configuration [#application-configuration] #### [App Schema](/docs/core/app-schema) [#app-schema] Define your entire application structure with navigation, branding, and global settings. ```typescript const app: AppComponentSchema = { type: 'app', title: 'My Application', layout: 'sidebar', menu: [...], actions: [...] }; ``` **Use Cases:** * Multi-page applications * Admin dashboards * CRM systems * Internal tools *** ### Theming & Branding [#theming--branding] #### [Theme Schema](/docs/core/theme-schema) [#theme-schema] Dynamic theming with light/dark modes, color palettes, and typography. Theming is **not** a component you declare in a page. There is no `type: 'theme'` node: the `ThemeComponentSchema` wrapper documented here until objectui#5489 was retired because no renderer ever implemented it, so a page declaring one got the registry's "Unknown component type" panel rather than a theme manager. A theme is a **document**, not a node. Author it as the `Theme` shape `@object-ui/types` re-exports from `@objectstack/spec/ui`, hand it to `ThemeProvider` (`@object-ui/react`), and `ThemeEngine` (`@object-ui/core`) turns it into the CSS variables your components already read. **What the theme document carries:** * Light/dark mode switching * 20+ semantic colors * Typography system * CSS variables * Tailwind integration *** ### Advanced Actions [#advanced-actions] #### [Enhanced Actions](/docs/core/enhanced-actions) [#enhanced-actions] Powerful action system with AJAX calls, chaining, conditions, and tracking. ```typescript const action: ActionSchema = { type: 'action', actionType: 'ajax', api: '/api/submit', chain: [...], condition: '${...}', tracking: {...} }; ``` **New Action Types:** * **`ajax`** - API calls with full request configuration * **`confirm`** - Confirmation dialogs * **`dialog`** - Modal/dialog actions **Key Features:** * Action chaining (sequential/parallel) * Conditional execution (a `condition` predicate gates whether an action runs) * Success / failure notices (`successMessage` / `errorMessage`) * Event tracking * Retry logic *** ### Reporting [#reporting] #### [Report Schema](/docs/core/report-schema) [#report-schema] Enterprise reports with aggregation, export, and scheduling. ```typescript import type { ReportComponentSchema } from '@object-ui/types'; const report: ReportComponentSchema = { type: 'report', title: 'Sales Report', fields: [ { name: 'revenue', aggregation: 'sum' }, { name: 'orders', aggregation: 'count' } ], schedule: { frequency: 'monthly', recipients: ['team@company.com'] } }; ``` **Features:** * Field aggregation (sum, avg, count, min, max) * Multiple export formats (PDF, Excel, CSV) * Scheduled reports * Email distribution * Interactive builder *** ## Quick Comparison [#quick-comparison] | Schema | Purpose | Best For | | ------------------------- | --------------------- | ------------------------------------- | | **AppComponentSchema** | Application structure | Multi-page apps, dashboards | | **Enhanced Actions** | Complex workflows | API integration, multi-step processes | | **ReportComponentSchema** | Data reporting | Analytics, business intelligence | ## View Components [#view-components] ObjectUI also includes enhanced view components: ### [Detail View](/docs/plugins/plugin-detail) [#detail-view] Rich detail pages with sections, tabs, and related records. ### [View Switcher](/docs/components/complex/view-switcher) [#view-switcher] Toggle between list, grid, kanban, calendar, timeline, and map views. ### [Filter UI](/docs/components/complex/filter-ui) [#filter-ui] Advanced filtering interface with multiple field types. ### [Sort UI](/docs/components/complex/sort-ui) [#sort-ui] Sort configuration with multiple fields. ## Installation & Setup [#installation--setup] ### Package Installation [#package-installation] All schemas are included in `@object-ui/types`. Install it in your project: ```bash npm install @object-ui/types # or pnpm add @object-ui/types # or yarn add @object-ui/types ``` ### TypeScript Usage [#typescript-usage] Import the type definitions you need: ```typescript import type { AppComponentSchema, ActionSchema, ReportComponentSchema } from '@object-ui/types'; ``` ### Runtime Validation [#runtime-validation] For runtime validation, use the included Zod schemas: ```typescript import { AppComponentSchema, ActionSchema, ReportComponentSchema } from '@object-ui/types/zod'; const myConfig = { type: 'app', title: 'My Application', layout: 'sidebar' }; const result = AppComponentSchema.safeParse(myConfig); if (result.success) { // Valid configuration const app = result.data; } else { // Handle validation errors console.error(result.error); } ``` ## Quick Start Example [#quick-start-example] Here's a complete example showing how to build a simple CRM application using ObjectUI schemas: ```typescript import type { AppComponentSchema } from '@object-ui/types'; // Define your application structure const app: AppComponentSchema = { type: 'app', name: 'enterprise-crm', title: 'Enterprise CRM', layout: 'sidebar', menu: [ { type: 'item', label: 'Dashboard', icon: 'LayoutDashboard', path: '/dashboard' }, { type: 'group', label: 'Sales', children: [ { type: 'item', label: 'Leads', path: '/leads' }, { type: 'item', label: 'Deals', path: '/deals' } ] } ], actions: [ { type: 'user', label: 'User Name', items: [ { type: 'item', label: 'Profile', path: '/profile' }, { type: 'item', label: 'Logout', path: '/logout' } ] } ] }; ``` This creates a professional-looking CRM application with: * A sidebar layout with navigation menu * Sales section with leads and deals * User menu with profile and logout options Theming is configured separately, as a theme document handed to `ThemeProvider` — see [Theme Schema](/docs/core/theme-schema). ## Advanced Features [#advanced-features] ObjectUI provides advanced schemas and capabilities for enterprise applications: ### Core Schemas [#core-schemas-1] ObjectUI includes these top-level schemas: * **`AppComponentSchema`** - Define your entire application structure * **`ReportComponentSchema`** - Create data reports with aggregation ### Enhanced ActionSchema [#enhanced-actionschema] The `ActionSchema` provides comprehensive action handling: * ✅ Action types: `ajax`, `confirm`, `dialog` * ✅ Action chaining via the `chain` array (sequential or parallel) * ✅ Conditional execution with the `condition` property * ❌ Success/failure callbacks: `onSuccess` / `onFailure` were RETIRED (objectui#7068) — both faces refuse them; write `successMessage` / `errorMessage` for notices, and the spec's `onSuccess` block `{ navigate, openIn }` on `UIActionSchema` for post-success navigation (objectui#5934) * ✅ Event tracking with the `tracking` configuration * ✅ Automatic retry logic ### View Components [#view-components-1] ObjectUI includes enhanced view components: * **`DetailViewSchema`** - Rich detail pages with sections and tabs * **`ViewSwitcherSchema`** - Toggle between list, grid, kanban, calendar views * **`FilterUISchema`** - Advanced filtering interface * **`SortUISchema`** - Multi-field sort configuration ## Getting Started [#getting-started] ### Installation Steps [#installation-steps] 1. **Install package** - Add `@object-ui/types` to your project ```bash npm install @object-ui/types@latest ``` 2. **Configure application** - Define your app structure with AppComponentSchema (optional) 3. **Set up theming** - Hand a `Theme` document to `ThemeProvider` for consistent styling (optional) 4. **Implement actions** - Use advanced action features like `confirm` and chaining 5. **Test your application** - Verify all functionality works as expected ## Learning Resources [#learning-resources] * **[Schema Type Reference](/docs/api/schema-reference)** - Complete schema reference with JSON examples. * **[Quick Start](/docs/guide/quick-start)** - Render your first ObjectUI schema. * **[Schema Rendering](/docs/guide/schema-rendering)** - Understand the renderer pipeline. * **[Component Registry](/docs/guide/component-registry)** - Learn how schema `type` values resolve to components. ## Getting Help [#getting-help] ### Community Support [#community-support] * **[GitHub Discussions](https://github.com/objectstack-ai/objectui/discussions)** - Ask questions and share ideas * **[GitHub Issues](https://github.com/objectstack-ai/objectui/issues)** - Report bugs and request features ### Official Documentation [#official-documentation] * **[Documentation Site](https://www.objectui.org/docs)** - Full documentation and guides * **[Schema Reference](/docs/api/schema-reference)** - Detailed schema documentation ## Next Steps [#next-steps] Ready to build with ObjectUI? Here's what to do next: 1. **[Review schema documentation](/docs/api/schema-reference)** - Learn about each schema in detail 2. **[Try the Quick Start](/docs/guide/quick-start)** - Build your first ObjectUI application 3. **[Explore components](/docs/components)** - See the core renderer catalog 4. **[Explore plugins](/docs/plugins)** - Add heavier widgets such as grids, kanban, charts, maps, and reports # Schema Playground # Schema Playground [#schema-playground] The Schema Playground is the fastest way to learn ObjectUI. Write JSON schemas in an editor, see the rendered UI instantly, and iterate on your designs without setting up a project. ## How It Works [#how-it-works] The playground follows the core ObjectUI rendering pipeline: ``` JSON Editor → Schema Validation → SchemaRenderer → Live Preview ``` 1. **Write** a JSON schema in the left panel 2. **Preview** the rendered UI in the right panel in real-time 3. **Iterate** by modifying properties and seeing changes instantly ## Setting Up the Playground [#setting-up-the-playground] Add the playground to any React project with ObjectUI installed: ```tsx import { useState } from 'react'; import { SchemaRenderer } from '@object-ui/react'; import { initializeComponents } from '@object-ui/components'; // Side-effect import: loading the package runs its own field registration. import '@object-ui/fields'; initializeComponents(); function SchemaPlayground() { const [schema, setSchema] = useState('{\n "type": "button",\n "label": "Click me"\n}'); const [error, setError] = useState(null); const parsed = (() => { try { const obj = JSON.parse(schema); if (error) setError(null); return obj; } catch (e) { setError((e as Error).message); return null; } })(); return (

JSON Schema