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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .changeset/sdui-component-props-enforce-or-remove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
"@objectstack/spec": major
---

refactor(spec)!: reconcile the SDUI component props with the renderers that serve them — 4 keys retired, 9 declared (#5775)

#5068 wired the first parse `ComponentPropsMap` ever had, and the corpus it
landed on diverged in **both** directions: keys objectui's renderers honour that
the schema never declared, and keys the schema declared — one of them
**required** — that no renderer has ever read. Maintainer ruling (2026-08-06),
direction A: the #5611 rule again, *the delivered and authorized shape is the
contract*.

The sharpest case is the record picker. It required `displayField`, which
appears in no renderer; `record-picker.tsx` resolves `props.labelField ?? 'name'`
and renders `row[labelField]`. So an author who followed the schema and wrote
`displayField: 'title'` got a dropdown listing `name`, with a success receipt and
no diagnostic anywhere — ADR-0078 exactly. Two spellings of one concept, of which
only the undeclared one was ever read.

**FROM → TO**

| was | now | fix |
|---|---|---|
| `element:record_picker` `displayField: string` (**required**) | `labelField?: string` | Rename the key; the value (a field name) is unchanged. Optional now — the renderer defaults to `name`. |
| `element:record_picker` `searchFields?: string[]` | *(removed)* | Delete the key. Use `filter` / `dataSource.filter` to restrict what the picker offers. |
| `element:record_picker` `multiple?: boolean` | *(removed)* | Delete the key. Multi-record selection is not implemented on this element. |
| `page:card` `body?: Component[]` | `children?: Component[]` | Rename the key; the value is unchanged. `footer` is a distinct slot and is untouched. |

`searchFields` and `multiple` go under ADR-0049 enforce-or-remove: the control is
a single-select `Select` with no search input, binding **one** record id into a
page variable — so `searchFields` narrowed nothing and `multiple: true` selected
nothing extra while reporting success. Either returns the day the capability is
implemented (#5021 / #4988 precedent); a declaration is not a roadmap.

Newly **declared**, because the renderers already honour them (nine keys, no
behaviour change — this is the schema catching up):
`element:record_picker` `labelField` / `valueField` / `label` / `emptyText`;
`record:path` `stages[].terminal` (`'won' | 'lost'`, honoured ahead of the
renderer's value/label token heuristic); `page:tabs` `items[].value` (the stable
`?tab=` URL token) and `items[].count`; `page:card` `children`; and `children` on
`page:section` / `page:footer` / `page:sidebar`, which were declared `EmptyProps`
— "zero props" — while all three renderers render a child list.

The retirement kit:

- Four `retiredKey()` tombstones in `ui/component.zod.ts`, each carrying its own
prescription. `ComponentPropsMap`'s entries STRIP, so a bare deletion would
have replaced one silent no-op with another; a tombstone types the key `never`
(tsc at the authoring site) and raises the prescription at parse time.
- **ADR-0087 D2 conversions + D3 chain step** —
`record-picker-display-field-to-label-field`, `record-picker-inert-keys-removed`,
`page-card-body-to-children`, all `retiredFromLoadPath`, so `os migrate meta`
rewrites sources and the loader stays loud. Region level is the reach, as for
`page-header-subtitle-alias`: `PageComponentSchema` declares no children key, so
a component nested inside another's `properties` is covered by the tombstone
rather than the walk.
- `RETIRED_KEYS_BY_MAJOR[17]` entries for all four keys; baselines
(`authorable-surface/ui.json`, `json-schema.manifest/ui.json`, `api-surface/ui.json`)
and reference docs regenerated.
- Pins both ways: the prescription is asserted per key, and a clean parse is
asserted not to materialize any of them.

Not in scope, deliberately: `page:card.visible` is a component-level visibility
predicate written into `properties` and hoisted by the renderer — a page to
rewrite onto the ADR-0089 `visibleWhen`, not a key to declare.

No runtime behaviour changes. The renderers already read the declared spelling of
every key, and the four retired ones never had an effect to lose.
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Protocol Reference
description: Every schema published by @objectstack/spec — 1610 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1611 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/. */}
Expand Down Expand Up @@ -32,8 +32,8 @@ counts are sums of the rows they head. Regenerate with
| [Shared Protocol](/docs/references/shared) | 8 | 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) | 37 | 295 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 17 | 155 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **201** | **1610** | 14 protocol modules |
| [UI Protocol](/docs/references/ui) | 17 | 156 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **201** | **1611** | 14 protocol modules |

