From 8ee8dcc4253a4a5fa948d0473a4fc55d01f88e06 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 08:25:24 +0000 Subject: [PATCH 1/7] docs(ui): generate the component reference from sdui.manifest.json, with a drift check scripts/gen-sdui-reference.mjs renders content/docs/ui/components/ from the repo-root manifest: one page per namespace (html-tier elements and the plugin-* blocks each share a page), every input as a table row, and an index linking all components. --check fails when the committed pages differ; lint.yml runs it next to the manifest's own check. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibwMyAnTNeas3ANYhyi --- .github/workflows/lint.yml | 11 + content/docs/ui/components/action.mdx | 112 ++++ content/docs/ui/components/element.mdx | 80 +++ content/docs/ui/components/html.mdx | 460 ++++++++++++++++ content/docs/ui/components/index.mdx | 141 +++++ content/docs/ui/components/meta.json | 12 + content/docs/ui/components/page.mdx | 85 +++ content/docs/ui/components/plugins.mdx | 387 +++++++++++++ content/docs/ui/components/record.mdx | 181 ++++++ content/docs/ui/components/ui.mdx | 172 ++++++ content/docs/ui/meta.json | 1 + package.json | 2 + scripts/gen-sdui-reference.mjs | 727 +++++++++++++++++++++++++ 13 files changed, 2371 insertions(+) create mode 100644 content/docs/ui/components/action.mdx create mode 100644 content/docs/ui/components/element.mdx create mode 100644 content/docs/ui/components/html.mdx create mode 100644 content/docs/ui/components/index.mdx create mode 100644 content/docs/ui/components/meta.json create mode 100644 content/docs/ui/components/page.mdx create mode 100644 content/docs/ui/components/plugins.mdx create mode 100644 content/docs/ui/components/record.mdx create mode 100644 content/docs/ui/components/ui.mdx create mode 100644 scripts/gen-sdui-reference.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d9313b69388..d616d33a552 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1013,6 +1013,17 @@ jobs: continue-on-error: true run: node scripts/check-sdui-manifest.mjs --self-test && node scripts/check-sdui-manifest.mjs + # The component reference under content/docs/ui/components/ is generated + # from that same manifest by scripts/gen-sdui-reference.mjs, so it sits + # next to the manifest's own check: a PR that changes the manifest and + # not the reference reds here, and the two land in one PR. Offline and + # unfiltered for the reason the step above gives. The script header is + # the authority on the page layout and on what the drift check compares. + - name: Component reference matches the SDUI manifest + id: component-reference-matches-the-sdui-manifest + continue-on-error: true + run: pnpm check:sdui-reference + # pm-dispatch SKILL.md line ratchet (#7341 / #5925 item 7): the PM skill # is read in full by every seat session and every Routine fire, so its # size is a per-round tax on the whole fleet. The #7341 extraction moved diff --git a/content/docs/ui/components/action.mdx b/content/docs/ui/components/action.mdx new file mode 100644 index 00000000000..3a0eea5cd1e --- /dev/null +++ b/content/docs/ui/components/action.mdx @@ -0,0 +1,112 @@ +--- +title: "Action components — component reference" +navTitle: "Actions" +description: "The action:* components that put an action on a page: a button, an icon, a group or a menu." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Generated from sdui.manifest.json by node scripts/gen-sdui-reference.mjs; change the manifest or the generator, never this page. */} + +The `action:*` components put an action on a page as a button, an icon button, a group of buttons or a menu. + + +This page is generated from the repo-root `sdui.manifest.json`, the component manifest CI +regenerates from the renderer registry. Regenerate it with +`node scripts/gen-sdui-reference.mjs`; `node scripts/gen-sdui-reference.mjs --check` fails when it is stale. + + +## `action:button` [#action-button] + +Namespace `action`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `name` | `string` | | — | +| `label` | `string` | | — | +| `icon` | `string` | | — | +| `actionType` | `enum`: `script`, `url`, `modal`, `flow`, `api` | | — | +| `target` | `string` | | — | +| `variant` | `enum`: `default`, `primary`, `secondary`, `destructive`, `outline`, `ghost` | | — | +| `size` | `enum`: `default`, `sm`, `md`, `lg`, `icon` | | Button size: the Button primitive's `default`, `sm`, `lg` or `icon`, plus `md`, which renders as `default` (default: `default`) | +| `className` | `string` | | — | +| `visible` | `boolean` \| `string` \| `object` | | Visibility predicate: `true`/`false`, a bare CEL expression, or the `{ dialect: 'cel', source }` envelope, evaluated against the row the host binds; the button is not rendered when it is false, nor when the predicate fails to evaluate. Omit for always-visible | +| `disabled` | `boolean` \| `string` \| `object` | | Disabled predicate, in the same three shapes as `visible`: the button is shown but cannot be pressed while it holds. Omit for never-disabled | +| `params` | `array` | | The parameters to collect from the user before the action runs (`ActionParam` objects: `name`, `type`, `label`, …); each collected value reaches the executor under its parameter `name`. Static execution values ride `properties.params` instead | +| `description` | `string` | | Action description — the parameter dialog shows it under its title | +| `openIn` | `enum`: `self`, `new-tab` | | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab | +| `method` | `string` | | HTTP method of an `api` action (`POST` when omitted) | +| `bodyExtra` | `object` | | Static request-body fields of an `api` action, merged last, so a constant here overrides a collected value of the same name | +| `bodyShape` | `enum` \| `object`: `flat` | | How an `api` action shapes its request body: `flat` (the default) or `{ wrap: KEY }` to nest the collected values under that key, with `bodyExtra` beside it | +| `operation` | `enum`: `update` | | `update` declares a single-record field write: the row runs the declared action its `name` names, so it needs a `name`. The runner posts the collected values to the platform action route under that name, and the route writes that action's own `patch` | +| `confirmText` | `string` | | Confirmation question asked before the action runs; the action runs only if it is confirmed | +| `successMessage` | `string` | | Toast text when the action succeeds; a `${result.*}` token reads the handler's answer (e.g. `${result.id}`) | +| `errorMessage` | `string` | | Toast text when the action fails, in place of the raw error | +| `refreshAfter` | `boolean` | | Refresh the surrounding data after the action succeeds | +| `locations` | `array` of `string` | | The placements the action declares (`list_toolbar`, `list_item`, `record_header`, …). A `script` or `flow` action declared record-scoped (`list_item`, `record_header`, `record_more` or `record_section`) refuses to run when no record or single selection is in scope, instead of running without one | +| `toast` | `object` | | Toast behaviour: `{ showOnSuccess?, showOnError?, duration? }` — `false` suppresses that toast, `duration` is handed to the toast | +| `resultDialog` | `object` | | One-shot result dialog for a value the response shows exactly once (a 2FA code, a fresh secret); it replaces the success toast | +| `onSuccess` | `object` | | Post-success navigation `{ navigate, openIn? }`: `navigate` is a route template that can read `${result.*}`, `openIn` is `self` (the default) or `newTab` | +| `objectName` | `string` | | Object the action acts on — dispatch goes to this object instead of the page's. Omit to act on the page's object | +| `recordIdField` | `string` | | For a `script` action run against a single selected row: the row field whose value is sent as the record id (default `id`) | +| `undoable` | `boolean` | | Offer an Undo affordance after an update action: once an `operation: update` succeeds, its success toast offers Undo, which writes back the values the fields it wrote held on the record in scope (the record page's record, or the row the host binds), provided that record carries each of them. The one limit: a button with no record in scope (standalone, or writing a record other than the one in scope) offers no Undo, because there is no row to restore | + +## `action:group` [#action-group] + +Namespace `action`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `label` | `string` | | — | +| `icon` | `string` | | — | +| `actions` | `array` of `object` | | The actions in this group, in order. Each member is an action object the group draws and runs itself (`name`, `label`, `icon`, `type`, `target`, `locations`, `visible`, `disabled`, …); a member's executor is its own `type` | +| `display` | `enum`: `inline`, `dropdown` | | — | +| `location` | `enum`: `list_toolbar`, `list_item`, `record_header`, `record_more`, `record_related`, `record_section` | | Render only the members whose `locations` include this location. Omit to render every member | +| `variant` | `enum`: `default`, `secondary`, `outline`, `ghost` | | — | +| `size` | `enum`: `default`, `sm`, `lg`, `icon` | | Button size for the dropdown trigger and for every inline member that sets none | +| `visible` | `boolean` \| `string` \| `object` | | Visibility predicate for the whole group: `true`/`false`, a bare CEL expression, or the `{ dialect: 'cel', source }` envelope, evaluated against the row the host binds. Omit for always-visible | +| `className` | `string` | | — | + +## `action:icon` [#action-icon] + +Namespace `action`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `name` | `string` | | — | +| `label` | `string` | | — | +| `icon` | `string` | | — | +| `actionType` | `enum`: `script`, `url`, `modal`, `flow`, `api` | | — | +| `target` | `string` | | — | +| `variant` | `enum`: `default`, `secondary`, `destructive`, `outline`, `ghost` | | — | +| `className` | `string` | | — | +| `visible` | `boolean` \| `string` \| `object` | | Visibility predicate: `true`/`false`, a bare CEL expression, or the `{ dialect: 'cel', source }` envelope, evaluated against the row the host binds; the icon is not rendered when it is false. Omit for always-visible | +| `disabled` | `boolean` \| `string` \| `object` | | Disabled predicate, in the same three shapes as `visible`: the icon is shown but cannot be pressed while it holds. Omit for never-disabled | +| `params` | `array` | | The parameters to collect from the user before the action runs (`ActionParam` objects: `name`, `type`, `label`, …); each collected value reaches the executor under its parameter `name`. Static execution values ride `properties.params` instead | +| `description` | `string` | | Action description — the tooltip text when there is no `label`, and what the parameter dialog shows under its title | +| `openIn` | `enum`: `self`, `new-tab` | | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab | +| `method` | `string` | | HTTP method of an `api` action (`POST` when omitted) | +| `bodyExtra` | `object` | | Static request-body fields of an `api` action, merged last, so a constant here overrides a collected value of the same name | +| `bodyShape` | `enum` \| `object`: `flat` | | How an `api` action shapes its request body: `flat` (the default) or `{ wrap: KEY }` to nest the collected values under that key, with `bodyExtra` beside it | +| `operation` | `enum`: `update` | | `update` declares a single-record field write: the row runs the declared action its `name` names, so it needs a `name`. The runner posts the collected values to the platform action route under that name, and the route writes that action's own `patch` | +| `confirmText` | `string` | | Confirmation question asked before the action runs; the action runs only if it is confirmed | +| `successMessage` | `string` | | Toast text when the action succeeds; a `${result.*}` token reads the handler's answer (e.g. `${result.id}`) | +| `errorMessage` | `string` | | Toast text when the action fails, in place of the raw error | +| `refreshAfter` | `boolean` | | Refresh the surrounding data after the action succeeds | +| `locations` | `array` of `string` | | The placements the action declares (`list_toolbar`, `list_item`, `record_header`, …). A `script` or `flow` action declared record-scoped (`list_item`, `record_header`, `record_more` or `record_section`) refuses to run when no record or single selection is in scope, instead of running without one | +| `toast` | `object` | | Toast behaviour: `{ showOnSuccess?, showOnError?, duration? }` — `false` suppresses that toast, `duration` is handed to the toast | +| `resultDialog` | `object` | | One-shot result dialog for a value the response shows exactly once (a 2FA code, a fresh secret); it replaces the success toast | +| `onSuccess` | `object` | | Post-success navigation `{ navigate, openIn? }`: `navigate` is a route template that can read `${result.*}`, `openIn` is `self` (the default) or `newTab` | +| `objectName` | `string` | | Object the action acts on — dispatch goes to this object instead of the page's. Omit to act on the page's object | + +## `action:menu` [#action-menu] + +Namespace `action`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `label` | `string` | | — | +| `icon` | `string` | | — | +| `actions` | `array` of `object` | | The menu's actions, in order. Each member is an action object the menu draws and runs itself (`name`, `label`, `icon`, `type`, `target`, `visible`, `disabled`, `tags`, …); a member's executor is its own `type` | +| `variant` | `enum`: `default`, `secondary`, `outline`, `ghost` | | — | +| `size` | `enum`: `default`, `sm`, `lg`, `icon` | | Trigger button size (renderer default: `icon`) | +| `visible` | `boolean` \| `string` \| `object` | | Visibility predicate for the whole menu: `true`/`false`, a bare CEL expression, or the `{ dialect: 'cel', source }` envelope, evaluated against the row the host binds; a predicate that fails to evaluate hides it. Omit for always-visible | +| `className` | `string` | | — | diff --git a/content/docs/ui/components/element.mdx b/content/docs/ui/components/element.mdx new file mode 100644 index 00000000000..a77e6f8cf81 --- /dev/null +++ b/content/docs/ui/components/element.mdx @@ -0,0 +1,80 @@ +--- +title: "Element components — component reference" +navTitle: "Elements" +description: "The element:* components: text, numbers, buttons, dividers, definition lists and repeaters." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Generated from sdui.manifest.json by node scripts/gen-sdui-reference.mjs; change the manifest or the generator, never this page. */} + +The `element:*` components are small, self-contained page elements. + + +This page is generated from the repo-root `sdui.manifest.json`, the component manifest CI +regenerates from the renderer registry. Regenerate it with +`node scripts/gen-sdui-reference.mjs`; `node scripts/gen-sdui-reference.mjs --check` fails when it is stale. + + +## `element:button` [#element-button] + +Namespace `element`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `label` | `string` \| `object` | yes | Accepts an inline translation map (\{ en, "zh-CN", … \}) | +| `action` | `object` | | Inline ActionDef executed on click (url / navigation / api / script / modal / flow); omitted → renders inert | +| `variant` | `enum`: `primary`, `secondary`, `danger`, `ghost`, `link` | | — | +| `size` | `enum`: `small`, `medium`, `large` | | — | +| `icon` | `string` | | Lucide icon name | +| `iconPosition` | `enum`: `left`, `right` | | — | +| `disabled` | `boolean` | | — | + +## `element:definition-list` [#element-definition-list] + +Namespace `element`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `items` | `array` of `object` | | Term/description pairs [\{ term, description \}], in order: `term` is the row label, `description` is shown as-is (an object as JSON, an omitted one as an em dash). Omitted or empty renders the empty state: the active language’s "No data" (`common.noData`) | +| `columns` | `enum`: `[object Object]`, `[object Object]` | | Grid columns from the small breakpoint up: the NUMBER 1 or 2 (default 1) | +| `inline` | `boolean` | | Term and description on one baseline-aligned row | + +## `element:divider` [#element-divider] + +Namespace `element`. + +It takes no inputs. + +## `element:number` [#element-number] + +Namespace `element`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `aggregate` | `enum`: `count`, `sum`, `avg`, `min`, `max` | yes | — | +| `field` | `string` | | Measure field (required for every aggregate except count) | +| `format` | `enum`: `number`, `currency`, `percent` | | — | +| `prefix` | `string` | | — | +| `suffix` | `string` | | — | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `element:repeater` [#element-repeater] + +Namespace `element`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `titleField` | `string` | | — | +| `fields` | `array` | | Fields shown after the title on each line, in order: a bare field name, or `{ field }` | +| `emptyText` | `string` | | — | +| `divided` | `boolean` | | Separator between rows | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `element:text` [#element-text] + +Namespace `element`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `content` | `string` \| `object` | yes | Accepts an inline translation map (\{ en, "zh-CN", … \}) | +| `variant` | `enum`: `h1`, `h2`, `h3`, `h4`, `h5`, `h6`, `body`, `caption`, `overline` | | One of the nine text styles ui:text uses: h1-h6 (rendered as that heading level), body, caption or overline. The earlier spellings heading and subheading are refused; write h2 or h3 instead. | +| `align` | `enum`: `left`, `center`, `right` | | — | diff --git a/content/docs/ui/components/html.mdx b/content/docs/ui/components/html.mdx new file mode 100644 index 00000000000..cef93673db0 --- /dev/null +++ b/content/docs/ui/components/html.mdx @@ -0,0 +1,460 @@ +--- +title: "HTML elements — component reference" +navTitle: "HTML Elements" +description: "The native HTML elements an html page source may use, and the props each accepts." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Generated from sdui.manifest.json by node scripts/gen-sdui-reference.mjs; change the manifest or the generator, never this page. */} + +These are the safe native HTML elements of the public tier. Each renders as the element of the same name and takes few props beyond `className` and its children. + + +This page is generated from the repo-root `sdui.manifest.json`, the component manifest CI +regenerates from the renderer registry. Regenerate it with +`node scripts/gen-sdui-reference.mjs`; `node scripts/gen-sdui-reference.mjs --check` fails when it is stale. + + +## `a` [#a] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `href` | `string` | | — | +| `target` | `string` | | — | +| `rel` | `string` | | — | +| `title` | `string` | | — | +| `children` | `slot` | | — | + +## `abbr` [#abbr] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `title` | `string` | | — | +| `children` | `slot` | | — | + +## `address` [#address] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `article` [#article] + +Namespace `ui` · tier `html` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `aside` [#aside] + +Namespace `ui` · tier `html` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `b` [#b] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `blockquote` [#blockquote] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `cite` | `string` | | — | +| `children` | `slot` | | — | + +## `br` [#br] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | + +## `cite` [#cite] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `code` [#code] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `dd` [#dd] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `del` [#del] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `dl` [#dl] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `dt` [#dt] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `em` [#em] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `figcaption` [#figcaption] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `figure` [#figure] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `footer` [#footer] + +Namespace `ui` · tier `html` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `h1` [#h1] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `h2` [#h2] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `h3` [#h3] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `h4` [#h4] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `h5` [#h5] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `h6` [#h6] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `header` [#header] + +Namespace `ui` · tier `html` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `hr` [#hr] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | + +## `i` [#i] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `img` [#img] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `src` | `string` | | — | +| `alt` | `string` | | — | +| `width` | `number` | | — | +| `height` | `number` | | — | +| `title` | `string` | | — | + +## `ins` [#ins] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `label` [#label] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `text` | `string` | yes | — | +| `className` | `string` | | — | + +## `li` [#li] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `main` [#main] + +Namespace `ui` · tier `html` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `mark` [#mark] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `nav` [#nav] + +Namespace `ui` · tier `html` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `ol` [#ol] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `p` [#p] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `pre` [#pre] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `q` [#q] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `cite` | `string` | | — | +| `children` | `slot` | | — | + +## `section` [#section] + +Namespace `ui` · tier `html` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `small` [#small] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `span` [#span] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | Inline content, rendered ahead of `value` | + +## `strong` [#strong] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `sub` [#sub] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `sup` [#sup] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `table` [#table] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `caption` | `string` | | — | +| `footer` | `string` | | — | +| `columns` | `array` | | Array of \{ header, accessorKey, className, cellClassName, width \} | +| `data` | `array` | | Array of objects | +| `className` | `string` | | — | + +## `time` [#time] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `dateTime` | `string` | | — | +| `children` | `slot` | | — | + +## `u` [#u] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `ul` [#ul] + +Namespace `ui` · tier `html`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | diff --git a/content/docs/ui/components/index.mdx b/content/docs/ui/components/index.mdx new file mode 100644 index 00000000000..ccea5b3fb07 --- /dev/null +++ b/content/docs/ui/components/index.mdx @@ -0,0 +1,141 @@ +--- +title: "Component reference — every component a page can place" +navTitle: "Component Reference" +description: "Every component in the public SDUI component manifest — 106 components with their inputs, generated from sdui.manifest.json." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Generated from sdui.manifest.json by node scripts/gen-sdui-reference.mjs; change the manifest or the generator, never this page. */} + +These are the 106 components of the public tier: the components an `html` page's +`source` may use, and the types a page places in its regions. Each component lists its +inputs, the props it accepts, with the coarse type, the allowed values of an enum and +whether it is required. An `html` page source is checked against this same list when it is +validated and saved, so an unknown component or a missing required input is reported +there rather than drawn. See [Pages](/docs/ui/pages) for how a page is authored. + + +This page is generated from the repo-root `sdui.manifest.json`, the component manifest CI +regenerates from the renderer registry. Regenerate it with +`node scripts/gen-sdui-reference.mjs`; `node scripts/gen-sdui-reference.mjs --check` fails when it is stale. + + +## Pages + +- [Layout & Basic](/docs/ui/components/ui): 14 components. +- [Page Structure](/docs/ui/components/page): 7 components. +- [Record](/docs/ui/components/record): 11 components. +- [Elements](/docs/ui/components/element): 6 components. +- [Actions](/docs/ui/components/action): 4 components. +- [Plugin Blocks](/docs/ui/components/plugins): 16 components. +- [HTML Elements](/docs/ui/components/html): 48 components. + +## Every component + +| Component | Namespace | Inputs | Page | +| :--- | :--- | ---: | :--- | +| [`a`](/docs/ui/components/html#a) | `ui` | 6 | [HTML Elements](/docs/ui/components/html) | +| [`abbr`](/docs/ui/components/html#abbr) | `ui` | 3 | [HTML Elements](/docs/ui/components/html) | +| [`accordion`](/docs/ui/components/ui#accordion) | `ui` | 4 | [Layout & Basic](/docs/ui/components/ui) | +| [`action:button`](/docs/ui/components/action#action-button) | `action` | 28 | [Actions](/docs/ui/components/action) | +| [`action:group`](/docs/ui/components/action#action-group) | `action` | 9 | [Actions](/docs/ui/components/action) | +| [`action:icon`](/docs/ui/components/action#action-icon) | `action` | 25 | [Actions](/docs/ui/components/action) | +| [`action:menu`](/docs/ui/components/action#action-menu) | `action` | 7 | [Actions](/docs/ui/components/action) | +| [`address`](/docs/ui/components/html#address) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`alert`](/docs/ui/components/ui#alert) | `ui` | 5 | [Layout & Basic](/docs/ui/components/ui) | +| [`article`](/docs/ui/components/html#article) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`aside`](/docs/ui/components/html#aside) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`b`](/docs/ui/components/html#b) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`badge`](/docs/ui/components/ui#badge) | `ui` | 4 | [Layout & Basic](/docs/ui/components/ui) | +| [`blockquote`](/docs/ui/components/html#blockquote) | `ui` | 3 | [HTML Elements](/docs/ui/components/html) | +| [`box`](/docs/ui/components/ui#box) | `ui` | 2 | [Layout & Basic](/docs/ui/components/ui) | +| [`br`](/docs/ui/components/html#br) | `ui` | 1 | [HTML Elements](/docs/ui/components/html) | +| [`button`](/docs/ui/components/ui#button) | `ui` | 5 | [Layout & Basic](/docs/ui/components/ui) | +| [`card`](/docs/ui/components/ui#card) | `ui` | 4 | [Layout & Basic](/docs/ui/components/ui) | +| [`cite`](/docs/ui/components/html#cite) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`code`](/docs/ui/components/html#code) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`container`](/docs/ui/components/ui#container) | `ui` | 5 | [Layout & Basic](/docs/ui/components/ui) | +| [`dashboard`](/docs/ui/components/plugins#dashboard) | `plugin-dashboard` | 10 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`dd`](/docs/ui/components/html#dd) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`del`](/docs/ui/components/html#del) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`dl`](/docs/ui/components/html#dl) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`dt`](/docs/ui/components/html#dt) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`element:button`](/docs/ui/components/element#element-button) | `element` | 7 | [Elements](/docs/ui/components/element) | +| [`element:definition-list`](/docs/ui/components/element#element-definition-list) | `element` | 3 | [Elements](/docs/ui/components/element) | +| [`element:divider`](/docs/ui/components/element#element-divider) | `element` | 0 | [Elements](/docs/ui/components/element) | +| [`element:number`](/docs/ui/components/element#element-number) | `element` | 6 | [Elements](/docs/ui/components/element) | +| [`element:repeater`](/docs/ui/components/element#element-repeater) | `element` | 5 | [Elements](/docs/ui/components/element) | +| [`element:text`](/docs/ui/components/element#element-text) | `element` | 3 | [Elements](/docs/ui/components/element) | +| [`em`](/docs/ui/components/html#em) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`embeddable-form`](/docs/ui/components/plugins#embeddable-form) | `plugin-form` | 7 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`figcaption`](/docs/ui/components/html#figcaption) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`figure`](/docs/ui/components/html#figure) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`flex`](/docs/ui/components/ui#flex) | `ui` | 7 | [Layout & Basic](/docs/ui/components/ui) | +| [`footer`](/docs/ui/components/html#footer) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`grid`](/docs/ui/components/ui#grid) | `ui` | 4 | [Layout & Basic](/docs/ui/components/ui) | +| [`h1`](/docs/ui/components/html#h1) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`h2`](/docs/ui/components/html#h2) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`h3`](/docs/ui/components/html#h3) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`h4`](/docs/ui/components/html#h4) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`h5`](/docs/ui/components/html#h5) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`h6`](/docs/ui/components/html#h6) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`header`](/docs/ui/components/html#header) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`hr`](/docs/ui/components/html#hr) | `ui` | 1 | [HTML Elements](/docs/ui/components/html) | +| [`i`](/docs/ui/components/html#i) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`icon`](/docs/ui/components/ui#icon) | `ui` | 4 | [Layout & Basic](/docs/ui/components/ui) | +| [`image`](/docs/ui/components/ui#image) | `ui` | 3 | [Layout & Basic](/docs/ui/components/ui) | +| [`img`](/docs/ui/components/html#img) | `ui` | 6 | [HTML Elements](/docs/ui/components/html) | +| [`ins`](/docs/ui/components/html#ins) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`label`](/docs/ui/components/html#label) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`li`](/docs/ui/components/html#li) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`list-view`](/docs/ui/components/plugins#list-view) | `plugin-list` | 42 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`main`](/docs/ui/components/html#main) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`mark`](/docs/ui/components/html#mark) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`markdown`](/docs/ui/components/plugins#markdown) | `plugin-markdown` | 2 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`nav`](/docs/ui/components/html#nav) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`object-calendar`](/docs/ui/components/plugins#object-calendar) | `plugin-calendar` | 11 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`object-chart`](/docs/ui/components/plugins#object-chart) | `plugin-charts` | 6 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`object-form`](/docs/ui/components/plugins#object-form) | `plugin-form` | 40 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`object-gantt`](/docs/ui/components/plugins#object-gantt) | `plugin-gantt` | 18 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`object-grid`](/docs/ui/components/plugins#object-grid) | `plugin-grid` | 31 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`object-kanban`](/docs/ui/components/plugins#object-kanban) | `plugin-kanban` | 15 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`object-map`](/docs/ui/components/plugins#object-map) | `plugin-map` | 10 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`object-master-detail-form`](/docs/ui/components/plugins#object-master-detail-form) | `plugin-form` | 15 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`object-metric`](/docs/ui/components/plugins#object-metric) | `plugin-dashboard` | 19 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`object-pivot`](/docs/ui/components/plugins#object-pivot) | `plugin-dashboard` | 12 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`object-timeline`](/docs/ui/components/plugins#object-timeline) | `plugin-timeline` | 16 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`object-tree`](/docs/ui/components/plugins#object-tree) | `plugin-tree` | 6 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`ol`](/docs/ui/components/html#ol) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`p`](/docs/ui/components/html#p) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`page:accordion`](/docs/ui/components/page#page-accordion) | `page` | 3 | [Page Structure](/docs/ui/components/page) | +| [`page:card`](/docs/ui/components/page#page-card) | `page` | 4 | [Page Structure](/docs/ui/components/page) | +| [`page:footer`](/docs/ui/components/page#page-footer) | `page` | 1 | [Page Structure](/docs/ui/components/page) | +| [`page:header`](/docs/ui/components/page#page-header) | `page` | 7 | [Page Structure](/docs/ui/components/page) | +| [`page:section`](/docs/ui/components/page#page-section) | `page` | 1 | [Page Structure](/docs/ui/components/page) | +| [`page:sidebar`](/docs/ui/components/page#page-sidebar) | `page` | 1 | [Page Structure](/docs/ui/components/page) | +| [`page:tabs`](/docs/ui/components/page#page-tabs) | `page` | 4 | [Page Structure](/docs/ui/components/page) | +| [`pre`](/docs/ui/components/html#pre) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`q`](/docs/ui/components/html#q) | `ui` | 3 | [HTML Elements](/docs/ui/components/html) | +| [`record:activity`](/docs/ui/components/record#record-activity) | `record` | 11 | [Record](/docs/ui/components/record) | +| [`record:alert`](/docs/ui/components/record#record-alert) | `record` | 8 | [Record](/docs/ui/components/record) | +| [`record:details`](/docs/ui/components/record#record-details) | `record` | 11 | [Record](/docs/ui/components/record) | +| [`record:discussion`](/docs/ui/components/record#record-discussion) | `record` | 5 | [Record](/docs/ui/components/record) | +| [`record:highlights`](/docs/ui/components/record#record-highlights) | `record` | 7 | [Record](/docs/ui/components/record) | +| [`record:history`](/docs/ui/components/record#record-history) | `record` | 3 | [Record](/docs/ui/components/record) | +| [`record:line_items`](/docs/ui/components/record#record-line_items) | `record` | 16 | [Record](/docs/ui/components/record) | +| [`record:path`](/docs/ui/components/record#record-path) | `record` | 2 | [Record](/docs/ui/components/record) | +| [`record:quick_actions`](/docs/ui/components/record#record-quick_actions) | `record` | 9 | [Record](/docs/ui/components/record) | +| [`record:reference_rail`](/docs/ui/components/record#record-reference_rail) | `record` | 1 | [Record](/docs/ui/components/record) | +| [`record:related_list`](/docs/ui/components/record#record-related_list) | `record` | 16 | [Record](/docs/ui/components/record) | +| [`section`](/docs/ui/components/html#section) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`small`](/docs/ui/components/html#small) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`span`](/docs/ui/components/html#span) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`stack`](/docs/ui/components/ui#stack) | `ui` | 6 | [Layout & Basic](/docs/ui/components/ui) | +| [`strong`](/docs/ui/components/html#strong) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`sub`](/docs/ui/components/html#sub) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`sup`](/docs/ui/components/html#sup) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`table`](/docs/ui/components/html#table) | `ui` | 5 | [HTML Elements](/docs/ui/components/html) | +| [`tabs`](/docs/ui/components/ui#tabs) | `ui` | 3 | [Layout & Basic](/docs/ui/components/ui) | +| [`text`](/docs/ui/components/ui#text) | `ui` | 3 | [Layout & Basic](/docs/ui/components/ui) | +| [`time`](/docs/ui/components/html#time) | `ui` | 3 | [HTML Elements](/docs/ui/components/html) | +| [`u`](/docs/ui/components/html#u) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | +| [`ul`](/docs/ui/components/html#ul) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | diff --git a/content/docs/ui/components/meta.json b/content/docs/ui/components/meta.json new file mode 100644 index 00000000000..04a9b96bd65 --- /dev/null +++ b/content/docs/ui/components/meta.json @@ -0,0 +1,12 @@ +{ + "title": "Component Reference", + "pages": [ + "ui", + "page", + "record", + "element", + "action", + "plugins", + "html" + ] +} diff --git a/content/docs/ui/components/page.mdx b/content/docs/ui/components/page.mdx new file mode 100644 index 00000000000..1858379db2b --- /dev/null +++ b/content/docs/ui/components/page.mdx @@ -0,0 +1,85 @@ +--- +title: "Page structure components — component reference" +navTitle: "Page Structure" +description: "The page:* components that give a page its structure: header, footer, sidebar, sections, cards, tabs and accordions." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Generated from sdui.manifest.json by node scripts/gen-sdui-reference.mjs; change the manifest or the generator, never this page. */} + +The `page:*` components give a page its structure. All of them except the header are containers for other components. + + +This page is generated from the repo-root `sdui.manifest.json`, the component manifest CI +regenerates from the renderer registry. Regenerate it with +`node scripts/gen-sdui-reference.mjs`; `node scripts/gen-sdui-reference.mjs --check` fails when it is stale. + + +## `page:accordion` [#page-accordion] + +Namespace `page` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `items` | `array` of `object` | yes | Panel definitions [\{ label, icon?, collapsed?, children \}] — collapsed: false opens a panel by default | +| `allowMultiple` | `boolean` | | — | +| `variant` | `enum`: `flush`, `card` | | — | + +## `page:card` [#page-card] + +Namespace `page` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `title` | `string` \| `object` | | Accepts an inline translation map (\{ en, "zh-CN", … \}) | +| `bordered` | `boolean` | | — | +| `children` | `slot` | | Card content components, in order (the card body slot) | +| `footer` | `slot` | | — | + +## `page:footer` [#page-footer] + +Namespace `page` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `children` | `slot` | | Child components rendered inside this container, in order | + +## `page:header` [#page-header] + +Namespace `page`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `title` | `string` \| `object` | | Supports \{field\} interpolation and inline translation maps; falls back to the record title | +| `subtitle` | `string` \| `object` | | Same interpolation as Title | +| `actions` | `array` of `string` | | Action IDS — the names of actions declared on the object's own metadata — rendered in the header before any host-injected system actions. An id whose action declares neither record_header nor record_more in its locations renders nowhere. | +| `recordChrome` | `boolean` | | Set false for the bare h1 header on non-record pages | +| `showCopyId` | `boolean` | | — | +| `maxVisible` | `number` | | How many header actions render as inline buttons on desktop before the rest fold into the overflow menu. A positive integer — the contract rejects 0 and fractional values. Two kinds of action are routed to the overflow menu regardless of this budget and never occupy an inline slot: an action whose locations declare record_more without record_header, and any action with component action:menu. The host's own buttons (the record page's Edit) take no slot and never fold. The first inline action, or the one declaring variant primary, is the one filled primary button; the others are outline buttons. An action declaring variant danger is never the primary: inline it is a destructive button, and in the overflow menu it is a red row below a separator. | +| `mobileMaxVisible` | `number` | | The same inline-button budget on mobile viewports, where horizontal room is scarce. A positive integer, defaulting to 1; the desktop half is Max Inline Actions, and the overflow routing rules stated there apply unchanged. | + +## `page:section` [#page-section] + +Namespace `page` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `children` | `slot` | | Child components rendered inside this container, in order | + +## `page:sidebar` [#page-sidebar] + +Namespace `page` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `children` | `slot` | | Child components rendered inside this container, in order | + +## `page:tabs` [#page-tabs] + +Namespace `page` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `items` | `array` of `object` | yes | Tab definitions [\{ label, value?, icon?, count?, visibleWhen?, children \}] — value is the stable ?tab= URL token, count auto-derives from record:related_list descendants when omitted | +| `tabStyle` | `enum`: `line`, `card`, `pill` | | — | +| `position` | `enum`: `top`, `left` | | — | +| `alwaysShowStrip` | `boolean` | | Keep the tab strip visible when only one tab survives. Default false: a lone pill is clutter rather than an affordance, so a one-tab strip is hidden and its panel renders bare. Count the tabs AFTER each item visibleWhen predicate has been evaluated — a page authored with four tabs of which three are conditional reaches this rule whenever the other three are false. | diff --git a/content/docs/ui/components/plugins.mdx b/content/docs/ui/components/plugins.mdx new file mode 100644 index 00000000000..ee672621c97 --- /dev/null +++ b/content/docs/ui/components/plugins.mdx @@ -0,0 +1,387 @@ +--- +title: "Plugin blocks — component reference" +navTitle: "Plugin Blocks" +description: "The object-bound data blocks: grids, lists, forms, boards, calendars, charts, maps and more." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Generated from sdui.manifest.json by node scripts/gen-sdui-reference.mjs; change the manifest or the generator, never this page. */} + +These blocks come from the view plugins. Most are bound to an object, through their own `objectName` input or the node's `dataSource` binding, and draw its records. + + +This page is generated from the repo-root `sdui.manifest.json`, the component manifest CI +regenerates from the renderer registry. Regenerate it with +`node scripts/gen-sdui-reference.mjs`; `node scripts/gen-sdui-reference.mjs --check` fails when it is stale. + + +## `object-calendar` [#object-calendar] + +Namespace `plugin-calendar`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `objectName` | `string` | | ObjectQL object name. The record source is one of `data`, `staticData` and `objectName`; the `object-calendar` schema refuses a block that declares none of them, unless the node's `dataSource.object` names the object, which then lands on this key. | +| `calendar` | `object` | | startDateField, endDateField, titleField, colorField | +| `filter` | `array` | | Base query filter — the `ViewFilterRule` array `[{ field, operator, value }, ...]`, narrowing the records the calendar fetches. Lowered to `$filter` on the query. | +| `sort` | `array` | | Sort order in `[{ field, order }]` form, ordering the records the calendar fetches. Lowered to `$orderby` on the query. | +| `defaultView` | `enum`: `month`, `week`, `day` | | The grid the calendar opens on. A narrow viewport downgrades `day` to `month`; the author’s choice is otherwise honoured on first render. | +| `locale` | `string` | | BCP-47 tag used to format the dates and times this calendar renders. A tag `Intl` refuses is dropped — the same answer as an absent key — rather than passed through to throw out of render. | +| `data` | `array` | | Pre-fetched records drawn as-is, IN PLACE OF the calendar’s own query — `objectName`, `filter` and `sort` are then unused and `staticData` is never reached. Each member is a RECORD, read for the fields the `calendar` config names plus its `id`; a member with no value in the start field is counted in the unscheduled area rather than dropped. | +| `staticData` | `array` | | Inline records, read SECOND on the record-source ladder: an authored `data` wins and this key is then never reached, while `objectName` is read AFTER it, so a calendar carrying both draws these rows and issues no query. Members are records read exactly as `data`’s are. | +| `loading` | `boolean` | | External loading state, honoured ONLY alongside an array `data`: it then replaces the calendar with its loading placeholder. On a calendar fed by `staticData` or by `objectName` it is dropped, because that calendar owns its own loading state. | +| `navigation` | `object` | | What an event click opens — the `{ mode, size, openNewTab, preventNavigation }` block a list view declares. With the key ABSENT a click opens the record in a drawer. `mode` is `drawer` (the overlay), `new_window`, `page` or `none`, and a block written without `mode` takes the spec’s `drawer` default. `page` opens the record page through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views); under a host that publishes none, such as an embedded renderer, there is no record page to open and the click opens nothing. `openNewTab: true` opens the record page in a new tab and outranks every mode except `none`, `preventNavigation: true` opens nothing, and `size` sets the overlay width. An overlay mode wins over a parent view’s click handler; any other mode hands the click to it. | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `object-chart` [#object-chart] + +Namespace `plugin-charts`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `objectName` | `string` | | Object this chart aggregates. Not required: the node's `dataSource` binding can name the object instead, and `dataSource.object` lands on this key, outranking an authored value. With neither, and no inline `data`, the chart shows a hint naming this key instead of an empty frame. | +| `data` | `array` | | Optional static data | +| `filter` | `array` | | — | +| `aggregate` | `object` | | Aggregation config: \{ field, function, groupBy \} | +| `drillDown` | `object` | | Segment drill config: \{ enabled?, filter?, title?, target?: 'drawer' \| 'dialog', columns?, maxRows? \}. Present = on; \{\} is enough. Clicking a segment opens the underlying records filtered by the clicked category. | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `dashboard` [#dashboard] + +Namespace `plugin-dashboard`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `widgets` | `array` | | The widget tree — the spec’s DashboardWidget[]. Each widget binds a dataset (ADR-0021) and may carry a layout (\{ x, y, w, h \}) and filterBindings. When omitted the dashboard renders an empty grid. | +| `label` | `string` \| `object` | | Display name, shown as the header title when `header` is declared — a string or an inline per-locale map such as \{ en, "zh-CN" \}. Spec-canonical spelling; the legacy `title` spelling is not authoring surface. | +| `description` | `string` \| `object` | | Header description shown under the title — a string or an inline per-locale map. Rendered only when `header` is declared and `header.showDescription` is not false. | +| `header` | `object` | | Header block: \{ showTitle?, showDescription?, actions? \}. Strict — the contract rejects any other key. Renders nothing (zero pixels) when everything it would show is suppressed. | +| `globalFilters` | `array` | | Dashboard-level filter bar — the spec’s GlobalFilter[]. Filter values live as dashboard variables (readable in widget expressions as page.\) and are AND-merged into each bound widget’s query per its filterBindings. | +| `dateRange` | `object` | | Built-in date-range filter: \{ field?, defaultRange?, allowCustomRange? \}. `defaultRange` takes the spec’s date presets plus "custom"; the bound field defaults to created_at. | +| `refreshIntervalSeconds` | `number` | | Auto-refresh period in seconds. Zero or a negative value disables the timer, and it only runs when the host wires an onRefresh handler. | +| `columns` | `number` | | — | +| `gap` | `number` | | — | +| `className` | `string` | | — | + +## `object-metric` [#object-metric] + +Namespace `plugin-dashboard`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `objectName` | `string` | | Object this metric aggregates. Not required: the node's `dataSource` binding can name the object instead, and `dataSource.object` lands on this key, outranking an authored value. With neither, and no `fallbackValue`, the tile shows a hint naming this key instead of a value. | +| `label` | `string` \| `object` | | Heading of the tile, and of the drill-down panel when `title` is not set. Accepts either a plain string or an inline per-locale map (`{ en: "Pipeline", "zh-CN": "销售管道" }`) — the `I18nLabel` union the contract admits on this key — and the tile resolves the map against the active UI language, falling back through base language, a region-qualified sibling, `default`, then `en`, and finally to any remaining entry. | +| `aggregate` | `object` | | Aggregation config: \{ field, function, groupBy \} | +| `icon` | `string` | | — | +| `description` | `string` \| `object` | | Helper text rendered under the value. Accepts either a plain string or an inline per-locale map (`{ en: "This quarter", "zh-CN": "本季度" }`), resolved against the active UI language with the same fallback chain as `label`. | +| `title` | `string` \| `object` | | Heading of the drill-down panel. Defaults to `label` — set it only when the records list wants a different name from the tile. Accepts either a plain string or an inline per-locale map (`{ en: "Open deals", "zh-CN": "进行中的商机" }`), resolved against the active UI language with the same fallback chain as `label`. | +| `filter` | `array` | | Criteria the aggregation is scoped by. The same filter narrows the drill-down list, so the number and the records behind it always agree. | +| `colorVariant` | `enum`: `default`, `blue`, `teal`, `orange`, `purple`, `success`, `warning`, `danger` | | Colour of the icon container. Semantic, not decorative: `success` / `warning` / `danger` should track what the number means. | +| `variant` | `enum`: `card`, `bare` | | `card` draws the tile’s own surface; `bare` drops it, for a metric already sitting inside a card. | +| `format` | `string` | | Numeral-style format pattern, e.g. `0,0`, `$0,0`, `0%`. Use `currency` instead of hard-coding a currency symbol here. | +| `currency` | `string` | | ISO 4217 code, e.g. `USD`. Enables locale-aware currency formatting of the value. | +| `prefix` | `string` | | Static text placed before the formatted value. | +| `suffix` | `string` | | Static text placed after the formatted value. | +| `invert` | `boolean` | | Display `1 - value` — for gauges whose good direction is down, such as error rate shown as uptime. | +| `fallbackValue` | `string` | | Value shown when no data source resolves. For static/demo tiles; a bound metric should not need it. | +| `trend` | `object` | | Static trend badge: `{ value, label, direction }`. Use `compareTo` instead when the trend should be computed from data. | +| `compareTo` | `object` | | Period-over-period comparison, `{ kind: "previousPeriod" }` or `{ kind: "previousYear" }` — the computed alternative to a static `trend`. | +| `drillDown` | `object` | | Click-through config that opens the records behind the number. `drillDown.filter` and `drillDown.mode` do not apply to a metric, which has no clicked point to filter by and no row to open, so the list is always scoped by this block’s own `filter`; a drill `filter` belongs on `object-chart` or `object-pivot`, and `mode` on `object-data-table`. | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `object-pivot` [#object-pivot] + +Namespace `plugin-dashboard`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `objectName` | `string` | | Object this pivot table cross-tabulates. Not required: the node's `dataSource` binding can name the object instead, and `dataSource.object` lands on this key, outranking an authored value. With neither, the table shows a hint naming this key and fetches nothing. | +| `title` | `string` | | — | +| `rowField` | `string` | yes | — | +| `columnField` | `string` | yes | — | +| `valueField` | `string` | yes | — | +| `aggregation` | `enum`: `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]` | | — | +| `showRowTotals` | `boolean` | | — | +| `showColumnTotals` | `boolean` | | — | +| `filter` | `array` | | — | +| `format` | `string` | | — | +| `drillDown` | `object` | | Click-through config that opens the records behind a clicked cell, header or total — in a drawer, a dialog, the object’s list page (`target: "navigate"`), or an analytical report (`report`). The drilled list is this block’s `filter` narrowed by the clicked value. `drillDown.mode` does not apply: every click point on a pivot is an aggregated bucket, so it always drills through; `mode` belongs on `object-data-table`. | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `embeddable-form` [#embeddable-form] + +Namespace `plugin-form`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `formId` | `string` | yes | — | +| `objectName` | `string` | | Object the form creates a record in. Not required: the node's `dataSource` binding can name the object instead, and `dataSource.object` lands on this key, outranking an authored value. With neither, the form shows a hint naming this key instead of a form that cannot submit. | +| `title` | `string` | | — | +| `description` | `string` | | — | +| `fields` | `array` | | Bare field names to show, in order (each looked up in the object schema). NOT the same vocabulary as `sections[].fields`, which also accepts the spec `FormFieldSchema` object (identity key `field`, e.g. `{ field: "note", colSpan: 2 }`) — that shape resolves to no name HERE and is silently skipped (`EmbeddableForm` passes this array straight through to `` with no `sections`, so it renders through the same `SimpleObjectForm` as `object-form` above — see its `fields` description). | +| `allowMultiple` | `boolean` | | — | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `object-form` [#object-form] + +Namespace `plugin-form`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `objectName` | `string` | | Object this form creates or edits. Not required: the node's `dataSource` binding can name the object instead, and `dataSource.object` lands on this key, outranking an authored value. With neither, and no inline fields (non-empty `customFields`, or `sections` whose every field is inline), the form shows a hint naming this key instead of a form with no fields. | +| `fields` | `array` | | Bare field names to show, in order (each looked up in the object schema). NOT the same vocabulary as `sections[].fields`, which also accepts the spec `FormFieldSchema` object (identity key `field`, e.g. `{ field: "note", colSpan: 2 }`) — that shape resolves to no name HERE and is silently skipped (SimpleObjectForm in ObjectForm.tsx; buildFlatFields in flatFields.ts for the drawer/modal presentations). | +| `mode` | `enum`: `create`, `edit`, `view` | | — | +| `formType` | `enum`: `simple`, `tabbed`, `wizard`, `split`, `drawer`, `modal` | | — | +| `sections` | `array` | | — | +| `title` | `string` \| `object` | | Heading of the drawer and modal presentations (`formType: "drawer"` / `"modal"`). Accepts either a plain string or an inline per-locale map (`{ en: "New order", "zh-CN": "新建订单" }`) — the `I18nLabel` union the contract admits on this key — and the form resolves the map against the active UI language, falling back through base language, a region-qualified sibling, `default`, then `en`, and finally to any remaining entry. | +| `description` | `string` \| `object` | | Subtitle under the drawer and modal heading. Accepts either a plain string or an inline per-locale map (`{ en: "Enter the order details", "zh-CN": "填写订单信息" }`), resolved against the active UI language with the same fallback chain as `title`. | +| `layout` | `enum`: `vertical`, `horizontal` | | Label placement: `vertical` puts each label above its field (the default), `horizontal` beside it. | +| `columns` | `number` | | — | +| `defaultTab` | `string` | | — | +| `tabPosition` | `enum`: `top`, `bottom`, `left`, `right` | | — | +| `allowSkip` | `boolean` | | — | +| `showStepIndicator` | `boolean` | | — | +| `splitDirection` | `enum`: `horizontal`, `vertical` | | — | +| `splitSize` | `number` | | — | +| `splitResizable` | `boolean` | | — | +| `drawerSide` | `enum`: `top`, `bottom`, `left`, `right` | | — | +| `drawerWidth` | `string` | | — | +| `modalSize` | `enum`: `sm`, `default`, `lg`, `xl`, `full` | | — | +| `modalCloseButton` | `boolean` | | Show the modal presentation’s close (X) button. Default `true`; `false` hides it. Forwarded by `ObjectForm`’s modal route and honoured by `ModalForm` on both of its dialog arms. With the button hidden the modal still closes on Escape, and on the Cancel action when that is shown. | +| `contentLayout` | `enum`: `simple`, `tabbed` | | How the modal presentation lays out sections. `tabbed` differs from `simple` only when more than one section has a field to show (`ModalForm` tests `schema.contentLayout === "tabbed" && groups.length > 1`). | +| `confirmOnDiscard` | `boolean` | | Ask before discarding unsaved edits when a drawer/modal form is dismissed. Set `false` to close immediately. | +| `recordId` | `string` | | The record to load in `edit` / `view` mode. Leave unset for `create`. | +| `customFields` | `array` | | Field definitions merged over the set generated from object metadata. With inline definitions and no data source, this becomes the only field source. | +| `initialValues` | `object` | | Values to prefill in `create` mode. | +| `initialData` | `object` | | Alternate spelling of `initialValues` that the drawer/modal presentations read FIRST — PER MEMBER (`{ ...initialValues, ...initialData }`), so a member this key says nothing about keeps its `initialValues` value. Prefer `initialValues` in new schemas. | +| `readOnly` | `boolean` | | Render every field read-only, whatever `mode` says. | +| `submitText` | `string` \| `object` | | Label of the Save button. A map no locale limb resolves shows the default label. Accepts either a plain string or an inline per-locale map (`{ en: "Save order", "zh-CN": "保存订单" }`), resolved against the active UI language with the same fallback chain as `title`. | +| `cancelText` | `string` \| `object` | | Label of the Cancel button. Accepts either a plain string or an inline per-locale map (`{ en: "Back", "zh-CN": "返回" }`), resolved against the active UI language with the same fallback chain as `title`. | +| `nextText` | `string` \| `object` | | Label of the next-step button (wizard). Accepts either a plain string or an inline per-locale map (`{ en: "Continue", "zh-CN": "继续" }`), resolved against the active UI language with the same fallback chain as `title`. | +| `prevText` | `string` \| `object` | | Label of the previous-step button (wizard). Accepts either a plain string or an inline per-locale map (`{ en: "Previous", "zh-CN": "上一步" }`), resolved against the active UI language with the same fallback chain as `title`. | +| `showSubmit` | `boolean` | | — | +| `showCancel` | `boolean` | | — | +| `showReset` | `boolean` | | — | +| `submitBehavior` | `object` | | Declarative post-submit behaviour, one of `{ kind: "thank-you", title?, message? }`, `{ kind: "redirect", url, delayMs? }`, `{ kind: "continue" }`, `{ kind: "next-record" }`. When present it takes precedence over `successMessage` / `navigateOnSuccess` / `resetOnSuccess`. | +| `successMessage` | `string` \| `object` | | Toast shown after a successful submit. Ignored when `submitBehavior` or `navigateOnSuccess` is set. Accepts either a plain string or an inline per-locale map (`{ en: "Order saved", "zh-CN": "订单已保存" }`), resolved against the active UI language with the same fallback chain as `title`. | +| `navigateOnSuccess` | `string` | | Path to navigate to after a successful create/update. Supports `{id}` / `{recordId}` interpolation from the saved record and is same-origin-guarded. Takes precedence over `successMessage`. | +| `resetOnSuccess` | `boolean` | | Clear the form after a successful submit instead of keeping the saved values. | +| `mobile` | `object` | | Phone-only presentation overrides, e.g. `{ stepper: "auto", stepperMinFields: 8, fullscreenLongText: true }`. | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `object-master-detail-form` [#object-master-detail-form] + +Namespace `plugin-form`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `objectName` | `string` | | The PARENT object this form creates or edits. Not required: the node's `dataSource` binding can name the object instead, and `dataSource.object` lands on this key, outranking an authored value. With neither, the form shows a hint naming this key instead of an empty parent form. | +| `mode` | `enum`: `create`, `edit` | | — | +| `sections` | `array` | | — | +| `details` | `array` of `object` | yes | The child collections, one OBJECT per entry, judged by the spec's closed `details` entry: `childObject` (required) names the child object; `relationshipField` is the FK on the child back to the parent, derived from the child's metadata when omitted; `columns` are the line grid's columns as the spec's inline grid column objects (`{ name, … }`, never a bare field name), derived from the child's fields when omitted; `formFields` names the per-row form's fields; `inlineMode` is `grid` (editable cells) or `form` (list + per-row form); `amountField` is the CHILD column summed and `totalField` the PARENT field that sum is saved to; `title`, `minRows`, `maxRows` and `addLabel` dress the section and its grid. An undeclared member key is refused by name. `sortField` is not a member: the line-position field is derived from the child object (objectui#11070), and since `@objectstack/spec` 17.7.0 the entry refuses an authored `sortField` with the spec's retired-key message (objectstack-ai/objectstack#21589). | +| `recordId` | `string` | | The parent record to load in `edit` mode. Leave unset for `create`. | +| `formType` | `enum`: `simple`, `tabbed` | | How the PARENT half of the form is presented. The detail grids below it are unaffected. | +| `fields` | `array` | | Which parent fields to show, in order — and it is NOT ignored when `sections` is given: the two INTERSECT. The parent field pool is built from this key first and every section then resolves its NAMED members (a bare name, or the `{ field }` entry) against that pool, so such a member this key does not list is dropped from the rendered form, and a section that loses EVERY member that way disappears with its heading. An inline field entry (`{ name, type, … }`) names nothing to resolve and is drawn whatever this key lists, as on every form type (objectui#11615). Each such drop is reported once via `console.warn` (objectui#9884); it is not repaired, because this key bounds what the form DRAWS and edits, not what Save writes: on a create, the submitted set is the drawn fields plus any parent value seeded through `initialValues` (or its alternate spelling `initialData`), drawn or not. A seed for an undeclared, server-owned, computed or read-only field is still stripped, as on any save. Author one or the other, or list every section member here too. Members are bare field names; NOT the spec `FormFieldSchema` object `sections[].fields` accepts (identity key `field`) — that shape resolves to no name here and is silently skipped (the parent form renders through the same `ObjectForm` / `SimpleObjectForm` as `object-form` — see its `fields` description). | +| `title` | `string` \| `object` | | Names the record in the built-in "… saved" toast after an edit save, which shows only when the host supplies no `onSuccess`. Accepts either a plain string or an inline per-locale map (`{ en: "Purchase order", "zh-CN": "采购单" }`) — the `I18nLabel` union the contract admits on this key — and the renderer resolves the map against the active UI language, falling back through base language, a region-qualified sibling, `default`, then `en`, and finally to any remaining entry. | +| `submitText` | `string` \| `object` | | Label of the button that saves the parent and every detail row in one batch. Defaults to "Save" when editing a record (`mode: "edit"` with a `recordId`) and to "Create" otherwise, in the active UI language; an empty string, or a map no locale limb resolves, shows the default too. Accepts either a plain string or an inline per-locale map (`{ en: "Save order", "zh-CN": "保存订单" }`), resolved against the active UI language with the same fallback chain as `title`. | +| `cancelText` | `string` \| `object` | | Label of the Cancel button, which renders only when the host supplies an `onCancel` callback (a runtime slot, not authorable in a JSON document). Defaults to "Cancel", in the active UI language; an empty string, or a map no locale limb resolves, shows the default too. Accepts either a plain string or an inline per-locale map (`{ en: "Back", "zh-CN": "返回" }`), resolved against the active UI language with the same fallback chain as `title`. | +| `showSubmit` | `boolean` | | — | +| `initialValues` | `object` | | Values to prefill on the PARENT record in `create` mode. | +| `initialData` | `object` | | Alternate spelling of `initialValues` the renderer also reads: the `parentSchema` memo carries both keys onto the parent form, which merges them PER MEMBER with this one winning. Prefer `initialValues` in new schemas. | +| `taxRateField` | `string` | | Name of the field ON THE CHILD object that holds each line’s tax rate. Feeds the line-items totals row; leave unset when the detail rows carry no tax. | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `object-gantt` [#object-gantt] + +Namespace `plugin-gantt`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `objectName` | `string` | | ObjectQL object name. The record source is one of `data`, `staticData` and `objectName`; the `object-gantt` schema refuses a block that declares none of them, unless the node's `dataSource.object` names the object, which then lands on this key. | +| `gantt` | `object` | | Gantt-timeline configuration, the author face — the same block `ListViewSchema.gantt` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored. The spec requires `startDateField`, `endDateField` and `titleField`, which name the record fields a task's bar starts at, ends at and is titled by; the other members (`progressField`, `colorField`, `dependenciesField`, `parentField`, `viewMode`, `exportFileName`, …) are optional. | +| `data` | `object` | | A `{ provider, … }` data-source configuration, read FIRST on the record-source ladder: a gantt carrying one never reaches `staticData` and never queries `objectName`. `{ provider: 'value', items }` charts those rows, `{ provider: 'object', object }` queries that object and `{ provider: 'api', read }` reads through that request. A bare array is not this key’s shape and is not a record source: the gantt falls through to `staticData`, then `objectName`, so inline rows belong under `staticData`. | +| `staticData` | `array` | | Inline records, read SECOND on the record-source ladder: a `data` configuration wins and this key is then never reached, while `objectName` is read AFTER it, so a gantt carrying both charts these rows and never queries that object. `filter` and `sort` narrow and order these rows exactly as they do fetched ones. | +| `filter` | `array` | | Base query filter in the rule-array form `[{ field, operator, value }, ...]`, narrowing the tasks the gantt charts — from every record source it reads: the object, an `api` request, or inline rows. Context tokens such as `{current_user_id}` are resolved first, then the filter is lowered to `$filter` on the query. The MongoDB-style record form is not this key’s shape. | +| `sort` | `array` | | Task order in `[{ field, order }]` form, ordering the rows the gantt charts. Lowered to `$orderby` on the same query. | +| `navigation` | `object` | | What a task click opens — the `{ mode, size, openNewTab, preventNavigation }` block a list view declares. With the key ABSENT a click opens the task's record in a drawer, this renderer's own default. `mode` is `drawer` (the overlay), `new_window`, `page` or `none`, and a block written without `mode` takes the spec's `drawer` default. `drawer` opens the task's record in the drawer; `none` opens nothing. `page` opens the task's record page in the same tab and `new_window` opens it in a new tab. The gantt builds that address itself from the page it is on and does not use a record navigator the host publishes, so a host that publishes one changes nothing: on the object's own list or view route (`…/OBJECT/view/VIEW`) the address is the record page `…/OBJECT/record/ID`, while on any other page, such as a custom page, `/OBJECT/record/ID` is appended to the current address. The object is `data.object` when `data` is the object provider, else `objectName`; on inline rows that name neither there is no record to open, and every mode, the default drawer included, opens nothing. `preventNavigation: true` opens nothing whatever the mode, `openNewTab: true` opens the record page in a new tab and outranks every mode except `none`, and `size` sets the overlay width. | +| `label` | `string` \| `object` | | Gantt label — the second link of the exported PNG/PDF file-name chain, after `gantt.exportFileName` and before the bound object's own label. A string is used as written, and an inline locale map (`{ en, "zh-CN", … }`) gives the entry for the display locale. The chart draws it nowhere else. | +| `skipWeekends` | `boolean` | | Measure duration and auto-schedule math in WORKING days, skipping Saturdays and Sundays. The day view also folds the Saturday and Sunday columns out of its axis. | +| `holidays` | `array` of `string` | | Additional non-working dates for the working calendar, ISO `yyyy-mm-dd` strings; folded into a Set for the duration math. A non-empty list turns the working calendar on by itself, without `skipWeekends`, and the day view folds each listed date's column out of its axis. | +| `persistLayout` | `boolean` | | Opt OUT of layout and filter-chip persistence — only an explicit `false` disables it; the storage key is `objectName:viewName`. The object half is `objectName`, else `data.object`, and `gantt` on inline rows that name neither. With `false` the toolbar has no save-layout button and nothing is stored. | +| `viewName` | `string` | | Layout-persistence scope, the second half of the `objectName:viewName` storage key (renderer default `'default'`). Two gantts on the same object with different names keep separate saved layouts, and a gantt restores only the layout saved under its own name. | +| `markers` | `array` | | Extra vertical reference lines drawn like the Today marker — each `{ date, label?, color? }`: `date` places the line (a date outside the drawn range draws none), `label` is drawn against it, `color` paints it. `color` is a CSS colour, the theme's primary colour when omitted. | +| `criticalPath` | `boolean` | | Seed the critical-path highlight ON; the toolbar toggle stays available either way | +| `showBaselines` | `boolean` | | Render the planned-vs-actual baseline bars — ON unless an explicit `false` disables it. A task draws one only when `gantt.baselineStartField` and `gantt.baselineEndField` give it both baseline dates. | +| `readOnly` | `boolean` | | Disable every write path on this gantt and lock the record drawer. A task click still opens the record, read-only. | +| `mobileReadOnly` | `boolean` | | Auto read-only on narrow viewports — ON unless an explicit `false` disables it. Narrow is under 640px: the chart's own width once it is measured, the viewport's until then. A narrow chart's own write paths are gated as under `readOnly`, read-only badge included, but the record drawer is locked by `readOnly` alone, so a task's record still opens writable. `false` keeps a narrow chart editable, and `readOnly: true` locks the chart and the drawer at any width. | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `object-grid` [#object-grid] + +Namespace `plugin-grid`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `objectName` | `string` | | Object this grid lists. Not required: the node's `dataSource` binding can name the object instead, and `dataSource.object` lands on this key, outranking an authored value. With neither, and no inline `data` rows, the grid shows an error saying an object name is required and fetches nothing. | +| `columns` | `array` | | Columns to show, either field names (`["name", "email"]`) or column objects (`[{ field: "name", label: "Full Name", width: 200 }]`). The canonical spelling — the deprecated `fields` is only read when this is absent. | +| `filter` | `array` | | Base query filter — the `ViewFilterRule` array `[{ field, operator, value }, ...]`, lowered to `$filter`. The canonical spelling — the retired `defaultFilters` is not read (`@objectstack/spec` 18 refuses it by name). | +| `label` | `string` \| `object` | | Grid label, used as the table caption, as the export file title and in the record-detail overlay heading. The canonical spelling — the deprecated `title` is only read when this is absent. Accepts either a plain string or an inline per-locale map (`{ en: "Accounts", "zh-CN": "客户" }`) — the `I18nLabel` union the contract admits on this key — and the grid resolves the map against the display locale (the workspace's regional default when one is configured, otherwise the active UI language), falling back through base language, a region-qualified sibling, `default`, then `en`, and finally to any remaining entry. | +| `description` | `string` \| `object` | | One line of help text drawn above the grid's rows, in muted type. Accepts either a plain string or an inline per-locale map (`{ en: "Everyone you work with", "zh-CN": "你的所有联系人" }`) — the `I18nLabel` union the contract admits on this key — resolved against the display locale the way `label` is. A map with no usable entry draws no line. | +| `sort` | `array` | | Initial sort order, `[{ field, order }]`. The only sort spelling this block reads — the retired single-sort `defaultSort` is refused by the protocol and ignored by the renderer. | +| `pagination` | `object` | | Pagination config, `{ pageSize, pageSizeOptions, … }`. Presence enables paging with the object's settings, and an explicit off wins — the deprecated flat `showPagination: false` turns paging off even beside this object, because this object declares no off switch of its own. Prefer it over the deprecated flat `pageSize` / `showPagination` pair. | +| `searchableFields` | `array` of `string` | | Fields the toolbar search box queries. A non-empty list is what enables search — prefer it over the deprecated boolean `showSearch`, which cannot say WHICH fields to search. | +| `data` | `object` | | Data source configuration — a `ViewData` object discriminated by `provider`: `{ provider: "object", object }` (what an omitted `data` falls back to, using `objectName`), `{ provider: "value", items: [...] }` for inline rows that bypass the object query, or `{ provider: "schema", schemaId }`. The canonical spelling — the deprecated `staticData` is the array-only shortcut for the `value` provider, so inline rows go under `items` here rather than in a bare array. | +| `rowHeight` | `enum`: `compact`, `short`, `medium`, `tall`, `extra_tall` | | Row density. An unrecognised value falls back to `compact` rather than erroring. | +| `frozenColumns` | `number` | | How many leading columns stay pinned while the grid scrolls horizontally. | +| `resizable` | `boolean` | | Let users drag column borders to resize. On by default. The only spelling — the legacy `resizableColumns` is retired and refused by name (objectui#6152). | +| `reorderableColumns` | `boolean` | | Let users drag columns into a different order. | +| `showColumnTypeIcons` | `boolean` | | Show a field-type icon in each column header. Off by default — the type is usually obvious from the cell content, and the icons compete with the column labels. | +| `rowColor` | `object` | | Rules that colour whole rows from a field value. | +| `conditionalFormatting` | `array` | | Row style rules, each `{ condition, style }` — a CEL `condition` over the row’s own `record.*` and a CSS `style` map, the rule a list view declares. The first matching rule styles that row. The native `{ field, operator, value }` rule, its `expression`, and a colour written beside `condition` instead of inside `style` are retired (objectui#11533). | +| `emptyState` | `object` | | What the grid draws in place of an empty table, `{ title, message, icon, link }`: a Lucide `icon` name, a `title` (default: the table's own "No results found"), a `message` (default: none) and one `link: { label, url }` the user follows (default: none) — an in-app path, or an `http(s)://` or `mailto:` address, opened as a `url` action opens it. `title`, `message` and the link's `label` each accept a plain string or an inline per-locale map, resolved against the display locale. Not drawn when a term typed into the grid's own server-side search box is what emptied it — the table and its search box stay, so the term can be cleared. Leave it out and an empty grid draws the table's own empty row. | +| `grouping` | `object` | | Group rows by one or more fields into collapsible sections. | +| `aggregations` | `array` | | Per-group roll-ups shown in group headers, `[{ field, type: "sum" \| "count" \| "avg" \| "min" \| "max" \| "count_distinct" }]`. Needs `grouping` to have anything to roll up. | +| `selection` | `object` | | Selection config, `{ type: "none" \| "single" \| "multiple" }`. Presence enables selection — an object with no `type` selects multiple rows — and an explicit off wins: `type: "none"` turns selection off even beside a declared `bulkActions`. The canonical spelling — the deprecated boolean/string `selectable` is only read when this is absent. | +| `rowActions` | `array` | | Names of actions offered on each row’s menu. | +| `bulkActions` | `array` | | Names of actions offered once rows are selected. Needs a multi-row `selection` to be reachable. | +| `batchActions` | `array` | | Legacy alias of `bulkActions`, and the one the renderer reads FIRST when both are set. Prefer `bulkActions` in new schemas. | +| `bulkActionDefs` | `array` | | Full inline bulk-action definitions, for actions that are not named entries in the object’s action set. Use `bulkActions` when the action already exists. | +| `editable` | `boolean` | | Enable inline cell editing (double-click or Enter opens a cell). | +| `singleClickEdit` | `boolean` | | With `editable`, a single click opens the cell instead of a double-click. Has no effect on a non-editable grid. | +| `keyboardNavigation` | `boolean` | | Arrow-key cell navigation on the WAI-ARIA grid pattern: the grid's data cells take one place in the Tab order instead of one each (a link or button inside a cell keeps its own), and the arrow keys move focus between them (Home / End to the ends of the row, Ctrl+Home / Ctrl+End to the ends of the page). Enter still opens an editable cell, and an edit ended with Enter or Escape returns focus to its cell. Defaults to on when the grid renders editable; a read-only grid keeps every cell its own Tab stop unless this is `true`, and `false` turns it off on an editable grid. | +| `navigation` | `object` | | What a row click does, `{ mode: "drawer" \| "page" \| "new_window" \| "none", … }`; a block written without `mode` opens the drawer. | +| `operations` | `object` | | Toggles for the built-in affordances, `{ create, update, delete, export }`, e.g. `{ delete: false }`. A declared block replaces the default, so an `update` / `delete` it does not name is withheld. | +| `exportOptions` | `object` | | Export config, `{ formats, maxRecords, includeHeaders, fileNamePrefix, streaming }`. `streaming` (default true) picks server-side streaming vs browser-side assembly for the export — a behaviour fork, not decoration; set it to `false` to force browser-side assembly. Needs `operations.export` to be reachable from the toolbar. | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `object-kanban` [#object-kanban] + +Namespace `plugin-kanban`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `objectName` | `string` | | Object this board lists. Not required: the node's `dataSource` binding can name the object instead, and `dataSource.object` lands on this key, outranking an authored value. With neither, and no rows from `data`, `bind` or a parent view, the board shows a hint naming this key instead of an empty board. | +| `columns` | `array` | | — | +| `filter` | `array` | | Base query filter — the `ViewFilterRule` array `[{ field, operator, value }, ...]`, narrowing the records the board fetches. Lowered to `$filter` on the query. | +| `limit` | `number` | | Row cap — the most records the board fetches, lowered to the query’s top-level `$top` (renderer default 100). The board renders every fetched record into a lane and offers no pagination, so this is the author’s window on the object rather than a page size. PRECEDENCE: a node-level `dataSource` binding’s own `limit` wins outright; the `pagination.pageSize` of a view that binding names fills this key only when the node leaves it unset. | +| `groupBy` | `string` | | Record field whose value buckets cards into lanes. Its picklist options become the lanes when `columns` is absent, and a drag between lanes writes the target lane’s value back to the record. A value matching no lane lands in the trailing “Uncategorized” lane rather than disappearing. | +| `cardTitle` | `string` | | Record field rendered as the card title. Read AHEAD of `titleField`, which is the legacy spelling of the same choice; when neither yields a value the shared record-display resolver names the card. | +| `titleField` | `string` | | Legacy spelling of `cardTitle` — the record field rendered as the card title. `cardTitle` wins when both are authored. | +| `swimlaneField` | `string` | | Record field that splits the board into horizontal swimlanes. When absent the board falls back to `grouping.fields[0].field`. | +| `coverImageField` | `string` | | Record field holding a card cover image — a URL string, or a file object carrying a `url`. Any other value leaves the card without a cover. | +| `data` | `array` | | Inline records to render instead of fetching. Authoring it SUPPRESSES the board’s own query entirely. Members are records: the board reads `id` (or `_id`) as the card identity, the `groupBy` field’s value as the lane, the card-title field, `coverImageField`, and every `cardFields` entry. Records handed down by a parent view and a `bind` expression both take priority over it. | +| `cardFields` | `array` | | Record field NAMES rendered as cells on each card, in the order written. Members are bare names, not entry objects. An explicit list wins over the object’s `highlightFields` role; unlike that fallback it is NOT filtered against the object definition, so a name the object no longer declares simply renders no cell. An empty array reads as omitted. | +| `grouping` | `object` | | Only `grouping.fields[0].field` is read, and only as the FALLBACK for `swimlaneField`: it names the record field that splits the board into horizontal swimlanes when no `swimlaneField` is authored. An explicit `swimlaneField` wins. Every other position inside `grouping`, later `fields` entries included, is inert on this board. | +| `conditionalFormatting` | `array` | | Per-card style rules, each `{ condition, style }` — a CEL `condition` over the card’s own `record.*` and a CSS `style` map, the rule a list view declares. The first matching rule styles that card alone. A rule comparing a relation field sees the stored foreign key rather than the expanded record. The native `{ field, operator, value }` rule and a colour written beside `condition` instead of inside `style` are retired and refused by name (objectui#11522). | +| `navigation` | `object` | | What a card click opens — the `{ mode, size, openNewTab, preventNavigation }` block a list view declares. With the key ABSENT a click opens the record in a drawer. `mode` is `drawer` (the overlay), `new_window`, `page` or `none`, and a block written without `mode` takes the spec’s `drawer` default. `page` opens the record page through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views); under a host that publishes none, such as an embedded renderer, there is no record page to open and the click opens nothing. `openNewTab: true` opens the record page in a new tab and outranks every mode except `none`, `preventNavigation: true` opens nothing, and `size` sets the overlay width. A click handler from a parent view outranks the whole key. | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `list-view` [#list-view] + +Namespace `plugin-list`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `data` | `object` | | Where this list's records come from, the spec's `ViewData`: `{ provider: 'object', object }` lists that object's records, and `{ provider: 'value', items }` hands the list its rows. Not required: the node's `dataSource` binding can name the object instead, and `dataSource.object` outranks this key. With neither, the list shows a hint naming `data.object` instead of an empty list. | +| `type` | `enum`: `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]` | | The list kind, the spec's list view `type`. Absent, a bound saved view's kind applies, else a grid. On a metadata page it is written `properties.type`; the node's own `type` stays `list-view`. | +| `columns` | `array` | | The fields the list shows, in order: field names (`["name", "stage"]`) or the spec's `ListColumn` objects. They are also the query's `$select`. Optional on a page node: a node bound to a saved view through `dataSource.view` draws that view's columns. | +| `filter` | `array` | | The base query filter, the spec's `ViewFilterRule` array `[{ field, operator, value }]`, lowered to `$filter`. | +| `sort` | `array` | | The initial sort order, `[{ field, order }]`, lowered to `$orderby` in the order written. | +| `pagination` | `object` | | The spec's pagination config, `{ pageSize, pageSizeOptions }`: `pageSize` caps each query (`$top`), and `pageSizeOptions` offers those sizes to the user. | +| `searchableFields` | `array` of `string` | | The field names the toolbar search box searches. A term typed into the box is sent with these fields only. | +| `label` | `string` \| `object` | | The list's label: it names the exported file and heads the record-detail overlay. A plain string, or an inline per-locale map (`{ en: "Open tasks", "zh-CN": "未完成任务" }`) resolved against the display locale. | +| `description` | `string` \| `object` | | One line about the list, drawn as the toolbar's info control (its tooltip and accessible name). A plain string, or an inline per-locale map resolved against the display locale. `appearance.showDescription: false` hides it. | +| `requiredPermissions` | `array` of `string` | | Capabilities the viewer must ALL hold to see this list, the names permission sets grant through `systemPermissions`. A viewer missing one sees an insufficient-permissions notice in place of the list, and no records are queried. Absent or empty: no gate. | +| `userFilters` | `object` | | The quick-filter bar above the list, the spec's `UserFilters` config: `{ element: "dropdown" \| "toggle" \| "tabs", fields \| tabs }`. Each filter narrows the query. | +| `userActions` | `object` | | Which toolbar controls the user gets, the spec's `UserActionsConfig` of per-control toggles: a control set to `false` (for example `sort: false`) is taken off the toolbar, and the controls it does not name stay. | +| `addRecord` | `object` | | The Add button, the spec's `AddRecordConfig`. The spec row declares `enabled` default `true` and `position` default `"bottom"`; the renderer does not apply those defaults: it draws the button only for an explicit `enabled: true`, and places an absent `position` at the top (objectui#13099 owns that gap). `position` places it: `"top"` (the toolbar), `"bottom"` (under the list) or `"both"`. A click opens the create form for the listed object, the one the object list's New opens: the record-form overlay, or the full-page create for an object whose `editMode` is `page`. The button is withheld for a viewer who may not create, or whose create the object greys, and on a host that serves no create. `mode` and `formView` are not read. `userActions.addRecordForm: false` also takes the button off. | +| `compactToolbar` | `boolean` | | Gather the grouping, row colour, density and hide-fields controls into one Display form. On by default; `false` draws one toolbar button per control. | +| `appearance` | `object` | | The spec's appearance config, `{ showDescription, allowedVisualizations }`. `showDescription: false` hides the description. `allowedVisualizations` narrows the kinds a view switcher offers, and a page node draws no switcher, so on a page it changes nothing: the node's `type` is the kind drawn. | +| `exportOptions` | `object` | | The export menu, `{ formats, maxRecords, includeHeaders, fileNamePrefix, streaming }`. Declaring it offers the export on the toolbar, in the listed formats; `fileNamePrefix` names the file. | +| `allowPrinting` | `boolean` | | Draw a Print button that opens the browser's print dialog for the list. | +| `showRecordCount` | `boolean` | | The record count in the list's footer. On by default; `false` takes it off. | +| `emptyState` | `object` | | What the list draws when no record comes back, the spec's `EmptyState`: `{ title, message, icon, link }`. `title`, `message` and the link's `label` each take a plain string or an inline per-locale map. | +| `hiddenFields` | `array` of `string` | | Field names taken out of `columns`. A name `columns` does not show takes nothing out. | +| `fieldOrder` | `array` of `string` | | Field names that order the columns left after `hiddenFields`. A column not listed comes after every listed one; a listed name that is not a column orders nothing. | +| `rowHeight` | `enum`: `compact`, `short`, `medium`, `tall`, `extra_tall` | | Row density. Absent, rows are `compact`. | +| `resizable` | `boolean` | | Let users drag column borders to resize the grid's columns. | +| `selection` | `object` | | Row selection, `{ type: "none" \| "single" \| "multiple" }`. | +| `navigation` | `object` | | What clicking a row does, the spec's `NavigationConfig`: `{ mode: "drawer" \| "page" \| "new_window" \| "none", … }`. The drawer opens the record over the list, headed by the list's `label`. | +| `grouping` | `object` | | Group the rows, `{ fields: [{ field, order, collapsed }] }`. The groups and their header counts come from the server. | +| `rowColor` | `object` | | Colour whole rows by a field's value, `{ field, colors: { VALUE: COLOR } }`. | +| `conditionalFormatting` | `array` of `object` | | Row style rules, each `{ condition, style }`: a CEL `condition` over the row's `record.*` and a CSS `style` map. The first rule that matches styles the row. | +| `inlineEdit` | `boolean` | | Open the grid in inline-edit mode, for users who may update the object. | +| `rowActions` | `array` of `string` | | Names of the actions offered on each row's menu. | +| `bulkActions` | `array` of `string` | | Names of the actions offered once rows are selected. Needs a multi-row `selection`. | +| `bulkActionDefs` | `array` of `object` | | Full bulk-action definitions, the spec's `BulkActionDef`: a mass update (`operation: "update"` with a `patch`) or delete, or a named action run once for the whole selection. | +| `options` | `object` | | Per-kind settings, `{ kanban, calendar, gantt, gallery, timeline, map, tree, chart }`, each judged by that kind's own block. The top-level block of the same name is the canonical spelling. | +| `kanban` | `object` | | The board, the spec's kanban block: `{ groupByField, columns, titleField, summarizeField }`. `groupByField` is the lane field and `columns` the fields on each card. | +| `calendar` | `object` | | The calendar, `{ startDateField, endDateField, titleField, colorField, allDayField }`. | +| `gantt` | `object` | | The gantt chart, `{ startDateField, endDateField, titleField, progressField, dependenciesField }`. Both date fields are needed to draw it. | +| `gallery` | `object` | | The gallery, `{ coverField, titleField, visibleFields, cardSize, … }`. | +| `timeline` | `object` | | The timeline, `{ startDateField, endDateField, titleField, groupByField, colorField, scale }`. | +| `map` | `object` | | The map, `{ locationField }` or `{ latitudeField, longitudeField }`, with `titleField`, `zoom` and `center`. | +| `tree` | `object` | | The tree, `{ parentField, labelField, fields, defaultExpandedDepth }`. | +| `chart` | `object` | | The chart a `type: "chart"` list draws, bound to an ADR-0021 dataset: `{ dataset, values, dimensions, chartType }`. There is no default binding. | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `object-map` [#object-map] + +Namespace `plugin-map`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `objectName` | `string` | | ObjectQL object name. The record source is one of `data`, `staticData` and `objectName`, or the node’s `dataSource` binding; the `object-map` schema refuses a block that declares none of them. | +| `map` | `object` | | The field configuration: `latitudeField` / `longitudeField` (or one `locationField` holding a `"lat,lng"` string, a `{ lat, lng }` object or a `[lat, lng]` pair) place each record’s marker, and a record with no readable coordinates is not plotted; `titleField` and `descriptionField` name what the marker popup shows; `zoom` and `center` (`[latitude, longitude]`) set the initial camera, which otherwise fits the markers; `style` is a MapLibre style URL, read after the node’s `mapStyle`. | +| `data` | `object` | | A `{ provider, … }` data-source configuration, read FIRST on the record-source ladder: a map carrying one never reaches `staticData` and never queries `objectName`. `{ provider: 'value', items }` plots those rows and `{ provider: 'object', object }` queries that object, both narrowed by `filter` and ordered by `sort`. A bare array is not this key’s shape and is not a record source: the map falls through to `staticData`, then `objectName`, so inline rows belong under `staticData`. | +| `staticData` | `array` | | Inline records, read SECOND on the record-source ladder: a `data` configuration wins and this key is then never reached, while `objectName` is read AFTER it, so a map carrying both plots these rows and never queries that object. `filter` and `sort` narrow and order these rows exactly as they do fetched ones. | +| `filter` | `array` | | Base query filter in the rule-array form `[{ field, operator, value }, ...]`, narrowing the markers the map plots — fetched rows and inline (`staticData`, `{ provider: 'value' }`) rows alike. Context tokens such as `{current_user_id}` are resolved first, then the filter is lowered to `$filter` on the query. The MongoDB-style record form is not this key’s shape. | +| `sort` | `array` | | Marker order in `[{ field, order }]` form, ordering the rows the map plots. Lowered to `$orderby` on the same query. | +| `mapStyle` | `string` | | MapLibre style URL or spec, overriding the public demo tiles. Read before `map.style`; NOT the base node `style`, which is an inline CSS record | +| `navigation` | `object` | | What a marker click opens — the `{ mode, size, openNewTab, preventNavigation }` block a list view declares. With the key ABSENT a click opens nothing: this renderer supplies no drawer default. `mode` is `drawer` (the overlay), `new_window`, `page` or `none`, and a block written without `mode` takes the spec’s `drawer` default. `drawer` opens the marker’s record in the drawer; `new_window` opens the record page in a new tab; `none` opens nothing. `page` opens the record page of the map’s `objectName` through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views); under a host that publishes none, such as an embedded renderer, or on a map that names no `objectName`, there is no record page to open and the click opens nothing. `preventNavigation: true` opens nothing whatever the mode, `openNewTab: true` opens the record page in a new tab and outranks every mode except `none`, and `size` sets the overlay width. A click handler from a parent view outranks the whole key. | +| `enableClustering` | `boolean` | | Group nearby markers into one numbered cluster. Absent, the map clusters only above 100 markers; `false` turns clustering off at any count. | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `markdown` [#markdown] + +Namespace `plugin-markdown`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `content` | `string` | yes | — | +| `className` | `string` | | — | + +## `object-timeline` [#object-timeline] + +Namespace `plugin-timeline`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `objectName` | `string` | | Object this timeline binds to. Optional because the component-level `dataSource` binding can supply the object instead — this block registers through `ElementDataSourceGate`, which lowers the binding onto this key before the renderer sees the node. A timeline drawn from `items`, `data` or a `bind` path issues no query and needs none to draw, but `navigation` still reads it: without it `page` opens nothing and `new_window` / `openNewTab` open no record page. | +| `variant` | `enum`: `vertical`, `horizontal`, `gantt` | | — | +| `filter` | `array` | | Base query filter in the rule-array form `[{ field, operator, value }, ...]`, narrowing the records the timeline fetches. Context tokens such as `{current_user_id}` are resolved first, then the filter is lowered to `$filter` on the query. Authored `items` or `data` suppress that query, so the key then narrows nothing. The MongoDB-style record form is not this key’s shape. | +| `sort` | `array` | | Order of the records the timeline fetches, in `[{ field, order }]` form, lowered to `$orderby` on the same query. The rail draws entries composed from records by their start date, earliest first, whatever this order. Authored `items` or `data` suppress the query, so the key then orders nothing. | +| `navigation` | `object` | | What an entry click opens — the `{ mode, size, openNewTab, preventNavigation }` block a list view declares. With the key ABSENT a click opens nothing: this renderer supplies no drawer default. `mode` is `drawer` (the overlay), `new_window`, `page` or `none`, and a block written without `mode` takes the spec’s `drawer` default. `drawer` opens the entry’s record in the drawer; `new_window` opens the record page in a new tab; `none` opens nothing. `page` opens the record page of the timeline’s `objectName` through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views); under a host that publishes none, such as an embedded renderer, or on a timeline that names no `objectName`, there is no record page to open and the click opens nothing. `preventNavigation: true` opens nothing whatever the mode, `openNewTab: true` opens the record page in a new tab and outranks every mode except `none`, and `size` sets the overlay width. On a timeline that names no `objectName`, `new_window` and `openNewTab` open no record page either: the new tab’s address is a slash and the entry’s `id` alone (its `_id` when it has no `id`, `undefined` when it has neither). A click handler from a parent view outranks the whole key. | +| `timeline` | `object` | | Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: \{ startDateField, endDateField, titleField, groupByField, colorField, scale \}. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling. On entries composed from records, `startDateField` is the entry’s date, `endDateField` the end printed after it, `titleField` the title, `groupByField` the field whose value heads each group (without it, entries group into date buckets), and `colorField` the field whose option colour, or the value itself when it is a colour literal, paints the marker. Each outranks the flat key of the same binding, and `titleField` / `startDateField` outrank `mapping`’s `title` / `date`. `scale` sets the axis unit of the gantt branch, which on this block draws authored `items` only. | +| `limit` | `number` | | Maximum number of records loaded onto the rail (row cap); lowered to the query's top-level `$top` (renderer default 100). A timeline renders one rail with no pagination control, so this is the author's window rather than a page size. A value that is not a positive integer is ignored with a console warning, and the default cap applies. | +| `data` | `array` | | Pre-fetched records — read FIRST as the rail's row source, ahead of the data-scope binding and the fetch, and composed into entries through the same `timeline` field bindings a fetched row takes; authoring it suppresses the object query entirely. Distinct from `items`, which is the already-composed entry shape and wins over this key when both are written. | +| `items` | `array` | | Static inline entries — read ahead of every record source, `data` above included, and bypasses the object query entirely (the renderer becomes a pass-through). Each entry is the kind this node's `variant` selects: a feed entry `{ time?, title, description?, variant?, icon?, content?, className? }` (`variant` absent / `vertical` / `horizontal`), or a gantt row `{ label, items? }` (`variant: 'gantt'`) whose bars are `{ title?, startDate?, endDate?, variant? }`, each date a string or epoch milliseconds. These are `@object-ui/types`'s `TimelineFeedItem` and `TimelineGanttItem`. They are drawn as written: the `timeline` block’s field bindings, `mapping` and `descriptionField` do not apply to them. | +| `dateFormat` | `enum`: `short`, `long`, `iso` | | How each entry's date is rendered (renderer default `short`): `short` / `long` are locale-formatted, `iso` is the locale-free machine form. | +| `rowLabel` | `string` | | Header label for the gantt row column — read by the gantt branch only, which on this block needs authored `items`. Without it the header shows the default label. | +| `minDate` | `string` | | Pin the gantt axis start (ISO `yyyy-mm-dd`) instead of deriving it from the rows; only a non-empty value is honoured. A value that is not a date, or a `minDate` after `maxDate`, refuses the chart with a diagnostic naming it. | +| `maxDate` | `string` | | Pin the gantt axis end (ISO `yyyy-mm-dd`) instead of deriving it from the rows; only a non-empty value is honoured. A value that is not a date, or a `maxDate` before `minDate`, refuses the chart with a diagnostic naming it. | +| `descriptionField` | `string` | | Field rendered as each entry's description (renderer default `description`). It is declared flat because the `timeline` block has no member for it, and `mapping.description` outranks it when both are written. | +| `mapping` | `object` | | Record-to-entry field mapping `{ title?, date?, description?, variant? }`, each a field name — the binding record read BETWEEN the `timeline` block and the flat fallbacks. Its `variant` member (the field whose value picks each marker colour, renderer default `variant`) is the only spelling that binding has. On the vertical rail a colour that `timeline.colorField` resolves for an entry outranks that marker colour. | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `object-tree` [#object-tree] + +Namespace `plugin-tree`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `objectName` | `string` | | ObjectQL object name. The record source is one of `data`, `staticData` and `objectName`, read in that order: a tree on `data` or `staticData` never queries this object. On a tree whose rows are inline it still names the record page a new-tab click opens. | +| `tree` | `object` | | The field configuration: `parentField` names the field holding each record’s parent id (auto-detected from the object’s self-referencing field when omitted), `labelField` the field drawn indented in the first column (default `name`), `fields` the further fields drawn as flat columns after it, and `defaultExpandedDepth` how many levels open on first draw (`0` shows roots only; omitted, every level is open). | +| `data` | `object` | | A `{ provider, … }` data-source configuration, read FIRST on the record-source ladder: a tree carrying one never reaches `staticData` and never queries `objectName`. `{ provider: 'value', items }` draws those records and `{ provider: 'object', object }` queries that object, both narrowed by `filter`. The `api` and `schema` providers draw no rows on the tree. A bare array is not this key’s shape and is not a record source: the tree falls through to `staticData`, then `objectName`, so inline rows belong under `staticData`. | +| `staticData` | `array` | | Inline records, read SECOND on the record-source ladder: a `data` configuration wins and this key is then never reached, while `objectName` is read AFTER it, so a tree carrying both draws these rows and never queries that object. Each record is placed under the record its `parentField` value names, and one naming no record is drawn as a root. `filter` narrows these rows exactly as it does fetched ones. | +| `filter` | `array` | | Base query filter in the rule-array form `[{ field, operator, value }, ...]`, narrowing the records the tree draws — fetched rows and inline (`staticData`, `{ provider: 'value' }`) rows alike. Context tokens such as `{current_user_id}` are resolved first, then the filter is lowered to `$filter` on the query. A record whose parent the filter removed is drawn as a root. The MongoDB-style record form is not this key’s shape. | +| `navigation` | `object` | | What a row click opens — the `{ mode, size, openNewTab, preventNavigation }` block a list view declares. With the key ABSENT a click opens nothing: this renderer supplies no drawer default. `mode` is `drawer` (the overlay), `new_window`, `page` or `none`, and a block written without `mode` takes the spec’s `drawer` default. `drawer` opens the row’s record in the drawer; `new_window` opens the record page in a new tab; `none` opens nothing. `page` opens the record page of `data.object` when `data` is the object provider, else of the tree’s `objectName`, through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views); under a host that publishes none, such as an embedded renderer, or on a tree that names neither (inline rows with no `objectName`), there is no record page to open and the click opens nothing. `preventNavigation: true` opens nothing whatever the mode, `openNewTab: true` opens the record page in a new tab and outranks every mode except `none`, and `size` sets the overlay width. A click handler from a parent view outranks the whole key. | diff --git a/content/docs/ui/components/record.mdx b/content/docs/ui/components/record.mdx new file mode 100644 index 00000000000..0465b3c1882 --- /dev/null +++ b/content/docs/ui/components/record.mdx @@ -0,0 +1,181 @@ +--- +title: "Record components — component reference" +navTitle: "Record" +description: "The record:* components that draw the record a record page opens: details, highlights, related lists, activity and more." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Generated from sdui.manifest.json by node scripts/gen-sdui-reference.mjs; change the manifest or the generator, never this page. */} + +The `record:*` components render from the record context a record page mounts, so they belong on a `type: 'record'` page. + + +This page is generated from the repo-root `sdui.manifest.json`, the component manifest CI +regenerates from the renderer registry. Regenerate it with +`node scripts/gen-sdui-reference.mjs`; `node scripts/gen-sdui-reference.mjs --check` fails when it is stale. + + +## `record:activity` [#record-activity] + +Namespace `record`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `types` | `array` of `string` | | Allow-list of feed item types to show (comment, field_change, task, event, email, call, note, file, record_create, record_delete, approval, sharing, system). Omit for all; unrecognised entries are ignored. | +| `filterMode` | `enum`: `all`, `comments_only`, `changes_only`, `tasks_only` | | Filter the timeline dropdown starts on. The user can still change it; an unrecognised value falls back to "all". | +| `showFilterToggle` | `boolean` | | Expose the activity-type filter dropdown in the panel header | +| `limit` | `number` | | Items per page. Also caps the scoped sys_activity read; "Load more" grows the window by this much. | +| `showCompleted` | `boolean` | | Include completed activities (sys_activity type "completed", which surfaces as a task item). Off by default. A milestone row (metadata.kind "milestone") is not a completed activity and shows either way. A logged interaction, a task item that points at its source record through source_object / source_id (a logged call, meeting or email), also shows either way. | +| `unifiedTimeline` | `boolean` | | Mix field changes and comments in one timeline (Airtable style). Off keeps the panel a discussion stream — field changes stay in record:history. | +| `showCommentInput` | `boolean` | | Show the composer. Requires a host discussion context to persist the comment (record detail pages provide one); without it the feed is read-only. | +| `enableMentions` | `boolean` | | Offer @-mention autocomplete in the composer, from the host discussion context's user list. Off withholds the suggestions. | +| `enableReactions` | `boolean` | | Show emoji reactions on feed items. Toggling one requires a host discussion context; without it existing reactions still render, read-only. | +| `enableThreading` | `boolean` | | Group replies under their parent comment. Posting a reply requires a host discussion context. | +| `showSubscriptionToggle` | `boolean` | | NOT IMPLEMENTED — no record-subscription backend exists yet, so this renders nothing whatever it is set to. Declared for spec parity only (objectui#3165). | + +## `record:alert` [#record-alert] + +Namespace `record`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `severity` | `enum`: `info`, `warning`, `error`, `success` | | — | +| `title` | `string` \| `object` | | Accepts an inline translation map (\{ en, "zh-CN", … \}) | +| `body` | `string` \| `object` | | Accepts an inline translation map (\{ en, "zh-CN", … \}) | +| `visible` | `boolean` \| `string` \| `object` | | Gates the banner against the current record: `true`/`false`, a bare CEL expression, or the `{ dialect: 'cel', source }` envelope | +| `icon` | `string` | | Lucide icon name; defaults to the severity icon | +| `action` | `object` | | \{ actionName, label?, variant? \} — the action the banner offers | +| `dismissible` | `boolean` | | — | +| `dismissKey` | `string` | | Stable key the dismissal is remembered under | + +## `record:details` [#record-details] + +Namespace `record`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `columns` | `enum`: `1`, `2`, `3`, `4` | | Number of columns for field layout (1-4) | +| `sections` | `array` of `object` | | Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` are the field names shown in this section, in order — required unless `group` supplies the members instead (the spec refuses a section carrying neither, and refuses one carrying both). `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.\._sections.\.label, so a section without a name shows its authored label in every locale. `columns` (1-4) is THIS section's field-grid width; omit it and the renderer derives the width. Authoring `sections` at all makes it the only source of the detail body; omit it and the body falls back to the object's highlightFields. @objectstack/spec 17.3.0 declares eight more member keys on an entry, and this renderer honours all eight: `icon` (a Lucide name on the section header), `description` (sub-heading copy under the heading), `collapsible` and `defaultCollapsed` (a foldable section and its initial state), `showBorder` (force the Card wrapper on or off, overriding the heading-derived default) and `headerColor` (a header tint from the shared palette) through DetailSection, plus `group` — the ADR-0085 §5 REFERENCE form, the alternative to enumerating `fields`. `{ group: 'contact_info' }` inherits the object's `fieldGroups` entry with that key: its members (every visible field pointing at it, in declaration order), its presentation (label, icon, description, collapse) and its `visibleWhen` all come from the group, so the section restates none of it — the predicate is evaluated against the bound record exactly as the entry form evaluates it, and a group it hides renders nothing (a `visibleWhen` written on a section itself is refused by the spec and not read: gate the group and reference it) and the spec refuses those keys beside `group`; `columns`, `showBorder` and `headerColor` stay yours because they are how THIS page lays the section out. A `group` naming no declared group renders nothing and is reported to the console (`@objectstack/lint` flags it as `page-section-group-unknown`). `hideEmpty` is the eighth, and it decides whether an ALL-empty section exists: it defaults to on, so a section whose fields are every one of them empty renders nothing at all — no heading, no skeleton — and `hideEmpty: false` is the spelling that keeps the section on a brand-new record: in read-only mode as its heading plus one fold row that counts the empty fields and reveals them on request (the same fold a partly-filled section uses, objectui#13386), in edit mode as every field an editable row. It decides ONLY the all-empty case; the empty rows of a section that still has a filled one belong to DetailSection's auto-hide heuristic plus the reader's show-empty toggle, which no authored value overrides in either polarity (objectui#7129 Q2-C), and inline-edit mode renders the section either way so its fields stay reachable. Restored under objectui#8603 (maintainer 2026-09-15) after objectui#7129 retired it on the premise, since falsified upstream, that the spec refused the key. None of the eight has a designer control yet; they are authorable in source mode only, tracked as a deferred feature. @objectstack/spec 18 declares a ninth, `count` (`{ field }`): the renderer fills the heading's `{{ count }}` hole with how many values that multi-value field holds on the current record — `{ name: 'tagging', label: '{{ count }} tags', count: { field: 'tags' }, fields: ['tags'] }` reads `3 tags` when three are selected. An absent or empty value counts 0; `count` changes only the heading, never whether the section renders, so with `hideEmpty` on an all-empty section is still absent. The hole is filled after the heading is translated, so each locale of an inline label map and a translated objects.\._sections.\.label write the same `{{ count }}`. The spec refuses `count` beside `group`, on a section with no `label`, and on a label with no hole; a heading on a section without `count` prints as written. Like the eight, `count` has no designer control yet. | +| `fields` | `array` of `string` | | Explicit field list (overrides highlightFields) | +| `hideFields` | `array` of `string` | | Field names to omit from the body — applied to the top-level `fields` list AND to every section's `fields`. Bare field names only. Authors rarely need it: the synth pipeline fills it with the fields already shown in `record:highlights`, and hand-authored pages get the same dedup live from HighlightFieldsContext, so its purpose is suppressing a field you do not want repeated (the page H1 title field is dropped for you too). Hiding every field of a section leaves that section out entirely. | +| `inlineEdit` | `boolean` | | Offer the per-field double-click / pencil inline-edit affordances in the detail body. On by default, and an OPT-OUT only: the value is combined with the object's own editability (system, engine-owned, append-only and better-auth objects are not user-editable unless they opened userActions.edit) and with the server's effective API operation set for the object, so `false` always wins while `true` cannot open editing the platform refuses. The edit session and the atomic Save bar are hosted by the page, so one draft spans the highlights strip and this body. | +| `showHeader` | `boolean` | | Render the detail body's own title / follow-star / copy-id chip above the fields. Off by default because this block is normally composed under a `page:header` that already draws that chrome, and turning it on there shows the record title twice. Set it true only when this block is the whole page. | +| `enforceFieldSecurity` | `boolean` | | Fold this block's field list through the caller's FIELD-read permissions before rendering, so a field the permission set denies leaves no empty row behind (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — with it off a denied field still arrives masked or stripped, and with it on the server still decides every value. | +| `redactFields` | `array` of `string` | | Field names this block never renders, whatever the permission answer (renderer default: render everything authored). Presentation only, evaluated in the browser after the record is fetched — the values are still in the page, so this is NOT a data-access control and NOT the object's `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. Neighbours `hideFields`, which is the dedupe channel the renderer also writes to. | +| `requiredPermissions` | `array` of `string` | | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open. | +| `emptyState` | `object` | | What this block draws in its place when its `dataSource` binding answers no row, `{ title, message, icon, link }` — the list view's own empty-state shape: a Lucide `icon` name, a `title` (default: "Record not found"), a `message` (default: none) and one `link: { label, url }` the user follows (default: none), opened as a `url` action opens it. `title`, `message` and the link's `label` each accept a plain string or an inline per-locale map, resolved against the display locale. Drawn then and only then — never on a block with no `dataSource` (it reads the record page's record instead), never while the bound read is loading, and never in place of the error a failed read shows; a read the server refuses as forbidden reads as no row, so it is drawn for that too. Leave it out and a binding with no row keeps the block's own placeholder. | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `record:discussion` [#record-discussion] + +Namespace `record`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `position` | `enum`: `bottom`, `right`, `left` | | Where the panel docks relative to the record body | +| `width` | `string` | | Panel width as a CSS value (side positions only) | +| `collapsible` | `boolean` | | — | +| `defaultCollapsed` | `boolean` | | — | +| `feed` | `object` | | Activity-feed configuration nested inside the panel — the same shape as record:activity. The spec declares this key as RecordActivityProps (RecordChatterProps.feed), so its members are the inputs record:activity declares; see that block for what each one does. | + +## `record:highlights` [#record-highlights] + +Namespace `record`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `fields` | `array` | yes | Key fields to highlight (1-7), bare names or \{name,label?,type?,readonly?\}. Set readonly: true on an entry to render that chip read-only — it suppresses the inline-edit affordance and the HeaderHighlight editability gate enforces it. Use it for hook/automation-maintained columns that must not be hand-edited from the record header; marking the OBJECT field readonly instead would also strip the hook's own write-back. | +| `layout` | `enum`: `horizontal`, `vertical` | | Layout orientation for highlight fields | +| `enforceFieldSecurity` | `boolean` | | Fold this block's highlight chips through the caller's FIELD-read permissions before rendering, so a field the permission set denies leaves no empty chip behind (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — the record is fetched either way and the server still decides every value. | +| `redactFields` | `array` of `string` | | Field names this block never renders as a chip, whatever the permission answer (renderer default: render every field authored). Presentation only, evaluated in the browser after the record is fetched — the values are still in the page, so this is NOT a data-access control and NOT the object's `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. | +| `requiredPermissions` | `array` of `string` | | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open. | +| `emptyState` | `object` | | What this block draws in its place when its `dataSource` binding answers no row, `{ title, message, icon, link }` — the list view's own empty-state shape: a Lucide `icon` name, a `title` (default: "Record not found"), a `message` (default: none) and one `link: { label, url }` the user follows (default: none), opened as a `url` action opens it. `title`, `message` and the link's `label` each accept a plain string or an inline per-locale map, resolved against the display locale. Drawn then and only then — never on a block with no `dataSource` (it reads the record page's record instead), never while the bound read is loading, and never in place of the error a failed read shows; a read the server refuses as forbidden reads as no row, so it is drawn for that too. Leave it out and a binding with no row keeps the block's own placeholder. | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `record:history` [#record-history] + +Namespace `record`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `limit` | `number` | | Maximum history entries to display | +| `emptyText` | `string` | | Copy shown when the record has no history | +| `unknownUserText` | `string` | | Copy substituted when an entry has no resolvable actor | + +## `record:line_items` [#record-line_items] + +Namespace `record`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `childObject` | `string` | | The child object whose records the panel lists, edits and saves. Not required: the node's `dataSource` binding can name the object instead, and `dataSource.object` lands on this key, outranking an authored value. With neither, the panel shows a configuration hint naming this key and loads nothing. | +| `relationshipField` | `string` | yes | — | +| `columns` | `array` | yes | — | +| `totalField` | `string` | | — | +| `amountField` | `string` | | — | +| `parentObject` | `string` | | The PARENT object the line total is written to on Save, together with `totalField`. Defaults to the object of the record the page shows, and outranks it when set. With neither, Save writes the lines alone and no parent field. | +| `parentId` | `string` | | The parent record whose lines the panel lists and saves: it loads `childObject` rows whose `relationshipField` equals this id, and a line added in the grid is saved with this id in `relationshipField`. Outranks `recordId` and the record the page shows; leave it unset on a record page, where the record shown is the parent. With no parent id at all the panel asks the user to save the record first and loads nothing. | +| `recordId` | `string` | | The parent record id, read only when `parentId` is unset. It outranks the record the page shows. | +| `title` | `string` | | Heading of the panel. Defaults to "Line Items" in the active UI language. A plain string, drawn as authored. | +| `readonly` | `boolean` | | Shows the lines without editing: no Save button, no Add line / Duplicate row / Remove row actions, and the grid draws a read-only table. | +| `minRows` | `number` | | Fewest lines the user can leave: every line's Remove row action is disabled while the grid holds this many lines or fewer. It adds no blank lines to reach the number. | +| `maxRows` | `number` | | Most lines the user can hold: at this many lines Add line and Duplicate row are disabled and the trailing blank entry row is not drawn. | +| `filter` | `array` of `object` | | Additional criteria for the lines, as spec `ViewFilterRule` entries (`[{ field, operator, value }]`). AND-combined with the parent relationship condition, never a replacement for it: it can only narrow this record's lines. Also the key a per-element `dataSource` binding's composed filter lands on. | +| `sort` | `array` of `object` | | Load order for the lines, as `[{ field, order }]` entries applied in list order; `order` is `asc` or `desc`. Without it the lines arrive in storage order. | +| `limit` | `number` | | Most lines loaded (default 500). The grid has no pagination: every loaded line is editable and saved as one batch, and lines past the cap are not loaded. Must be a positive integer: any other value is ignored, with a console warning. | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `record:path` [#record-path] + +Namespace `record`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `statusField` | `string` | yes | Field representing the current status/stage | +| `stages` | `array` of `object` | | Explicit stage definitions [\{ value, label \}] (else derived from field metadata) | + +## `record:quick_actions` [#record-quick_actions] + +Namespace `record`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `actionNames` | `array` of `string` | | Action names to expose, in order — resolved from the actions declared on the object. With no names (and no host-supplied actions) nothing is looked up: the bar renders its empty placeholder on a design / preview surface and nothing at runtime | +| `requiredPermissions` | `array` of `string` | | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open. | +| `location` | `enum`: `list_toolbar`, `list_item`, `record_header`, `record_more`, `record_related`, `record_section` | | Which declared action location this bar renders | +| `align` | `enum`: `start`, `center`, `end` | | — | +| `inline` | `boolean` | | Render in the flow instead of folding into the record header | +| `variant` | `string` | | Passed to the Button primitive; a per-action variant overrides it | +| `size` | `string` | | Passed to the Button primitive; a per-action size overrides it | +| `emptyState` | `object` | | What this block draws in its place when its `dataSource` binding answers no row, `{ title, message, icon, link }` — the list view's own empty-state shape: a Lucide `icon` name, a `title` (default: "Record not found"), a `message` (default: none) and one `link: { label, url }` the user follows (default: none), opened as a `url` action opens it. `title`, `message` and the link's `label` each accept a plain string or an inline per-locale map, resolved against the display locale. Drawn then and only then — never on a block with no `dataSource` (it reads the record page's record instead), never while the bound read is loading, and never in place of the error a failed read shows; a read the server refuses as forbidden reads as no row, so it is drawn for that too. Leave it out and a binding with no row keeps the block's own placeholder. | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | + +## `record:reference_rail` [#record-reference_rail] + +Namespace `record`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `hideEmpty` | `boolean` | | Drop the rail entirely when no entries resolve | + +## `record:related_list` [#record-related_list] + +Namespace `record`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `objectName` | `string` | yes | Related object name (e.g. "task") | +| `relationshipField` | `string` | yes | Field on the related object pointing back to this record | +| `relationshipValueField` | `string` | | Which field OF THIS PARENT record `relationshipField` stores. Defaults to "id"; set it to the field a name-keyed junction points at (e.g. "name" when the junction column holds the machine name of the parent record). The resolved value drives three things at once — the list filter, the Add-picker link value, and the pre-filled create form — so they cannot drift apart. While the parent record is still loading, a non-"id" field resolves to null and the list holds its fetch rather than querying on an empty value. | +| `parentDataSource` | `object` | | The PARENT record this related list hangs off, bound by a query of its own: an object binding `{ object, view?, filter?, sort?, limit? }`, the shape `dataSource` takes. The FIRST row of the bound set by its `sort` is the parent (declare `limit: 1`; `objectui validate` warns without it), and `relationshipValueField` is read on that row. When no row answers, the list draws a notice saying so in its place, never an empty list. Without this key, the parent is the record page's record. The node-level `dataSource` on this block is the CHILD rows the list draws, and it is never read as the parent. | +| `columns` | `array` of `string` | | Fields to display in the related list. Optional: without it, a `dataSource` binding that names a view supplies that view's columns, and with neither the list derives its columns from the related object (its `highlightFields`, otherwise its listable fields). Authored columns win over both. | +| `sort` | `array` | | — | +| `limit` | `number` | | Records to display initially | +| `filter` | `array` of `object` | | Additional filter criteria, as spec `ViewFilterRule` entries (`[{ field, operator, value }]`). AND-combined with the parent relationship condition, never a replacement for it: it can only narrow this record's children. Also the key a per-element `dataSource` binding's composed filter lands on. | +| `title` | `string` \| `object` | | Heading of the list. Defaults to the related object's label (its translation when one is loaded, otherwise the humanized object name). Accepts either a plain string or an inline per-locale map (`{ en: "Open tasks", "zh-CN": "未完成任务" }`) — the `I18nLabel` union the contract admits on this key — and the list resolves the map against the active UI language, falling back through base language, a region-qualified sibling, `default`, then `en`, and finally to any remaining entry. | +| `showViewAll` | `boolean` | | — | +| `actions` | `array` of `string` | | Action IDs available for related records | +| `add` | `object` | | Adds an "Add" button that assigns EXISTING records instead of creating one — the m2m/junction case. Shape: `{ picker: { object, valueField?, labelField?, filter? }, linkField?, label? }`. `picker.object` (required) is the object whose records the dialog offers. `picker.valueField` is the field of the picked record used as the link value (default "id"); `picker.labelField` is the column shown in the picker rows (default "name", and the other columns are derived from that object's schema). With `linkField` set, selecting records CREATES rows in this list's own object as `{ [relationshipField]: parentValue, [linkField]: pickedId }` — the junction case; omit `linkField` and the picked child is RE-PARENTED instead, by setting its own `relationshipField` to this parent. `label` is the button text (default "Add", localizable inline). Setting `add` also enables generic link removal on rows when no host delete handler is wired. `picker.filter` restricts which records the dialog offers — a list of `{ field, operator, value }` rules in the same vocabulary as this list's own `filter`, applied as a hard constraint the user cannot widen (it never appears as an editable filter row). | +| `enforceFieldSecurity` | `boolean` | | Fold this list's `columns` through the caller's FIELD-read permissions on the RELATED object before rendering (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — the rows are fetched either way and the server still decides every value. | +| `redactFields` | `array` of `string` | | Field names this list never renders, whatever the permission answer (renderer default: render every column authored or derived). Applies to the authored `columns` AND to the columns the list derives for itself when none are authored. Presentation only, evaluated in the browser after the rows are fetched — the values are still in the page, so this is NOT a data-access control and NOT the object's `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. | +| `requiredPermissions` | `array` of `string` | | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open. | +| `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | diff --git a/content/docs/ui/components/ui.mdx b/content/docs/ui/components/ui.mdx new file mode 100644 index 00000000000..b00794f2786 --- /dev/null +++ b/content/docs/ui/components/ui.mdx @@ -0,0 +1,172 @@ +--- +title: "Layout and basic components — component reference" +navTitle: "Layout & Basic" +description: "The layout primitives and basic display components: flex, grid, stack, card, tabs, text, button and more." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Generated from sdui.manifest.json by node scripts/gen-sdui-reference.mjs; change the manifest or the generator, never this page. */} + +These are the layout primitives and basic display components. The containers among them take child components; the rest draw text, images, icons, badges and buttons. + + +This page is generated from the repo-root `sdui.manifest.json`, the component manifest CI +regenerates from the renderer registry. Regenerate it with +`node scripts/gen-sdui-reference.mjs`; `node scripts/gen-sdui-reference.mjs --check` fails when it is stale. + + +## `accordion` [#accordion] + +Namespace `ui`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `accordionType` | `enum`: `single`, `multiple` | | — | +| `collapsible` | `boolean` | | — | +| `items` | `array` | | Array of \{ trigger, content, value \} | +| `className` | `string` | | — | + +## `alert` [#alert] + +Namespace `ui`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `title` | `string` | yes | — | +| `description` | `string` | | — | +| `variant` | `enum`: `default`, `destructive` | | — | +| `className` | `string` | | — | +| `children` | `slot` | | Rich description content, rendered when `description` is not set | + +## `badge` [#badge] + +Namespace `ui`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `label` | `string` | | — | +| `variant` | `enum`: `default`, `secondary`, `destructive`, `outline` | | — | +| `className` | `string` | | — | +| `children` | `slot` | | Rich label content, rendered when `label` is not set | + +## `box` [#box] + +Namespace `ui` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `button` [#button] + +Namespace `ui`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `label` | `string` | | — | +| `variant` | `enum`: `default`, `secondary`, `destructive`, `outline`, `ghost`, `link` | | — | +| `size` | `enum`: `default`, `sm`, `lg`, `icon` | | — | +| `className` | `string` | | — | +| `children` | `slot` | | Rich label content, rendered when `label` is not set | + +## `card` [#card] + +Namespace `ui` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `title` | `string` | | — | +| `description` | `string` | | — | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `container` [#container] + +Namespace `ui` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `maxWidth` | `enum`: `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]` | | — | +| `padding` | `enum`: `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]` | | Padding step on the container spacing scale; 0 is none. Default 4. | +| `centered` | `boolean` | | — | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `flex` [#flex] + +Namespace `ui` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `direction` | `enum`: `row`, `col`, `row-reverse`, `col-reverse` | | — | +| `justify` | `enum`: `start`, `end`, `center`, `between`, `around`, `evenly` | | — | +| `align` | `enum`: `start`, `end`, `center`, `baseline`, `stretch` | | — | +| `gap` | `enum`: `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]` | | Gap step between items; 0 is none. Default 2. | +| `wrap` | `boolean` | | Allow flex items to wrap | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `grid` [#grid] + +Namespace `ui` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `columns` | `enum` \| `object`: `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]` | | Column count, 1 to 12, or an object of such counts keyed by breakpoint (xs, sm, md, lg, xl, 2xl). Default 2. | +| `gap` | `enum`: `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]` | | Gap step between items; 0 is none. Default 4. | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `icon` [#icon] + +Namespace `ui`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `icon` | `string` | | — | +| `size` | `number` | | — | +| `color` | `string` | | — | +| `className` | `string` | | — | + +## `image` [#image] + +Namespace `ui`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `src` | `string` | | — | +| `alt` | `string` | | — | +| `className` | `string` | | — | + +## `stack` [#stack] + +Namespace `ui` · a container: it takes child components. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `direction` | `enum`: `col`, `row`, `col-reverse`, `row-reverse` | | — | +| `gap` | `enum`: `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]`, `[object Object]` | | Gap step between items; 0 is none. Default 2. | +| `align` | `enum`: `start`, `end`, `center`, `stretch`, `baseline` | | — | +| `justify` | `enum`: `start`, `end`, `center`, `between`, `around`, `evenly` | | — | +| `className` | `string` | | — | +| `children` | `slot` | | — | + +## `tabs` [#tabs] + +Namespace `ui`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `defaultValue` | `string` | yes | — | +| `className` | `string` | | — | +| `items` | `array` | | — | + +## `text` [#text] + +Namespace `ui`. + +| Input | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `content` | `string` | yes | — | +| `variant` | `enum`: `h1`, `h2`, `h3`, `h4`, `h5`, `h6`, `body`, `caption`, `overline` | | — | +| `align` | `enum`: `left`, `center`, `right`, `justify` | | — | diff --git a/content/docs/ui/meta.json b/content/docs/ui/meta.json index 9bf31a8cd75..d12a8a44853 100644 --- a/content/docs/ui/meta.json +++ b/content/docs/ui/meta.json @@ -13,6 +13,7 @@ "forms", "doc-pages", "setup-app", + "components", "---Recipes---", "create-vs-edit-form", "field-grouping-and-order", diff --git a/package.json b/package.json index a0c7eb67709..87daa6587cd 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,8 @@ "check:bash32-floor": "node scripts/check-bash32-floor.mjs --self-test && node scripts/check-bash32-floor.mjs", "check:stack-collection-maps": "node scripts/check-stack-collection-maps.mjs --self-test && node scripts/check-stack-collection-maps.mjs", "check:widget-option-census": "node scripts/check-widget-option-census.mjs --self-test && node scripts/check-widget-option-census.mjs", + "check:sdui-reference": "node scripts/gen-sdui-reference.mjs --self-test && node scripts/gen-sdui-reference.mjs --check", + "gen:sdui-reference": "node scripts/gen-sdui-reference.mjs", "check:doc-authoring": "node scripts/check-doc-authoring.mjs --self-test && node scripts/check-doc-authoring.mjs", "check:doc-anchors": "node scripts/check-doc-anchors.mjs --self-test && node scripts/check-doc-anchors.mjs", "check:doc-frontmatter": "node scripts/check-doc-frontmatter.mjs --self-test && node scripts/check-doc-frontmatter.mjs", diff --git a/scripts/gen-sdui-reference.mjs b/scripts/gen-sdui-reference.mjs new file mode 100644 index 00000000000..eaacb0e2d2e --- /dev/null +++ b/scripts/gen-sdui-reference.mjs @@ -0,0 +1,727 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * gen-sdui-reference -- the docs site's component reference, generated from + * the repo-root `sdui.manifest.json`. + * + * node scripts/gen-sdui-reference.mjs # write content/docs/ui/components/ + * node scripts/gen-sdui-reference.mjs --check # exit 1 when the committed pages drift + * node scripts/gen-sdui-reference.mjs --self-test # prove the battery can go red + * + * ## What it produces, and why from the manifest + * + * `sdui.manifest.json` is the public-tier component manifest (ADR-0080): the + * components a metadata author may place, each with its inputs, their coarse + * types, enums and descriptions. CI regenerates it from the registry under + * `ui/` and fails when the committed copy differs, so it is the one list that + * cannot disagree with the renderer. A hand-written page per component can, + * and the hand pages that existed covered 17 of the manifest's components. + * This generator covers every one of them, and `--check` keeps the pages equal + * to the manifest they were generated from. + * + * ## Layout: one page per namespace, two grouping rules + * + * At 100+ components a page per component is mostly one-row pages (48 of them + * are HTML elements whose only inputs are `className` and `children`), so the + * reference is one page per namespace with a heading per component: + * + * - a component whose `tier` is `html` goes to the HTML elements page, + * whatever its namespace; + * - every `plugin-*` namespace shares the plugin blocks page, because each + * holds one to three components; + * - every other namespace has its own page, and a namespace with no row in + * NAMESPACE_PAGES is REFUSED with the remedy -- a new namespace needs its + * page's title and introduction written once, by a person, never guessed. + * + * Each component heading carries an explicit id (`[#action-button]` for + * `action:button`), so its anchor does not depend on a slugger's punctuation + * rules, and two components whose ids collide are refused. + * + * ## The directory is wholly generated + * + * Every file under `content/docs/ui/components/` is written by this script: + * the pages, the index and `meta.json`. A write removes any other file there, + * and `--check` fails on one, so a page cannot linger after its namespace + * leaves the manifest. Edit this script, never a page. + * + * ## Absence is loud + * + * An unknown component key, input key or tier value is refused rather than + * skipped: the manifest gaining a field means the reference is about to omit + * something it carries, and the remedy is to render it here. An empty manifest, + * a component whose key is not its `type`, and a type that would not appear on + * exactly one page are refused too. + */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; + +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const MANIFEST_PATH = 'sdui.manifest.json'; +const OUT_DIR = 'content/docs/ui/components'; +const URL_BASE = '/docs/ui/components'; +const COMMAND = 'node scripts/gen-sdui-reference.mjs'; + +const COMPONENT_KEYS = new Set(['type', 'namespace', 'inputs', 'tier', 'isContainer']); +const INPUT_KEYS = new Set(['name', 'type', 'description', 'enum', 'of', 'required', 'binding']); +const TIERS = new Set(['html']); + +const HTML_PAGE = { + slug: 'html', + title: 'HTML elements — component reference', + navTitle: 'HTML Elements', + description: 'The native HTML elements an html page source may use, and the props each accepts.', + intro: + 'These are the safe native HTML elements of the public tier. Each renders as the element ' + + 'of the same name and takes few props beyond `className` and its children.', +}; + +const PLUGINS_PAGE = { + slug: 'plugins', + title: 'Plugin blocks — component reference', + navTitle: 'Plugin Blocks', + description: 'The object-bound data blocks: grids, lists, forms, boards, calendars, charts, maps and more.', + intro: + 'These blocks come from the view plugins. Most are bound to an object, through their own ' + + '`objectName` input or the node\'s `dataSource` binding, and draw its records.', +}; + +// One row per namespace that has a page of its own. A namespace the manifest +// gains is refused until it has a row here. +const NAMESPACE_PAGES = Object.freeze({ + ui: { + slug: 'ui', + title: 'Layout and basic components — component reference', + navTitle: 'Layout & Basic', + description: 'The layout primitives and basic display components: flex, grid, stack, card, tabs, text, button and more.', + intro: + 'These are the layout primitives and basic display components. The containers among ' + + 'them take child components; the rest draw text, images, icons, badges and buttons.', + }, + page: { + slug: 'page', + title: 'Page structure components — component reference', + navTitle: 'Page Structure', + description: 'The page:* components that give a page its structure: header, footer, sidebar, sections, cards, tabs and accordions.', + intro: + 'The `page:*` components give a page its structure. All of them except the header are ' + + 'containers for other components.', + }, + record: { + slug: 'record', + title: 'Record components — component reference', + navTitle: 'Record', + description: 'The record:* components that draw the record a record page opens: details, highlights, related lists, activity and more.', + intro: + 'The `record:*` components render from the record context a record page mounts, so they ' + + 'belong on a `type: \'record\'` page.', + }, + element: { + slug: 'element', + title: 'Element components — component reference', + navTitle: 'Elements', + description: 'The element:* components: text, numbers, buttons, dividers, definition lists and repeaters.', + intro: 'The `element:*` components are small, self-contained page elements.', + }, + action: { + slug: 'action', + title: 'Action components — component reference', + navTitle: 'Actions', + description: 'The action:* components that put an action on a page: a button, an icon, a group or a menu.', + intro: + 'The `action:*` components put an action on a page as a button, an icon button, a ' + + 'group of buttons or a menu.', + }, +}); + +// The order pages take in the sidebar. +const PAGE_ORDER = ['ui', 'page', 'record', 'element', 'action', 'plugins', 'html']; + +const GENERATED_COMMENT = + `{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Generated from ${MANIFEST_PATH} by ${COMMAND}; ` + + 'change the manifest or the generator, never this page. */}'; + +const GENERATED_CALLOUT = [ + '', + `This page is generated from the repo-root \`${MANIFEST_PATH}\`, the component manifest CI`, + 'regenerates from the renderer registry. Regenerate it with', + `\`${COMMAND}\`; \`${COMMAND} --check\` fails when it is stale.`, + '', +].join('\n'); + +class ReferenceRenderError extends Error {} + +const fail = (message) => { + throw new ReferenceRenderError(message); +}; + +// ── Manifest shape ───────────────────────────────────────────────────────── + +function validateManifest(manifest) { + const components = manifest?.components; + if (!components || typeof components !== 'object' || Array.isArray(components)) { + fail(`${MANIFEST_PATH} has no \`components\` object.`); + } + const entries = Object.entries(components); + if (entries.length === 0) { + fail(`${MANIFEST_PATH} lists no components, so there is nothing to document. Refusing to write an empty reference.`); + } + for (const [key, comp] of entries) { + for (const k of Object.keys(comp)) { + if (!COMPONENT_KEYS.has(k)) { + fail(`component \`${key}\` carries \`${k}\`, a key this generator does not render. Render it in ${COMMAND} (COMPONENT_KEYS) rather than letting the reference omit it.`); + } + } + if (comp.type !== key) fail(`component key \`${key}\` names type \`${comp.type}\`; the manifest keys each component by its type.`); + if (typeof comp.namespace !== 'string' || comp.namespace === '') fail(`component \`${key}\` has no namespace.`); + if (!Array.isArray(comp.inputs)) fail(`component \`${key}\` has no \`inputs\` array.`); + if (comp.tier !== undefined && !TIERS.has(comp.tier)) { + fail(`component \`${key}\` has tier \`${comp.tier}\`, which this generator does not know. Decide its page in ${COMMAND} (TIERS, pageFor).`); + } + for (const input of comp.inputs) { + for (const k of Object.keys(input)) { + if (!INPUT_KEYS.has(k)) { + fail(`input \`${input.name}\` of \`${key}\` carries \`${k}\`, a key this generator does not render. Render it in ${COMMAND} (INPUT_KEYS) rather than letting the reference omit it.`); + } + } + if (typeof input.name !== 'string' || input.name === '') fail(`component \`${key}\` has an input with no name.`); + } + } + return entries.map(([, comp]) => comp); +} + +function pageFor(comp) { + if (comp.tier === 'html') return HTML_PAGE; + if (comp.namespace.startsWith('plugin-')) return PLUGINS_PAGE; + const page = NAMESPACE_PAGES[comp.namespace]; + if (!page) { + fail(`component \`${comp.type}\` is in namespace \`${comp.namespace}\`, which has no page. Add a NAMESPACE_PAGES row (slug, title, navTitle, description, intro) and its slug to PAGE_ORDER in ${COMMAND}.`); + } + return page; +} + +// ── Markdown rendering ───────────────────────────────────────────────────── + +// Text for an MDX table cell. Outside code spans, `{` `}` would open a JSX +// expression, `<` a JSX tag and `|` a new cell, so they are escaped; inside a +// code span only `|` is, because the table splits cells before code spans are +// read. A backtick run with no closing run of the same length is literal text. +function cellText(text) { + const s = String(text).replace(/\s*\n\s*/g, ' ').trim(); + let out = ''; + let i = 0; + while (i < s.length) { + if (s[i] === '`') { + let j = i; + while (s[j] === '`') j += 1; + const fence = s.slice(i, j); + const close = s.indexOf(fence, j); + if (close !== -1 && s[close + fence.length] !== '`') { + out += fence + s.slice(j, close).replace(/\|/g, '\\|') + fence; + i = close + fence.length; + continue; + } + out += '\\`'.repeat(fence.length); + i = j; + continue; + } + const ch = s[i]; + out += ch === '{' || ch === '}' || ch === '<' || ch === '|' ? `\\${ch}` : ch; + i += 1; + } + return out; +} + +const code = (value) => `\`${String(value).replace(/\|/g, '\\|')}\``; + +function typeCell(input) { + const types = Array.isArray(input.type) ? input.type : [input.type]; + let cell = types.map((t) => (t === 'array' && input.of ? `${code('array')} of ${code(input.of)}` : code(t))).join(' \\| '); + if (Array.isArray(input.enum) && input.enum.length > 0) { + cell += `: ${input.enum.map((v) => code(v)).join(', ')}`; + } + if (input.binding) cell += ` (${code(input.binding)} data binding)`; + return cell; +} + +const anchorId = (type) => type.replace(/[^A-Za-z0-9_-]+/g, '-'); + +function renderComponent(comp) { + const facts = [`Namespace ${code(comp.namespace)}`]; + if (comp.tier) facts.push(`tier ${code(comp.tier)}`); + if (comp.isContainer) facts.push('a container: it takes child components'); + const lines = [`## ${code(comp.type)} [#${anchorId(comp.type)}]`, '', `${facts.join(' · ')}.`, '']; + if (comp.inputs.length === 0) { + lines.push('It takes no inputs.'); + } else { + lines.push('| Input | Type | Required | Description |', '| :--- | :--- | :--- | :--- |'); + for (const input of comp.inputs) { + const description = input.description ? cellText(input.description) : '—'; + lines.push(`| ${code(input.name)} | ${typeCell(input)} | ${input.required ? 'yes' : ''} | ${description} |`); + } + } + return lines.join('\n'); +} + +function frontmatter(page) { + return [ + '---', + `title: ${JSON.stringify(page.title)}`, + `navTitle: ${JSON.stringify(page.navTitle)}`, + `description: ${JSON.stringify(page.description)}`, + '---', + ].join('\n'); +} + +function renderPage(page, comps) { + return [ + frontmatter(page), + '', + GENERATED_COMMENT, + '', + page.intro, + '', + GENERATED_CALLOUT, + '', + comps.map(renderComponent).join('\n\n'), + '', + ].join('\n'); +} + +function renderIndex(rows, pages) { + const index = { + title: 'Component reference — every component a page can place', + navTitle: 'Component Reference', + description: `Every component in the public SDUI component manifest — ${rows.length} components with their inputs, generated from ${MANIFEST_PATH}.`, + }; + const table = [ + '| Component | Namespace | Inputs | Page |', + '| :--- | :--- | ---: | :--- |', + ...rows.map(({ comp, page }) => + `| [${code(comp.type)}](${URL_BASE}/${page.slug}#${anchorId(comp.type)}) | ${code(comp.namespace)} | ${comp.inputs.length} | [${page.navTitle}](${URL_BASE}/${page.slug}) |`), + ]; + const pageList = pages.map(({ page, comps }) => `- [${page.navTitle}](${URL_BASE}/${page.slug}): ${comps.length} components.`); + return [ + frontmatter(index), + '', + GENERATED_COMMENT, + '', + `These are the ${rows.length} components of the public tier: the components an \`html\` page's`, + '`source` may use, and the types a page places in its regions. Each component lists its', + 'inputs, the props it accepts, with the coarse type, the allowed values of an enum and', + 'whether it is required. An `html` page source is checked against this same list when it is', + 'validated and saved, so an unknown component or a missing required input is reported', + 'there rather than drawn. See [Pages](/docs/ui/pages) for how a page is authored.', + '', + GENERATED_CALLOUT, + '', + '## Pages', + '', + ...pageList, + '', + '## Every component', + '', + ...table, + '', + ].join('\n'); +} + +/** The whole generated directory, as `{ relativeFileName: content }`. */ +export function renderReference(manifest) { + const comps = validateManifest(manifest); + const byPage = new Map(); + const ids = new Map(); + for (const comp of comps) { + const page = pageFor(comp); + const id = anchorId(comp.type); + if (ids.has(id)) fail(`components \`${ids.get(id)}\` and \`${comp.type}\` share the heading id \`${id}\`; change anchorId() in ${COMMAND} so each keeps its own.`); + ids.set(id, comp.type); + if (!byPage.has(page.slug)) byPage.set(page.slug, { page, comps: [] }); + byPage.get(page.slug).comps.push(comp); + } + const bySlug = (a, b) => (a < b ? -1 : a > b ? 1 : 0); + const pages = PAGE_ORDER.filter((slug) => byPage.has(slug)).map((slug) => byPage.get(slug)); + for (const slug of byPage.keys()) { + if (!PAGE_ORDER.includes(slug)) fail(`page \`${slug}\` is missing from PAGE_ORDER in ${COMMAND}.`); + } + const files = {}; + const rows = []; + for (const entry of pages) { + entry.comps.sort((a, b) => bySlug(a.namespace, b.namespace) || bySlug(a.type, b.type)); + files[`${entry.page.slug}.mdx`] = renderPage(entry.page, entry.comps); + for (const comp of entry.comps) rows.push({ comp, page: entry.page }); + } + rows.sort((a, b) => bySlug(a.comp.type, b.comp.type)); + files['index.mdx'] = renderIndex(rows, pages); + files['meta.json'] = `${JSON.stringify({ title: 'Component Reference', pages: pages.map((p) => p.page.slug) }, null, 2)}\n`; + + // Every manifest type is a heading on exactly one page. + for (const comp of comps) { + const heading = `## ${code(comp.type)} [#${anchorId(comp.type)}]\n`; + const hits = Object.values(files).reduce((n, text) => n + text.split(heading).length - 1, 0); + if (hits !== 1) fail(`component \`${comp.type}\` is a heading on ${hits} pages, not exactly one.`); + } + return { files, componentCount: comps.length, pageCount: pages.length }; +} + +// ── Disk ─────────────────────────────────────────────────────────────────── + +/** Differences between the rendered files and the directory, as messages. */ +export function diffReference(files, dir) { + const problems = []; + const present = existsSync(dir) ? readdirSync(dir, { withFileTypes: true }) : []; + if (!existsSync(dir)) problems.push(`${dir}/ does not exist.`); + for (const [name, expected] of Object.entries(files)) { + const path = join(dir, name); + if (!existsSync(path)) { + problems.push(`${name} is missing.`); + continue; + } + const actual = readFileSync(path, 'utf8'); + if (actual === expected) continue; + const a = actual.split('\n'); + const e = expected.split('\n'); + let line = 0; + while (line < a.length && line < e.length && a[line] === e[line]) line += 1; + problems.push(`${name} differs from the manifest's rendering, first at line ${line + 1}:\n committed: ${JSON.stringify(a[line] ?? '')}\n generated: ${JSON.stringify(e[line] ?? '')}`); + } + for (const entry of present) { + if (!(entry.name in files)) problems.push(`${entry.name} is not a generated file; everything under this directory is generated.`); + } + return problems; +} + +function writeReference(files, dir) { + mkdirSync(dir, { recursive: true }); + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!(entry.name in files)) rmSync(join(dir, entry.name), { recursive: true, force: true }); + } + for (const [name, text] of Object.entries(files)) writeFileSync(join(dir, name), text); +} + +function readManifest() { + const path = join(REPO_ROOT, MANIFEST_PATH); + if (!existsSync(path)) fail(`${MANIFEST_PATH} is missing at the repo root.`); + return JSON.parse(readFileSync(path, 'utf8')); +} + +// ── Self-test ────────────────────────────────────────────────────────────── + +// Set as the self-test's last statement, after its success line prints, and +// read at the dispatch: a `return` above the verdict must not exit 0. +let selfTestReachedVerdict = false; + +// The self-test is table-driven: each row's label is a declared battery with a +// floor of 1, registered as the loop body's first statement. The roster is a +// literal, so a deleted or renamed row names itself. +const SELF_TEST_BATTERIES = Object.freeze({ + 'a component of each kind lands on its page, and the index links every one': 1, + 'components are sorted by type and inputs keep manifest order': 1, + 'rendering twice is byte-identical': 1, + 'braces, angle brackets and pipes are escaped outside code spans': 1, + 'a pipe inside a code span is escaped and the braces in it are not': 1, + 'an unclosed backtick is literal text': 1, + 'enum values, array element types and data bindings reach the type cell': 1, + 'a missing description renders a dash and required renders yes': 1, + 'an empty manifest is refused': 1, + 'an unknown component key is refused': 1, + 'an unknown input key is refused': 1, + 'an unknown tier is refused': 1, + 'a namespace with no page is refused': 1, + 'a component keyed by something other than its type is refused': 1, + 'two components sharing a heading id are refused': 1, + 'check: the rendering of a directory just written is clean': 1, + 'check: one hand-edited line is drift, naming the file and line': 1, + 'check: a missing file and a stray file are drift': 1, + 'write: a stale file in the directory is removed': 1, + 'the real manifest renders every component on exactly one page': 1, +}); +const SELF_TEST_BATTERY_FLOOR = 20; + +function selfTest() { + const comp = (type, namespace, inputs = [], extra = {}) => ({ type, namespace, inputs, ...extra }); + const manifestOf = (...comps) => ({ components: Object.fromEntries(comps.map((c) => [c.type, c])) }); + const sample = () => + manifestOf( + comp('stack', 'ui', [{ name: 'gap', type: 'number' }], { isContainer: true }), + comp('button', 'ui', [{ name: 'label', type: 'string', required: true }, { name: 'variant', type: 'enum', enum: ['default', 'ghost'] }]), + comp('h1', 'ui', [{ name: 'children', type: 'slot' }], { tier: 'html' }), + comp('page:card', 'page', [], { isContainer: true }), + comp('record:path', 'record', [{ name: 'stages', type: 'array', of: 'object' }]), + comp('element:text', 'element', [{ name: 'content', type: ['string', 'object'] }]), + comp('action:button', 'action', [{ name: 'label', type: 'string' }]), + comp('object-grid', 'plugin-grid', [{ name: 'dataSource', type: 'object', binding: 'object', description: 'Binding: { object, view }.' }]), + ); + const refuses = (manifest, pattern) => () => { + try { + renderReference(manifest); + } catch (err) { + if (err instanceof ReferenceRenderError && pattern.test(err.message)) return null; + return `threw the wrong refusal: ${err.message}`; + } + return 'rendered instead of refusing'; + }; + const withTmp = (fn) => { + const root = mkdtempSync(join(tmpdir(), 'gen-sdui-reference-')); + try { + return fn(join(root, 'components')); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }; + + const cases = [ + { + label: 'a component of each kind lands on its page, and the index links every one', + run() { + const { files } = renderReference(sample()); + const want = { + 'ui.mdx': ['`stack`', '`button`'], + 'html.mdx': ['`h1`'], + 'page.mdx': ['`page:card`'], + 'record.mdx': ['`record:path`'], + 'element.mdx': ['`element:text`'], + 'action.mdx': ['`action:button`'], + 'plugins.mdx': ['`object-grid`'], + }; + for (const [file, types] of Object.entries(want)) { + if (!files[file]) return `${file} was not rendered`; + for (const t of types) if (!files[file].includes(`## ${t}`)) return `${t} is not a heading on ${file}`; + } + if (files['ui.mdx'].includes('## `h1`')) return 'an html-tier component also landed on the ui page'; + const meta = JSON.parse(files['meta.json']); + if (meta.pages.join() !== 'ui,page,record,element,action,plugins,html') return `meta.json pages are ${meta.pages.join()}`; + if (!files['index.mdx'].includes(`(${URL_BASE}/action#action-button)`)) return 'the index does not link action:button to its anchor'; + if (!files['index.mdx'].includes(`(${URL_BASE}/plugins#object-grid)`)) return 'the index does not link object-grid to its anchor'; + if (!files['action.mdx'].includes('## `action:button` [#action-button]')) return 'action:button has no explicit heading id'; + for (const [file, text] of Object.entries(files)) { + if (file.endsWith('.mdx') && !(text.includes('AUTO-GENERATED') && text.includes(COMMAND))) return `${file} does not say it is generated and how`; + } + return null; + }, + }, + { + label: 'components are sorted by type and inputs keep manifest order', + run() { + const ui = renderReference(sample()).files['ui.mdx']; + if (ui.indexOf('## `button`') > ui.indexOf('## `stack`')) return 'button is not before stack'; + const m = manifestOf(comp('box', 'ui', [{ name: 'zeta', type: 'string' }, { name: 'alpha', type: 'string' }])); + const box = renderReference(m).files['ui.mdx']; + if (box.indexOf('`zeta`') > box.indexOf('`alpha`')) return 'inputs were reordered'; + return null; + }, + }, + { + label: 'rendering twice is byte-identical', + run() { + const a = renderReference(sample()).files; + const b = renderReference(sample()).files; + return JSON.stringify(a) === JSON.stringify(b) ? null : 'two renderings differ'; + }, + }, + { + label: 'braces, angle brackets and pipes are escaped outside code spans', + run() { + const got = cellText('a { b } d | e'); + return got === 'a \\{ b \\} \\ d \\| e' ? null : `got ${JSON.stringify(got)}`; + }, + }, + { + label: 'a pipe inside a code span is escaped and the braces in it are not', + run() { + const got = cellText('use `{ a: "x" | "y" }` here'); + return got === 'use `{ a: "x" \\| "y" }` here' ? null : `got ${JSON.stringify(got)}`; + }, + }, + { + label: 'an unclosed backtick is literal text', + run() { + const got = cellText('a ` b {c}'); + return got === 'a \\` b \\{c\\}' ? null : `got ${JSON.stringify(got)}`; + }, + }, + { + label: 'enum values, array element types and data bindings reach the type cell', + run() { + const { files } = renderReference(sample()); + if (!files['ui.mdx'].includes('| `variant` | `enum`: `default`, `ghost` |')) return 'enum values missing'; + if (!files['record.mdx'].includes('| `stages` | `array` of `object` |')) return 'array element type missing'; + if (!files['element.mdx'].includes('| `content` | `string` \\| `object` |')) return 'type union missing'; + if (!files['plugins.mdx'].includes('`object` (`object` data binding)')) return 'binding missing'; + if (!files['plugins.mdx'].includes('Binding: \\{ object, view \\}.')) return 'description braces not escaped'; + return null; + }, + }, + { + label: 'a missing description renders a dash and required renders yes', + run() { + const ui = renderReference(sample()).files['ui.mdx']; + if (!ui.includes('| `label` | `string` | yes | — |')) return 'required/dash row wrong'; + if (!renderReference(sample()).files['page.mdx'].includes('It takes no inputs.')) return 'empty inputs not stated'; + return null; + }, + }, + { label: 'an empty manifest is refused', run: refuses({ components: {} }, /lists no components/) }, + { label: 'an unknown component key is refused', run: refuses(manifestOf(comp('box', 'ui', [], { slots: [] })), /carries `slots`/) }, + { label: 'an unknown input key is refused', run: refuses(manifestOf(comp('box', 'ui', [{ name: 'x', type: 'string', defaultValue: 1 }])), /carries `defaultValue`/) }, + { label: 'an unknown tier is refused', run: refuses(manifestOf(comp('box', 'ui', [], { tier: 'internal' })), /tier `internal`/) }, + { label: 'a namespace with no page is refused', run: refuses(manifestOf(comp('ai:suggestion', 'ai')), /namespace `ai`, which has no page/) }, + { + label: 'a component keyed by something other than its type is refused', + run: refuses({ components: { box: comp('stack', 'ui') } }, /key `box` names type `stack`/), + }, + { + label: 'two components sharing a heading id are refused', + run: refuses(manifestOf(comp('page:card', 'page'), comp('page-card', 'page')), /share the heading id `page-card`/), + }, + { + label: 'check: the rendering of a directory just written is clean', + run: () => withTmp((dir) => { + const { files } = renderReference(sample()); + writeReference(files, dir); + const problems = diffReference(files, dir); + return problems.length === 0 ? null : problems.join('; '); + }), + }, + { + label: 'check: one hand-edited line is drift, naming the file and line', + run: () => withTmp((dir) => { + const { files } = renderReference(sample()); + writeReference(files, dir); + const path = join(dir, 'ui.mdx'); + writeFileSync(path, readFileSync(path, 'utf8').replace('| `gap` | `number` |', '| `gap` | `string` |')); + const problems = diffReference(files, dir); + return problems.length === 1 && /^ui\.mdx differs .* line \d+/.test(problems[0]) ? null : `got ${JSON.stringify(problems)}`; + }), + }, + { + label: 'check: a missing file and a stray file are drift', + run: () => withTmp((dir) => { + const { files } = renderReference(sample()); + writeReference(files, dir); + rmSync(join(dir, 'html.mdx')); + writeFileSync(join(dir, 'button.mdx'), '# hand page\n'); + const problems = diffReference(files, dir); + const ok = problems.some((p) => p.startsWith('html.mdx is missing')) && problems.some((p) => p.startsWith('button.mdx is not a generated file')); + return ok && problems.length === 2 ? null : `got ${JSON.stringify(problems)}`; + }), + }, + { + label: 'write: a stale file in the directory is removed', + run: () => withTmp((dir) => { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'gone.mdx'), 'stale\n'); + writeReference(renderReference(sample()).files, dir); + return existsSync(join(dir, 'gone.mdx')) ? 'gone.mdx survived a write' : null; + }), + }, + { + label: 'the real manifest renders every component on exactly one page', + run() { + const manifest = readManifest(); + const { files, componentCount } = renderReference(manifest); + const all = Object.values(files).join('\n'); + const n = Object.keys(manifest.components).length; + if (componentCount !== n) return `rendered ${componentCount} of ${n}`; + for (const type of Object.keys(manifest.components)) { + if (!all.includes(`## ${code(type)} [#${anchorId(type)}]`)) return `${type} has no heading`; + } + return null; + }, + }, + ]; + + const batterySeen = new Map(); + const registerCase = (name) => { + batterySeen.set(name, (batterySeen.get(name) ?? 0) + 1); + }; + + let failed = 0; + for (const c of cases) { + registerCase(c.label); + let problem; + try { + problem = c.run(); + } catch (err) { + problem = `threw: ${err.message}`; + } + if (problem) { + failed += 1; + console.error(` ✗ ${c.label}\n ${problem}`); + } else { + console.log(` ✓ ${c.label}`); + } + } + + const floorFailure = (message) => { + console.error(`✗ self-test floor: ${message}`); + failed += 1; + }; + const declared = Object.keys(SELF_TEST_BATTERIES); + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + floorFailure(`SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR}.`); + } + const labels = cases.map((c) => c.label); + const duplicated = [...new Set(labels.filter((name, i) => labels.indexOf(name) !== i))]; + if (duplicated.length > 0) floorFailure(`row labels used twice: ${duplicated.map((n) => JSON.stringify(n)).join(', ')}.`); + for (const [name, count] of batterySeen) { + if (!declared.includes(name)) floorFailure(`battery "${name}" registered ${count} case(s) but is not declared in SELF_TEST_BATTERIES.`); + } + for (const name of declared) { + const count = batterySeen.get(name) ?? 0; + if (count < SELF_TEST_BATTERIES[name]) { + floorFailure(`battery "${name}" DID NOT RUN — ${count} case(s) registered, ${SELF_TEST_BATTERIES[name]} pinned.`); + } + } + + if (failed > 0) { + console.error(`\n✗ gen-sdui-reference self-test: ${failed} failure(s) (cases and floor).`); + process.exit(1); + } + console.log(`\n✓ gen-sdui-reference self-test: ${cases.length} cases pass.`); + selfTestReachedVerdict = true; +} + +// ── Dispatch ─────────────────────────────────────────────────────────────── + +function main() { + if (process.argv.includes('--self-test')) { + selfTest(); + if (!selfTestReachedVerdict) { + console.error('\n✗ gen-sdui-reference self-test: selfTest() returned without reaching its verdict, so no success line was printed.'); + process.exit(1); + } + return; + } + const dir = join(REPO_ROOT, OUT_DIR); + let rendered; + try { + rendered = renderReference(readManifest()); + } catch (err) { + if (!(err instanceof ReferenceRenderError)) throw err; + console.error(`✗ gen-sdui-reference: ${err.message}`); + process.exit(1); + } + const { files, componentCount, pageCount } = rendered; + if (process.argv.includes('--check')) { + const problems = diffReference(files, dir); + if (problems.length > 0) { + console.error(`✗ gen-sdui-reference --check: ${OUT_DIR}/ is not what ${MANIFEST_PATH} generates:\n`); + for (const p of problems) console.error(` - ${p}`); + console.error(`\nRun \`${COMMAND}\` and commit what it writes. Never edit a page under ${OUT_DIR}/ by hand.`); + process.exit(1); + } + console.log(`✓ gen-sdui-reference --check: ${OUT_DIR}/ matches ${MANIFEST_PATH} (${componentCount} components on ${pageCount} pages).`); + return; + } + writeReference(files, dir); + console.log(`✓ gen-sdui-reference: wrote ${componentCount} components on ${pageCount} pages, plus index.mdx and meta.json, to ${OUT_DIR}/.`); +} + +if (isEntrypoint(import.meta.url)) { + main(); +} From c6982112b7a0709b9467c37df4aa3aa62ff1386a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 08:46:19 +0000 Subject: [PATCH 2/7] docs(ui): list the component reference on the UI section's landing page Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibwMyAnTNeas3ANYhyi --- content/docs/ui/index.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/content/docs/ui/index.mdx b/content/docs/ui/index.mdx index b9f48556bb7..12a027e2dc6 100644 --- a/content/docs/ui/index.mdx +++ b/content/docs/ui/index.mdx @@ -50,6 +50,7 @@ export const CrmApp = App.create({ + ### Recipes From e70b0af176eaf4e1281f11d37a6b589c2fc7706d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 08:57:10 +0000 Subject: [PATCH 3/7] docs(ui): treat the generated component reference as generated in the audit scope and the merge driver The docs-accuracy audit's hand-written scope now excludes content/docs/ui/components/** beside references/, and the directory is routed to the os-regen merge driver with gen:sdui-reference as its regeneration. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibwMyAnTNeas3ANYhyi --- .gitattributes | 1 + scripts/docs-audit/affected-docs.mjs | 12 ++++++++---- scripts/docs-audit/check-audit-scope.mjs | 7 ++++--- scripts/docs-audit/handwritten-docs.json | 7 ++++--- scripts/regen-artifacts.mjs | 19 +++++++++++++++++++ 5 files changed, 36 insertions(+), 10 deletions(-) diff --git a/.gitattributes b/.gitattributes index e9249761624..71fa7d6c77f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -158,3 +158,4 @@ content/docs/references/** merge=os-regen skills/*/references/_index.md merge=os-regen skills/objectstack-ui/references/react-blocks.md merge=os-regen scripts/platform-object-tenancy-census.json merge=os-regen +content/docs/ui/components/** merge=os-regen diff --git a/scripts/docs-audit/affected-docs.mjs b/scripts/docs-audit/affected-docs.mjs index 5f7cb487f7a..923556d9d5b 100644 --- a/scripts/docs-audit/affected-docs.mjs +++ b/scripts/docs-audit/affected-docs.mjs @@ -11,7 +11,9 @@ // node scripts/docs-audit/affected-docs.mjs --bridge-coverage [--json] # how much of the DECLARED client-bound route surface the `sdk` bridge can reach (diff-free) // // Scope: hand-written docs only = content/docs/**/*.mdx MINUS content/docs/references/** -// (references are generated from packages/spec and handled by a separate regenerate pass). +// and content/docs/ui/components/** (references are generated from packages/spec and +// handled by a separate regenerate pass; the component reference is generated from +// sdui.manifest.json by scripts/gen-sdui-reference.mjs, whose --check holds it). // // THE ABOVE SCOPE IS THE RECALL DENOMINATOR (#13306, maintainer ruling 2026-08-31). Any // recall figure for this tool — "of the docs pages that should have been listed, how many @@ -198,7 +200,7 @@ import { execSync } from 'node:child_process'; import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { join, relative, sep } from 'node:path'; // The one answer to "is this span a comment, or code?" (#9367). Dependency-free and // side-effect-free on import, so the no-install contract this script runs under holds. // `codeOnly` IS the tree's one comments+literals projection (#15776), imported @@ -1074,9 +1076,11 @@ function walk(dir, out = []) { return out; } const docsRoot = join(repoRoot, 'content/docs'); -const refsRoot = join(repoRoot, 'content/docs/references'); +// The generated trees, each wholly written by its generator: `references/` by +// packages/spec's build-docs.ts, `ui/components/` by scripts/gen-sdui-reference.mjs. +const generatedRoots = ['content/docs/references', 'content/docs/ui/components'].map((r) => join(repoRoot, r) + sep); const handwritten = walk(docsRoot) - .filter((p) => !p.startsWith(refsRoot)) + .filter((p) => !generatedRoots.some((root) => p.startsWith(root))) .map((p) => relative(repoRoot, p)) .sort(); diff --git a/scripts/docs-audit/check-audit-scope.mjs b/scripts/docs-audit/check-audit-scope.mjs index 60debd41a35..8709f2d73a7 100644 --- a/scripts/docs-audit/check-audit-scope.mjs +++ b/scripts/docs-audit/check-audit-scope.mjs @@ -251,9 +251,10 @@ export function renderArtifact(docs) { { readme: [ 'GENERATED — do not hand-edit. `node scripts/docs-audit/check-audit-scope.mjs --write`', - 'derives this from the filesystem (every content/docs/**/*.mdx except references/,', - 'via `affected-docs.mjs --all`); the same script without --write is a CI gate that', - 'fails when this file and content/docs/ disagree in EITHER direction.', + 'derives this from the filesystem (every content/docs/**/*.mdx except the generated', + 'references/ and ui/components/, via `affected-docs.mjs --all`); the same script', + 'without --write is a CI gate that fails when this file and content/docs/ disagree', + 'in EITHER direction.', '', 'THIS FILE IS THE SINGLE SOURCE for "which docs are hand-written". Two consumers:', ' 1. .claude/workflows/docs-accuracy-audit.js — its body runs in a sandbox with no', diff --git a/scripts/docs-audit/handwritten-docs.json b/scripts/docs-audit/handwritten-docs.json index 20ddede3713..e90b94c1df6 100644 --- a/scripts/docs-audit/handwritten-docs.json +++ b/scripts/docs-audit/handwritten-docs.json @@ -1,9 +1,10 @@ { "readme": [ "GENERATED — do not hand-edit. `node scripts/docs-audit/check-audit-scope.mjs --write`", - "derives this from the filesystem (every content/docs/**/*.mdx except references/,", - "via `affected-docs.mjs --all`); the same script without --write is a CI gate that", - "fails when this file and content/docs/ disagree in EITHER direction.", + "derives this from the filesystem (every content/docs/**/*.mdx except the generated", + "references/ and ui/components/, via `affected-docs.mjs --all`); the same script", + "without --write is a CI gate that fails when this file and content/docs/ disagree", + "in EITHER direction.", "", "THIS FILE IS THE SINGLE SOURCE for \"which docs are hand-written\". Two consumers:", " 1. .claude/workflows/docs-accuracy-audit.js — its body runs in a sandbox with no", diff --git a/scripts/regen-artifacts.mjs b/scripts/regen-artifacts.mjs index e4c7b8c2320..581c961bd33 100644 --- a/scripts/regen-artifacts.mjs +++ b/scripts/regen-artifacts.mjs @@ -323,6 +323,25 @@ export const REGEN_ARTIFACTS = Object.freeze([ check: 'check:platform-object-tenancy-census', owner: ROOT_OWNER, }, + // The component reference: every file under the directory is rendered whole + // from the repo-root `sdui.manifest.json` by scripts/gen-sdui-reference.mjs, and + // a write removes anything else there, so no hand-written region exists for a + // deferral to launder. Two branches that each change the manifest produce pages + // describing neither side once merged; only regenerating from the merged + // manifest reaches the answer. The manifest itself is not routed — it is built + // from the registry under `ui/` — so a merge resolves it first and this row's + // regeneration reads the result. + // + // Same LOCAL-facility caveat as its neighbours: `check:sdui-reference` runs in + // `lint.yml` on `pull_request` and `merge_group` with no `paths:` filter and + // re-renders from the manifest rather than reading the pages back. No + // `readsDist`: the generator reads the tracked manifest and nothing built. + { + path: 'content/docs/ui/components/**', + gen: 'gen:sdui-reference', + check: 'check:sdui-reference', + owner: ROOT_OWNER, + }, ]); /** From 6d485bed7fa1a05a514184c3871fe14a351489f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:08:17 +0000 Subject: [PATCH 4/7] docs(ui): skip the generated component reference in the role-word scan, beside the other generated trees Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibwMyAnTNeas3ANYhyi --- scripts/check-role-word.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/check-role-word.mjs b/scripts/check-role-word.mjs index 7a9a6df67f2..212f1e81938 100644 --- a/scripts/check-role-word.mjs +++ b/scripts/check-role-word.mjs @@ -127,7 +127,7 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist']); // justifies the entry above is what decides it rather than weighing against it: // the reserved word emitted into a page by its generator is a REAL finding whose // remedy is that generator. Naming the generated tree is how the gate is told -// where to send such a finding, and exactly two trees under these roots have a +// where to send such a finding, and exactly three trees under these roots have a // fix site this gate cannot otherwise reach: // // content/docs/references/ generated from spec by @@ -138,6 +138,11 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist']); // `gen:upgrade-guide` from the ADR-0087 // conversion and migration registries; the // fix site is the registry `.ts` +// content/docs/ui/components/ the component reference, rendered whole +// from `sdui.manifest.json` by +// scripts/gen-sdui-reference.mjs; the fix +// site is the input description in the +// component's registration under `ui/` // // The second tree is also UNTRACKED: gitignored, written on demand by the // `@objectstack/docs` build, never committed, so CI's checkout never holds it. @@ -157,7 +162,7 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist']); // scripts/regen-artifacts.mjs — the one register of untracked generator output — // and runs this gate as a child process over a fixture page inside each skipped // tree, with the same page outside it as the control. -const SKIP_SUBTREES = new Set(['content/docs/references', 'content/docs/protocol-upgrade']); +const SKIP_SUBTREES = new Set(['content/docs/references', 'content/docs/protocol-upgrade', 'content/docs/ui/components']); const EXTENSIONS = new Set(['.mdx', '.md']); const BASELINE_PATH = 'scripts/role-word-baseline.json'; const WORD = /\brole(?:s)?\b/gi; From a6ea4208b330b1fd9be093270ae3885b624d32f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:38:17 +0000 Subject: [PATCH 5/7] docs(ui): classify the component reference as a whole-file artifact in os-regen-merge's table Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibwMyAnTNeas3ANYhyi --- scripts/pm/os-regen-merge.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/pm/os-regen-merge.sh b/scripts/pm/os-regen-merge.sh index efb9d50ce6e..4a0e007ad6f 100644 --- a/scripts/pm/os-regen-merge.sh +++ b/scripts/pm/os-regen-merge.sh @@ -489,6 +489,7 @@ content/docs/references/** whole skills/*/references/_index.md whole skills/objectstack-ui/references/react-blocks.md whole scripts/platform-object-tenancy-census.json whole +content/docs/ui/components/** whole ' # The `mixed:` rows of the driver table in module $1. Failure is the caller's to report — From 906bbcaa0ba3686dff651edc78dd05a2de7f5cc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:46:10 +0000 Subject: [PATCH 6/7] docs(ui): retire the Console, Studio and Setup app pages; name the section User Interface The three pages described products rather than metadata. Their author-facing facts move to the page that owns each behavior: - apps: navigationContributions, the missing-anchor diagnostic and the Setup app's group anchors - views: how a list opens a record (navigation.mode, ?recordId, the drawer stack), per-user list state never writing the view, the 403 for a gated view - deployment/validating-metadata: the load-time _diagnostics envelope, the /api/v1/meta/diagnostics sweep, and what a predicate that cannot be evaluated does at runtime - actions: why a placed action appears nowhere - automation/approvals: the record's Approvals tab The product tour stays in build-without-code. The old URLs answer 308s (/docs/ui/setup-app to /docs/ui/apps, console and studio to build-without-code), and /docs/concepts/setup-app is re-pointed so no chain forms. The audit's hand-written list drops the three pages. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibwMyAnTNeas3ANYhyi --- apps/docs/redirects.mjs | 8 +- content/docs/automation/approvals.mdx | 9 + content/docs/deployment/cloud-connection.mdx | 4 +- .../docs/deployment/validating-metadata.mdx | 67 +++++ .../docs/permissions/record-view-auditing.mdx | 4 +- content/docs/ui/actions.mdx | 2 +- content/docs/ui/apps.mdx | 56 ++++ content/docs/ui/console.mdx | 164 ------------ content/docs/ui/index.mdx | 12 +- content/docs/ui/meta.json | 5 +- content/docs/ui/setup-app.mdx | 158 ------------ content/docs/ui/studio.mdx | 241 ------------------ content/docs/ui/views.mdx | 35 +++ scripts/docs-audit/handwritten-docs.json | 3 - 14 files changed, 184 insertions(+), 584 deletions(-) delete mode 100644 content/docs/ui/console.mdx delete mode 100644 content/docs/ui/setup-app.mdx delete mode 100644 content/docs/ui/studio.mdx diff --git a/apps/docs/redirects.mjs b/apps/docs/redirects.mjs index 2c59e548cc8..687fa76fdf2 100644 --- a/apps/docs/redirects.mjs +++ b/apps/docs/redirects.mjs @@ -60,7 +60,13 @@ export const docsRedirects = [ ['/docs/guides/metadata/dashboard', '/docs/ui/dashboards'], ['/docs/guides/public-forms', '/docs/ui/forms'], ['/docs/guides/metadata/doc', '/docs/ui/doc-pages'], - ['/docs/concepts/setup-app', '/docs/ui/setup-app'], + ['/docs/concepts/setup-app', '/docs/ui/apps'], + // 2026-10-28: the Setup, Console and Studio pages were retired. Their + // author-facing facts moved into apps, views, actions, approvals and + // validating-metadata; the product tour is build-without-code. + ['/docs/ui/setup-app', '/docs/ui/apps'], + ['/docs/ui/console', '/docs/build-without-code'], + ['/docs/ui/studio', '/docs/build-without-code'], ['/docs/guides/solutions/create-vs-edit-form', '/docs/ui/create-vs-edit-form'], ['/docs/guides/solutions/field-grouping-and-order', '/docs/ui/field-grouping-and-order'], // The page was renamed role-based -> audience-based (the direction diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx index 9a7e0b7439c..af96eadf629 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -758,6 +758,15 @@ reassign user-picker) is collected by the generic action dialog with **no per-action UI code**. New decision capabilities ship as backend metadata, not hand-written buttons. +On the record itself, a record with approval requests grows an **Approvals** tab +beside Details and Related, with a request-count badge: the current step, decision +progress, the approvers it is waiting on, the merged decision timeline and a remind +button for the submitter. Every viewer who can read the record sees it, not only +approvers. A request opens on its record page through the approvals routes rather +than the data API, so an approver who is not an administrator sees the whole page, +decision actions included, although the data API stays closed to them on the +approval objects. + Each action's visibility is gated by a **server-computed per-viewer block** the service attaches to every request it returns: diff --git a/content/docs/deployment/cloud-connection.mdx b/content/docs/deployment/cloud-connection.mdx index ae93cad2e5d..24a7bbd451e 100644 --- a/content/docs/deployment/cloud-connection.mdx +++ b/content/docs/deployment/cloud-connection.mdx @@ -252,6 +252,6 @@ to cloud-hosted runtimes; a self-hosted server leaves both unset. A set connection platform-managed: no Cloud Connection page, and no connect or disconnect. -The Setup app itself, and how plugins add entries to it, is described in -[Setup app](/docs/ui/setup-app). Running the server is covered in +How plugins add entries to the Setup app is described in +[Apps](/docs/ui/apps#contributing-navigation-to-another-app). Running the server is covered in [Self-hosting](/docs/deployment/self-hosting). diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index b34daf7eeb4..0426a2d887d 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -777,6 +777,73 @@ schema, so a schema error is `os validate`'s verdict to give — but a rule that can fail the build fails `os lint` too. +## Metadata diagnostics on load + +Every metadata item is also validated against its schema when a server loads it, and +the result travels with the item as a `_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, and whatever depends on the item (rendering, +queries, automation) is unsafe to use. Warnings are advisory. `path` is dot-delimited +(`fields.email.type`, `columns.0.bind`). An environment overlay merges first and is +validated after, so the envelope describes the effective item. + +`GET /api/v1/meta/diagnostics` sweeps every type and item in one request: + +| Query | Default | Effect | +| :--- | :--- | :--- | +| `severity` | `error` | `error` returns invalid items only; `warning` also returns warning-only items | +| `type` | — | One metadata type | +| `package` | — | One package | + +The response carries `entries` (each `{ type, name, diagnostics }`), `total`, +`scannedTypes`, `scannedItems` and per-type `stats`. `total === 0` is a usable CI gate +against a running server; add `severity=warning` to hold warnings to the same bar. +Studio shows the same envelope on the metadata directory, each type's list, each +item's edit page and the governance page (`…/metadata/_diagnostics`). + +## When a predicate cannot be evaluated at runtime + +A predicate that gets past authoring and fails to evaluate (a misspelled root, a type +error, a missing function) never fails silently. The Console warns once per distinct +predicate source, in production builds too, naming the node, the key, the predicate and +the engine's reason: + +```text +[ObjectUI] A visibility predicate could not be evaluated - node "record:alert" (id: "a1") + visibleWhen: "nosuchroot.status == 'draft'" + Reason: ... nosuchroot is not defined +``` + +What the failure does depends on what the predicate guards: + +| Predicate | On failure | +| :--- | :--- | +| A field's `visibleWhen`, `readonlyWhen`, `requiredWhen`; a view's `visibleOn`; an option's `visibleWhen`; list conditional formatting | **Fail open**: the rule's safe default applies, so a broken rule never hides a field. A rule that never fires while its field stays visible is the symptom, usually a bare field name where `record.` was meant | +| An object field group's `visibleWhen` | **Fail closed**: the whole group is hidden on the form and the record page | +| A page node's `visibleWhen` / `visible` / `hidden`, and a `page:tabs` item's `visibleWhen` | **Fail closed**: the node or tab is withheld; a withheld related list is not counted in its tab badge | +| A navigation item's or area's `visible` | **Fail open**: the item renders for everyone, reported under `app-shell:visible` | + +At **submit** a broken field rule is refused, not passed. A field `visibleWhen` that +cannot be evaluated refuses the submit on the client, with a message naming the field +and the rule, because no server evaluates `visibleWhen`. A broken `requiredWhen` or +`readonlyWhen` is refused by the server, and the form shows that refusal beside the +input. A `visibleWhen` that reads `previous` cannot be evaluated on a create form, so +such a form is always refused. + +A **blank** rule (`''`, whitespace, or an envelope whose `source` is blank) is not "no +rule". A blank field rule is refused when it is authored, and one already stored is +refused at submit. A blank gate (`visible`, `hidden`, `disabled`, a page node's +`visibleWhen`) reads as "no gate" at runtime and is reported once with the `[blank]` +reason. + ## The workflow ```bash diff --git a/content/docs/permissions/record-view-auditing.mdx b/content/docs/permissions/record-view-auditing.mdx index 6a4f1f13c37..da819757095 100644 --- a/content/docs/permissions/record-view-auditing.mdx +++ b/content/docs/permissions/record-view-auditing.mdx @@ -204,8 +204,8 @@ concerns. ## Reading the trail `sys_audit_log` ships a **Record Views** list view — filtered to `action: read`, -newest first. In the Setup app, Diagnostics › [Audit Log](/docs/ui/setup-app), -the entry the plugin contributes, opens the audit browser, which offers that +newest first. In the Setup app, Diagnostics › Audit Log ([contributed](/docs/ui/apps#contributing-navigation-to-another-app) by the plugin), +opens the audit browser, which offers that view as a preset; its "Open as list" link opens the object list on it. The object is append-only and exposes only `get` and `list` on the data API; diff --git a/content/docs/ui/actions.mdx b/content/docs/ui/actions.mdx index 21e5b1d6ff8..b1d6fe662f0 100644 --- a/content/docs/ui/actions.mdx +++ b/content/docs/ui/actions.mdx @@ -663,7 +663,7 @@ and [Connect an MCP Client](/docs/ai/connect-mcp). | Symptom | Cause → fix | |:---|:---| -| Button doesn't appear | `locations` doesn't include the surface; or the `visible` CEL throws (e.g. a bare, unprefixed field name) and fail-closed hides it; or the user fails `requiredPermissions` | +| Button doesn't appear | `locations` doesn't include the surface (empty `locations` is right for a selection-only action that a list view's `bulkActions` places); or the `visible` CEL throws (e.g. a bare, unprefixed field name) and fail-closed hides it; or the user fails `requiredPermissions`, which hides the button at every location with no greyed-out control and no message | | Click → `Action 'x' … not found` | `target`-style script with no registered handler — add the `registerAction` call in `onEnable` (Path B above) | | REST → 400 naming the action's `type` | The type has no server dispatch (`url`/`modal`/`form`/`api`) — open its `target` in the client, or call that endpoint directly | | Appears in UI, missing from `list_actions` (MCP) | `ai.exposed` not `true`, `ai.description` under 40 characters, or the type isn't headless-callable | diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index a97b2fb38b1..20c7be01bd4 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -749,6 +749,62 @@ in 17.1). Both the app and `branding` are strict: a top-level `logo` or `theme` refused with a pointer to `branding`, and a misspelled key inside `branding` is refused rather than dropped. +## Contributing navigation to another app + +A package can add menu entries to an app it does not own with +`navigationContributions` (ADR-0029 D7). The Setup app is built this way: it ships +as a shell of empty group anchors, and each capability plugin contributes the +entries for the objects it owns, so the rendered menu always matches the loaded +plugins. A disabled plugin contributes nothing and its slot stays empty. + +```typescript +navigationContributions: [ + { + app: 'setup', + group: 'group_integrations', + priority: 100, + items: [ + { id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook', requiresObject: 'sys_webhook' }, + ], + }, +] +``` + +| Property | Type | Description | +| :--- | :--- | :--- | +| `app` | `string` | The target app's name, e.g. `setup` | +| `group` | `string` | optional. A group nav item id in the target app; omit it to append at the app's top level | +| `priority` | `number` | Merge order within the group, lower first (default `200`, the object-extender convention) | +| `items` | `NavigationItem[]` | Ordinary [navigation items](#navigation-items), with the usual `requiresObject`, `requiredPermissions` and `visible` gates | + +The runtime merges every contribution into the target app's `navigation` by group +id and priority when the app is read, so registration order does not matter. + +**A group id the target app does not declare is not refused.** The items are +appended at the app's top level and a `nav_contribution_group_missing` diagnostic +is raised, at `warn` by the runtime and as a warning by `os build` and +`os validate` when both packages are composed into one artifact. Neither fails +the build. The anchor id is the whole contract between the shell and its +contributors, and a typo renders a working menu with the entry one level up, so +read that warning. + +The Setup app's anchors: + +| Group id | Holds | +| :--- | :--- | +| `group_overview` | System Overview dashboard | +| `group_apps` | Packages and the marketplace | +| `group_people_org` | Users, organizations, business units, teams, invitations | +| `group_access_control` | Positions, permission sets, sharing rules, API keys | +| `group_approvals` | Approvals inbox, requests, history, delegations | +| `group_configuration` | Settings, localization, authentication, email, storage, AI, feature flags, branding | +| `group_diagnostics` | Sessions, notification events, audit log | +| `group_integrations` | Webhooks, cloud connection | +| `group_advanced` | OAuth applications | + +Setup is for platform administration and needs `setup.access`. For +project-specific administration pages, declare an app of your own. + ## Complete Example {/* os:check */} diff --git a/content/docs/ui/console.mdx b/content/docs/ui/console.mdx deleted file mode 100644 index 6d29e63126d..00000000000 --- a/content/docs/ui/console.mdx +++ /dev/null @@ -1,164 +0,0 @@ ---- -title: The Console — how it renders your metadata -navTitle: Console -description: What the Console does with the apps, views and pages you declare — its configuration, the record surfaces, what each user's own state keeps, and how a broken predicate is reported ---- - -The **Console** (`/_console/`) is the workspace end users live in, and the renderer for -everything in this module: it declares no apps, objects or views of its own, and draws -whatever the server it is connected to publishes. [Build without code](/docs/build-without-code) -tours it from a user's side; this page is the reference for how it behaves, so an author -can predict what a declaration will look like. - -## Configuration - -The Console has no configuration file. It has two inputs: - -- **Which server.** A build-time variable, `VITE_SERVER_URL`, names the backend. Empty - means same origin, which is how the Console ships inside a server; an absolute origin is - for a Console deployed apart from its backend, and opts into CORS. In development the - Vite server proxies `/api/*` to `DEV_PROXY_TARGET`, or `http://localhost:3000`. -- **What the server pushes.** Before it mounts, the Console reads - `/api/v1/runtime/config` from that server: product branding, feature flags - (marketplace, AI Studio, SSO, custom domain) and the cloud URL. An operator changes - them on the server, with no Console rebuild. - -Apps, objects and views are metadata, fetched over HTTP when the Console connects and -loaded on demand. To change what it shows, change the metadata: in code, or in Studio and -publish. - -## Navigating - -- **App switcher.** Every app the server serves the user, with an **Edit app in Studio** - row at the end for workspace admins. An **Edit app** button in the top bar (the pencil - alone on narrow screens) opens the app's owning package in Studio: on a dashboard, page - or report it deep-links to that surface's design page in the Interfaces pillar - (`/studio/:packageId/interfaces?surface=page:NAME`); elsewhere it opens the package's - Data tab. Interfaces are designed in Studio; there is no in-page edit panel. -- **Navigation.** The sidebar renders the app's `navigation` tree; see - [Apps](/docs/ui/apps#navigation-items). A `visible` predicate reads `current_user` and - `features`, e.g. `visible: "'admin' in current_user.positions"`. -- **Command palette.** `⌘K` opens a searchable command bar over the open app. On the - Studio landing (`/studio`) it searches the Studio instead: packages, their objects and - their flows, by label or machine name. - -## Record surfaces - -### The record page and its overlay - -Clicking a row, card, node, bar or event opens the record in an overlay whose mode is the -view's `navigation.mode`: `drawer`, `modal`, `split` or `popover`. It means the same on -every list-type view, and every one of them (grid, tree, kanban, calendar, gantt, list, -gallery, map and timeline) opens the same record panel, so a record reads with its -declared labels and typed values, without its `hidden` fields, whichever view it came -from. In `split` the view moves into the left panel; in `popover` the panel is anchored to -what was clicked. - -The record opened from a list is the root of the drawer, and it is addressable: -`?recordId=` on the list's URL, so a reload, a shared link or Back reopens it. A record -opened inside the drawer (a related row, a lookup value) opens one level down in the same -drawer. Those levels are in-memory: a reload shows the root record. `⤢` on any level opens -that record's full page, with the levels below it as its `?from=` trail, and the page's -**Collapse to drawer** returns to the drawer with the stack it left. - -A record page's layout is described in [Pages](/docs/ui/pages). - -### Approvals on a record - -A record with approval requests grows an **Approvals** tab beside Details and Related, with -a request-count badge: the current step, decision progress, the approvers it is waiting on, -the merged decision timeline and a remind button for the submitter. Every viewer who can -read the record sees it, not only approvers. A request opens on its record page through -the approvals routes rather than the data API, so an approver who is not an administrator -sees the whole page, decision actions and timeline included, although the data API stays -closed to them on the approval objects. - -### Dashboards and list views a user may not open - -The server lists only the dashboards and list views a user may open, so one gated by -`requiredPermissions` is not in their lists. Opened by its URL, the Console asks the -server about it by name; a `403 PERMISSION_DENIED` shows "You don't have access" with the -server's sentence naming the item, not "not found", and the list page does not switch to -the default view. A name that does not exist still reads "not found" (the list page opens -the default view). - -### Notifications - -The header bell reads the server-side inbox (`sys_inbox_message`, with read state in -`sys_notification_receipt`) through one shared feed: the bell's Notifications tab, its -unread badge and any bell a page places with a `global:notifications` node read the same -feed on one cadence, so they cannot disagree about read state. - -## What each user's own state keeps - -Some state belongs to the user, not to the metadata. It follows them across browsers and -devices, and never changes anything for anyone else. It is stored in -`sys_user_preference`, one row per user and key, and cached in the browser, so the first -paint never waits for it; with no server row the Console runs from the browser copy. - -| State | Key | Limit | -|:---|:---|:---| -| Favorites (starred records, apps) and sidebar pins | `ui.favorites` | 20 favorites and 20 pins, kept apart | -| Recently visited items | `ui.recent` | 8 | -| Flow designer's recently added node types | per user | 5 | -| A list's view-tab order, and per view its sort, filters, column order and widths, density and hidden fields | `ui.list.` | — | - -- **Pins.** The sidebar's **Pinned** section is the only part of the sidebar a user orders: - drag a pinned row, or focus it and use Space, the arrow keys and Space again (Escape - cancels). The app's own menu keeps the order its author gave it. -- **Recents store identity, not labels.** An entry keeps its type and name and is labelled - when it renders, so a rename or a language switch shows at once. -- **List state never writes the view.** A header sort, a column drag or resize, a filter - change, a density change, Hide Fields and a Manage Views drag write the user's row, never - the view metadata, so they work even where the tenant's metadata is read-only. The view's - authored `sort`, filter, columns, `rowHeight`, `hiddenFields` and tab order stay the - default for every user with no state of their own, and the view editor in Studio is where - an author changes them for everyone. A link carrying list state (`uf_*` parameters) wins - over the stored state. A user's hidden fields only subtract from the columns the view - shows, never add one. **Reset view** in the page header's ⋯ menu drops the active view's - stored state, and an administrator can delete the row under Setup → User Preferences. -- **Overlay width.** The record overlay remembers the width a user dragged it to, per - object and across view types, in the browser only. Widths under 360 px are not restored, - a drag stops at 95% of the viewport, and double-clicking the handle returns to the - authored width. - -## When a predicate cannot be evaluated - -A predicate that fails to evaluate (a misspelled root, a type error, a missing function) -never fails silently. The Console warns once per distinct predicate source, in production -builds as well, naming the node, the key, the predicate and the engine's reason: - -```text -[ObjectUI] A visibility predicate could not be evaluated - node "record:alert" (id: "a1") - visibleWhen: "nosuchroot.status == 'draft'" - Reason: ... nosuchroot is not defined -``` - -What the failure does depends on what the predicate guards: - -| Predicate | On failure | -|:---|:---| -| A field's `visibleWhen`, `readonlyWhen`, `requiredWhen`; a view's `visibleOn`; an option's `visibleWhen`; list conditional formatting | **Fail open**: the rule's safe default applies, so a broken rule never hides a field. A rule that never fires while its field stays visible is the symptom, usually a bare field name where `record.` was meant | -| An object field group's `visibleWhen` | **Fail closed**: the whole group, header and members, is hidden on the form and the record page | -| A page node's `visibleWhen` / `visible` / `hidden`, and a `page:tabs` item's `visibleWhen` | **Fail closed**: the node or tab is withheld. A withheld related list is not counted in its tab badge, and if the active tab is withheld the strip falls back to the first visible one | -| A navigation item's or area's `visible` | **Fail open**: the item renders for everyone, which is why the warning is reported under `app-shell:visible` | - -At **submit** a broken field rule is refused, not passed. A field `visibleWhen` that -cannot be evaluated refuses the submit on the client (on the record form, the internal and -public form pages, and a wizard's last step), with a message naming the field and the rule, -because no server evaluates `visibleWhen`. A broken `requiredWhen` or `readonlyWhen` is -refused by the server, and the form shows that refusal beside the input. A `visibleWhen` -that reads `previous` cannot be evaluated on a create form, so such a form is always -refused. - -A **blank** rule (`''`, whitespace, or an envelope whose `source` is blank) is not "no -rule". A blank field rule is refused when it is authored, and one already stored is refused -at submit like any rule that cannot be evaluated. A blank gate (`visible`, `hidden`, -`disabled`, a page node's `visibleWhen`) is read as "no gate" at runtime and reported once -with the `[blank]` reason. - -## Related - -- [Studio](/docs/ui/studio) — designing objects and flows, and the metadata diagnostics. -- [Apps](/docs/ui/apps) · [Pages](/docs/ui/pages) · [Views](/docs/ui/views) · [Forms](/docs/ui/forms) -- [Build without code](/docs/build-without-code) — the Console, Studio and Setup from a user's side. diff --git a/content/docs/ui/index.mdx b/content/docs/ui/index.mdx index a76e2d5e12f..3b56fff6833 100644 --- a/content/docs/ui/index.mdx +++ b/content/docs/ui/index.mdx @@ -1,10 +1,10 @@ --- -title: UI engine — render views from metadata -navTitle: UI Engine +title: User interface — apps, pages and views from metadata +navTitle: User Interface description: Apps, views, dashboards, branding, and public forms — server-driven UI declared as metadata and rendered by the ObjectUI runtime. --- -The UI engine turns metadata into user interfaces: you declare **apps**, **views**, **pages**, and **dashboards** as data, and any compliant renderer draws them. This module is the practical documentation for the **ObjectUI layer** — the UI Protocol specified normatively in the [ObjectUI spec](/docs/protocol/objectui). Because the UI is data, it ships inside the same artifact as your schema, respects the same permissions, and can be generated or modified by AI agents through the same typed surface. +The UI layer turns metadata into user interfaces: you declare **apps**, **views**, **pages**, and **dashboards** as data, and any compliant renderer draws them. This module is the practical documentation for the **ObjectUI layer** — the UI Protocol specified normatively in the [ObjectUI spec](/docs/protocol/objectui). Because the UI is data, it ships inside the same artifact as your schema, respects the same permissions, and can be generated or modified by AI agents through the same typed surface. An app is a navigation shell over your objects (from the CRM example app): @@ -33,8 +33,7 @@ export const CrmApp = App.create({ - **Pages** compose free-form layouts from widgets; **Dashboards** combine charts, reports, and datasets for analytics. - **Branding** colours an app: `app.branding.primaryColor` / `accentColor` drive the console's `--primary` / `--accent` variables. (The standalone `themes` collection was retired in spec 17.1, ADR-0049 — it was parsed but never applied.) - **Public forms** expose a scoped slice of an object to external audiences through **anonymous entry routes** for public data collection ([Forms](/docs/ui/forms), [public data collection](/docs/ui/public-data-collection)). -- The **Setup App** — the platform's built-in administration UI — is itself rendered from the same protocol ([Setup App](/docs/ui/setup-app)). -- The **Console** renders all of it for end users, and **Studio** is where it is authored without code ([Console](/docs/ui/console), [Studio](/docs/ui/studio)). +- The **Console** renders all of it for users, **Studio** edits it without code, and **Setup** administers the platform; all three are toured in [Build without code](/docs/build-without-code). ## What's in this module @@ -50,9 +49,6 @@ export const CrmApp = App.create({ - - - diff --git a/content/docs/ui/meta.json b/content/docs/ui/meta.json index 58621917e99..d9c25e11c2c 100644 --- a/content/docs/ui/meta.json +++ b/content/docs/ui/meta.json @@ -1,5 +1,5 @@ { - "title": "Views & Apps", + "title": "User Interface", "pages": [ "apps", "pages", @@ -12,9 +12,6 @@ "translations", "forms", "doc-pages", - "setup-app", - "console", - "studio", "components", "---Recipes---", "create-vs-edit-form", diff --git a/content/docs/ui/setup-app.mdx b/content/docs/ui/setup-app.mdx deleted file mode 100644 index 12f6fe28d9f..00000000000 --- a/content/docs/ui/setup-app.mdx +++ /dev/null @@ -1,158 +0,0 @@ ---- -title: Setup app — administer a running runtime -navTitle: Setup App -description: The platform's built-in administration UI — what it is, what it shows, and how it is wired ---- - -The **Setup App** (`/apps/setup`) is the built-in administration console -for every ObjectStack project. It surfaces the platform `sys_*` objects, -the System Overview dashboard, and a set of settings pages behind a -left-hand navigation tree. - -Under ADR-0029 D7 the app is **not** a fixed navigation tree. `SETUP_APP` -ships as a thin **shell** of stable, empty navigation group anchors -("slots"); the actual menu entries are contributed at runtime by the -packages that own the underlying objects, via `navigationContributions`. -The runtime merges every contribution into the app's `navigation` tree by -group id + priority on read, so the rendered menu reflects exactly which -capability plugins are loaded — a disabled capability contributes nothing -and its slot stays empty. - -The app itself ships from the dedicated `@objectstack/setup` package -(package id `com.objectstack.setup`), which registers it at runtime. Per -ADR-0048 (one app per package), `/apps/setup` — resolvable as -`/apps/` — maps to exactly this app. - -## Where it lives - -| File | Purpose | -|:---|:---| -| `packages/platform-objects/src/apps/setup.app.ts` | The `App` definition — the navigation **shell** (group anchors), branding, required permissions | -| `packages/platform-objects/src/apps/setup-nav.contributions.ts` | `SETUP_NAV_CONTRIBUTIONS` — the nav entries owned by `@objectstack/platform-objects`, merged into the shell at runtime | -| `packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts` | "Overview → System Overview" dashboard | -| `packages/apps/setup/src/index.ts` | `@objectstack/setup` package — `SetupAppPlugin.start()` performs the runtime registration via `manifest.register({ apps: [SETUP_APP], navigationContributions: SETUP_NAV_CONTRIBUTIONS })` | - -> The `requiredPermissions: ['setup.access']` permission declared on the -> Setup App is **contributed by `plugin-security`** (in its default -> permission sets), not by the package that registers the app. Other -> capability plugins (e.g. `plugin-audit`, `plugin-webhooks`, -> `plugin-approvals`) contribute their own Setup nav entries into the -> shared group anchors. - -## Navigation - -`setup.app.ts` defines the stable group anchors (the shell); the entries -under each group are merged in from the contributing packages. The group -anchors are: - -| Group (anchor id) | Filled by | -|:---|:---| -| **Overview** (`group_overview`) | System Overview dashboard — `platform-objects` | -| **Apps** (`group_apps`) | Packages — `platform-objects`; marketplace — `cloud-connection` (capability plugin) | -| **People & Organization** (`group_people_org`) | Users · Organization · Business Units · Teams · Organizations · Invitations — `platform-objects` | -| **Access Control** (`group_access_control`) | Positions / Permission Sets — `plugin-security`; Sharing Rules / Record Shares — `plugin-sharing`; API Keys — `platform-objects` | -| **Approvals** (`group_approvals`) | Approvals Inbox (the `approvals:inbox` component) · Requests · Action History · Delegations (OOO) — `plugin-approvals` | -| **Configuration** (`group_configuration`) | All Settings · Localization · Company · Authentication · Email · File Storage · AI & Embedder · Knowledge · Feature Flags — `platform-objects`; Branding — `service-settings`, only where the `branding` settings namespace is offered (the `single` tenancy posture) | -| **Diagnostics** (`group_diagnostics`) | Sessions · Notification Events — `platform-objects`; Audit Log — `plugin-audit` | -| **Integrations** (`group_integrations`) | `plugin-webhooks`; [Cloud Connection](/docs/deployment/cloud-connection) — `cloud-connection`, except on a runtime holding the environment service key, which registers no entry | -| **Advanced** (`group_advanced`) | OAuth Applications — `platform-objects` | - -The exact rendered menu depends on which capability plugins are loaded. -A few notable entries: - -- **Sessions** (`sys_session`) — better-auth session records, contributed - into `group_diagnostics`. -- **OAuth Applications** (`sys_oauth_application`) — third-party OAuth - client registrations, contributed into `group_advanced`. -- **Signing Keys (JWKS)** (`sys_jwks`) — **not** in the nav (#7544). The - object is the environment's JWT signing-key store (`private_key`) and - declares `enable.apiEnabled: false` / `apiMethods: []`, so the generic data - API answers `OBJECT_API_DISABLED` (404) on every list — for **every** - persona, platform admin included, since that gate reads only the object's - `enable` block and takes no user or permissions. The entry that used to be - here carried a `manage_platform_settings` gate, which could not prune it: - a permission gate and an API-disabled object are independent conditions. - (`sys_verification` and `sys_device_code` are likewise deliberately absent: - sensitive, ephemeral secrets — not browsable, and also `private`. All three - are pinned in `setup-nav-dead-key-tombstone.test.ts`.) -- **Audit Log** (the console's `audit:log` page) — contributed by - `plugin-audit` into `group_diagnostics`. It is the one audit entry: the - page filters `sys_audit_log` by action, object, actor and date, offers the - object's list views (Writes, Auth, Record Views, …) as presets, and links - to the object list ("Open as list"), which has no menu entry of its own. - (The `sys_activity` and `sys_comment` objects also live in `plugin-audit`, - but they are not contributed as Setup nav entries.) - -### When a contribution names an anchor that is not there - -Aiming at a `group` id the target app does not declare is **not** refused and -the entry is **not** dropped: the items are appended at the app's **top level** -and the merge continues. That is deliberate — the merge is a read-time fold -precisely so registration order does not matter (a contributor may register -before the app it aims at), and a contribution into an *optional* anchor has to -keep working when the plugin owning that anchor is not loaded. - -It is, however, **loud**. The relocation emits a -`nav_contribution_group_missing` diagnostic at `warn` — naming the contributing -package, the target app, the missing group id and the relocated items — so it -survives `OS_REGISTRY_LOG=warn` and appears in boot output. The same finding is -carried on the app itself, readable as -`registry.getAppNavDiagnostics(appName)`, and it is raised once per distinct -mis-aim rather than once per read of the app. - -`os build` **and `os validate`** answer the same question at compile time -whenever the contributing package and the target app are composed into one -artifact. Both print the finding and both carry it in `--json` under the -existing `warnings` key, beside the authoring-rule advisories and the -capability hints — the payload is deliberately closed, so a new class of -finding fills a declared key rather than adding one. They **report** there; -neither fails the build. - -⚠️ The anchor id is the whole contract between a shell and its contributors, -and a contributor cannot see the shell's ids at authoring time. A typo -therefore produces a menu that renders, passes a smoke test, and has silently -moved the entry one level up — which is why the diagnostic exists rather than a -refusal. - -## Why a shell + contributions - -The Setup App is a shell of empty group anchors rather than a fixed -navigation tree because the objects it surfaces are owned by different -capability plugins (auth / security / sharing / audit / webhooks / -approvals). Letting each package contribute its own menu entries keeps the -menu for an object shipping with the package that owns the object — the -UI-layer analog of object `extend`. Benefits: - -- The rendered menu always matches the loaded capabilities: a disabled - plugin contributes nothing and its slot stays empty. -- No code in `setup.app.ts` needs to know about objects that live in other - packages; entries are merged by group id + priority on read. -- The shell can still be type-checked against `App` from - `@objectstack/spec/ui`, and contributions against - `NavigationContribution`. - -## Customising it - -You do not edit `setup.app.ts` to add entries — it only holds the empty -group anchors. To add a new entry: - -1. Register the object/dashboard in the package that owns it (or in a - project-local plugin if it is project-specific). -2. Add a `NavigationContribution` targeting the relevant group id - (`app: 'setup'`, `group: 'group_*'`) — either in - `SETUP_NAV_CONTRIBUTIONS` for `@objectstack/platform-objects`-owned - objects, or in the `navigationContributions` of the plugin that owns - the object. -3. If the entry needs its own permission, declare it where the relevant - permissions live (e.g. `setup.access` is granted in `plugin-security`'s - default permission sets) so the `requiredPermissions` check picks it - up. - -For project-local Setup-style entries, define a separate App in your -project — Setup is reserved for platform-level administration. - -## Related - -- [App Metadata](/docs/ui/apps) — `App` schema reference -- [Authentication](/docs/permissions/authentication) — where `setup.access` is granted -- [Deployment Overview](/docs/deployment) — the platform-runtime lifecycle that serves Setup; ObjectOS Cloud is one venue inside it diff --git a/content/docs/ui/studio.mdx b/content/docs/ui/studio.mdx deleted file mode 100644 index f793b8e4c4b..00000000000 --- a/content/docs/ui/studio.mdx +++ /dev/null @@ -1,241 +0,0 @@ ---- -title: Studio — designing objects, flows and interfaces -navTitle: Studio -description: What Studio offers a metadata author — package scope, the object design panels, the Flow Designer, drafts and publishing, and how metadata diagnostics are surfaced ---- - -**Studio** (`/studio`) is where metadata is authored without code. Everything it edits is -the same metadata a package declares in TypeScript: an object's `validations[]` and -`actions[]`, a `hook`, a `flow`, an app, a page. It saves those edits as **drafts** of the -package that owns them, and nothing reaches users until the package is published. This -page describes how Studio behaves; [Build without code](/docs/build-without-code) is the -tour. - -## Access and package scope - -- **Access.** Studio needs the `studio.access` capability. A caller without it who opens a - Studio URL stays on that URL and sees one sentence saying Studio access is needed, with - a **Back to home** link to the declared landing. The builder is not loaded for them. -- **Package scope.** Studio always works inside one package. Metadata counts, diagnostics - and the metadata lists follow the selected package (`?package=` on the metadata - directory), and only project packages are offered: system and cloud packages never - appear. -- **Read-only packages.** The Studio home lists code and installed packages under - "Installed (read-only · browsable)". A card opens the package for browsing - (`/studio/:packageId/data`); its **Customize with an overlay** link opens the package's - metadata directory (`/apps/setup/metadata?package=:packageId`). Whether an item can take - an environment overlay is decided per metadata type (`allowEnvironmentOverlay`), not per - package, and the directory marks each type. -- **Flows in no package.** A flow that belongs to no package, such as a clone of a packaged - flow made from Setup › Packaged automation, is listed and edited at - `/studio/~org/automations`, reached from the Studio home ("Not in a package") or the - package switcher. -- **Search.** On the Studio landing, `⌘K` searches packages, their objects and their flows - by label or machine name. The Automations pillar's flow rail has its own search box over - label and machine name. - -## Drafts and publishing - -Every Studio edit is a draft of its package. The top bar's **Changes** count lists the -pending drafts and **Publish** promotes them; leaving a pillar that holds an unsent edit -asks first. In a package with no app yet, **Create app** saves the new app as a draft and -opens the **Interfaces** pillar, where apps, pages, dashboards and reports are designed. -The Console's **Edit app** button deep-links there -(`/studio/:packageId/interfaces?surface=page:NAME`). - -When the AI copilot saves a change to a live app as a draft, its card offers **Review -changes and publish** and **Undo**. Review opens the **Changes** drawer for the package -(its link is `/studio/:packageId/:tab?changes=review`); Undo discards the package's -pending drafts (`POST /api/v1/packages/:id/discard-drafts`) and asks first when other -drafts are pending. - -## Object design - -Selecting an object in the **Data** pillar (`/studio/:packageId/data`) opens two tabs over -it, **Records** and **Form**, and an **Advanced** menu with **Validations**, **Hooks**, -**Actions**, **API** and **Settings**. Validations, Hooks and Actions are each a list with -an editor driven by the metadata it edits, and each can add new entries: - -| Panel | Edits | Notes | -|:---|:---|:---| -| **Validations** | The object's `validations[]` ([Validation rules](/docs/data-modeling/validation-rules)) | Every rule type: `script`, `cross_field`, `state_machine`, `format`, `json_schema`, `conditional`. The **New** menu opens on common rules in plain words (*End date on or after start date*, *Number can't be negative*); a rule whose type carries a condition is saved only once it has one | -| **Hooks** | The separate `hook` metadata targeting this object | The form is generated from the running server's `hook` schema (`/api/v1/meta/types`), so it always matches the server's contract. Saved per hook | -| **Actions** | The object's `actions[]` ([Actions](/docs/ui/actions)) | A type-aware inspector, with a **More fields** form generated from the `action` schema so no property is un-editable | - -Validations and Actions are saved with the object's own **Save draft**. - -### Why a configured action can be missing from the app - -An action that is saved, published and placed can still appear on no surface. There are -two reasons, and the Actions panel states both (the running app states neither, because a -user should not be told about capabilities they do not have): - -- **No placement.** `locations` is empty, so the action surfaces nowhere. A - selection-only action is legitimate here: a list view's `bulkActions` places it. -- **The capability gate.** `requiredPermissions` is enforced with a `403` on the action - route and mirrored as a hide: a viewer who lacks any listed capability gets no button, - no greyed-out control and no message, at every location at once. - -The action inspector's **Placement** section names the gating capabilities, says the -action is hidden rather than disabled, and says so when the signed-in session itself lacks -one. A capability string registered nowhere is a different problem, caught by the advisory -capability-reference lint that runs over pending drafts at **Publish**. - -## The Flow Designer - -The Flow Designer is the visual editor for `flow` metadata, opened from a flow's -**Design** tab; the **Form** tab holds the same flow as an editable tree, and the two stay -in sync. Everything on the canvas is a node or edge of the flow, so the document runs -unchanged on the automation engine ([Flows](/docs/automation/flows)). - -### The canvas - -| Gesture | Result | -|:---|:---| -| Click a card | Select it and open its inspector | -| Drag a card | Reposition it | -| Hover a card, then its bottom **+** | Append a connected child node | -| Drag a card's connect handle onto another node | Connect the two. Refused, with the reason, for the node itself, a pair another connection already joins, or a node the flow does not have | -| Zoom controls / **Fit** | Scale and re-center the graph | - -A selected connection's **From** and **To** re-point it, under the same refusals. -Structural problems (an undeclared cycle, an unreachable node) show as a red ring and a -corner badge on the card and in a banner on the canvas; a banner row selects and pans to -its element. - -### Adding nodes - -**Add node** opens the palette: every node type the flow can use, grouped as **Data** -(create, update, get, delete record), **Logic** (decision, loop, set variables, parallel, -try / catch), **Human** (approval, screen), **Integration** (HTTP request, connector, -script) and **Flow** (subflow, wait, end). - -- **Search** filters across all groups, matching a node's label, hint and type as a - case-insensitive substring. `↑` / `↓` move the highlight, `Enter` inserts, `Esc` closes, - and the box takes focus when the palette opens. -- **Recently used** tops the list while the search is empty: the last five node types the - user inserted, kept per user (see [the Console](/docs/ui/console#what-each-users-own-state-keeps)). - Types from a plugin since uninstalled drop out. -- **Server-merged.** The engine publishes its registered actions at - `GET /api/v1/automation/actions`, and plugins contribute node types there. The palette - overlays them on the built-in list, taking the engine's labels and appending engine-only - types, so it matches what the connected server runs. If the endpoint is unreachable the - built-in list is used. - -### The node inspector - -The inspector shows a node's **ID**, **Label**, **Node Type** and **Configuration**. New -nodes start with the spec's defaults where it has one (a *Wait* node carries a timer, an -*HTTP* node defaults to `GET`), and what the author must still supply is marked with a red -`*`: an HTTP node's URL, a record node's object, a decision branch's label and expression, -a screen field's name. The marker follows the spec, so a key required only in some -configurations is marked only while it applies, and the spec's error shows on the node and -in **Problems** until the value is supplied. - -- **Server-driven forms.** For a node whose executor publishes a `configSchema`, the - inspector renders a form from that schema: scalars, enums as selects, typed references - as pickers, maps as key/value editors, and arrays of objects as repeaters (nested arrays - as nested repeaters). A plugin's node gets a real form without designer code. -- **Approval approvers.** The **Value** cell offers candidates from where the row's - **Type** says they live: a lookup over the directory object for `user`, `team` and - `department`, and a select over the registry's declared items for a registry-bound kind - such as `position`. A pencil toggle allows typing a value. -- **Decision branches.** The Branches editor sets each branch's label, CEL expression and - **target node** in one table; the target wires the branch's outgoing edge. The same - binding is editable from the edge's **Branch** picker, because routing lives on the - edges. -- **Assignments.** One variable per row, each value either text or a CEL expression, - chosen with the row's *Write as a CEL expression* toggle: - -```json -{ - "assignments": { - "label": "{record.name}", - "digest": { "dialect": "cel", "source": "joinNonEmpty(names, \", \")" } - } -} -``` - - Text is `{token}` interpolation, stored as typed; an expression is stored as the - `{ dialect: 'cel', source }` envelope and evaluated with the CEL standard library. The - toggle appears only on maps whose values the spec allows to be an expression. - -### Panels - -| Panel | Shows | -|:---|:---| -| **Variables** | The flow's declared variables | -| **Problems** | Structural and server validation issues; each one reveals its element on the canvas | -| **Debug** | A step-through simulator that walks the graph and highlights the active and visited nodes | -| **Runs** | The published flow's execution history, read from the engine | - -## Metadata diagnostics - -Every metadata item a package ships is validated against its spec schema when it is -loaded, and the result travels with the item as a `_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, and whatever depends on the item (rendering, -queries, automation) is unsafe to use. Warnings are advisory: the item stays valid. -`path` is dot-delimited (`fields.email.type`, `columns.0.bind`). - -### The sweep endpoint - -`GET /api/v1/meta/diagnostics` validates every metadata type and item in one request. - -| Query | Default | Effect | -|:---|:---|:---| -| `severity` | `error` | `error` returns invalid items only; `warning` also returns items that have only warnings | -| `type` | — | One metadata type | -| `package` | — | One package | - -The response carries `entries` (each `{ type, name, diagnostics }`), `total`, -`scannedTypes`, `scannedItems`, and `stats`, a per-type `{ count, locked, packages }` that -lets a directory render counts and a package filter without another request. `total === 0` -is a usable CI gate; pass `severity=warning` to hold warnings to the same bar. - -### Where Studio shows them - -1. **The metadata directory** (`/apps/setup/metadata`). Each type tile carries the item - count, a red ⚠ count for items with errors, and an amber ⚠ count for warning-only items. - A **View all issues** link opens the governance page. -2. **A type's list.** Invalid rows get a red ⚠ and a tinted row, warning-only rows an amber - one; the header shows **Invalid** and **Warnings** counts, and hovering the ⚠ shows the - first three messages. -3. **An item's edit page.** A banner lists the first errors with their paths (and a - parallel amber banner the warnings), and the same errors are threaded into the form, so - the offending inputs show them before the first save. Typing in an input clears its - message until the next save validates again. On an object draft, every field rule - (`visibleWhen`, `readonlyWhen`, `requiredWhen`) is also linted as a CEL predicate with - the server's validators: an unknown field, or a field read bare instead of as - `record.`, shows under `fields..`. The field inspector's rule - editors give the same verdict as you type, with completion for the object's fields and - the `record`, `previous` and `parent` roots. A formula field's `expression` is linted the - same way and shows its inferred result type. -4. **The governance page** (`…/metadata/_diagnostics`). One sortable table of every invalid - item across every type, linked to each edit page, with a severity toggle for - warning-only items: the page for a release-readiness review. - -A predicate that gets past authoring is still reported at runtime; see -[When a predicate cannot be evaluated](/docs/ui/console#when-a-predicate-cannot-be-evaluated). - -### Authoring metadata that validates cleanly - -- Author with the `define*` helpers from `@objectstack/spec`, so TypeScript catches most - shape errors before they reach a server. -- Run `os validate` before publishing; it runs the validators the server runs on load. -- An environment overlay merges first and is validated after, and the edit banner shows - the effective item. If only the overlay is wrong, the package's own artifact is fine. - -## Related - -- [The Console](/docs/ui/console) — how the published metadata renders for users. -- [Flows](/docs/automation/flows) · [Actions](/docs/ui/actions) · [Validation rules](/docs/data-modeling/validation-rules) -- [Validating metadata](/docs/deployment/validating-metadata) — every author-time rule. diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 9a41e957594..e8443f090fa 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -465,6 +465,41 @@ app page or action, where `features.*` stays bound and stays legal. A record *fi `features` is unaffected — `record.features.x` is member access, not the scope root. +## How a list opens a record + +Clicking a row, card, node, bar or event opens the record in an overlay whose mode is +the view's `navigation.mode`: `drawer`, `modal`, `split` or `popover`. Every list-type +view (grid, tree, kanban, calendar, gantt, list, gallery, map and timeline) opens the +same record panel, so a record reads with its declared labels and typed values, without +its `hidden` fields, whichever view it came from. In `split` the view moves into the +left panel; in `popover` the panel is anchored to what was clicked. + +The record opened from a list is addressable: `?recordId=` on the list's URL, so a +reload, a shared link or Back reopens it. A record opened inside the drawer (a related +row, a lookup value) opens one level down in the same drawer; those levels are +in-memory, so a reload shows the root record. `⤢` opens a level's full page, and +**Collapse to drawer** returns to the stack it left. A record page's own layout is +described in [Pages](/docs/ui/pages). + +### A user's list state never writes the view + +A header sort, a column drag or resize, a filter change, a density change, Hide Fields +and a Manage Views drag are saved for that user (`sys_user_preference`, key +`ui.list.`), never to the view metadata, so they work even where the tenant's +metadata is read-only. What you author (`sort`, filters, columns, `rowHeight`, +`hiddenFields`, tab order) is the default for every user with no state of their own. + +- A link carrying list state (`uf_*` parameters) wins over the stored state. +- A user's hidden fields only subtract from the columns the view shows, never add one. +- **Reset view** in the page header's ⋯ menu drops the active view's stored state. + +### A view the user may not open + +The server lists only the views and dashboards a user may open, so one gated by +`requiredPermissions` is absent from their lists. Opened by URL, it answers +`403 PERMISSION_DENIED`, and the Console shows "You don't have access" with the +server's sentence, not "not found". A name that does not exist still reads "not found". + ## Complete Example One container covering a default grid, a kanban saved view, and a tabbed default form — mirroring `examples/app-showcase/src/ui/views/task.view.ts`: diff --git a/scripts/docs-audit/handwritten-docs.json b/scripts/docs-audit/handwritten-docs.json index 6793e88eee5..51486ef69ad 100644 --- a/scripts/docs-audit/handwritten-docs.json +++ b/scripts/docs-audit/handwritten-docs.json @@ -215,7 +215,6 @@ "content/docs/ui/actions.mdx", "content/docs/ui/apps.mdx", "content/docs/ui/audience-based-interfaces.mdx", - "content/docs/ui/console.mdx", "content/docs/ui/create-vs-edit-form.mdx", "content/docs/ui/dashboards.mdx", "content/docs/ui/doc-pages.mdx", @@ -227,8 +226,6 @@ "content/docs/ui/public-data-collection.mdx", "content/docs/ui/react-pages.mdx", "content/docs/ui/reports.mdx", - "content/docs/ui/setup-app.mdx", - "content/docs/ui/studio.mdx", "content/docs/ui/translations.mdx", "content/docs/ui/views.mdx", "content/docs/upgrading.mdx" From 3c36420c2b745ac8a0484d63ad5ce46f999f77a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:52:57 +0000 Subject: [PATCH 7/7] docs(ui): regenerate the component reference after main renamed the action variant to danger Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibwMyAnTNeas3ANYhyi --- content/docs/ui/components/action.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/ui/components/action.mdx b/content/docs/ui/components/action.mdx index 3a0eea5cd1e..ec4725f10e7 100644 --- a/content/docs/ui/components/action.mdx +++ b/content/docs/ui/components/action.mdx @@ -25,7 +25,7 @@ Namespace `action`. | `icon` | `string` | | — | | `actionType` | `enum`: `script`, `url`, `modal`, `flow`, `api` | | — | | `target` | `string` | | — | -| `variant` | `enum`: `default`, `primary`, `secondary`, `destructive`, `outline`, `ghost` | | — | +| `variant` | `enum`: `default`, `primary`, `secondary`, `danger`, `outline`, `ghost` | | — | | `size` | `enum`: `default`, `sm`, `md`, `lg`, `icon` | | Button size: the Button primitive's `default`, `sm`, `lg` or `icon`, plus `md`, which renders as `default` (default: `default`) | | `className` | `string` | | — | | `visible` | `boolean` \| `string` \| `object` | | Visibility predicate: `true`/`false`, a bare CEL expression, or the `{ dialect: 'cel', source }` envelope, evaluated against the row the host binds; the button is not rendered when it is false, nor when the predicate fails to evaluate. Omit for always-visible | @@ -76,7 +76,7 @@ Namespace `action`. | `icon` | `string` | | — | | `actionType` | `enum`: `script`, `url`, `modal`, `flow`, `api` | | — | | `target` | `string` | | — | -| `variant` | `enum`: `default`, `secondary`, `destructive`, `outline`, `ghost` | | — | +| `variant` | `enum`: `default`, `secondary`, `danger`, `outline`, `ghost` | | — | | `className` | `string` | | — | | `visible` | `boolean` \| `string` \| `object` | | Visibility predicate: `true`/`false`, a bare CEL expression, or the `{ dialect: 'cel', source }` envelope, evaluated against the row the host binds; the icon is not rendered when it is false. Omit for always-visible | | `disabled` | `boolean` \| `string` \| `object` | | Disabled predicate, in the same three shapes as `visible`: the icon is shown but cannot be pressed while it holds. Omit for never-disabled |