Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .changeset/8067-component-input-member-kind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
'@object-ui/types': minor
'@object-ui/sdui-parser': minor
'@object-ui/components': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-grid': minor
---

`ComponentInput.of` — the coarse kind of an input's MEMBERS, with readers on day one
(objectui#8067).

A registration's `type: 'array'` said a value was a list and stopped there, so a member
that drifted from `@objectstack/spec` was invisible to every layer that reads a
declaration. `page:header.actions` is the measured cost: the contract declares
`z.array(z.string())` ("Action IDs"), the renderer read the members as `ActionDef`
objects, and the repo-wide parity gate in
`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` stayed green for the
whole life of the drift because both sides carried the key and neither could say what
was inside it. What settled it was a maintainer ruling, not a test — and even after the
fix, "these are ids" survived only as English in the registration's `description`.

**What is new.** `ComponentInput` gains an optional `of`, carrying the same coarse-kind
vocabulary as `type` one level down: the ELEMENTS of an `array`, or the VALUES of an
`object` used as a map. One kind, or an array of them for a member contract that is a
union, with `type`'s semantics — a member passes when any declared arm accepts it. The
manifest serializer forwards it, so `sdui.manifest.json` now carries seven keys per
input instead of six.

**Three readers ship with it**, which was the bar this slot had to clear (objectui#5905
is the precedent: five `ComponentInput` keys declared and read by nothing). The
repo-wide parity gate compares every declared `of` against the member kind
`ComponentPropsMap[type]` actually accepts and fails on one the contract refuses;
`sdui-parser`'s `validateTree` reports a member that fits no declared kind, as a new
`member-type-mismatch` diagnostic naming the offending positions; and the generated
`sdui-intrinsics.d.ts` narrows the authoring type — `page:header`'s `actions` is
`string[]` where it used to be `unknown[]`.

**Fifteen keys now declare one**, across ten blocks, each DERIVED rather than chosen:
every container key's member position was probed with one value of each coarse kind and
a declaration written only where exactly one kind was accepted. A member contract that
admits several kinds — `record:highlights.fields` takes a field name or an inline field
object — is deliberately left undeclared and pinned with its reason, because picking one
arm there is a narrowing this repo leaves un-gated and picking all of them would
advertise shapes only a per-block pin can vouch for.

**The ceiling is unchanged.** `of` is a KIND and never a value domain, so the maintainer
ruling of 2026-08-17 quoted on `ComponentInput.type` — the coarse arm plus `description`
is the publication face's expression ceiling, and spec is the sole judge of values —
stands exactly as written. `of: 'object'` says the members are objects; which keys they
carry is still `description`'s job and `os validate`'s.

**Nothing published before this changes.** An input that declares no `of` validates,
serializes and types byte-identically: `validateTree` checks no member, the serializer
emits no key, and the codegen emits the same `unknown[]`.
477 changes: 459 additions & 18 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions content/docs/guide/plugin-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,14 +366,16 @@ export interface BoardSchema extends BaseSchema {
}
```

Declare `ComponentInput` entries when registering: they are what the published manifest (`sdui.manifest.json`) and the JSX-page compiler's diagnostics read. Each entry carries the six keys the manifest forwards — `name`, `type`, `required`, `enum`, `binding`, `description`; a default belongs in the renderer's own fallback read and, for the author, in `description` (`label`, `defaultValue` and `advanced` are retired keys — nothing ever read them):
Declare `ComponentInput` entries when registering: they are what the published manifest (`sdui.manifest.json`) and the JSX-page compiler's diagnostics read. Each entry carries the seven keys the manifest forwards — `name`, `type`, `of`, `required`, `enum`, `binding`, `description`; a default belongs in the renderer's own fallback read and, for the author, in `description` (`label`, `defaultValue` and `advanced` are retired keys — nothing ever read them).

`of` is the coarse kind of an `array`'s elements, or of the values of an `object` used as a map. Declare it whenever the contract admits exactly one kind there: `type: 'array'` alone says a value is a list and stops, so a member of the wrong shape reaches the renderer with nothing anywhere reporting it. With `of` declared, the parser reports a member no declared kind accepts, and the generated `sdui-intrinsics.d.ts` types the elements (`string[]` rather than `unknown[]`). Like `type`, it names a KIND and never a domain: `of: 'object'` says the elements are objects, not which keys they carry — spell that out in `description`, and let `os validate` judge the values. A member contract that genuinely accepts several kinds takes the array form (`of: ['string', 'object']`), or no `of` at all:

<!-- doc-snippet: fragment — continues the board example: ComponentRegistry and BoardRenderer both come from step 3's src/index.tsx; this block shows only the inputs metadata -->
```tsx
ComponentRegistry.register('board', BoardRenderer, {
inputs: [
{ name: 'columns', type: 'array', required: true },
{ name: 'items', type: 'array', required: true },
{ name: 'columns', type: 'array', of: 'object', required: true },
{ name: 'items', type: 'array', of: 'object', required: true },
{
name: 'layout',
type: 'enum',
Expand Down
7 changes: 7 additions & 0 deletions packages/components/src/renderers/basic/record-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,13 @@ ComponentRegistry.register('record_picker', elementDataSourceBlock(ElementRecord
// does NOT, which is worth saying in the description because it is the
// form an author is most likely to reach for.
type: 'array',
// The MEMBER kind, machine-readable rather than only described
// (objectui#8067). `z.array(z.object({ … }))` accepts exactly one coarse
// kind at its member position — an object — so the fact the paragraph
// below spends a sentence on ("the terse string form is not accepted") is
// now a claim the repo-wide parity gate compares against the contract,
// `validateTree` reports on, and `sdui-intrinsics.d.ts` types.
of: 'object',
description:
'Row order, as an array of `{ field, order }` entries — `[{ field: "name", order: "asc" }]`. It becomes the `$orderby` of the picker\'s own query, so it decides the order records are offered in. `order` is `asc` or `desc`; the terse string form (`"name asc"`) is not accepted by the contract. PRECEDENCE: identical to `filter` above and for the same reason — the renderer reads `dataSource.sort ?? sort`, so a node-level `dataSource` binding (or the saved view its `view` names) REPLACES this key outright rather than merging with it; it applies only when the node carries no `dataSource`, or that `dataSource` and its view both leave `sort` unset.',
},
Expand Down
6 changes: 3 additions & 3 deletions packages/components/src/renderers/layout/containers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -785,7 +785,7 @@ ComponentRegistry.register('tabs', PageTabsRenderer, {
// `unknown-prop` on an author who wrote it anyway, and the renderer honoured
// it regardless. Same defect as `record:details.hideFields` in objectui#3808.
inputs: [
{ name: 'items', type: 'array', required: true, description: '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' },
{ name: 'items', type: 'array', of: 'object', required: true, description: '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' },
{ name: 'tabStyle', type: 'enum', enum: ['line', 'card', 'pill'] },
{ name: 'position', type: 'enum', enum: ['top', 'left'] },
{ name: 'alwaysShowStrip', type: 'boolean', description: '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.' },
Expand Down Expand Up @@ -962,7 +962,7 @@ ComponentRegistry.register('accordion', PageAccordionRenderer, {
category: 'layout',
isContainer: true,
inputs: [
{ name: 'items', type: 'array', required: true, description: 'Panel definitions [{ label, icon?, collapsed?, children }] — collapsed: false opens a panel by default' },
{ name: 'items', type: 'array', of: 'object', required: true, description: 'Panel definitions [{ label, icon?, collapsed?, children }] — collapsed: false opens a panel by default' },
{ name: 'allowMultiple', type: 'boolean' },
{ name: 'variant', type: 'enum', enum: ['flush', 'card'] },
],
Expand Down Expand Up @@ -2029,7 +2029,7 @@ ComponentRegistry.register('header', PageHeaderRenderer, {
// map form this description tells the author to write.
{ name: 'title', type: ['string', 'object'], description: 'Supports {field} interpolation and inline translation maps; falls back to the record title' },
{ name: 'subtitle', type: ['string', 'object'], description: 'Same interpolation as Title' },
{ name: 'actions', type: 'array', description: "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." },
{ name: 'actions', type: 'array', of: 'string', description: "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." },
{ name: 'breadcrumb', type: 'boolean' },
{ name: 'recordChrome', type: 'boolean', description: 'Set false for the bare h1 header on non-record pages' },
{ name: 'showStar', type: 'boolean' },
Expand Down
Loading
Loading