---

Expand Down Expand Up @@ -366,7 +366,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a

## UI Protocol

**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **17 pages, 155 schemas**
**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **17 pages, 156 schemas**

Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer.

Expand All @@ -377,7 +377,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`, `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`, `PageAccordionProps`, `PageCardProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordPathProps`, `RecordRelatedListProps` |
| [`component.zod.ts`](/docs/references/ui/component) | `AIChatWindowProps`, `ElementButtonProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementTextInputProps`, `ElementTextProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordPathProps`, `RecordRelatedListProps` |
| [`dashboard.zod.ts`](/docs/references/ui/dashboard) | `Dashboard`, `DashboardHeader`, `DashboardHeaderAction`, `DashboardWidget`, `DashboardWidgetOptions`, `GlobalFilter`, `GlobalFilterOptionsFrom`, `WidgetActionType`, `WidgetColorVariant` |
| [`dataset.zod.ts`](/docs/references/ui/dataset) | `Dataset`, `DatasetDimension`, `DatasetMeasure`, `DerivedMeasureOp` |
| [`i18n.zod.ts`](/docs/references/ui/i18n) | `AriaProps`, `DateFormat`, `I18nLabel`, `I18nObject`, `LocaleConfig`, `NumberFormat`, `PluralRule` |
Expand Down
31 changes: 24 additions & 7 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ Empty Properties Schema
## TypeScript Usage

```typescript
import { AIChatWindowProps, ElementButtonPropsSchema, ElementFilterPropsSchema, ElementFormPropsSchema, ElementImagePropsSchema, ElementMetadataViewerPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, ElementTextInputPropsSchema, ElementTextPropsSchema, PageAccordionProps, PageCardProps, PageHeaderProps, PageTabsProps, RecordActivityProps, RecordChatterProps, RecordDetailsProps, RecordHighlightsField, RecordHighlightsProps, RecordPathProps, RecordRelatedListProps } from '@objectstack/spec/ui';
import { AIChatWindowProps, ElementButtonPropsSchema, ElementFilterPropsSchema, ElementFormPropsSchema, ElementImagePropsSchema, ElementMetadataViewerPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, ElementTextInputPropsSchema, ElementTextPropsSchema, PageAccordionProps, PageCardProps, PageContainerProps, PageHeaderProps, PageTabsProps, RecordActivityProps, RecordChatterProps, RecordDetailsProps, RecordHighlightsField, RecordHighlightsProps, RecordPathProps, RecordRelatedListProps } from '@objectstack/spec/ui';
import type { PageContainerProps } from '@objectstack/spec/ui';

// Validate data
const result = AIChatWindowProps.parse(data);
Expand Down Expand Up @@ -142,12 +143,16 @@ const result = AIChatWindowProps.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Object to pick records from |
| **displayField** | `string` | ✅ | Field to display as the record label |
| **searchFields** | `string[]` | optional | Fields to search against |
| **labelField** | `string` | optional | Field rendered as each row's text (default `name`) |
| **valueField** | `string` | optional | Field whose value is written into the bound page variable (default `id`) |
| **label** | `string` | optional | Control label rendered above the select |
| **filter** | `any` | optional | Filter criteria for available records |
| **multiple** | `boolean` | ✅ | Allow multiple record selection |
| **targetVariable** | `string` | optional | Page variable to bind selected record ID(s) |
| **placeholder** | `string` | optional | Placeholder text |
| **emptyText** | `string` | optional | Text shown when the query returns no records (default "No records") |
| **displayField** | `never` | optional | [REMOVED] `element:record_picker` property `displayField` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0087 D2) — it was a required declaration no renderer ever read, while the renderer honoured `labelField` for the same thing and defaulted to `name`. Rename the key to `labelField`; the value (a field name) is unchanged. Run `os migrate meta --from 16` to rewrite it automatically. |
| **searchFields** | `never` | optional | [REMOVED] `element:record_picker` property `searchFields` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0049) — the picker renders a plain single-select with no search input, so no renderer ever read it and it narrowed nothing. Delete the key. To restrict which records the picker offers, use `filter` (or the component-level `dataSource.filter`), which the query path does apply. Run `os migrate meta --from 16` to remove it automatically. |
| **multiple** | `never` | optional | [REMOVED] `element:record_picker` property `multiple` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0049) — the picker is a single-select `Select` and the bound page variable holds one record id, so `multiple: true` selected nothing extra and reported success. Delete the key; multi-record selection is not implemented on this element. Run `os migrate meta --from 16` to remove it automatically. |
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |


