diff --git a/.changeset/20371-component-props-action-element-rows.md b/.changeset/20371-component-props-action-element-rows.md new file mode 100644 index 00000000000..444f82e773a --- /dev/null +++ b/.changeset/20371-component-props-action-element-rows.md @@ -0,0 +1,21 @@ +--- +"@objectstack/spec": minor +--- + +`ComponentPropsMap` declares `action:button`, `action:group`, `action:menu`, `action:icon`, `element:definition-list` and `element:repeater` — six blocks in objectui's curated public vocabulary that had no row (#20371). Each row is strict from birth, with its key set measured from the objectui renderer's own read points at the `.objectui-sha` pin, not transcribed from objectui's `UIActionSchema`, the registrations' `inputs`, or this package's object-metadata `ActionSchema`. + +Clause-②: yes + +Six new declared rows on a published surface, and two types the `element:` vocabulary now answers for, so the accept set a consumer writes against grows. Nothing previously accepted by a declared row is refused and nothing is retired. + +What changes at the authoring doors (`os validate` / `os build` / `os lint`): + +- **`element:definition-list` and `element:repeater` are no longer refused as `component-type-unknown`.** Both sit inside the reserved `element:` namespace; with no enum member and no row, the vocabulary refused them although objectui registers, publishes and offers both in the Studio page designer. They join the `element:` vocabulary through their rows (no enum member), and a typo inside the namespace (`element:repeatr`) is still refused. +- **The props gate now judges all six.** The four `action:*` types sat outside every reserved namespace, so an authored `properties` bag on them was skipped — a misspelled key parsed, stored and did nothing. Findings stay at the gate's existing warning tier. + +Measured decisions worth knowing when you author these blocks: + +- **`action:button` / `action:icon`** — `name` is optional (the renderer reads `name ?? label`). The executor is `actionType`; `type` inside `properties` is refused with a rename to `actionType` (on a page component `type` is the component itself). `visible` / `disabled` take a boolean, a CEL string or a `{ dialect, source }` envelope. The legacy `enabled` fallback and the host-only `autoTrigger` flag are refused with a prescription. `action:icon` reads no `size`. `objectName` names the object the action acts on (forwarded to the runner; omitted, the action acts on the page's object). +- **`action:group` / `action:menu`** — `actions` is a LIST of action objects (a member's executor is its own `type`); a bare list of action names is refused. A member's `objectName` rides the member object; the containers themselves read no `objectName`. `action:group` reads no group-level `name`, so it is refused with a prescription. `variant` / `size` take the Button primitive's vocabulary; `primary` and `md` are accepted only on `action:button` (and `primary` on `action:icon`), where the renderer maps them. +- **`element:definition-list`** — `items` of strict `{ term, description? }`; `columns` is the NUMBER `1` or `2` (the string `'2'` renders one column and is refused). +- **`element:repeater`** — `object` is required; `filter` / `sort` take the family's one orthography (`ViewFilterRule[]`, `SortItem[]`); `fields` takes a field name or `{ field }` (an unrendered `label` is refused). diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index ca3560cf403..6c2aea6ead3 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1516 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1522 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -33,8 +33,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | -| [UI Protocol](/docs/references/ui) | 16 | 159 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **196** | **1516** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 165 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **196** | **1522** | 14 protocol modules | --- @@ -363,7 +363,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 159 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 165 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -374,7 +374,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`app.zod.ts`](/docs/references/ui/app) | `ActionNavItem`, `App`, `AppBranding`, `AppContextSelector`, `ComponentNavItem`, `DashboardNavItem`, `DocNavItem`, `GroupNavItem`, `NavigationArea`, `NavigationContribution`, `NavigationItem`, `ObjectNavItem`, `PageNavItem`, `ReportNavItem`, `UrlNavItem` | | [`bulk-action.zod.ts`](/docs/references/ui/bulk-action) | `BulkActionDef`, `BulkActionExecution`, `BulkActionOperation`, `BulkActionParam` | | [`chart.zod.ts`](/docs/references/ui/chart) | `ChartAggregate`, `ChartAggregateFunction`, `ChartAnnotation`, `ChartAxis`, `ChartConfig`, `ChartDrillDown`, `ChartGroupBy`, `ChartInteraction`, `ChartSeries`, `ChartType` | -| [`component.zod.ts`](/docs/references/ui/component) | `AIChatWindowProps`, `ElementButtonProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementTextInputProps`, `ElementTextProps`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGanttProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMapProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `ObjectTimelineProps`, `ObjectTreeProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordAlertAction`, `RecordAlertProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordHistoryProps`, `RecordPathProps`, `RecordQuickActionsProps`, `RecordReferenceRailProps`, `RecordRelatedListProps`, `ReferenceRailEntry` | +| [`component.zod.ts`](/docs/references/ui/component) | `AIChatWindowProps`, `ActionButtonProps`, `ActionGroupProps`, `ActionIconProps`, `ActionMenuProps`, `ElementButtonProps`, `ElementDefinitionListProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementRepeaterProps`, `ElementTextInputProps`, `ElementTextProps`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGanttProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMapProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `ObjectTimelineProps`, `ObjectTreeProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordAlertAction`, `RecordAlertProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordHistoryProps`, `RecordPathProps`, `RecordQuickActionsProps`, `RecordReferenceRailProps`, `RecordRelatedListProps`, `ReferenceRailEntry` | | [`dashboard.zod.ts`](/docs/references/ui/dashboard) | `Dashboard`, `DashboardHeader`, `DashboardHeaderAction`, `DashboardWidget`, `DashboardWidgetChartConfig`, `DashboardWidgetOptions`, `GlobalFilter`, `GlobalFilterOptionsFrom`, `WidgetActionType`, `WidgetColorVariant` | | [`dataset.zod.ts`](/docs/references/ui/dataset) | `Dataset`, `DatasetDimension`, `DatasetMeasure`, `DerivedMeasureOp` | | [`expression-bindable-text-keys.zod.ts`](/docs/references/ui/expression-bindable-text-keys) | `ExpressionBindableTextKey` | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index c8d195f8b8f..930d64f9d7a 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1,7 +1,7 @@ --- title: Component schema — UI Protocol reference navTitle: Component -description: "Component schemas of the ObjectStack UI Protocol: AIChatWindowProps and 37 more — each property with its type, default and a TypeScript example." +description: "Component schemas of the ObjectStack UI Protocol: AIChatWindowProps and 43 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,8 +13,8 @@ description: "Component schemas of the ObjectStack UI Protocol: AIChatWindowProp ## TypeScript Usage ```typescript -import { AIChatWindowProps, ElementButtonPropsSchema, ElementFilterPropsSchema, ElementFormPropsSchema, ElementImagePropsSchema, ElementMetadataViewerPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, ElementTextInputPropsSchema, ElementTextPropsSchema, ObjectCalendarPropsSchema, ObjectFormPropsSchema, ObjectGanttPropsSchema, ObjectGridPropsSchema, ObjectKanbanPropsSchema, ObjectMapPropsSchema, ObjectMasterDetailFormPropsSchema, ObjectMetricPropsSchema, ObjectTimelinePropsSchema, ObjectTreePropsSchema, PageAccordionProps, PageCardProps, PageContainerProps, PageHeaderProps, PageTabsProps, RecordActivityProps, RecordAlertActionSchema, RecordAlertProps, RecordChatterProps, RecordDetailsProps, RecordHighlightsField, RecordHighlightsProps, RecordHistoryProps, RecordPathProps, RecordQuickActionsProps, RecordReferenceRailProps, RecordRelatedListProps, ReferenceRailEntrySchema } from '@objectstack/spec/ui'; -import type { ElementNumberProps, ElementRecordPickerProps, ObjectCalendarProps, ObjectFormProps, ObjectGanttProps, ObjectGridProps, ObjectKanbanProps, ObjectMapProps, ObjectMasterDetailFormProps, ObjectMetricProps, ObjectTimelineProps, ObjectTreeProps, PageContainerProps, RecordAlertAction, RecordAlertProps, RecordHighlightsField, RecordHistoryProps, RecordPathProps, RecordQuickActionsProps, RecordReferenceRailProps, ReferenceRailEntry } from '@objectstack/spec/ui'; +import { AIChatWindowProps, ActionButtonPropsSchema, ActionGroupPropsSchema, ActionIconPropsSchema, ActionMenuPropsSchema, ElementButtonPropsSchema, ElementDefinitionListPropsSchema, ElementFilterPropsSchema, ElementFormPropsSchema, ElementImagePropsSchema, ElementMetadataViewerPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, ElementRepeaterPropsSchema, ElementTextInputPropsSchema, ElementTextPropsSchema, ObjectCalendarPropsSchema, ObjectFormPropsSchema, ObjectGanttPropsSchema, ObjectGridPropsSchema, ObjectKanbanPropsSchema, ObjectMapPropsSchema, ObjectMasterDetailFormPropsSchema, ObjectMetricPropsSchema, ObjectTimelinePropsSchema, ObjectTreePropsSchema, PageAccordionProps, PageCardProps, PageContainerProps, PageHeaderProps, PageTabsProps, RecordActivityProps, RecordAlertActionSchema, RecordAlertProps, RecordChatterProps, RecordDetailsProps, RecordHighlightsField, RecordHighlightsProps, RecordHistoryProps, RecordPathProps, RecordQuickActionsProps, RecordReferenceRailProps, RecordRelatedListProps, ReferenceRailEntrySchema } from '@objectstack/spec/ui'; +import type { ActionButtonProps, ActionGroupProps, ActionIconProps, ActionMenuProps, ElementDefinitionListProps, ElementNumberProps, ElementRecordPickerProps, ElementRepeaterProps, ObjectCalendarProps, ObjectFormProps, ObjectGanttProps, ObjectGridProps, ObjectKanbanProps, ObjectMapProps, ObjectMasterDetailFormProps, ObjectMetricProps, ObjectTimelineProps, ObjectTreeProps, PageContainerProps, RecordAlertAction, RecordAlertProps, RecordHighlightsField, RecordHistoryProps, RecordPathProps, RecordQuickActionsProps, RecordReferenceRailProps, ReferenceRailEntry } from '@objectstack/spec/ui'; // Validate data const result = AIChatWindowProps.parse(data); @@ -42,6 +42,115 @@ const result = AIChatWindowProps.parse(data); | **role** | `string` | optional | WAI-ARIA role attribute (e.g., "dialog", "navigation", "alert") | +--- + +## ActionButtonProps + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | optional | Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone | +| **label** | `string` | optional | Button text. A literal string, placed as-is — localize through the translation bundle entry for this component id, not an inline locale map | +| **icon** | `string` | optional | Lucide icon name drawn left of the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | +| **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. Write it here, not as `type`: on a page component `type` is the component itself | +| **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Button variant — the Button primitive's vocabulary, plus `primary`, which renders as `default` (renderer default: `default`) | +| **size** | `Enum<'default' \| 'sm' \| 'lg' \| 'icon' \| 'md'>` | optional | Button size — the Button primitive's vocabulary, plus `md`, which renders as `default` (renderer default: `default`) | +| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the button is not rendered when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible | +| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the button is shown but cannot be pressed while it is TRUE. Omit for never-disabled | +| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs; an object is forwarded as the static parameter values | +| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title | +| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType` | +| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab | +| **endpoint** | `string` | optional | API endpoint for an `api` action, forwarded to the runner | +| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner | +| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner | +| **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | +| **operation** | `any` | optional | Declarative single-record write, forwarded to the runner together with `patch` | +| **patch** | `any` | optional | Field values the declarative `operation` writes, forwarded to the runner | +| **confirmText** | `string` | optional | Confirmation question asked before the action runs | +| **successMessage** | `string` | optional | Toast shown when the action succeeds | +| **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error | +| **refreshAfter** | `boolean` | optional | Refresh the surrounding data after the action runs | +| **undoable** | `boolean` | optional | Offer an Undo affordance after an update action | +| **recordIdField** | `string` | optional | Row field whose value identifies the record the action acts on | +| **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Action locations, forwarded to the runner — the console uses them to tell a record-scoped action from an object-level one | +| **toast** | `any` | optional | Toast behaviour, forwarded to the runner | +| **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | +| **onSuccess** | `any` | optional | Declared post-success navigation, forwarded to the runner | +| **objectName** | `string` | optional | Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page's object. Omit to act on the page's object | + + +--- + +## ActionGroupProps + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **actions** | `Record[]` | optional | The actions in this group, in order — each an action object the group draws and runs itself (`name`, `label`, `icon`, `type`, `target`, `visible`, `disabled`, …); a member's executor is its `type` | +| **display** | `Enum<'inline' \| 'dropdown'>` | optional | Display mode: `inline` renders every action as a button row; `dropdown` renders one trigger button and lists the actions in its menu (renderer default: `inline`) | +| **location** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>` | optional | Render only the members whose `locations` include this location. Omit to render every member | +| **label** | `string` | optional | Dropdown trigger text (renderer default: `Actions`). Inline mode renders no group label. A literal string — localize through the translation bundle entry for this component id | +| **icon** | `string` | optional | Lucide icon name on the dropdown trigger. Inline mode renders no group icon | +| **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link'>` | optional | Button variant for the dropdown trigger and for every inline member that sets none — the Button primitive's vocabulary (renderer default: `outline`) | +| **size** | `Enum<'default' \| 'sm' \| 'lg' \| 'icon'>` | optional | Button size for the dropdown trigger and for every inline member that sets none — the Button primitive's vocabulary | +| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate for the whole group — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds. Omit for always-visible | + + +--- + +## ActionIconProps + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | optional | Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button` | +| **label** | `string` | optional | Accessible label and tooltip text (and its first letter stands in when no icon resolves). A literal string — localize through the translation bundle entry for this component id | +| **icon** | `string` | optional | Lucide icon name, resolved through the shared action-icon resolver (renderer registration default `play`) | +| **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. Write it here, not as `type` | +| **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Button variant — the Button primitive's vocabulary, plus `primary`, which renders as `default` (renderer default: `ghost`) | +| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, 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 \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the icon is shown but cannot be pressed while it is TRUE. Omit for never-disabled | +| **description** | `string` | optional | Action description — the tooltip text when there is no `label`, and forwarded to the runner for the parameter dialog | +| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user; an object is forwarded as the static parameter values | +| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType` | +| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab | +| **endpoint** | `string` | optional | API endpoint for an `api` action, forwarded to the runner | +| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner | +| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner | +| **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | +| **operation** | `any` | optional | Declarative single-record write, forwarded to the runner together with `patch` | +| **patch** | `any` | optional | Field values the declarative `operation` writes, forwarded to the runner | +| **confirmText** | `string` | optional | Confirmation question asked before the action runs | +| **successMessage** | `string` | optional | Toast shown when the action succeeds | +| **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error | +| **refreshAfter** | `boolean` | optional | Refresh the surrounding data after the action runs | +| **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Action locations, forwarded to the runner — the console uses them to tell a record-scoped action from an object-level one | +| **toast** | `any` | optional | Toast behaviour, forwarded to the runner | +| **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | +| **onSuccess** | `any` | optional | Declared post-success navigation, forwarded to the runner | +| **objectName** | `string` | optional | Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page's object. Omit to act on the page's object | + + +--- + +## ActionMenuProps + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **actions** | `Record[]` | optional | The menu's actions, in order — each an action object the menu draws and runs itself (`name`, `label`, `icon`, `type`, `target`, `visible`, `disabled`, `tags`, …); a member's executor is its `type` | +| **label** | `string` | optional | Trigger text and accessible label; omit for an icon-only trigger labelled "More actions". A literal string — localize through the translation bundle entry for this component id | +| **icon** | `string` | optional | Lucide icon name on the trigger (renderer default: the horizontal ellipsis) | +| **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link'>` | optional | Trigger button variant — the Button primitive's vocabulary (renderer default: `ghost`) | +| **size** | `Enum<'default' \| 'sm' \| 'lg' \| 'icon'>` | optional | Trigger button size — the Button primitive's vocabulary (renderer default: `icon`) | +| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate for the whole menu — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; a predicate that fails to evaluate hides it. Omit for always-visible | + + --- ## ElementButtonProps @@ -86,6 +195,26 @@ const result = AIChatWindowProps.parse(data); | **role** | `string` | optional | WAI-ARIA role attribute (e.g., "dialog", "navigation", "alert") | +--- + +## ElementDefinitionListProps + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **items** | `{ term: string; description?: any }[]` | optional | Term/description pairs, in order. Omitted or empty renders the "No details" empty state | +| **columns** | `Enum<1 \| 2>` | optional | Grid columns from the small breakpoint up — the NUMBER `1` or `2` (renderer default: 1) | +| **inline** | `boolean` | optional | Put each term and its description on one baseline-aligned row instead of stacking them | + +### Nested Shape: `ElementDefinitionListProps.items[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **term** | `string` | ✅ | The term — rendered as the row's label, as-is | +| **description** | `any` | optional | The value shown under (or beside) the term — a string or number as-is, an object as JSON; omitted renders an em dash | + + --- ## ElementFilterProps @@ -252,6 +381,49 @@ Sort field and direction pair | **role** | `string` | optional | WAI-ARIA role attribute (e.g., "dialog", "navigation", "alert") | +--- + +## ElementRepeaterProps + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | Object whose records the list repeats over — required: without it the list never queries | +| **titleField** | `string` | optional | Field shown first on each line, emphasized | +| **fields** | `(string \| { field: string })[]` | optional | Fields shown after the title on each line, in order — a bare field name, or `{ field }` | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter rules narrowing the records — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` in this map shares | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order — `[{ field, order }]` | +| **limit** | `integer` | optional | Maximum records fetched and shown | +| **emptyText** | `string` | optional | Copy shown when the query returns no records (renderer default: "No records"). A literal string — localize through the translation bundle entry for this component id | +| **divided** | `boolean` | optional | Draw a separator between lines (renderer default: true) | + +### Nested Shape: `ElementRepeaterProps.fields[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name | + +### Nested Shape: `ElementRepeaterProps.filter[number]` + +View filter rule + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | + +### Nested Shape: `ElementRepeaterProps.sort[number]` + +Sort field and direction pair + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to sort by | +| **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction | + + --- ## ElementTextInputProps diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 8e8a5dfc3a7..0133c3b34d0 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,7 +21,7 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 453 | +| Object sites in them | 461 | | Still-open (strip) sites | 125 | | Files carrying at least one | 22 | @@ -44,12 +44,12 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 180 | 170 | 3 | 0 | 7 | +| `ui/` | 188 | 178 | 3 | 0 | 7 | | `data/` | 159 | 76 | 1 | 0 | 82 | | `automation/` | 67 | 43 | 0 | 1 | 23 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **453** | **323** | **4** | **1** | **125** | +| **total** | **461** | **331** | **4** | **1** | **125** | ## File-level triage — site counts @@ -66,7 +66,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `app.zod.ts` | 19 | | `bulk-action.zod.ts` | 4 | | `chart.zod.ts` | 8 | -| `component.zod.ts` | 48 | +| `component.zod.ts` | 56 | | `dashboard.zod.ts` | 11 | | `dataset.zod.ts` | 4 | | `i18n.zod.ts` | 1 | @@ -76,7 +76,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `sharing.zod.ts` | 1 | | `view.zod.ts` | 62 | | `widget.zod.ts` | 1 | -| **total** | **180** | +| **total** | **188** | ### `data/` — sites @@ -155,7 +155,7 @@ over it is here. ### `ui/` — open -**7 strip of 180**, in 4 file(s). +**7 strip of 188**, in 4 file(s). | File | Strip | Sites | |---|---|---| @@ -163,7 +163,7 @@ over it is here. | `app.zod.ts` | 1 | 19 | | `view.zod.ts` | 4 | 62 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **180** | +| **total** | **7** | **188** | | Bucket | Sites | |---|---| diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index c7a4e63478a..f21b363262e 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -392,6 +392,58 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ covers: ['ui/component.zod.ts:RecordAlertProps.visible'], note: 'A SEPARATE row from `cel-ui` on purpose, on both axes that row fixes at once: `cel-ui` is `fail-soft-log` (its form-view section/field predicates fault OPEN) and its evaluator is the SchemaRenderer, while this one faults closed through the record:alert renderer. ⚠️ And the honest limit, measured here: because `PageComponentSchema.properties` is an opaque bag served verbatim, a predicate authored in `properties` on a raw `Page` object literal never reaches `ExpressionInputSchema`\'s transform at all — a bare string there stays on the console\'s LEGACY JS evaluator, which has no `has()`, and only an explicit `{dialect:"cel"}` envelope routes to CEL. So the SCHEMA declares the CEL contract this row records while the page path can still deliver the legacy one; the platform\'s sys_user page carries that reading at its own declaration site and gates on the component-NODE `visibleWhen` instead. ⛔ NOT MEASURED HERE: the renderer, which is in objectui and not in this checkout.', }, + // #20371 — the `action:*` page-component rows. Each renderer evaluates its own + // `visible` / `disabled` off the hoisted `properties` bag, AND `SchemaRenderer`'s + // node gate evaluates the same hoisted keys first, so every surface below has + // two evaluation legs; the row states what the COMPOSITION does to a predicate + // that faults. Split three ways because the faces differ, per renderer — the + // collision rule `cel-action-visible` / `cel-action-disabled` were split on. + // Read (not run) at the `.objectui-sha` pin `dd3f7e1be356` (re-read there + // 2026-09-28; first read at `f8a9d0fb0596`, and across that hop the node-gate + // evaluators and `useCondition` are code-identical, so every fault face below + // stands and only line numbers moved): the renderers are `components/src/renderers/action/action-{button,menu,icon,group}.tsx`, + // `useCondition` is `react/src/hooks/useExpression.ts:200-244`, the node gate's + // two legs are `react/src/SchemaRenderer.tsx` `evaluateVisibilityPredicate` + // (:1157) and `evaluateEnablementPredicate` (:1247, :1879-1891). Tracker anchors + // kept out of the strings below (`check:doc-authoring`): the node gate's fault + // report is objectui#6038 (visibility legs) and objectui#6445 (enablement legs); + // the empty-`disabled` fix `cel-action-disabled` cites is objectui#3842. + { + id: 'cel-action-block-visible-closed', + summary: '`action:button` / `action:menu` page-block visibility (props `visible`) — the block is not rendered when the predicate is FALSE', + dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-closed', + enforcement: + 'BUILD-TIME GATE, measured here: lint/page-envelope-audit.ts door 3 parses `ComponentPropsMap["action:button" | "action:menu"]`, the one door that reaches `properties.visible`. EVALUATOR, two legs: (1) SchemaRenderer node gate `evaluateVisibilityPredicate` on the hoisted value, page scope — a fault there answers `evaluateCondition`\'s fail-soft `true` (shown) and is REPORTED in every build; (2) the renderer\'s own `useCondition(toPredicateInput(schema.visible), recordData, { throwOnError: true })` (action-button.tsx:117-120, action-menu.tsx:224-227) against the host-bound row — a fault returns `false` and warns once per predicate (useExpression.ts:215-238), and the block returns null (action-button.tsx:335, action-menu.tsx:322). The legs AND, so a faulting predicate HIDES the block: fail-CLOSED, the face `cel-action-visible` records for a registered action', + covers: [ + 'ui/component.zod.ts:ActionButtonPropsSchema.visible', + 'ui/component.zod.ts:ActionMenuPropsSchema.visible', + ], + note: 'Separate from `cel-action-visible` (a registered object action, keyed by `name` in `actions[]`, drawn by `ActionEngine`) because this slot is a PAGE component\'s props bag drawn through `SchemaRenderer`, and separate from `cel-action-block-visible-soft` because `action:icon` / `action:group` evaluate the same key WITHOUT `throwOnError` and fault the other way. ⚠️ The same honest limit `cel-record-alert-visible` records, and for the same reason: `PageComponentSchema.properties` is an opaque bag served verbatim, so a bare string never passes `EvaluatedExpressionInputSchema`\'s transform on the page path and reaches the console\'s LEGACY JS evaluator (`evaluateCondition` routes only an explicit `{dialect:"cel"}` envelope to CEL); the pin carries that envelope through the bag loops intact (`preservePredicateEnvelope`, SchemaRenderer.tsx:469). ⛔ NOT MEASURED HERE: the renderers were read at the pin, not run.', + }, + { + id: 'cel-action-block-visible-soft', + summary: '`action:icon` / `action:group` page-block visibility (props `visible`) — the block is not rendered when the predicate is FALSE', + dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', + enforcement: + 'BUILD-TIME GATE, measured here: lint/page-envelope-audit.ts door 3 parses `ComponentPropsMap["action:icon" | "action:group"]`. EVALUATOR, two legs: (1) SchemaRenderer node gate `evaluateVisibilityPredicate` on the hoisted value — a fault answers the fail-soft `true` (shown) and is REPORTED in every build; (2) the renderer\'s own `useCondition(toPredicateInput(schema.visible), recordData)` WITHOUT `throwOnError` (action-icon.tsx:97, action-group.tsx:238) — a fault answers `evaluateCondition`\'s fail-soft `true` as well (ExpressionEvaluator.ts:387-408), and action-icon.tsx:219-221 names that policy "fail-soft, unlike `action:button`\'s fail-closed one". Both legs show, so a faulting predicate leaves the block RENDERED, and the node gate is the leg that logs it: fail-SOFT-LOG', + covers: [ + 'ui/component.zod.ts:ActionIconPropsSchema.visible', + 'ui/component.zod.ts:ActionGroupPropsSchema.visible', + ], + note: 'The opposite fault face to `cel-action-block-visible-closed` on the same key, which is why the two do not share a row. The `-log` half rests on the node gate\'s report; the renderer leg itself is silent for a bare string and warns only on the `{dialect:"cel"}` route. A literal `false` on `action:group` is honoured by the node gate, not by the renderer, whose `:343` check is a truthiness test. Same bare-string limit as `cel-action-block-visible-closed`. ⛔ NOT MEASURED HERE: the renderers were read at the pin, not run.', + }, + { + id: 'cel-action-block-disabled', + summary: '`action:button` / `action:icon` page-block disabling (props `disabled`) — the button stays on screen and cannot be pressed while the predicate is TRUE', + dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-closed', + enforcement: + 'BUILD-TIME GATE, measured here: lint/page-envelope-audit.ts door 3 parses `ComponentPropsMap["action:button" | "action:icon"]`. EVALUATOR, two legs, both un-negated: (1) SchemaRenderer node gate `evaluateEnablementPredicate` on the hoisted `disabled` (SchemaRenderer.tsx:1879-1891), forwarded as the `disabled` prop — a fault answers `evaluateCondition`\'s fail-soft `true`, which on this leg means GREYED OUT, and is REPORTED in every build (SchemaRenderer.tsx:1247-1274); (2) the renderer\'s own `useCondition(toPredicateInput(schema.disabled), recordData)` (action-button.tsx:130 + :370-376, action-icon.tsx:102 + :257-263), the same `true` on a fault. The legs OR, so a faulting predicate leaves the button DISABLED: the action is refused, fail-CLOSED', + covers: [ + 'ui/component.zod.ts:ActionButtonPropsSchema.disabled', + 'ui/component.zod.ts:ActionIconPropsSchema.disabled', + ], + note: 'Deliberately NOT `fail-soft-log` like `cel-action-disabled`: the renderers measured here answer a faulting `disabled` with `true`, and on an un-negated enablement leg that `true` greys the control out — SchemaRenderer.tsx:1258-1265 states exactly that asymmetry ("on the negated visibility legs that means SHOWN, here it means GREYED OUT"). The objectui fix `cel-action-disabled` cites is about an EMPTY `disabled: \'\'`, which `hasDeclaredVisibilityGate` / `hasDeclaredPredicate` now treat as no gate, not about a faulting one. ⚠️ Scope consequence worth knowing: the node-gate leg evaluates at PAGE scope, so a row-scoped `record.*` predicate that does not resolve there faults and greys the button out whatever the row says. Same bare-string limit as `cel-action-block-visible-closed`. ⛔ NOT MEASURED HERE: the renderers were read at the pin, not run.', + }, { id: 'cel-flow', summary: 'flow / loader branching + filter predicates', diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index d5bbfb0a06b..789c7f53cbc 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -11,11 +11,23 @@ "ActionAi (type)", "ActionAiParsed (type)", "ActionAiSchema (const)", + "ActionButtonProps (type)", + "ActionButtonPropsParsed (type)", + "ActionButtonPropsSchema (const)", "ActionEngineFacade (interface)", + "ActionGroupProps (type)", + "ActionGroupPropsParsed (type)", + "ActionGroupPropsSchema (const)", "ActionHandler (type)", "ActionHandlerContext (interface)", + "ActionIconProps (type)", + "ActionIconPropsParsed (type)", + "ActionIconPropsSchema (const)", "ActionLocation (type)", "ActionLocationSchema (const)", + "ActionMenuProps (type)", + "ActionMenuPropsParsed (type)", + "ActionMenuPropsSchema (const)", "ActionNavItem (type)", "ActionNavItemParsed (type)", "ActionNavItemSchema (const)", @@ -146,6 +158,8 @@ "ElementDataSource (type)", "ElementDataSourceParsed (type)", "ElementDataSourceSchema (const)", + "ElementDefinitionListProps (type)", + "ElementDefinitionListPropsSchema (const)", "ElementFilterPropsSchema (const)", "ElementFormPropsSchema (const)", "ElementImagePropsSchema (const)", @@ -156,6 +170,9 @@ "ElementRecordPickerProps (type)", "ElementRecordPickerPropsParsed (type)", "ElementRecordPickerPropsSchema (const)", + "ElementRepeaterProps (type)", + "ElementRepeaterPropsParsed (type)", + "ElementRepeaterPropsSchema (const)", "ElementTextInputPropsSchema (const)", "ElementTextPropsSchema (const)", "ExpandViewResult (interface)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 593727e61dc..f034e256898 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -60,6 +60,75 @@ "ui/ActionAi:outputSchema", "ui/ActionAi:paramHints", "ui/ActionAi:requiresConfirmation", + "ui/ActionButtonProps:actionType", + "ui/ActionButtonProps:bodyExtra", + "ui/ActionButtonProps:bodyShape", + "ui/ActionButtonProps:confirmText", + "ui/ActionButtonProps:description", + "ui/ActionButtonProps:disabled", + "ui/ActionButtonProps:endpoint", + "ui/ActionButtonProps:errorMessage", + "ui/ActionButtonProps:icon", + "ui/ActionButtonProps:label", + "ui/ActionButtonProps:locations", + "ui/ActionButtonProps:method", + "ui/ActionButtonProps:name", + "ui/ActionButtonProps:objectName", + "ui/ActionButtonProps:onSuccess", + "ui/ActionButtonProps:openIn", + "ui/ActionButtonProps:operation", + "ui/ActionButtonProps:params", + "ui/ActionButtonProps:patch", + "ui/ActionButtonProps:recordIdField", + "ui/ActionButtonProps:refreshAfter", + "ui/ActionButtonProps:resultDialog", + "ui/ActionButtonProps:size", + "ui/ActionButtonProps:successMessage", + "ui/ActionButtonProps:target", + "ui/ActionButtonProps:toast", + "ui/ActionButtonProps:undoable", + "ui/ActionButtonProps:variant", + "ui/ActionButtonProps:visible", + "ui/ActionGroupProps:actions", + "ui/ActionGroupProps:display", + "ui/ActionGroupProps:icon", + "ui/ActionGroupProps:label", + "ui/ActionGroupProps:location", + "ui/ActionGroupProps:size", + "ui/ActionGroupProps:variant", + "ui/ActionGroupProps:visible", + "ui/ActionIconProps:actionType", + "ui/ActionIconProps:bodyExtra", + "ui/ActionIconProps:bodyShape", + "ui/ActionIconProps:confirmText", + "ui/ActionIconProps:description", + "ui/ActionIconProps:disabled", + "ui/ActionIconProps:endpoint", + "ui/ActionIconProps:errorMessage", + "ui/ActionIconProps:icon", + "ui/ActionIconProps:label", + "ui/ActionIconProps:locations", + "ui/ActionIconProps:method", + "ui/ActionIconProps:name", + "ui/ActionIconProps:objectName", + "ui/ActionIconProps:onSuccess", + "ui/ActionIconProps:openIn", + "ui/ActionIconProps:operation", + "ui/ActionIconProps:params", + "ui/ActionIconProps:patch", + "ui/ActionIconProps:refreshAfter", + "ui/ActionIconProps:resultDialog", + "ui/ActionIconProps:successMessage", + "ui/ActionIconProps:target", + "ui/ActionIconProps:toast", + "ui/ActionIconProps:variant", + "ui/ActionIconProps:visible", + "ui/ActionMenuProps:actions", + "ui/ActionMenuProps:icon", + "ui/ActionMenuProps:label", + "ui/ActionMenuProps:size", + "ui/ActionMenuProps:variant", + "ui/ActionMenuProps:visible", "ui/ActionNavItem:actionDef", "ui/ActionNavItem:badge", "ui/ActionNavItem:badgeVariant", @@ -377,6 +446,9 @@ "ui/ElementDataSource:object", "ui/ElementDataSource:sort", "ui/ElementDataSource:view", + "ui/ElementDefinitionListProps:columns", + "ui/ElementDefinitionListProps:inline", + "ui/ElementDefinitionListProps:items", "ui/ElementFilterProps:aria [RETIRED]", "ui/ElementFilterProps:fields [RETIRED]", "ui/ElementFilterProps:layout [RETIRED]", @@ -422,6 +494,14 @@ "ui/ElementRecordPickerProps:sort", "ui/ElementRecordPickerProps:targetVariable [RETIRED]", "ui/ElementRecordPickerProps:valueField", + "ui/ElementRepeaterProps:divided", + "ui/ElementRepeaterProps:emptyText", + "ui/ElementRepeaterProps:fields", + "ui/ElementRepeaterProps:filter", + "ui/ElementRepeaterProps:limit", + "ui/ElementRepeaterProps:object", + "ui/ElementRepeaterProps:sort", + "ui/ElementRepeaterProps:titleField", "ui/ElementTextInputProps:aria", "ui/ElementTextInputProps:defaultValue", "ui/ElementTextInputProps:description", diff --git a/packages/spec/declaration-map/ui.json b/packages/spec/declaration-map/ui.json index 84bd3941c77..31ffbe0df1b 100644 --- a/packages/spec/declaration-map/ui.json +++ b/packages/spec/declaration-map/ui.json @@ -6,8 +6,16 @@ "Action": "ui/Action", "ActionAi": "ui/ActionAi", "ActionAiSchema": "ui/ActionAi", + "ActionButtonProps": "ui/ActionButtonProps", + "ActionButtonPropsSchema": "ui/ActionButtonProps", + "ActionGroupProps": "ui/ActionGroupProps", + "ActionGroupPropsSchema": "ui/ActionGroupProps", + "ActionIconProps": "ui/ActionIconProps", + "ActionIconPropsSchema": "ui/ActionIconProps", "ActionLocation": "ui/ActionLocation", "ActionLocationSchema": "ui/ActionLocation", + "ActionMenuProps": "ui/ActionMenuProps", + "ActionMenuPropsSchema": "ui/ActionMenuProps", "ActionNavItem": "ui/ActionNavItem", "ActionNavItemSchema": "ui/ActionNavItem", "ActionParam": "ui/ActionParam", @@ -92,6 +100,8 @@ "ElementButtonPropsSchema": "ui/ElementButtonProps", "ElementDataSource": "ui/ElementDataSource", "ElementDataSourceSchema": "ui/ElementDataSource", + "ElementDefinitionListProps": "ui/ElementDefinitionListProps", + "ElementDefinitionListPropsSchema": "ui/ElementDefinitionListProps", "ElementFilterPropsSchema": "ui/ElementFilterProps", "ElementFormPropsSchema": "ui/ElementFormProps", "ElementImagePropsSchema": "ui/ElementImageProps", @@ -100,6 +110,8 @@ "ElementNumberPropsSchema": "ui/ElementNumberProps", "ElementRecordPickerProps": "ui/ElementRecordPickerProps", "ElementRecordPickerPropsSchema": "ui/ElementRecordPickerProps", + "ElementRepeaterProps": "ui/ElementRepeaterProps", + "ElementRepeaterPropsSchema": "ui/ElementRepeaterProps", "ElementTextInputPropsSchema": "ui/ElementTextInputProps", "ElementTextPropsSchema": "ui/ElementTextProps", "ExpressionBindableTextKey": "ui/ExpressionBindableTextKey", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 65d1e049a8f..b818696662f 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 211, - "droppedRefinementSites": 609, + "publishedSchemasWithDroppedRefinements": 212, + "droppedRefinementSites": 610, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -1209,6 +1209,11 @@ "filter.element" ] }, + "ui/ElementRepeaterProps": { + "sites": [ + "filter.element" + ] + }, "ui/FormField": { "sites": [ "in.publicPicker.filter.element" diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 08486d318ca..a46afed5f30 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -10,11 +10,23 @@ "ActionAi": "src/ui/action.zod.ts#ActionAi (type)", "ActionAiParsed": "src/ui/action.zod.ts#ActionAiParsed (type)", "ActionAiSchema": "src/ui/action.zod.ts#ActionAiSchema (const)", + "ActionButtonProps": "src/ui/component.zod.ts#ActionButtonProps (type)", + "ActionButtonPropsParsed": "src/ui/component.zod.ts#ActionButtonPropsParsed (type)", + "ActionButtonPropsSchema": "src/ui/component.zod.ts#ActionButtonPropsSchema (const)", "ActionEngineFacade": "src/ui/action-params.zod.ts#ActionEngineFacade (interface)", + "ActionGroupProps": "src/ui/component.zod.ts#ActionGroupProps (type)", + "ActionGroupPropsParsed": "src/ui/component.zod.ts#ActionGroupPropsParsed (type)", + "ActionGroupPropsSchema": "src/ui/component.zod.ts#ActionGroupPropsSchema (const)", "ActionHandler": "src/ui/action-params.zod.ts#ActionHandler (type)", "ActionHandlerContext": "src/ui/action-params.zod.ts#ActionHandlerContext (interface)", + "ActionIconProps": "src/ui/component.zod.ts#ActionIconProps (type)", + "ActionIconPropsParsed": "src/ui/component.zod.ts#ActionIconPropsParsed (type)", + "ActionIconPropsSchema": "src/ui/component.zod.ts#ActionIconPropsSchema (const)", "ActionLocation": "src/ui/action.zod.ts#ActionLocation (type)", "ActionLocationSchema": "src/ui/action.zod.ts#ActionLocationSchema (const)", + "ActionMenuProps": "src/ui/component.zod.ts#ActionMenuProps (type)", + "ActionMenuPropsParsed": "src/ui/component.zod.ts#ActionMenuPropsParsed (type)", + "ActionMenuPropsSchema": "src/ui/component.zod.ts#ActionMenuPropsSchema (const)", "ActionNavItem": "src/ui/app.zod.ts#ActionNavItem (type)", "ActionNavItemParsed": "src/ui/app.zod.ts#ActionNavItemParsed (type)", "ActionNavItemSchema": "src/ui/app.zod.ts#ActionNavItemSchema (const)", @@ -142,6 +154,8 @@ "ElementDataSource": "src/ui/page.zod.ts#ElementDataSource (type)", "ElementDataSourceParsed": "src/ui/page.zod.ts#ElementDataSourceParsed (type)", "ElementDataSourceSchema": "src/ui/page.zod.ts#ElementDataSourceSchema (const)", + "ElementDefinitionListProps": "src/ui/component.zod.ts#ElementDefinitionListProps (type)", + "ElementDefinitionListPropsSchema": "src/ui/component.zod.ts#ElementDefinitionListPropsSchema (const)", "ElementFilterPropsSchema": "src/ui/component.zod.ts#ElementFilterPropsSchema (const)", "ElementFormPropsSchema": "src/ui/component.zod.ts#ElementFormPropsSchema (const)", "ElementImagePropsSchema": "src/ui/component.zod.ts#ElementImagePropsSchema (const)", @@ -152,6 +166,9 @@ "ElementRecordPickerProps": "src/ui/component.zod.ts#ElementRecordPickerProps (type)", "ElementRecordPickerPropsParsed": "src/ui/component.zod.ts#ElementRecordPickerPropsParsed (type)", "ElementRecordPickerPropsSchema": "src/ui/component.zod.ts#ElementRecordPickerPropsSchema (const)", + "ElementRepeaterProps": "src/ui/component.zod.ts#ElementRepeaterProps (type)", + "ElementRepeaterPropsParsed": "src/ui/component.zod.ts#ElementRepeaterPropsParsed (type)", + "ElementRepeaterPropsSchema": "src/ui/component.zod.ts#ElementRepeaterPropsSchema (const)", "ElementTextInputPropsSchema": "src/ui/component.zod.ts#ElementTextInputPropsSchema (const)", "ElementTextPropsSchema": "src/ui/component.zod.ts#ElementTextPropsSchema (const)", "ExpandViewResult": "src/ui/view.zod.ts#ExpandViewResult (interface)", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index f3f25a5dbe8..d3c58865bf9 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -5,7 +5,11 @@ "ui/AIChatWindowProps", "ui/Action", "ui/ActionAi", + "ui/ActionButtonProps", + "ui/ActionGroupProps", + "ui/ActionIconProps", "ui/ActionLocation", + "ui/ActionMenuProps", "ui/ActionNavItem", "ui/ActionParam", "ui/ActionSession", @@ -49,12 +53,14 @@ "ui/DocNavItem", "ui/ElementButtonProps", "ui/ElementDataSource", + "ui/ElementDefinitionListProps", "ui/ElementFilterProps", "ui/ElementFormProps", "ui/ElementImageProps", "ui/ElementMetadataViewerProps", "ui/ElementNumberProps", "ui/ElementRecordPickerProps", + "ui/ElementRepeaterProps", "ui/ElementTextInputProps", "ui/ElementTextProps", "ui/ExpressionBindableTextKey", diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 83b95a97eb7..8a409bda17d 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -275,7 +275,7 @@ import type * as M187 from './shared/duration.zod.js'; import type * as M188 from './ai/build-progress.zod.js'; // --------------------------------------------------------------------------- -// 780 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 781 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1497,6 +1497,11 @@ export type Iso_ui_chart__ChartTypeSchema = Assert, z.infer< typeof M170.ElementDefinitionListPropsSchema > >>; export type Iso_ui_component__ObjectFormPropsSchema = Assert, z.infer< typeof M170.ObjectFormPropsSchema > >>; export type Iso_ui_component__ObjectMasterDetailFormPropsSchema = Assert, z.infer< typeof M170.ObjectMasterDetailFormPropsSchema > >>; export type Iso_ui_component__PageContainerProps = Assert, z.infer< typeof M170.PageContainerProps > >>; @@ -1662,7 +1667,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 780 isomorphic pins', () => { + it('still declares all 781 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2332,7 +2337,23 @@ describe('ADR-0122 type-alias convention', () => { // touch disjoint pins (M164's one and M167's two, M78's three); #19920 // landed first, so this entry's arrow starts from its 783. The count // below was re-derived from the merged file, not added up. -3 removed. - expect(pins).toHaveLength(780); + // + // 780 -> 781 is #20371's `ElementDefinitionListPropsSchema` — the + // `element:definition-list` row of `ComponentPropsMap` (ui/component.zod.ts, + // module slot M170), the (RISE) case once. Isomorphism MEASURED, not + // assumed: a strict item of one `z.string()` and one `z.unknown()`, a + // `z.literal([1, 2])` and a `z.boolean()`, every member optional but the + // item's `term`, with no `.default()`, `.transform()`, `.catch()` or + // `.pipe()` anywhere. Its five siblings in that card are NOT here: the four + // `action:*` rows carry `EvaluatedExpressionInputSchema` on `visible` (and + // `disabled`), whose bare-string arm transforms to the canonical envelope, + // and `element:repeater` carries `ViewFilterRuleSchema` on `filter` — so + // each declares an `XParsed` alias instead. +1 added. + // Authored off 786 and re-derived on two merges — #19920's 786 -> 783, + // then the connector resilience retirement's 783 -> 780 — so this entry's + // arrow starts from 780. The count below was re-derived from the merged + // file, not added up. + expect(pins).toHaveLength(781); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either diff --git a/packages/spec/src/ui/component-action-element-rows-20371.test.ts b/packages/spec/src/ui/component-action-element-rows-20371.test.ts new file mode 100644 index 00000000000..be7eec60ad7 --- /dev/null +++ b/packages/spec/src/ui/component-action-element-rows-20371.test.ts @@ -0,0 +1,355 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #20371 — six `ComponentPropsMap` rows for the curated objectui public blocks +// that had none: `action:button`, `action:group`, `action:menu`, `action:icon`, +// `element:definition-list`, `element:repeater`. +// +// Before these rows the four `action:*` types were skipped by the props gate +// (outside every namespace the enum populates) and the two `element:*` lists +// were refused as `component-type-unknown` (inside the reserved `element:` +// namespace with neither an enum member nor a row). Each row is strict from +// birth, with its key set measured from the renderer's read points at the +// `.objectui-sha` pin — the per-key citations live in `component.zod.ts`, +// section 4b. The key sets are asserted WHOLE, as #18305's were: a row derived +// from read points is a claim about a complete set, and only an equality holds +// a later addition to having been measured too. + +import { describe, expect, it } from 'vitest'; +import { + ActionButtonPropsSchema, + ActionGroupPropsSchema, + ActionIconPropsSchema, + ActionMenuPropsSchema, + ComponentPropsMap, + ElementDefinitionListPropsSchema, + ElementRepeaterPropsSchema, +} from './component.zod'; +import { + KNOWN_COMPONENT_TYPE_CANDIDATES, + STRING_ARM_REGISTERED_TYPES, + hasReservedComponentNamespace, + isKnownComponentType, +} from './component-type-vocabulary'; +import { PageComponentSchema, PageComponentType } from './page.zod'; + +type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[]; errors?: Issue[][] }; + +/** The issues of a failed safeParse — fails the test when the parse succeeded. */ +const issuesOf = (result: { success: boolean; error?: { issues: unknown[] } }): Issue[] => { + expect(result.success).toBe(false); + return result.error!.issues as Issue[]; +}; + +/** The one `unrecognized_keys` issue at the bag's root. */ +const unknownKeyIssue = (result: { success: boolean; error?: { issues: unknown[] } }): Issue => { + const at = issuesOf(result).filter((i) => i.code === 'unrecognized_keys' && i.path.length === 0); + expect(at).toHaveLength(1); + return at[0]!; +}; + +/** Top-level declared keys of a (lazy) strict object schema. */ +const keysOf = (schema: unknown): string[] => + Object.keys((schema as { shape: Record }).shape).sort(); + +const ROWS = { + 'action:button': ActionButtonPropsSchema, + 'action:group': ActionGroupPropsSchema, + 'action:menu': ActionMenuPropsSchema, + 'action:icon': ActionIconPropsSchema, + 'element:definition-list': ElementDefinitionListPropsSchema, + 'element:repeater': ElementRepeaterPropsSchema, +} as const; + +describe('the six rows exist and are the exported schemas (#20371)', () => { + it.each(Object.entries(ROWS))('`%s` dispatches to its exported schema', (type, schema) => { + expect(Object.keys(ComponentPropsMap)).toContain(type); + expect((ComponentPropsMap as Record)[type]).toBe(schema); + }); + + it('the page node still parses all six through the open `type` arm — the rows add no parse change there', () => { + for (const type of Object.keys(ROWS)) { + const node = PageComponentSchema.safeParse({ type, properties: { anything: 1 } }); + // The carrier stays an open bag: the rows are judged at the authoring + // door (the props gate), not by `PageComponentSchema.parse`. + expect(node.success, type).toBe(true); + } + }); +}); + +describe('key sets, asserted whole — measured from the renderers\' read points', () => { + // Forwarded to the action runner by `action:button` / `action:icon` + // (`execute({ ...forwarded })`). `undoable` / `recordIdField` are forwarded + // by the button only. `objectName` joined the forward with the pin that + // carries it; the four renderers forward it, the two containers per member. + const FORWARDED = [ + 'params', 'description', 'target', 'openIn', 'endpoint', 'method', 'bodyExtra', 'bodyShape', + 'operation', 'patch', 'confirmText', 'successMessage', 'errorMessage', 'refreshAfter', + 'locations', 'toast', 'resultDialog', 'onSuccess', 'objectName', + ]; + + it('action:button', () => { + expect(keysOf(ActionButtonPropsSchema)).toEqual([ + 'name', 'label', 'icon', 'actionType', 'variant', 'size', 'visible', 'disabled', + ...FORWARDED, 'undoable', 'recordIdField', + ].sort()); + }); + + it('action:icon — no `size` (fixed icon size), no `undoable` / `recordIdField` (not forwarded)', () => { + expect(keysOf(ActionIconPropsSchema)).toEqual([ + 'name', 'label', 'icon', 'actionType', 'variant', 'visible', 'disabled', ...FORWARDED, + ].sort()); + }); + + it('action:group — no group-level `name` (never read)', () => { + expect(keysOf(ActionGroupPropsSchema)).toEqual( + ['actions', 'display', 'location', 'label', 'icon', 'variant', 'size', 'visible'].sort(), + ); + }); + + it('action:menu', () => { + expect(keysOf(ActionMenuPropsSchema)).toEqual( + ['actions', 'label', 'icon', 'variant', 'size', 'visible'].sort(), + ); + }); + + it('element:definition-list', () => { + expect(keysOf(ElementDefinitionListPropsSchema)).toEqual(['items', 'columns', 'inline'].sort()); + }); + + it('element:repeater', () => { + expect(keysOf(ElementRepeaterPropsSchema)).toEqual( + ['object', 'titleField', 'fields', 'filter', 'sort', 'limit', 'emptyText', 'divided'].sort(), + ); + }); + + it('no row declares a node key (`className` / `style` / `id` stay on the node)', () => { + for (const [type, schema] of Object.entries(ROWS)) { + for (const nodeKey of ['className', 'style', 'id', 'dataSource', 'responsiveStyles']) { + expect(keysOf(schema), `${type}.${nodeKey}`).not.toContain(nodeKey); + } + } + }); +}); + +describe('one accepted authored example per type, taken from objectui', () => { + it('action:button — the node objectui\'s AGENTS.md teaches', () => { + const authored = { label: 'Open details', actionType: 'url', target: '/users/ada' }; + expect(ActionButtonPropsSchema.parse(authored)).toEqual(authored); + }); + + it('action:icon — the registration\'s own defaults', () => { + const authored = { icon: 'play', actionType: 'script', variant: 'ghost' as const }; + expect(ActionIconPropsSchema.parse(authored)).toEqual(authored); + }); + + it('action:group — the registration\'s defaults with one member action', () => { + const authored = { + display: 'inline' as const, + variant: 'outline' as const, + size: 'sm' as const, + actions: [{ name: 'approve', label: 'Approve', type: 'api', target: '/api/approve' }], + }; + expect(ActionGroupPropsSchema.parse(authored)).toEqual(authored); + }); + + it('action:menu — the registration\'s defaults with one member action', () => { + const authored = { + variant: 'ghost' as const, + actions: [{ name: 'archive', label: 'Archive', type: 'script', tags: ['separator-before'] }], + }; + expect(ActionMenuPropsSchema.parse(authored)).toEqual(authored); + }); + + it('element:definition-list — objectui\'s own renderer specimen', () => { + const authored = { + items: [ + { term: 'Status', description: 'Active' }, + { term: 'Owner', description: 'Ada' }, + ], + }; + expect(ElementDefinitionListPropsSchema.parse(authored)).toEqual(authored); + }); + + it('element:repeater — objectui\'s own renderer specimen', () => { + const authored = { object: 'showcase_category', fields: ['name'], emptyText: 'Nothing here' }; + expect(ElementRepeaterPropsSchema.parse(authored)).toEqual(authored); + }); +}); + +describe('strict from birth — an unknown key is refused on every row', () => { + it.each(Object.entries(ROWS))('`%s` refuses an undeclared key, naming its surface', (type, schema) => { + const base = type === 'element:repeater' ? { object: 'task' } : {}; + const issue = unknownKeyIssue(schema.safeParse({ ...base, notARealProp: 1 })); + expect(issue.keys).toEqual(['notARealProp']); + expect(issue.message).toContain(`\`${type}\``); + }); + + it('a node key written into the bag gets the wrong-layer prescription, not a bare refusal', () => { + const issue = unknownKeyIssue(ActionButtonPropsSchema.safeParse({ label: 'X', className: 'mt-2' })); + expect(issue.keys).toEqual(['className']); + expect(issue.message).toContain('NODE'); + }); +}); + +describe('what the measurement decided, pinned', () => { + it('`name` is OPTIONAL on action:button — the renderer reads `schema.name ?? schema.label`', () => { + expect(ActionButtonPropsSchema.safeParse({ label: 'Save', actionType: 'script' }).success).toBe(true); + }); + + it('`type` is refused on action:button / action:icon and renamed to `actionType`', () => { + for (const schema of [ActionButtonPropsSchema, ActionIconPropsSchema]) { + const issue = unknownKeyIssue(schema.safeParse({ label: 'X', type: 'url' })); + expect(issue.keys).toEqual(['type']); + expect(issue.message).toContain('`actionType`'); + } + }); + + it('`enabled` and `autoTrigger` are read-but-not-authorable on action:button / action:icon', () => { + for (const schema of [ActionButtonPropsSchema, ActionIconPropsSchema]) { + for (const key of ['enabled', 'autoTrigger']) { + const issue = unknownKeyIssue(schema.safeParse({ label: 'X', [key]: true })); + expect(issue.keys).toEqual([key]); + } + } + }); + + it('action:icon refuses `size` — the renderer pins the icon size', () => { + expect(unknownKeyIssue(ActionIconPropsSchema.safeParse({ size: 'sm' })).keys).toEqual(['size']); + // Firing control: the button sibling reads `size`. + expect(ActionButtonPropsSchema.safeParse({ size: 'sm' }).success).toBe(true); + }); + + it('action:group refuses a group-level `name`, which the registration publishes and nothing reads', () => { + expect(unknownKeyIssue(ActionGroupPropsSchema.safeParse({ name: 'toolbar' })).keys).toEqual(['name']); + }); + + it('`variant` / `size`: `primary` and `md` only where the renderer maps them', () => { + // action:button maps both; action:icon maps `primary` (no `size` at all). + expect(ActionButtonPropsSchema.safeParse({ variant: 'primary', size: 'md' }).success).toBe(true); + expect(ActionIconPropsSchema.safeParse({ variant: 'primary' }).success).toBe(true); + // action:menu hands both to the Button primitive unmapped; action:group + // maps `md` on its dropdown trigger only, not in its default inline mode. + for (const schema of [ActionMenuPropsSchema, ActionGroupPropsSchema]) { + const variant = issuesOf(schema.safeParse({ variant: 'primary' })); + expect(variant.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_value', 'variant']]); + const size = issuesOf(schema.safeParse({ size: 'md' })); + expect(size.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_value', 'size']]); + } + }); + + it('`actions` is a LIST of action objects — a bare action-name list is refused', () => { + for (const schema of [ActionGroupPropsSchema, ActionMenuPropsSchema]) { + const issues = issuesOf(schema.safeParse({ actions: ['approve'] })); + expect(issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_type', 'actions.0']]); + // The object form, and the record shape the registration publishes, is + // not a list either. + expect(schema.safeParse({ actions: { approve: {} } }).success).toBe(false); + } + }); + + it('a bare CEL `visible` normalizes to the canonical envelope; a boolean stays a literal', () => { + const parsed = ActionMenuPropsSchema.parse({ visible: "record.status == 'open'" }); + expect(parsed.visible).toEqual({ dialect: 'cel', source: "record.status == 'open'" }); + expect(ActionButtonPropsSchema.parse({ visible: false, disabled: true })).toEqual({ visible: false, disabled: true }); + }); + + it('definition-list `columns` is the NUMBER 1 or 2 — the registration\'s string spelling is refused', () => { + expect(ElementDefinitionListPropsSchema.safeParse({ columns: 2 }).success).toBe(true); + const issues = issuesOf(ElementDefinitionListPropsSchema.safeParse({ columns: '2' })); + expect(issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_value', 'columns']]); + expect(issues[0]!.message).toContain('`columns: 2`'); + expect(ElementDefinitionListPropsSchema.safeParse({ columns: 3 }).success).toBe(false); + }); + + it('definition-list items are strict — the designer\'s old `label` / `value` pair is refused and renamed', () => { + const issues = issuesOf(ElementDefinitionListPropsSchema.safeParse({ items: [{ label: 'A', value: 1 }] })); + const keyIssue = issues.find((i) => i.code === 'unrecognized_keys'); + expect(keyIssue?.path).toEqual(['items', 0]); + expect(keyIssue?.keys).toEqual(['label', 'value']); + expect(keyIssue?.message).toContain('`term`'); + expect(keyIssue?.message).toContain('`description`'); + // `term` is required on an item; `items` itself is optional (the renderer + // shows its "No details" state for absent and empty alike). + expect(issues.some((i) => i.code === 'invalid_type' && i.path.join('.') === 'items.0.term')).toBe(true); + expect(ElementDefinitionListPropsSchema.safeParse({}).success).toBe(true); + }); + + it('repeater `object` is required — without it the list never queries', () => { + const issues = issuesOf(ElementRepeaterPropsSchema.safeParse({ fields: ['name'] })); + expect(issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_type', 'object']]); + }); + + it('repeater `fields` takes a name or `{ field }`; the unrendered `label` is refused inside the union', () => { + expect(ElementRepeaterPropsSchema.safeParse({ object: 't', fields: ['a', { field: 'b' }] }).success).toBe(true); + const [union] = issuesOf(ElementRepeaterPropsSchema.safeParse({ object: 't', fields: [{ field: 'b', label: 'B' }] })); + expect(union!.code).toBe('invalid_union'); + expect(union!.path).toEqual(['fields', 0]); + // Exactly one arm judged keys, and only keys — the shape the props gate + // unpacks back onto its unknown-key rule. + const keyArms = union!.errors!.filter((arm) => arm.some((i) => i.code === 'unrecognized_keys')); + expect(keyArms).toHaveLength(1); + expect(keyArms[0]!.map((i) => i.keys)).toEqual([['label']]); + }); + + it('repeater `filter` / `sort` are the family\'s one orthography — the record form is refused', () => { + const ok = ElementRepeaterPropsSchema.safeParse({ + object: 'task', + filter: [{ field: 'status', operator: 'equals', value: 'open' }], + sort: [{ field: 'due_date', order: 'asc' }], + limit: 10, + }); + expect(ok.success).toBe(true); + const record = issuesOf(ElementRepeaterPropsSchema.safeParse({ object: 'task', filter: { status: 'open' } })); + expect(record.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_type', 'filter']]); + const limit = issuesOf(ElementRepeaterPropsSchema.safeParse({ object: 'task', limit: 0 })); + expect(limit.map((i) => [i.code, i.path.join('.')])).toEqual([['too_small', 'limit']]); + }); + + it('`objectName` is declared where the renderer forwards it — on the action, never on a container', () => { + for (const schema of [ActionButtonPropsSchema, ActionIconPropsSchema]) { + expect(schema.parse({ label: 'Close child', objectName: 'task' })).toEqual({ label: 'Close child', objectName: 'task' }); + } + // `action:group` / `action:menu` forward each MEMBER's `objectName`: it + // rides the member object, which this row does not judge ... + for (const schema of [ActionGroupPropsSchema, ActionMenuPropsSchema]) { + const member = { name: 'close', label: 'Close', type: 'script', objectName: 'task' }; + expect(schema.safeParse({ actions: [member] }).success).toBe(true); + // ... and a container-level one is read by nothing. + expect(unknownKeyIssue(schema.safeParse({ objectName: 'task' })).keys).toEqual(['objectName']); + } + }); + + it('repeater: the `object-*` family\'s `objectName` is refused and renamed to `object`', () => { + const issue = unknownKeyIssue(ElementRepeaterPropsSchema.safeParse({ object: 'task', objectName: 'task' })); + expect(issue.keys).toEqual(['objectName']); + expect(issue.message).toContain('`object`'); + }); +}); + +describe('the `element:` vocabulary admits the two lists through their rows', () => { + it('both are reserved-namespace AND known — `component-type-unknown` no longer refuses them', () => { + for (const type of ['element:definition-list', 'element:repeater']) { + expect(hasReservedComponentNamespace(type), type).toBe(true); + expect(isKnownComponentType(type), type).toBe(true); + expect(KNOWN_COMPONENT_TYPE_CANDIDATES, type).toContain(type); + } + // Firing control: a typo inside the same namespace is still unknown, so + // the claim did not open the namespace — it named two members of it. + expect(isKnownComponentType('element:repeatr')).toBe(false); + expect(hasReservedComponentNamespace('element:repeatr')).toBe(true); + }); + + it('they join by ROW, not by enum member or string-arm ledger entry (the `element:metadata_viewer` shape)', () => { + for (const type of ['element:definition-list', 'element:repeater']) { + expect(PageComponentType.options as readonly string[]).not.toContain(type); + expect(STRING_ARM_REGISTERED_TYPES).not.toContain(type); + } + }); + + it('the four `action:*` types stay outside every reserved namespace — the rows add no vocabulary claim', () => { + for (const type of ['action:button', 'action:group', 'action:menu', 'action:icon']) { + expect(hasReservedComponentNamespace(type), type).toBe(false); + expect(isKnownComponentType(type), type).toBe(true); + } + }); +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 4b0cec84c07..d133710476d 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2673,6 +2673,589 @@ export const ElementTextInputPropsSchema = lazySchema(() => strictObject({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), })); +/** + * ---------------------------------------------------------------------- + * 4b. Curated public blocks that had no row: the `action:*` quartet and the + * two `element:*` lists (#20371) + * ---------------------------------------------------------------------- + * + * objectui's ADR-0080 curated public vocabulary carries six blocks this map + * had no row for — `action:button`, `action:group`, `action:menu`, + * `action:icon`, `element:definition-list`, `element:repeater` + * (`core/src/registry/public-blocks.ts:117-122` at the pin this repo builds + * against, `.objectui-sha` = `dd3f7e1be`; first measured at `.objectui-sha` + * pin `f8a9d0fb0`, every read point below re-derived at the current pin + * 2026-09-28). The missing row failed in two different ways: + * + * - The four `action:*` types sit outside every namespace the + * `PageComponentType` enum populates, so the #5068 props gate skipped them + * as unregistered custom strings: any key inside `properties` parsed, + * stored and rode through to the renderer, read or not. + * - The two `element:*` types sit INSIDE a reserved namespace with neither an + * enum member nor a row, so `component-type-unknown` refused the whole node + * (severity error) although objectui registers, publishes and offers both. + * + * A row closes both. `component-type-vocabulary.ts` derives the known set from + * `Object.keys(ComponentPropsMap)`, so the two `element:*` types join the + * `element:` vocabulary through their rows — the `element:metadata_viewer` + * shape, no enum member — and the props gate now dispatches on all six. Their + * three-part evidence (registration, publication, authorship) is written on + * the map rows below, as the vocabulary's string-arm ledger asks of every + * type that enters it without an enum member. + * + * KEY SETS ARE MEASURED FROM THE RENDERERS' READ POINTS at that pin — the + * #7751 / #8691 / #8744 method — never transcribed from objectui's + * `UIActionSchema`, from the registrations' `inputs`, or from this package's + * object-metadata `ActionSchema` (a different declaration: a registered action + * keyed by a required `name`, whose `type` is the executor). Per-key citations + * are in each schema's docblock; where a declaration disagrees with the read, + * the read wins and the disagreement is written beside the key. + * + * VALUE posture is #7751's. A key the renderer interprets itself gets a value + * schema from that read (the Button primitive's own vocabulary where it is + * handed straight to `Button`). A key the renderer only FORWARDS to the action + * runner (`execute({ ...forwarded, ...localContext })`) gets the scalar the + * runner's `ActionDef` types it as, and `z.unknown()` where `ActionDef` types + * it as a spec-derived block (`bodyShape`, `onSuccess`, `resultDialog`, …) — + * tightening those is a later ratchet with its own inventory. + * + * Deliberately NOT declared on the action rows, each with its reason: + * + * - `className` / `style` / `id` — read, but node keys (`COMPONENT_NODE_KEYS`). + * - `enabled` — the renderers' legacy fallback beside `disabled`: a + * back-compat read for stored documents, not a second authorable spelling + * (the #5775 `body` precedent). Refused with a prescription. + * - `autoTrigger` — a host transport flag; `auto-trigger.ts:18-19` says it is + * "NOT persisted metadata" and "hosts its only producers". Refused with a + * prescription. + * - `data` / `context` — the host's row and execute-context channels (React + * props the renderers take by name), not authorable surface. + * - `onClick` — a function; only a code-composed schema can carry one. + * + * `objectName` IS declared on `action:button` / `action:icon`: absent at the + * first measurement, it is forwarded to the runner at the current pin + * (`action-button.tsx:307`, `action-icon.tsx:195`), and the console resolves + * its dispatch target as `action.objectName || `. On + * `action:group` / `action:menu` the forward is the MEMBER's + * (`action-group.tsx:323`, `action-menu.tsx:313`), so it rides each member + * object and the container rows gain no key. + */ + +/** What an undeclared key on one of the four `action:*` blocks met before its row. */ +const actionBlockHistory = (type: string) => + `Until \`${type}\` had a ComponentPropsMap row, the authoring gate skipped it as an unregistered ` + + "type: every key inside `properties` parsed clean, was stored, and reached objectui's renderer, " + + 'which ignored any key it does not read.'; + +/** What happened to one of the two `element:*` lists before its row. */ +const elementListHistory = (type: string) => + `Until \`${type}\` had a ComponentPropsMap row, the whole node was refused as an unknown ` + + '`element:` type although objectui renders it, so no key inside `properties` was ever judged.'; + +/** + * The action-level condition shape the renderers evaluate — `visible` on all + * four, `disabled` on `action:button` / `action:icon`: a boolean literal, a + * CEL string (normalized to the `{ dialect, source }` envelope on parse), or + * the envelope itself. Every read goes through `toPredicateInput`, which takes + * exactly those three arms; it is the same union `ActionSchema`'s own + * condition keys and `record:alert.visible` declare. + */ +const actionCondition = () => + z.union([z.boolean(), EvaluatedExpressionInputSchema], { + error: (issue) => evaluatedExpressionUnionRefusal(issue.input), + }); + +/** objectui's `Button` primitive vocabulary (`ui/button.tsx:19-35` at the pin). */ +const BUTTON_PRIMITIVE_VARIANTS = ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'] as const; +const BUTTON_PRIMITIVE_SIZES = ['default', 'sm', 'lg', 'icon'] as const; + +/** + * The action-row aliases shared by `action:button` and `action:icon`. + * + * - `type` → `actionType`: `type` is the SDUI envelope's component + * discriminator, and the hoist refuses to copy a `properties.type` onto the + * node, so an executor written there is read by nothing. objectui renamed + * the input to `actionType` with no alias and no transition window + * (objectui#7415); an author copying an + * `ActionSchema` entry, whose executor IS `type`, brings that spelling along. + * - `visibleWhen` / `visibility` → `visible`: the `record:alert` pair. The + * renderer evaluates `visible` itself, so an author bringing the node + * spelling down into this bag is reaching for exactly that key. + */ +const ACTION_NODE_ALIASES = { type: 'actionType', visibleWhen: 'visible', visibility: 'visible' } as const; + +/** + * Read-but-not-authorable keys shared by `action:button` and `action:icon`, + * each read by the renderer and each refused here with what to write instead. + */ +const ACTION_NODE_GUIDANCE = { + enabled: '`enabled` is the renderer\'s legacy fallback for stored documents, not an authorable ' + + 'key: write `disabled` instead, with the condition inverted — `disabled` is the predicate ' + + 'that greys the action out when it evaluates TRUE.', + autoTrigger: '`autoTrigger` is a host transport flag, not metadata: a host sets it on a schema it ' + + 'composes at runtime to run the action once on mount (a deep link that asks for it). ' + + 'Authored into a page, it would run the action on every page load. Remove it.', +} as const; + +/** + * `action:button` — a button that runs one action through objectui's action + * runner (`components/src/renderers/action/action-button.tsx` at the pin). + * Read points, per key: + * + * - `name` — `:119` (the visibility-diagnostic label, `schema.name ?? + * schema.label`) and `:212` (forwarded). OPTIONAL, as it is read: an inline + * page button is not a registered object action, and the runner takes a + * nameless action on its `type` leg. `UIActionSchema` declares it required; + * the read does not. + * - `label` — `:383`, placed as a React child (so a literal string: an inline + * locale map is not resolved on this path; the translation bundle's + * `components..label` is the localization channel); also `:119`, `:216`. + * - `icon` — `:134`, through the shared `resolveIcon`. + * - `actionType` — `:211`, forwarded as the runner's `type`. + * - `variant` / `size` — `:137` / `:138`: `primary` → the primitive's + * `default` and `md` → `default`, everything else handed to `Button` as-is. + * - `visible` / `disabled` — `:117` + `:335` / `:130` + `:371`, evaluated + * against the row the host binds (`usePredicateRecordContext`). + * - `params` — `:176-179`. An array is the input list, forwarded as + * `actionParams`; the static values are read off `properties.params` + * itself (`readStaticParamValues`, `static-params.ts:91-101`). On a page + * node the two are one key: this row IS `properties`, and `SchemaRenderer`'s + * hoist makes `schema.params` the same object, so an array here is the input + * list and an object is the static values. (A node-level object `params` + * outside `properties` is ignored with a development warning.) + * - Forwarded to the runner (`:211-307`): `description`, `target`, `openIn`, + * `endpoint`, `method`, `bodyExtra`, `bodyShape`, `operation`, `patch`, + * `confirmText`, `successMessage`, `errorMessage`, `refreshAfter`, + * `undoable`, `recordIdField`, `locations`, `toast`, `resultDialog`, + * `onSuccess`, `objectName`. + * + * The registration's `inputs` (`:395-416`) publish seven of these twenty-nine + * keys; the other twenty-two are read and unpublished. It also publishes + * `className`, which is read but is a node key. + */ +export const ActionButtonPropsSchema = lazySchema(() => strictObject({ + surface: 'this `action:button`', + history: actionBlockHistory('action:button'), + guidanceSets: [COMPONENT_NODE_KEYS_GUIDANCE], + aliases: ACTION_NODE_ALIASES, + guidance: ACTION_NODE_GUIDANCE, +}, { + name: z.string().optional() + .describe('Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone'), + label: z.string().optional() + .describe('Button text. A literal string, placed as-is — localize through the translation bundle entry for this component id, not an inline locale map'), + icon: z.string().optional() + .describe('Lucide icon name drawn left of the label, resolved through the shared action-icon resolver (an unknown name draws no icon)'), + actionType: z.string().optional() + .describe('Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. Write it here, not as `type`: on a page component `type` is the component itself'), + variant: z.enum([...BUTTON_PRIMITIVE_VARIANTS, 'primary']).optional() + .describe('Button variant — the Button primitive\'s vocabulary, plus `primary`, which renders as `default` (renderer default: `default`)'), + size: z.enum([...BUTTON_PRIMITIVE_SIZES, 'md']).optional() + .describe('Button size — the Button primitive\'s vocabulary, plus `md`, which renders as `default` (renderer default: `default`)'), + visible: actionCondition().optional() + .describe('Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the button is not rendered when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible'), + disabled: actionCondition().optional() + .describe('Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the button is shown but cannot be pressed while it is TRUE. Omit for never-disabled'), + params: z.unknown().optional() + .describe('Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs; an object is forwarded as the static parameter values'), + description: z.string().optional() + .describe('Action description, forwarded to the runner — the parameter dialog shows it under its title'), + target: z.string().optional() + .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`'), + openIn: z.enum(['self', 'new-tab']).optional() + .describe('For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab'), + endpoint: z.string().optional().describe('API endpoint for an `api` action, forwarded to the runner'), + method: z.string().optional().describe('HTTP method for an `api` action, forwarded to the runner'), + bodyExtra: z.unknown().optional().describe('Static request-body fields for an `api` action, forwarded to the runner'), + bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'), + operation: z.unknown().optional().describe('Declarative single-record write, forwarded to the runner together with `patch`'), + patch: z.unknown().optional().describe('Field values the declarative `operation` writes, forwarded to the runner'), + confirmText: z.string().optional().describe('Confirmation question asked before the action runs'), + successMessage: z.string().optional().describe('Toast shown when the action succeeds'), + errorMessage: z.string().optional().describe('Toast shown when the action fails, in place of the raw error'), + refreshAfter: z.boolean().optional().describe('Refresh the surrounding data after the action runs'), + undoable: z.boolean().optional().describe('Offer an Undo affordance after an update action'), + recordIdField: z.string().optional().describe('Row field whose value identifies the record the action acts on'), + locations: z.array(ActionLocationSchema).optional() + .describe('Action locations, forwarded to the runner — the console uses them to tell a record-scoped action from an object-level one'), + toast: z.unknown().optional().describe('Toast behaviour, forwarded to the runner'), + resultDialog: z.unknown().optional().describe('One-shot result dialog for a value the response shows exactly once, forwarded to the runner'), + onSuccess: z.unknown().optional().describe('Declared post-success navigation, forwarded to the runner'), + objectName: z.string().optional() + .describe('Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page\'s object. Omit to act on the page\'s object'), +})); +/** Author state (ADR-0122: the bare name is the author state). */ +export type ActionButtonProps = z.input; +/** + * ADR-0122: the parsed state differs from the authored state on `visible` and `disabled` — the + * bare-string arm of the condition union normalizes to the canonical + * `{ dialect, source }` envelope (`EvaluatedExpressionInputSchema`'s transform). + */ +export type ActionButtonPropsParsed = z.infer; + +/** + * `action:icon` — an icon-only action button with a tooltip + * (`components/src/renderers/action/action-icon.tsx` at the pin). The same + * runner path as `action:button`, measured separately because the two differ: + * + * - `size` is NOT read — `:107` pins the primitive's `icon` size — so it is + * not declared (the registration does not publish it either). + * - `undoable` and `recordIdField` are NOT forwarded (`:134-196` lists the + * rest of `action:button`'s forward and not these two), so not declared. + * - `label` is read four times: `:152` (forwarded), `:265` (`aria-label`, + * falling back to `name`), `:274` (its first letter when no icon resolves) + * and `:280` / `:286` (the tooltip). `description` is the tooltip's + * fallback (`:286`) as well as forwarded (`:153`). + * - `visible` / `disabled` — `:97` + `:229` / `:102` + `:258`. `variant` — + * `:106`, `primary` mapped to `default`, renderer default `ghost`. + * - `params` — `:130-133`, routed exactly as on `action:button`. + * - Forwarded (`:147-195`): `actionType`, `name`, `target`, `openIn`, + * `endpoint`, `method`, `bodyExtra`, `bodyShape`, `operation`, `patch`, + * `confirmText`, `successMessage`, `errorMessage`, `refreshAfter`, + * `locations`, `toast`, `resultDialog`, `onSuccess`, `objectName`. + */ +export const ActionIconPropsSchema = lazySchema(() => strictObject({ + surface: 'this `action:icon`', + history: actionBlockHistory('action:icon'), + guidanceSets: [COMPONENT_NODE_KEYS_GUIDANCE], + aliases: ACTION_NODE_ALIASES, + guidance: { + ...ACTION_NODE_GUIDANCE, + size: '`action:icon` is always icon-sized — the renderer fixes the Button primitive\'s `icon` ' + + 'size and reads no `size`. Remove the key, or use `action:button` for a sized button.', + }, +}, { + name: z.string().optional() + .describe('Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button`'), + label: z.string().optional() + .describe('Accessible label and tooltip text (and its first letter stands in when no icon resolves). A literal string — localize through the translation bundle entry for this component id'), + icon: z.string().optional() + .describe('Lucide icon name, resolved through the shared action-icon resolver (renderer registration default `play`)'), + actionType: z.string().optional() + .describe('Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. Write it here, not as `type`'), + variant: z.enum([...BUTTON_PRIMITIVE_VARIANTS, 'primary']).optional() + .describe('Button variant — the Button primitive\'s vocabulary, plus `primary`, which renders as `default` (renderer default: `ghost`)'), + visible: actionCondition().optional() + .describe('Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the icon is not rendered when it is FALSE. Omit for always-visible'), + disabled: actionCondition().optional() + .describe('Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the icon is shown but cannot be pressed while it is TRUE. Omit for never-disabled'), + description: z.string().optional() + .describe('Action description — the tooltip text when there is no `label`, and forwarded to the runner for the parameter dialog'), + params: z.unknown().optional() + .describe('Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user; an object is forwarded as the static parameter values'), + target: z.string().optional() + .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`'), + openIn: z.enum(['self', 'new-tab']).optional() + .describe('For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab'), + endpoint: z.string().optional().describe('API endpoint for an `api` action, forwarded to the runner'), + method: z.string().optional().describe('HTTP method for an `api` action, forwarded to the runner'), + bodyExtra: z.unknown().optional().describe('Static request-body fields for an `api` action, forwarded to the runner'), + bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'), + operation: z.unknown().optional().describe('Declarative single-record write, forwarded to the runner together with `patch`'), + patch: z.unknown().optional().describe('Field values the declarative `operation` writes, forwarded to the runner'), + confirmText: z.string().optional().describe('Confirmation question asked before the action runs'), + successMessage: z.string().optional().describe('Toast shown when the action succeeds'), + errorMessage: z.string().optional().describe('Toast shown when the action fails, in place of the raw error'), + refreshAfter: z.boolean().optional().describe('Refresh the surrounding data after the action runs'), + locations: z.array(ActionLocationSchema).optional() + .describe('Action locations, forwarded to the runner — the console uses them to tell a record-scoped action from an object-level one'), + toast: z.unknown().optional().describe('Toast behaviour, forwarded to the runner'), + resultDialog: z.unknown().optional().describe('One-shot result dialog for a value the response shows exactly once, forwarded to the runner'), + onSuccess: z.unknown().optional().describe('Declared post-success navigation, forwarded to the runner'), + objectName: z.string().optional() + .describe('Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page\'s object. Omit to act on the page\'s object'), +})); +/** Author state (ADR-0122: the bare name is the author state). */ +export type ActionIconProps = z.input; +/** + * ADR-0122: the parsed state differs from the authored state on `visible` and `disabled` — the + * bare-string arm of the condition union normalizes to the canonical + * `{ dialect, source }` envelope (`EvaluatedExpressionInputSchema`'s transform). + */ +export type ActionIconPropsParsed = z.infer; + +/** + * The member list `action:group` and `action:menu` both read — a LIST, as the + * renderers read it (`schema.actions || []`, then `.filter` / `.map`), where + * both registrations publish the input as `type: 'object'`. + * + * Each member is an action object the container draws and runs ITSELF, never + * through `SchemaRenderer`, so a member is not a page component and this row + * does not judge its keys: the members' value contract is the runner's. What + * the containers read off a member, at the pin: `visible` / `disabled` / + * `enabled`, `icon`, `variant`, `className`, `label` (falling back to `name`), + * `tags` (a `separator-before` tag draws a divider), `name` (the React key) and + * the runner forward — which hands the runner the member's own `type`, not + * `actionType` (a member is an action entry, and an action entry's executor is + * `type`), its `objectName`, and its static values off the member's OWN + * `properties.params`, evaluated by the container + * (`readMemberStaticParamValues`, `static-params.ts:142-148`). A bare string + * is refused here: an action NAME list is `record:quick_actions`' + * `actionNames`, and a string member would render as an unlabeled button that + * runs nothing. + */ +const actionMemberList = () => z.array(z.record(z.string(), z.unknown())); + +/** + * `action:group` — a row or dropdown of actions + * (`components/src/renderers/action/action-group.tsx` at the pin). Read + * points, per key: + * + * - `actions` — `:248`, then filtered by `location` through `actionRendersAt` + * (`:249`); members are read at `:81-134` (inline), `:166-204` (dropdown) + * and forwarded at `:274-324` (static values `:274-280`, `objectName` + * `:323`). + * - `display` — `:346`, `inline` unless it is `dropdown`. + * - `label` / `icon` — `:365` / `:350`: the DROPDOWN trigger's text (default + * `Actions`) and icon. Inline mode renders neither. + * - `variant` — `:356` (dropdown trigger) and `:397` (each inline member's + * fallback); no `primary` mapping at group level, so the primitive's six. + * - `size` — `:357` maps `md` → `default` for the dropdown trigger, but `:398` + * hands the group size to each inline member raw, and `:91` maps only a + * member's OWN `md`. So `md` renders only in dropdown mode, and in the + * default inline mode reaches the Button primitive, which has no `md`. + * Declared: the primitive's four sizes. (The registration publishes + * `sm` / `md` / `lg`; `md` is objectui's to map on both paths or drop.) + * - `visible` — `:238` + `:343`. `:343` tests the raw value's truthiness, so a + * literal `false` is honoured by `SchemaRenderer`'s node gate, which also + * evaluates the hoisted value, rather than by this check. + * + * NOT read: the group's own `name`. The registration publishes it (`:415`), + * the renderer never reads `schema.name`, and inline mode only spreads it + * onto the wrapping `
` as a DOM attribute. Refused with a prescription, + * the `page:accordion` item `value` precedent: an author copying the + * designer's output is told what happened rather than merely refused. + */ +export const ActionGroupPropsSchema = lazySchema(() => strictObject({ + surface: 'this `action:group`', + history: actionBlockHistory('action:group'), + guidanceSets: [COMPONENT_NODE_KEYS_GUIDANCE], + aliases: { visibleWhen: 'visible', visibility: 'visible' }, + guidance: { + name: '`action:group` reads no group-level `name` — nothing renders, forwards or keys on it. ' + + 'Each member action\'s own `name` is what identifies it. Remove the key.', + }, +}, { + actions: actionMemberList().optional() + .describe('The actions in this group, in order — each an action object the group draws and runs itself (`name`, `label`, `icon`, `type`, `target`, `visible`, `disabled`, …); a member\'s executor is its `type`'), + display: z.enum(['inline', 'dropdown']).optional() + .describe('Display mode: `inline` renders every action as a button row; `dropdown` renders one trigger button and lists the actions in its menu (renderer default: `inline`)'), + location: ActionLocationSchema.optional() + .describe('Render only the members whose `locations` include this location. Omit to render every member'), + label: z.string().optional() + .describe('Dropdown trigger text (renderer default: `Actions`). Inline mode renders no group label. A literal string — localize through the translation bundle entry for this component id'), + icon: z.string().optional() + .describe('Lucide icon name on the dropdown trigger. Inline mode renders no group icon'), + variant: z.enum(BUTTON_PRIMITIVE_VARIANTS).optional() + .describe('Button variant for the dropdown trigger and for every inline member that sets none — the Button primitive\'s vocabulary (renderer default: `outline`)'), + size: z.enum(BUTTON_PRIMITIVE_SIZES).optional() + .describe('Button size for the dropdown trigger and for every inline member that sets none — the Button primitive\'s vocabulary'), + visible: actionCondition().optional() + .describe('Visibility predicate for the whole group — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds. Omit for always-visible'), +})); +/** Author state (ADR-0122: the bare name is the author state). */ +export type ActionGroupProps = z.input; +/** + * ADR-0122: the parsed state differs from the authored state on `visible` — the + * bare-string arm of the condition union normalizes to the canonical + * `{ dialect, source }` envelope (`EvaluatedExpressionInputSchema`'s transform). + */ +export type ActionGroupPropsParsed = z.infer; + +/** + * `action:menu` — a dropdown ("more") menu of actions + * (`components/src/renderers/action/action-menu.tsx` at the pin). Read + * points, per key: + * + * - `actions` — `:328`; members are read at `:80`, `:108-147` and `:389`, run + * through `ActionAutoTrigger` (`:349-356`), and forwarded at `:253-314` + * (static values `:253-259`, `objectName` `:313`). + * - `label` — `:370` (`aria-label`, default: the translated "More actions") + * and `:379-380` (trigger text; icon-only when omitted). + * - `icon` — `:229`, default the `MoreHorizontal` glyph. + * - `variant` / `size` — `:230` / `:231`, handed to the Button primitive + * unmapped (defaults `ghost` / `icon`): no `primary`, no `md` here. + * - `visible` — `:224` + `:322`, fail-closed; the same truthiness note as + * `action:group`'s applies to a literal `false`. + * + * The registration's `inputs` (`:410-419`) publish `label`, `icon`, + * `actions`, `variant` and `className`; `size` and `visible` are read and + * unpublished. + */ +export const ActionMenuPropsSchema = lazySchema(() => strictObject({ + surface: 'this `action:menu`', + history: actionBlockHistory('action:menu'), + guidanceSets: [COMPONENT_NODE_KEYS_GUIDANCE], + aliases: { visibleWhen: 'visible', visibility: 'visible' }, +}, { + actions: actionMemberList().optional() + .describe('The menu\'s actions, in order — each an action object the menu draws and runs itself (`name`, `label`, `icon`, `type`, `target`, `visible`, `disabled`, `tags`, …); a member\'s executor is its `type`'), + label: z.string().optional() + .describe('Trigger text and accessible label; omit for an icon-only trigger labelled "More actions". A literal string — localize through the translation bundle entry for this component id'), + icon: z.string().optional() + .describe('Lucide icon name on the trigger (renderer default: the horizontal ellipsis)'), + variant: z.enum(BUTTON_PRIMITIVE_VARIANTS).optional() + .describe('Trigger button variant — the Button primitive\'s vocabulary (renderer default: `ghost`)'), + size: z.enum(BUTTON_PRIMITIVE_SIZES).optional() + .describe('Trigger button size — the Button primitive\'s vocabulary (renderer default: `icon`)'), + visible: actionCondition().optional() + .describe('Visibility predicate for the whole menu — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; a predicate that fails to evaluate hides it. Omit for always-visible'), +})); +/** Author state (ADR-0122: the bare name is the author state). */ +export type ActionMenuProps = z.input; +/** + * ADR-0122: the parsed state differs from the authored state on `visible` — the + * bare-string arm of the condition union normalizes to the canonical + * `{ dialect, source }` envelope (`EvaluatedExpressionInputSchema`'s transform). + */ +export type ActionMenuPropsParsed = z.infer; + +/** + * `element:definition-list` — a compact key/value `
` + * (`components/src/renderers/basic/data-list.tsx` at the pin). Props are read + * through `readProps` (`:42-47`, `properties` first). Read points: `items` + * (`:48`), `columns` (`:49`), `inline` (`:63`), and per item `term` (`:66`) and + * `description` (`:68`). + * + * - `columns` is compared as the NUMBER `2` (`props.columns === 2`); any other + * value renders one column. Declared as the literal pair `1 | 2`, which is + * what the Studio designer writes (a `number` control, + * `previews/block-config.ts:307`). The registration's enum publishes the + * STRINGS `'1'` / `'2'` (`:82`), and the string `'2'` renders one column — + * the read wins. + * - `items` is optional, as it is read: absent and empty both render the + * renderer's own "No details" state (`:51-53`). The registration marks it + * required. + * - An item is strict. `term` is required — it is the row's only label, placed + * as a React child, so a literal string — and `description` takes any value + * (`toText` prints objects as JSON and an absent one as an em dash). The + * designer wrote `label` / `value` items until objectui#8279 and every row + * rendered blank; that is the mistake an item that refuses unknown keys + * stops at authoring time. + */ +export const ElementDefinitionListPropsSchema = lazySchema(() => strictObject({ + surface: 'this `element:definition-list`', + history: elementListHistory('element:definition-list'), + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { + items: z.array(strictObject({ + surface: 'this `element:definition-list` item', + history: elementListHistory('element:definition-list'), + // The objectui#8279 pair: the designer wrote `label` / `value` items, and + // every row rendered a blank term and an em dash. + aliases: { label: 'term', value: 'description' }, + }, { + term: z.string().describe('The term — rendered as the row\'s label, as-is'), + description: z.unknown().optional() + .describe('The value shown under (or beside) the term — a string or number as-is, an object as JSON; omitted renders an em dash'), + })).optional() + .describe('Term/description pairs, in order. Omitted or empty renders the "No details" empty state'), + columns: z.literal([1, 2], { + // The registration's own enum spells these as strings, so the string is + // the likeliest wrong value — and the one the renderer silently collapses + // to a single column. + error: (issue) => (issue.input === '1' || issue.input === '2' + ? `\`columns\` on this \`element:definition-list\` takes the NUMBER \`${String(issue.input)}\`, not ` + + `the string '${String(issue.input)}' — the renderer compares it to the number 2, so the string ` + + `renders a single column. Write \`columns: ${String(issue.input)}\`.` + : undefined), + }).optional() + .describe('Grid columns from the small breakpoint up — the NUMBER `1` or `2` (renderer default: 1)'), + inline: z.boolean().optional() + .describe('Put each term and its description on one baseline-aligned row instead of stacking them'), +})); +/** + * Author state (ADR-0122). No `XParsed`: the tree carries no default, transform, + * catch or pipe, so the two shapes coincide and the schema is pinned isomorphic in + * `type-alias-convention.pin.test.ts` instead. + */ +export type ElementDefinitionListProps = z.input; + +/** + * `element:repeater` — a data-bound, chrome-free list: one line per record + * (`components/src/renderers/basic/data-list.tsx` at the pin, props through + * `readProps`, `:97-107`). Read points: `object` (`:143`, `:155`), `filter` + * (`:120`, `:152` → `$filter`), `sort` (`:153` → `$orderby`), `limit` (`:154` → + * `$top`), `emptyText` (`:181`), `divided` (`:186`), `titleField` + * (`:191-192`) and `fields` (`:128`, `:194-196`). + * + * - `object` is REQUIRED, as `element:number`'s is: without it the renderer + * never queries and shows its "No records" state (`:143-146`, `:181`) — + * indistinguishable from an object that really has no rows. The + * registration marks it required too. + * - `filter` / `sort` are the family's one orthography from birth — + * `ViewFilterRule[]` and `SortItem[]` — and both are delivered: + * `ObjectStackAdapter.find` lowers a `{ field, operator, value }` array + * through `translateFilterArray` and serializes `{ field, order }` items + * through `serializeOrderBy` (`data-objectstack/src/index.ts:4782-4793`, + * `:760-786`). Before the query the renderer resolves the rules' context + * tokens (`{current_user_id}`, the date macros) through `useResolvedFilter` + * (`data-list.tsx:119-120`), so a rule's string `value` may be one. The + * rule-array door here is the bare one `record:related_list` declares, not a + * `ruleArrayFilterError` door: that prescription speaks to a door that used + * to take the record form, and a door wired to it joins the stored-row + * conversion's reach (`conversions/registry.ts`), which is not this row's to + * change. + * - `fields` takes a bare field name or `{ field }`. The renderer reads only + * `field` off the object form (`:196`) — the `label` its TS type and its + * registration's description both advertise is never rendered (the list has + * no header row), so it is refused with that reason. + * + * NOT read at the pin, whatever a sibling might suggest: the node-level + * `dataSource` binding. This renderer is not wrapped in objectui's + * element-data-source gate, so the query keys above are the only way to aim it. + */ +export const ElementRepeaterPropsSchema = lazySchema(() => strictObject({ + surface: 'this `element:repeater`', + history: elementListHistory('element:repeater'), + guidanceSets: COMPONENT_LEVEL_GUIDANCE, + // The same four query keys the element data-source binding declares, with + // its spellings for them (`ElementDataSourceSchema`, page.zod.ts), plus the + // `object-*` family's `objectName`. + aliases: { + objectName: 'object', filters: 'filter', where: 'filter', + orderBy: 'sort', sortBy: 'sort', top: 'limit', pageSize: 'limit', + }, +}, { + object: z.string() + .describe('Object whose records the list repeats over — required: without it the list never queries'), + titleField: z.string().optional() + .describe('Field shown first on each line, emphasized'), + fields: z.array(z.union([ + z.string(), + strictObject({ + surface: 'this `element:repeater` field', + history: elementListHistory('element:repeater'), + guidance: { + label: '`label` is not rendered — the repeater has no header row and prints only each ' + + 'field\'s value. Remove it, or write the bare field name.', + }, + }, { + field: z.string().describe('Field name'), + }), + ])).optional() + .describe('Fields shown after the title on each line, in order — a bare field name, or `{ field }`'), + filter: z.array(ViewFilterRuleSchema).optional() + .describe('Filter rules narrowing the records — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` in this map shares'), + sort: z.array(SortItemSchema).optional() + .describe('Sort order — `[{ field, order }]`'), + limit: z.number().int().positive().optional() + .describe('Maximum records fetched and shown'), + emptyText: z.string().optional() + .describe('Copy shown when the query returns no records (renderer default: "No records"). A literal string — localize through the translation bundle entry for this component id'), + divided: z.boolean().optional() + .describe('Draw a separator between lines (renderer default: true)'), +})); +/** Author state (ADR-0122: the bare name is the author state). */ +export type ElementRepeaterProps = z.input; +/** + * ADR-0122: the parsed state differs from the authored state on exactly one + * key — `filter` carries `ViewFilterRuleSchema`, whose `operator` is + * normalized on parse (why `ViewFilterRuleParsed` exists), the route + * `element:number` and `element:record_picker` took. + */ +export type ElementRepeaterPropsParsed = z.infer; + /** * ---------------------------------------------------------------------- * 5. Object-bound SDUI blocks (#7751, maintainer ruling 2026-08-12: direction A) @@ -4804,6 +5387,39 @@ export const ComponentPropsMap = { 'element:form': ElementFormPropsSchema, 'element:record_picker': ElementRecordPickerPropsSchema, 'element:text_input': ElementTextInputPropsSchema, + // #20371 — the two `element:*` lists in objectui's curated public + // vocabulary. Until these rows the pair sat inside the reserved `element:` + // namespace with no enum member and no row, so `component-type-unknown` + // refused both nodes outright. The rows are what admit them to the + // `element:` vocabulary (`component-type-vocabulary.ts` derives the known + // set from this map's keys), on the three-part evidence that vocabulary's + // string-arm ledger asks of a type admitted without an enum member — all + // measured at the pin this repo builds against (`.objectui-sha` = + // `dd3f7e1be`; re-measured there 2026-09-28): + // - registration: `@object-ui/components` registers both in the `element` + // namespace (`components/src/renderers/basic/data-list.tsx:75`, `:205`); + // - publication: both are `PUBLIC_BLOCKS` members + // (`core/src/registry/public-blocks.ts:117-118`), which is how the + // tracked `sdui.manifest.json` carries both; + // - authorship: the Studio page designer's palette offers both + // (`app-shell/src/views/metadata-admin/previews/block-types.ts:136-137`), + // each with its own inspector (`previews/block-config.ts:283-317`), so + // stored pages hold them. + 'element:definition-list': ElementDefinitionListPropsSchema, + 'element:repeater': ElementRepeaterPropsSchema, + + // Actions — #20371. The same curated vocabulary's four `action:*` blocks + // (`core/src/registry/public-blocks.ts:119-122` at the pin), registered by + // `@object-ui/components` in the `action` namespace + // (`components/src/renderers/action/`) and taught by objectui's own + // AGENTS.md as the node that runs an action. `action:` is not a namespace + // the enum populates, so these rows add no vocabulary claim; what they add + // is the props gate's dispatch, which skipped all four until now. Key sets + // measured from the renderers' read points — see section 4b above. + 'action:button': ActionButtonPropsSchema, + 'action:group': ActionGroupPropsSchema, + 'action:menu': ActionMenuPropsSchema, + 'action:icon': ActionIconPropsSchema, // Object-bound SDUI blocks (#7751, maintainer ruling 2026-08-12 direction A). // Key sets derived from the objectui renderers' own read points — see the