Expand Down Expand Up @@ -208,11 +213,23 @@ const result = AIChatWindowProps.parse(data);
| **title** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) |
| **bordered** | `boolean` | ✅ | |
| **actions** | `string[]` | optional | |
| **body** | `any[]` | optional | Card content components (slot) |
| **children** | `any[]` | optional | Card content components, in order (the card body slot) |
| **body** | `never` | optional | [REMOVED] `page:card` property `body` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0087 D2) — it was a second spelling of the composition slot every other container calls `children`, and the renderer reads both. Rename the key to `children`; the value (an array of child components) is unchanged. Run `os migrate meta --from 16` to rewrite it automatically. |
| **footer** | `any[]` | optional | Card footer components (slot) |
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |


---

## PageContainerProps

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **children** | `any[]` | optional | Child components rendered inside this container, in order |


---

## PageHeaderProps
Expand All @@ -239,7 +256,7 @@ const result = AIChatWindowProps.parse(data);
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'line' \| 'card' \| 'pill'>` | optional | |
| **position** | `Enum<'top' \| 'left'>` | optional | |
| **items** | `{ label: string; icon?: string; visibleWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; children: any[] }[]` | ✅ | |
| **items** | `{ label: string; icon?: string; visibleWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; value?: string; … }[]` | ✅ | |
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |


Expand Down Expand Up @@ -350,7 +367,7 @@ Type: `string`
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **statusField** | `string` | ✅ | Field name representing the current status/stage |
| **stages** | `{ value: string; label: string }[]` | optional | Explicit stage definitions (if not using field metadata) |
| **stages** | `{ value: string; label: string; terminal?: Enum<'won' \| 'lost'> }[]` | optional | Explicit stage definitions (if not using field metadata) |
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |


Expand Down
22 changes: 11 additions & 11 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
| Object sites in them | 456 |
| Still-open (strip) sites | 197 |
| Object sites in them | 457 |
| Still-open (strip) sites | 198 |
| Files carrying at least one | 30 |

Remaining strip sites by class:

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 42 |
| authorable — the ruling's forced scope | 43 |
| unresolved — needs a per-schema verdict | 33 |
| wire / open — out of forced scope | 107 |
| no door — no carrier, ADR-0049 territory | 14 |
Expand All @@ -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/` | 172 | 116 | 5 | 0 | 51 |
| `ui/` | 173 | 116 | 5 | 0 | 52 |
| `data/` | 162 | 54 | 1 | 0 | 107 |
| `automation/` | 75 | 49 | 0 | 0 | 26 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **456** | **253** | **6** | **0** | **197** |
| **total** | **457** | **253** | **6** | **0** | **198** |

## File-level triage — site counts

Expand All @@ -66,7 +66,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `app.zod.ts` | 18 |
| `bulk-action.zod.ts` | 3 |
| `chart.zod.ts` | 8 |
| `component.zod.ts` | 30 |
| `component.zod.ts` | 31 |
| `dashboard.zod.ts` | 11 |
| `dataset.zod.ts` | 4 |
| `i18n.zod.ts` | 6 |
Expand All @@ -77,7 +77,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `theme.zod.ts` | 6 |
| `view.zod.ts` | 53 |
| `widget.zod.ts` | 9 |
| **total** | **172** |
| **total** | **173** |

### `data/` — sites

Expand Down Expand Up @@ -157,22 +157,22 @@ over it is here.

### `ui/` — open

**51 strip of 172**, in 7 file(s).
**52 strip of 173**, in 7 file(s).

| File | Strip | Sites |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 18 |
| `chart.zod.ts` | 2 | 8 |
| `component.zod.ts` | 30 | 30 |
| `component.zod.ts` | 31 | 31 |
| `i18n.zod.ts` | 5 | 6 |
| `view.zod.ts` | 3 | 53 |
| `widget.zod.ts` | 9 | 9 |
| **total** | **51** | **172** |
| **total** | **52** | **173** |

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 33 |
| authorable — the ruling's forced scope | 34 |
| unresolved — needs a per-schema verdict | 0 |
| wire / open — out of forced scope | 3 |
| no door — no carrier, ADR-0049 territory | 14 |
Expand Down
Loading
Loading