diff --git a/.changeset/sdui-component-props-enforce-or-remove.md b/.changeset/sdui-component-props-enforce-or-remove.md new file mode 100644 index 0000000000..a4bd7b308d --- /dev/null +++ b/.changeset/sdui-component-props-enforce-or-remove.md @@ -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. diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index a8ac7b7839..59dbd1c111 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -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/. */} @@ -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 | --- @@ -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. @@ -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` | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index d2ad647b4b..940fe84480 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -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); @@ -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 | @@ -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 @@ -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 | @@ -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 | 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 b111f49fef..8b75e0e07c 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -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 | @@ -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 @@ -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 | @@ -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 @@ -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 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 1a69466e28..082f1a41dc 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -868,7 +868,7 @@ next person to open that file will look. | `widget.zod.ts` | **no door** | ⛔ **not strictness work** — the whole file measured unreachable from every authoring root (#4001 批 16), with no carrier key and zero parse in all three repos. ADR-0049 triage is **#5055**. See the triage row above, including why the campaign's own BFS said otherwise first (**#5056**) | | `chart.zod.ts` | **authorable** | **was `no gate` until #5020** (the cell carries one verdict on purpose — it is the machine-readable input to the generated subtotal, so the history lives here in the evidence). `ChartAggregateSchema` + `ChartGroupBySchema`'s object arm. Config / axis / series / annotation / interaction closed at 批 15; these two were held OUT of the ratchet as `no gate` — carrier live, no parse — because closing them would have gated nothing (#4583). **#5020 wired the parse, so the hold is over and these two are ordinary strictness work again.** `packages/lint/src/validate-react-page-props.ts` now calls `ChartAggregateSchema.safeParse()` on a static `aggregate={{…}}` literal, and the hand-derived `CHART_FUNCTIONS` list + count/field refinement twin are deleted. That is the path **#5022 demonstrated on one key** and this row was blocked on: `ChartDrillDownSchema` arrived with its gate already wired, parsing instead of re-deriving, while `aggregate` beside it did the opposite. ⚠️ **The flip is `no gate` → `authorable`, NOT → closed.** Both sites still STRIP: the parse the gate runs drops `groupby` / `dateGranularty` rather than reporting them, so the ADR-0078 failure mode survives until the posture changes. Converting the two object arms to `strictObject` is **#5583** (Blocked-by resolved; a sub-issue of #4001), which is also where the two `chart.test.ts` "still STRIPS — deliberate" pins invert and where the one product question lands — `groupBy` is declared REQUIRED here and in the published react-blocks type while the renderer honours its absence, so #5020's gate reports that single case at `warning` instead of gating a shape the platform delivers | | `i18n.zod.ts` | **split** · 5 no door | **批 16 closed the one real door**: `AriaPropsSchema` (`strictObject`, carried as `aria:` on ~30 shapes under six metadata-type roots — it was returning `aria: {}` for a legacy-spelled block). The 5 left are `I18nObject` / `PluralRule` / `NumberFormat` / `DateFormat` / `LocaleConfig`, all **no door** (#5055) — ⛔ **do not close them**. This row shrinks without disappearing, the third such in the ledger after `flow` (批 11) and `etl` (批 12): the reverse pin fires on ZERO, so a row parked at a deliberate floor looks exactly like a row nobody finished, and only the `Class` column separates them | -| `app.zod.ts` | covered | **批 19 ran the check and it came back NEGATIVE — no posture change; the `Class` was held at `verify` pending #5249 and is now `covered`, the verdict that ruling created (see below).** `BaseNavItemSchema`. The instruction here was to confirm the members' strictness was not already covering it before touching; it is, and the premise this row carried was wrong twice. (1) **The members do not `.extend()` the base — they spread `...BaseNavItemSchema.shape`.** That is a different mechanism, and the difference is the whole of finding 16: `.extend()` clones INHERIT the base's posture (which is how closing two `view` authoring schemas silently closed the Studio round-trip overlay), while a `...shape` spread copies the per-key schemas into a FRESH `z.object` whose posture is its own. Measured in both directions rather than read off the source, because *"closing the base closes the members"* and *"closing the base is a no-op"* are opposite claims: `strictBase.extend({…})` rejects an unknown key, `z.object({...strictBase.shape})` accepts it, `z.object({...openBase.shape}).strict()` rejects it. (2) **All nine branches already apply their own `.strict()`** with the curated `navItemUnknownKeyError` — asserted per branch through the real door (`AppSchema.navigation`, a `discriminatedUnion` on `type`), with a positive control (every base-contributed key, incl. `requiresService` which no branch declares itself, is ACCEPTED) and a negative control (an undeclared key is REJECTED) in the same run. The base is also module-private and has zero `.parse()` anywhere, so `.strict()` here would be a property of a parse that does not exist. Closing it is therefore a guaranteed no-op, and #4583 is explicit that a no-op closure is not neutral. ⚠️ **The open question was the VOCABULARY, not the measurement** — which is why 批 19 left the cell alone, since it is machine-read and a guess here would be published as a confident subtotal. The two-axis table above resolved carrier-absent + parse-absent to `no door`, whose prescribed follow-up is ADR-0049 retirement — and that prescription is *destructive* here: the vocabulary is fully ALIVE and fully GATED at nine consumers, so retiring the base would delete nine branches' shared keys. `no gate` is wrong for the mirror reason (the gate exists, at the members). `authorable` is the `FormFieldBaseSchema` precedent one row over in `view.zod.ts` — but that base really is `.extend()`ed, so closing it WOULD change behaviour, and calling this one `authorable` invites exactly the later sweep that "finishes the job" on a shape nothing parses. ✅ **RESOLVED at #5249 (maintainer ruling 2026-08-06, option A): the vocabulary grew a ninth verdict, `covered`, and this row is its first and — as of the sweep below — its ONLY instance.** The ruling took the same route 批 15 took for `no gate` rather than rounding to the nearest wrong answer, on the ground that the cell's readers are later agents and a verdict naming the wrong ACTION is amplified by whoever acts on it. The re-review the ruling required was run over all **197** strip sites in the five triaged directories, not just this file, and it is mechanical rather than a reading: `covered` requires the keys to reach consumers by `...X.shape` SPREAD (a spread lands them in a fresh `z.object` with its own posture, so the base is inert), whereas `.extend()`/`.merge()`/`.omit()` inherit posture and keep the base a real door. Exactly **one** of the 197 sites spreads — this one, into eight of the nine branches (`SeparatorNavItemSchema` declares its own two keys and spreads nothing, and is `.strict()` all the same). The three other module-private strip bases all resolve elsewhere and stay put: `view.zod.ts`'s `FormFieldBaseSchema` is `.extend()`ed at `:1475` → posture inherits → a real door → stays `authorable`; `query.zod.ts`'s `BaseQuerySchema` is `.extend()`ed at `:485` into `QuerySchema` → same → stays `open`; `component.zod.ts`'s `EmptyProps` is used as a VALUE under eleven `ComponentPropsMap` carrier keys → carrier present → not carrier-absent at all. The remaining ~50 sites are inline nested literals under a property, so they carry a carrier by construction and cannot be `covered`. Recorded in three places (the `BaseNavItemSchema` JSDoc + `app-strictness-batch19.test.ts` + this row); the pin includes a guard that fails if any branch ever stops rejecting unknown keys, which is the one change that would make this verdict need re-taking | +| `app.zod.ts` | covered | **批 19 ran the check and it came back NEGATIVE — no posture change; the `Class` was held at `verify` pending #5249 and is now `covered`, the verdict that ruling created (see below).** `BaseNavItemSchema`. The instruction here was to confirm the members' strictness was not already covering it before touching; it is, and the premise this row carried was wrong twice. (1) **The members do not `.extend()` the base — they spread `...BaseNavItemSchema.shape`.** That is a different mechanism, and the difference is the whole of finding 16: `.extend()` clones INHERIT the base's posture (which is how closing two `view` authoring schemas silently closed the Studio round-trip overlay), while a `...shape` spread copies the per-key schemas into a FRESH `z.object` whose posture is its own. Measured in both directions rather than read off the source, because *"closing the base closes the members"* and *"closing the base is a no-op"* are opposite claims: `strictBase.extend({…})` rejects an unknown key, `z.object({...strictBase.shape})` accepts it, `z.object({...openBase.shape}).strict()` rejects it. (2) **All nine branches already apply their own `.strict()`** with the curated `navItemUnknownKeyError` — asserted per branch through the real door (`AppSchema.navigation`, a `discriminatedUnion` on `type`), with a positive control (every base-contributed key, incl. `requiresService` which no branch declares itself, is ACCEPTED) and a negative control (an undeclared key is REJECTED) in the same run. The base is also module-private and has zero `.parse()` anywhere, so `.strict()` here would be a property of a parse that does not exist. Closing it is therefore a guaranteed no-op, and #4583 is explicit that a no-op closure is not neutral. ⚠️ **The open question was the VOCABULARY, not the measurement** — which is why 批 19 left the cell alone, since it is machine-read and a guess here would be published as a confident subtotal. The two-axis table above resolved carrier-absent + parse-absent to `no door`, whose prescribed follow-up is ADR-0049 retirement — and that prescription is *destructive* here: the vocabulary is fully ALIVE and fully GATED at nine consumers, so retiring the base would delete nine branches' shared keys. `no gate` is wrong for the mirror reason (the gate exists, at the members). `authorable` is the `FormFieldBaseSchema` precedent one row over in `view.zod.ts` — but that base really is `.extend()`ed, so closing it WOULD change behaviour, and calling this one `authorable` invites exactly the later sweep that "finishes the job" on a shape nothing parses. ✅ **RESOLVED at #5249 (maintainer ruling 2026-08-06, option A): the vocabulary grew a ninth verdict, `covered`, and this row is its first and — as of the sweep below — its ONLY instance.** The ruling took the same route 批 15 took for `no gate` rather than rounding to the nearest wrong answer, on the ground that the cell's readers are later agents and a verdict naming the wrong ACTION is amplified by whoever acts on it. The re-review the ruling required was run over all **197** strip sites in the five triaged directories, not just this file, and it is mechanical rather than a reading: `covered` requires the keys to reach consumers by `...X.shape` SPREAD (a spread lands them in a fresh `z.object` with its own posture, so the base is inert), whereas `.extend()`/`.merge()`/`.omit()` inherit posture and keep the base a real door. Exactly **one** of the 197 sites spreads — this one, into eight of the nine branches (`SeparatorNavItemSchema` declares its own two keys and spreads nothing, and is `.strict()` all the same). The three other module-private strip bases all resolve elsewhere and stay put: `view.zod.ts`'s `FormFieldBaseSchema` is `.extend()`ed at `:1475` → posture inherits → a real door → stays `authorable`; `query.zod.ts`'s `BaseQuerySchema` is `.extend()`ed at `:485` into `QuerySchema` → same → stays `open`; `component.zod.ts`'s `EmptyProps` is used as a VALUE under eight `ComponentPropsMap` carrier keys → carrier present → not carrier-absent at all (it was ten at the time of the #5249 sweep, and this cell said eleven; #5775 moved `page:section`/`page:footer`/`page:sidebar` off it onto the shared `PageContainerProps`, since all three renderers render a child list and "zero props" was the wrong declaration for a container. The count moves; the verdict does not). The remaining ~50 sites are inline nested literals under a property, so they carry a carrier by construction and cannot be `covered`. Recorded in three places (the `BaseNavItemSchema` JSDoc + `app-strictness-batch19.test.ts` + this row); the pin includes a guard that fails if any branch ever stops rejecting unknown keys, which is the one change that would make this verdict need re-taking | | `action-params.zod.ts` | wire | **out of scope** — `ActionSessionSchema`, the action-body `ctx.session` the runtime hands a body (#5697). Tolerant on purpose, same disposition as `data/hook.zod.ts`'s `HookContextSchema`. What this surface needed was never a closed door but a gate that RUNS: its consistency with the real producer is pinned in `packages/runtime/src/action-session-shape-contract.test.ts`, which asserts that a non-strict parse of the built object returns it UNCHANGED — so a key the builder starts producing without declaring it here is stripped, and the pin goes red | `sharing.zod.ts` and `notification.zod.ts` left this table at **#5015** by a route no other row has taken: not by being CLOSED, but by having their remaining sites REMOVED. Both were `no door` — ADR-0049 territory, explicitly out of this ratchet's scope — and the enforce-or-remove call came back REMOVE, so `EmbedConfigSchema` and `NotificationActionSchema` are gone rather than strict. Read the reverse pin carefully here, because it fires on zero either way and cannot tell the two routes apart: the `sharing.zod.ts` row said in as many words that it *"shrinks without disappearing — the first `no door` floor"*, and that was true right up until the floor was retired out from under it. A deliberate floor and a retired one look identical from the count; only the `Class` column and this paragraph separate them. `sharing.zod.ts` keeps its TRIAGE row above, because `SharingConfigSchema` is still there and still strict — the file is closed, not empty. `notification.zod.ts` keeps no row anywhere: it has zero object sites left. diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 2bc5afcee8..0644b8d5fe 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -214,6 +214,8 @@ Finally it retires the two inert `IndexSchema` keys, `indexes[].type` and `index It also retires the field-mapping `transform` key and the whole five-member `FieldMappingTransform` union behind it (#5552): `constant` / `cast` / `lookup` / `javascript` / `map`, declared on `shared/FieldMapping` and inherited by `integration/ConnectorFieldMapping` and `data/ExternalFieldMapping`. Nothing ever executed one. `fieldMappings` is spelled only inside `packages/spec` itself — the connector packages, the automation engine, REST and objectui never read it, and no code anywhere switches on `transform.type` — so all five members were declared-but-unenforced together, not just the one that got the bug filed. That one is the sharpest evidence though: `javascript`'s `.describe()` recommended the dialect `js`, which `ExpressionDialect` retired at #3278 (ADR-0058 addendum), so the envelope the documentation taught was rejected by the enum; the only spelling that parsed was the bare string, which `ExpressionInputSchema` wraps as `cel`; and the CEL that resulted could not evaluate the `value.toUpperCase()` the same line offered as its example. Three surfaces disagreeing about a capability with no implementation under any of them. Fixing the sentence alone was rejected (maintainer, 2026-08-06) as gilding a member that cannot run. The key is tombstoned rather than deleted because the schema and both extenders are plain `z.object`s and `ConnectorSchema.parse` is a live receiver, so a bare deletion would strip silently. What is NOT affected, despite the shared word: the import mapping's `mapping.fieldMapping[].transform`, a flat string enum applied row by row by the REST import path and live in the liveness ledger — including its own `javascript` value, which that path rejects with a 400 rather than pretending to run. +Last, it reconciles the SDUI component-props surface with the renderers that serve it (#5775). #5068 wired the first parse `ComponentPropsMap` ever had, and the corpus it landed on diverged in BOTH directions: keys objectui honours that the schema never declared, and keys the schema declared — one of them REQUIRED — that no renderer reads. The maintainer ruled direction A (2026-08-06), the #5611 rule again: the delivered and authorized shape is the contract. So the honoured keys are declared (`element:record_picker` `labelField`/`valueField`/`label`/`emptyText`, `record:path` `stages[].terminal`, `page:tabs` `items[].value`/`items[].count`, `page:card` `children`, and `children` on `page:section`/`page:footer`/`page:sidebar`, which were declared `EmptyProps` while their renderers rendered a child list), and four keys retire. Two are synonym renames: `element:record_picker.displayField` → `labelField` (the required key no renderer read, while `labelField ?? 'name'` is what actually renders the row — so an author who followed the schema got a picker listing `name` with no diagnostic, the ADR-0078 shape), and `page:card.body` → `children` (one composition key across every container; the card renderer already reads both, and the showcase authors `children`). Two are enforce-or-remove deletions: `element:record_picker.searchFields` and `.multiple` — the control is a shadcn single-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). Not in scope, and 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. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -263,6 +265,9 @@ It also retires the field-mapping `transform` key and the whole five-member `Fie | `theme-inert-token-scales-removed` | `theme.typography.fontSize / theme.typography.fontWeight / theme.typography.lineHeight / theme.typography.letterSpacing / theme.typography.fontFamily.heading / theme.typography.fontFamily.mono / theme.animation / theme.zIndex` | theme keys 'typography.fontSize'/'fontWeight'/'lineHeight'/'letterSpacing', 'typography.fontFamily.heading'/'mono', 'animation' and 'zIndex' removed (#5021, ADR-0049 — the engine emitted --font-size-*, --font-weight-*, --line-height-*, --letter-spacing-*, --duration-*, --timing-*, --z-*, --font-heading and --font-mono faithfully, and no first-party component or stylesheet has ever read one. Re-declare any variable you actually consume under customVars, which emits it verbatim) | retired — `migrate meta` only | | `page-header-subtitle-alias` | `page.component.page-header.description` | page-header component prop 'description' → 'subtitle' (objectui#3226 — the `subtitle ?? description` fallback retires) | live — protocol 17 loader accepts the old shape | | `object-index-type-partial-removed` | `object.indexes[].type / object.indexes[].partial` | object index keys 'indexes[].type'/'indexes[].partial' removed (#5248, #4943 — no driver ever read either: the index method is the dialect's choice and a partial index is built by a database-layer migration, not declared) | retired — `migrate meta` only | +| `record-picker-display-field-to-label-field` | `page.component.element:record_picker.displayField` | record-picker component prop 'displayField' → 'labelField' (#5775 — the required key no renderer read; `labelField ?? 'name'` is what renders the row) | retired — `migrate meta` only | +| `record-picker-inert-keys-removed` | `page.component.element:record_picker.searchFields / page.component.element:record_picker.multiple` | record-picker component props 'searchFields'/'multiple' removed (#5775 — the control is a plain single-select with no search box; neither key had a reader) | retired — `migrate meta` only | +| `page-card-body-to-children` | `page.component.page:card.body` | page:card component prop 'body' → 'children' (#5775 — one composition key across every container; the card renderer already reads both) | retired — `migrate meta` only | ### Semantic (delegated to you, with acceptance criteria) diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 24c89d4926..40edf4c03c 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -481,6 +481,10 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // `displayField` (#5775) — so gating today would fail the platform's own pages // to enforce declarations the platform does not keep. The error upgrade is a // separate step, once the warning-period inventory is empty. + // + // #5775 has since settled its half: `displayField` is retired in favour of the + // `labelField` the renderer actually reads, and the rest of the keys the + // renderers honour are declared. #5728 and two page rewrites are what remain. { name: 'validateComponentProps', tier: 'advisory', diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index f63e2cc87d..4a3ad3b857 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -183,7 +183,7 @@ describe('validateComponentProps — value verdicts', () => { type: 'element:record_picker', id: 'picker', dataSource: { object: 'project', limit: 50 }, - properties: { displayField: 'name' }, + properties: { labelField: 'name' }, }, ]), ); @@ -192,13 +192,83 @@ describe('validateComponentProps — value verdicts', () => { // …and still reports it when nothing supplies it (or the suppression above // would be indistinguishable from the rule never looking). const without = validateComponentProps( - stackWith([{ type: 'element:record_picker', properties: { displayField: 'name' } }]), + stackWith([{ type: 'element:record_picker', properties: { labelField: 'name' } }]), ); expect(invalid(without).map((f) => f.path)).toEqual([ 'pages[0].regions[0].components[0].properties.object', ]); }); + /** + * #5775 — the retirement's author-facing channel. `displayField` was the + * picker's REQUIRED prop and no renderer ever read it, so the tombstone's + * prescription has to reach whoever is still writing it. It arrives through + * the `safeParse` half of this rule (a `never` type is a value verdict, not + * an unknown key), carrying the rename verbatim. + */ + it('surfaces the retired `displayField` prescription (#5775)', () => { + const findings = validateComponentProps( + stackWith([ + { + type: 'element:record_picker', + properties: { object: 'project', displayField: 'name' }, + }, + ]), + ); + const retired = invalid(findings).filter((f) => f.path.endsWith('.displayField')); + expect(retired).toHaveLength(1); + expect(retired[0].severity).toBe('warning'); + expect(retired[0].message).toMatch(/removed in @objectstack\/spec 17\.0\.0[\s\S]*`labelField`/); + }); + + /** + * The showcase page that made #5775 necessary: it authors the shape the + * renderer serves, and the gate used to report it twice over — an undeclared + * `labelField`/`label` and a missing required `displayField`. It is clean now. + */ + it('reports nothing on the showcase picker (page-variables.page.ts:59)', () => { + const findings = validateComponentProps( + stackWith([ + { + type: 'element:record_picker', + id: 'project_picker', + dataSource: { object: 'showcase_project', limit: 50 }, + properties: { label: 'Project', labelField: 'name', placeholder: 'Choose a project…' }, + }, + ]), + ); + expect(findings).toEqual([]); + }); + + /** + * The container half of #5775. `page:card` `children` and the three thin + * containers' `children` were all reported as unknown keys while the + * renderers rendered exactly them; `record:path` `stages[].terminal` and the + * tab items' `value`/`count` are the same shape one level down. + */ + it('reports nothing on the container/child keys the renderers honour (#5775)', () => { + const findings = validateComponentProps( + stackWith([ + { type: 'page:card', properties: { title: 'Shortcuts', children: [{ type: 'element:text' }] } }, + { type: 'page:section', properties: { children: [{ type: 'element:text' }] } }, + { type: 'page:footer', properties: { children: [{ type: 'element:text' }] } }, + { type: 'page:sidebar', properties: { children: [{ type: 'element:text' }] } }, + { + type: 'page:tabs', + properties: { items: [{ label: 'Tasks', value: 'related:task', count: 3, children: [] }] }, + }, + { + type: 'record:path', + properties: { + statusField: 'status', + stages: [{ value: 'done', label: 'Done', terminal: 'won' }], + }, + }, + ]), + ); + expect(findings).toEqual([]); + }); + /** * The routing that keeps this gate whole across a future `strictObject` * batch. `AriaPropsSchema` is the one CLOSED shape inside these props (#4001 diff --git a/packages/lint/src/validate-component-props.ts b/packages/lint/src/validate-component-props.ts index 37c639ef32..a271967e83 100644 --- a/packages/lint/src/validate-component-props.ts +++ b/packages/lint/src/validate-component-props.ts @@ -55,13 +55,19 @@ * violations that are open contract questions, not authoring mistakes: * `I18nLabelSchema` is a plain `z.string()` while three published platform * pages author inline `{ en, 'zh-CN', … }` maps that objectui resolves - * (#5728), and the record picker declares a required `displayField` that no + * (#5728), and the record picker declared a required `displayField` that no * renderer reads while honouring an undeclared `labelField` (#5775). Gating * those would fail the platform's own pages to enforce declarations the * platform does not itself keep. So every finding here is advisory, the * warning-period inventory is the acceptance baseline for the error upgrade, * and the upgrade is its own step once the inventory is empty. * + * #5775 has since settled the spec side of that inventory — the keys the + * renderers honour are declared, and the four nothing read are tombstoned with + * ADR-0087 conversions. What stands between this rule and `error` is now #5728 + * and two page rewrites (`page:card.visible` → the ADR-0089 component-level + * `visibleWhen`; #5776's tab `key` → `value`), not the props map. + * * ## Unregistered types are SKIPPED — a required semantic, not leniency * * `PageComponentSchema.type` is `z.union([PageComponentType, z.string()])`, an diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index d9ec6b7997..397805d580 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -241,6 +241,7 @@ "PageComponentParsed (type)", "PageComponentSchema (const)", "PageComponentType (const)", + "PageContainerProps (type)", "PageHeaderProps (const)", "PageInput (type)", "PageNavItem (type)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 19654b61ac..bf138ee905 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -412,13 +412,17 @@ "ui/ElementNumberProps:prefix", "ui/ElementNumberProps:suffix", "ui/ElementRecordPickerProps:aria", - "ui/ElementRecordPickerProps:displayField", + "ui/ElementRecordPickerProps:displayField [RETIRED]", + "ui/ElementRecordPickerProps:emptyText", "ui/ElementRecordPickerProps:filter", - "ui/ElementRecordPickerProps:multiple", + "ui/ElementRecordPickerProps:label", + "ui/ElementRecordPickerProps:labelField", + "ui/ElementRecordPickerProps:multiple [RETIRED]", "ui/ElementRecordPickerProps:object", "ui/ElementRecordPickerProps:placeholder", - "ui/ElementRecordPickerProps:searchFields", + "ui/ElementRecordPickerProps:searchFields [RETIRED]", "ui/ElementRecordPickerProps:targetVariable", + "ui/ElementRecordPickerProps:valueField", "ui/ElementTextInputProps:aria", "ui/ElementTextInputProps:defaultValue", "ui/ElementTextInputProps:description", @@ -787,8 +791,9 @@ "ui/PageAccordionProps:items", "ui/PageCardProps:actions", "ui/PageCardProps:aria", - "ui/PageCardProps:body", + "ui/PageCardProps:body [RETIRED]", "ui/PageCardProps:bordered", + "ui/PageCardProps:children", "ui/PageCardProps:footer", "ui/PageCardProps:title", "ui/PageComponent:aria", @@ -804,6 +809,7 @@ "ui/PageComponent:type", "ui/PageComponent:visibility", "ui/PageComponent:visibleWhen", + "ui/PageContainerProps:children", "ui/PageHeaderProps:actions", "ui/PageHeaderProps:aria", "ui/PageHeaderProps:breadcrumb", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index 7aeec6b5ca..8b9ac9a046 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -102,6 +102,7 @@ "ui/PageCardProps", "ui/PageComponent", "ui/PageComponentType", + "ui/PageContainerProps", "ui/PageHeaderProps", "ui/PageNavItem", "ui/PageRegion", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 85e5effe4a..3bb2ce508f 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -337,6 +337,24 @@ "to": "object index keys 'indexes[].type'/'indexes[].partial' removed (#5248, #4943 — no driver ever read either: the index method is the dialect's choice and a partial index is built by a database-layer migration, not declared)", "conversionId": "object-index-type-partial-removed", "toMajor": 17 + }, + { + "surface": "page.component.element:record_picker.displayField", + "to": "record-picker component prop 'displayField' → 'labelField' (#5775 — the required key no renderer read; `labelField ?? 'name'` is what renders the row)", + "conversionId": "record-picker-display-field-to-label-field", + "toMajor": 17 + }, + { + "surface": "page.component.element:record_picker.searchFields / page.component.element:record_picker.multiple", + "to": "record-picker component props 'searchFields'/'multiple' removed (#5775 — the control is a plain single-select with no search box; neither key had a reader)", + "conversionId": "record-picker-inert-keys-removed", + "toMajor": 17 + }, + { + "surface": "page.component.page:card.body", + "to": "page:card component prop 'body' → 'children' (#5775 — one composition key across every container; the card renderer already reads both)", + "conversionId": "page-card-body-to-children", + "toMajor": 17 } ], "migrated": [ @@ -1078,6 +1096,24 @@ "to": "object index keys 'indexes[].type'/'indexes[].partial' removed (#5248, #4943 — no driver ever read either: the index method is the dialect's choice and a partial index is built by a database-layer migration, not declared)", "conversionId": "object-index-type-partial-removed", "toMajor": 17 + }, + { + "surface": "page.component.element:record_picker.displayField", + "to": "record-picker component prop 'displayField' → 'labelField' (#5775 — the required key no renderer read; `labelField ?? 'name'` is what renders the row)", + "conversionId": "record-picker-display-field-to-label-field", + "toMajor": 17 + }, + { + "surface": "page.component.element:record_picker.searchFields / page.component.element:record_picker.multiple", + "to": "record-picker component props 'searchFields'/'multiple' removed (#5775 — the control is a plain single-select with no search box; neither key had a reader)", + "conversionId": "record-picker-inert-keys-removed", + "toMajor": 17 + }, + { + "surface": "page.component.page:card.body", + "to": "page:card component prop 'body' → 'children' (#5775 — one composition key across every container; the card renderer already reads both)", + "conversionId": "page-card-body-to-children", + "toMajor": 17 } ], "migrated": [ diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 9a921eaccd..2b0581262d 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -4519,6 +4519,276 @@ const pageHeaderSubtitleAlias: MetadataConversion = { }, }; +/** + * The SDUI component-props reconciliation (protocol 17, #5775) — three entries + * below, one shared reason. + * + * #5068 wired the first parse `ComponentPropsMap` ever had and measured what + * the corpus actually authors against it. The measurement came back with + * divergence 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. The maintainer's 2026-08-06 ruling took + * direction A — the #5611 rule, "the delivered and authorized shape is the + * contract" — so the honoured keys were declared and the unread ones retire + * here. + * + * **Region level is the reach, deliberately.** {@link mapPageComponents} walks + * `pages[].regions[].components[]` and stops: `PageComponentSchema` declares no + * children key, so a picker nested inside a card's `children` sits in another + * component's free-form `properties` and is not typed page-component shape. + * Same boundary as {@link pageHeaderSubtitleAlias}, drawn for the same reason. + * The tombstones are what cover the rest: they type the key `never`, so a + * nested authoring site fails `tsc` and carries its own prescription at parse + * time whether or not a conversion could reach it. + * + * All three are **retired from the load path**: each key is tombstoned in + * `ui/component.zod.ts`, so the loader rejects it loudly with the prescription + * and only `os migrate meta` rewrites sources. + */ +const RECORD_PICKER_COMPONENT_TYPE = 'element:record_picker'; + +/** + * `element:record_picker.displayField` → `labelField` (protocol 17, #5775). + * + * Two spellings of one concept — "which field is the row's text" — of which the + * schema required the one nobody reads. `record-picker.tsx` resolves + * `props.labelField ?? 'name'` and renders `row[labelField]`; `displayField` + * appears nowhere in that renderer, and objectui's own component registry + * publishes `labelField` as the designer input. So an author who followed the + * schema and wrote `displayField: 'title'` got a picker listing `name`, with a + * success receipt and no diagnostic anywhere — the ADR-0078 shape. + * + * A rename rather than a deletion because the two keys are synonyms: the value + * (a field name) is exactly what `labelField` wants. Precedence is + * {@link renameKey}'s house rule (#4923) and nothing new — a redundant twin is + * dropped, a DISAGREEING pair is left for the author to reconcile rather than + * the loader picking a field. + */ +const recordPickerDisplayFieldToLabelField: MetadataConversion = { + id: 'record-picker-display-field-to-label-field', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'page.component.element:record_picker.displayField', + summary: + "record-picker component prop 'displayField' → 'labelField' (#5775 — the required key no renderer read; `labelField ?? 'name'` is what renders the row)", + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + if (component.type !== RECORD_PICKER_COMPONENT_TYPE) return component; + const properties = component.properties; + if (!isDict(properties)) return component; + const renamed = renameKey(properties, 'displayField', 'labelField'); + if (!renamed) return component; + emit({ from: 'displayField', to: 'labelField', path: `${path}.properties.labelField` }); + return { ...component, properties: renamed }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'showcase_page_variables', + regions: [ + { + name: 'main', + components: [ + { type: 'element:record_picker', properties: { object: 'showcase_project', displayField: 'title' } }, + // Both spellings, SAME value: the redundant twin goes (#4923). + { type: 'element:record_picker', properties: { object: 'a', labelField: 'name', displayField: 'name' } }, + // Both spellings, DIFFERENT fields: kept, so the author reconciles + // the two rather than the loader picking a column. + { type: 'element:record_picker', properties: { object: 'b', labelField: 'name', displayField: 'title' } }, + // `displayField` is a live LOOKUP-FIELD key elsewhere on the + // surface — a different component's business, untouched here. + { type: 'element:form', properties: { object: 'c', displayField: 'title' } }, + ], + }, + ], + }, + ], + }, + after: { + pages: [ + { + name: 'showcase_page_variables', + regions: [ + { + name: 'main', + components: [ + { type: 'element:record_picker', properties: { object: 'showcase_project', labelField: 'title' } }, + { type: 'element:record_picker', properties: { object: 'a', labelField: 'name' } }, + { type: 'element:record_picker', properties: { object: 'b', labelField: 'name', displayField: 'title' } }, + { type: 'element:form', properties: { object: 'c', displayField: 'title' } }, + ], + }, + ], + }, + ], + }, + expectedNotices: 2, + }, +}; + +/** + * `element:record_picker.searchFields` / `.multiple` — declared capabilities the + * control does not have (protocol 17, #5775, ADR-0049). + * + * The renderer is a shadcn `Select` over a `find()` result: no search input + * exists, so `searchFields` narrowed nothing, and the control is single-choice + * writing ONE id into the bound page variable, so `multiple: true` selected + * nothing extra while reporting success. Neither key has a reader anywhere in + * objectui. Enforce-or-remove: they are removed, not deprecated, and either + * returns the day the capability is implemented (#5021 / #4988 precedent). + * + * Pure lossless deletes — neither key ever had an effect to lose. + */ +const recordPickerInertKeysRemoved: MetadataConversion = { + id: 'record-picker-inert-keys-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'page.component.element:record_picker.searchFields / page.component.element:record_picker.multiple', + summary: + "record-picker component props 'searchFields'/'multiple' removed (#5775 — the control is a plain single-select with no search box; neither key had a reader)", + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + if (component.type !== RECORD_PICKER_COMPONENT_TYPE) return component; + const properties = component.properties; + if (!isDict(properties)) return component; + const stripped = stripKeys(properties, ['searchFields', 'multiple'], emit, `${path}.properties`); + if (stripped === properties) return component; + return { ...component, properties: stripped }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'picker_gallery', + regions: [ + { + name: 'main', + components: [ + { + type: 'element:record_picker', + properties: { object: 'showcase_project', searchFields: ['name', 'code'], multiple: true }, + }, + // `multiple` is a live FIELD key (lookup fields) — a different + // surface entirely, and not this entry's business. + { type: 'element:form', properties: { object: 'a', multiple: true } }, + ], + }, + ], + }, + ], + }, + after: { + pages: [ + { + name: 'picker_gallery', + regions: [ + { + name: 'main', + components: [ + { type: 'element:record_picker', properties: { object: 'showcase_project' } }, + { type: 'element:form', properties: { object: 'a', multiple: true } }, + ], + }, + ], + }, + ], + }, + expectedNotices: 2, + }, +}; + +/** + * `page:card.body` → `children` (protocol 17, #5775). + * + * Every container on this surface composes through `children` — `grid`, + * `flex`, `page:section`, `page:accordion` items, `page:tabs` items — and the + * card renderer reads `schema.body ?? schema.children` with its own comment + * saying authors expect `children` to work here too. The showcase's two cards + * author `children`. Only the declaration said `body`, which made the #5068 + * gate report the showcase's own correct pages as authoring an unknown key. + * + * Converging on `children` rather than declaring both: one composition key, not + * two de-facto contracts (Prime Directive #12). `footer` is a genuinely + * distinct slot and is untouched. The renderer keeps its `body ??` fallback for + * stored documents — that is objectui's to retire on its own schedule, exactly + * as {@link pageHeaderSubtitleAlias} left the kebab `page-header` registration + * alone. + */ +const pageCardBodyToChildren: MetadataConversion = { + id: 'page-card-body-to-children', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'page.component.page:card.body', + summary: + "page:card component prop 'body' → 'children' (#5775 — one composition key across every container; the card renderer already reads both)", + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + if (component.type !== 'page:card') return component; + const properties = component.properties; + if (!isDict(properties)) return component; + const renamed = renameKey(properties, 'body', 'children'); + if (!renamed) return component; + emit({ from: 'body', to: 'children', path: `${path}.properties.children` }); + return { ...component, properties: renamed }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'my_work', + regions: [ + { + name: 'sidebar', + components: [ + { + type: 'page:card', + properties: { title: 'Shortcuts', body: [{ type: 'element:text' }], footer: [{ type: 'element:text' }] }, + }, + // Both spellings, DIFFERENT content: kept, so the author picks + // which body the card should have. + { + type: 'page:card', + properties: { children: [{ type: 'element:text' }], body: [{ type: 'element:image' }] }, + }, + // `body` on a component that is not a card — not this entry's key. + { type: 'record:alert', properties: { body: 'Confirm the work before marking it done.' } }, + ], + }, + ], + }, + ], + }, + after: { + pages: [ + { + name: 'my_work', + regions: [ + { + name: 'sidebar', + components: [ + { + type: 'page:card', + properties: { title: 'Shortcuts', children: [{ type: 'element:text' }], footer: [{ type: 'element:text' }] }, + }, + { + type: 'page:card', + properties: { children: [{ type: 'element:text' }], body: [{ type: 'element:image' }] }, + }, + { type: 'record:alert', properties: { body: 'Confirm the work before marking it done.' } }, + ], + }, + ], + }, + ], + }, + expectedNotices: 1, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -4572,6 +4842,9 @@ export const CONVERSIONS_BY_MAJOR: Readonly> 'data/ExternalFieldMapping:transform', 'integration/ConnectorFieldMapping:transform', 'shared/FieldMapping:transform', + // #5775 — the SDUI component-props reconciliation. Three keys on the record + // picker (`displayField` was the REQUIRED one, and the synonym of the key + // the renderer actually reads) and the card's second spelling of the + // composition slot every other container calls `children`. + 'ui/ElementRecordPickerProps:displayField', + 'ui/ElementRecordPickerProps:multiple', + 'ui/ElementRecordPickerProps:searchFields', + 'ui/PageCardProps:body', ], }; diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index f1b1ebf1ab..9e1ad30341 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -241,6 +241,12 @@ import type * as M165 from './ui/responsive.zod.js'; import type * as M166 from './ui/theme.zod.js'; import type * as M167 from './ui/view.zod.js'; import type * as M168 from './ui/widget.zod.js'; +// Appended out of alphabetical order deliberately: the M-indices are positional +// identifiers the pin lines below reference by number, so a new module takes the +// next free index rather than renumbering 169 imports and every pin that names +// one. #5775 is the first entry from this file — `component.zod.ts` had no bare +// `X = z.infer` alias until `PageContainerProps` arrived. +import type * as M170 from './ui/component.zod.js'; // --------------------------------------------------------------------------- // 717 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. @@ -1316,6 +1322,14 @@ export type Iso715 = Assert, z. // ui/widget.zod.ts export type Iso716 = Assert, z.infer< typeof M168.WidgetLifecycleSchema > >>; +// ui/component.zod.ts +// #5775 — the shared `children` contract for `page:section`/`page:footer`/ +// `page:sidebar`. A lone optional array with no default, transform, catch or +// pipe anywhere in its tree, so the two shapes coincide and the phase-2 flip of +// the bare name changes nothing. `check:spec-parsed-alias` sent it here rather +// than to a `PageContainerPropsParsed`, which would be a permanent synonym. +export type Iso719 = Assert, z.infer< typeof M170.PageContainerProps > >>; + // --------------------------------------------------------------------------- // Representative spot-checks on the phase-1 ADDITIONS. // @@ -1371,7 +1385,7 @@ export type AFamilyParsedIsParseState = Assert< // --------------------------------------------------------------------------- describe('ADR-0122 type-alias convention', () => { - it('still declares all 719 isomorphic pins', () => { + it('still declares all 720 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 @@ -1390,7 +1404,7 @@ describe('ADR-0122 type-alias convention', () => { // `ConnectorType` / `ConnectorStatus` pins beside it). const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert { diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index a6b2e9c483..0f7fbe7bf6 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -3,6 +3,8 @@ import { PageHeaderProps, PageTabsProps, PageCardProps, + PageContainerProps, + RecordPathProps, RecordDetailsProps, RecordRelatedListProps, RecordHighlightsProps, @@ -117,13 +119,43 @@ describe('PageTabsProps', () => { }); }); +// #5775 — the two tab-item keys the renderer honours and the schema did not +// declare. `value` is the load-bearing one: it is the `?tab=` token, and the +// index-derived fallback (`tab-`) silently points at a different tab as soon +// as the item list changes. Declaring it is what unblocks #5776, whose showcase +// page authors this slot as `key` — neither spelling the renderer reads. +describe('PageTabsProps items[].value / items[].count (#5775)', () => { + it('accepts a stable `value` token and an explicit `count`', () => { + const result = PageTabsProps.parse({ + items: [ + { label: 'Details', value: 'details', children: [] }, + { label: 'Tasks', value: 'related:task', count: 3, children: [] }, + ], + }); + expect(result.items[0]!.value).toBe('details'); + expect(result.items[1]!.count).toBe(3); + }); + + it('leaves both undefined when unauthored — the renderer derives them', () => { + const result = PageTabsProps.parse({ items: [{ label: 'Details', children: [] }] }); + expect(result.items[0]!.value).toBeUndefined(); + expect(result.items[0]!.count).toBeUndefined(); + }); + + it('rejects a non-integer count rather than silently stripping it', () => { + expect(() => PageTabsProps.parse({ + items: [{ label: 'Tasks', count: 'many', children: [] }], + })).toThrow(); + }); +}); + describe('PageCardProps', () => { it('should accept empty card with defaults', () => { const result = PageCardProps.parse({}); expect(result.bordered).toBe(true); expect(result.title).toBeUndefined(); expect(result.actions).toBeUndefined(); - expect(result.body).toBeUndefined(); + expect(result.children).toBeUndefined(); expect(result.footer).toBeUndefined(); }); @@ -132,12 +164,60 @@ describe('PageCardProps', () => { title: 'Info Card', bordered: false, actions: ['edit', 'delete'], - body: ['component1'], + children: ['component1'], footer: ['footer-component'], }; const result = PageCardProps.parse(card); expect(result.title).toBe('Info Card'); expect(result.bordered).toBe(false); + expect(result.children).toEqual(['component1']); + }); + + // #5775 — `children` is the composition key on every container, and the card + // renderer already reads it (`schema.body ?? schema.children`). `body` was + // the second spelling of the same slot and is tombstoned; `footer` is a + // genuinely distinct slot and stays. + it('accepts the showcase card shape verbatim (my-work.page.ts:64)', () => { + const result = PageCardProps.parse({ + title: 'Shortcuts', + children: [{ type: 'element:text', properties: { content: 'Delivery Operations' } }], + }); + expect(result.children).toHaveLength(1); + }); + + it('rejects the retired `body` with the rename prescription', () => { + expect(() => PageCardProps.parse({ body: ['component1'] })) + .toThrow(/`body`.*removed.*`children`/s); + }); + + it('does not materialize the retired `body` on a clean parse', () => { + expect(PageCardProps.parse({ children: [] })).not.toHaveProperty('body'); + }); +}); + +describe('PageContainerProps — page:section / page:footer / page:sidebar (#5775)', () => { + // These three were declared `EmptyProps` ("zero props") while their renderers + // have always rendered `schema.children || schema.body`. Declaring zero props + // for a container that renders children is the ADR-0078 shape from the schema + // side: the #5068 gate reported every authored `children` as an unknown key. + it('declares `children` on all three thin containers', () => { + for (const type of ['page:section', 'page:footer', 'page:sidebar'] as const) { + const result = ComponentPropsMap[type].parse({ + children: [{ type: 'element:text' }], + }) as { children?: unknown[] }; + expect(result.children).toHaveLength(1); + } + }); + + it('keeps `children` optional — an empty container is still valid', () => { + expect(PageContainerProps.parse({})).toEqual({}); + }); + + // `body` is NOT a second authorable spelling here (Prime Directive #12). The + // renderers keep reading it as a back-compat fallback for stored documents; + // that fallback is objectui's to retire on its own schedule. + it('does not declare `body` as a second composition key', () => { + expect(PageContainerProps.parse({ body: ['x'] })).not.toHaveProperty('body'); }); }); @@ -683,38 +763,86 @@ describe('Interactive Elements — element:record_picker', () => { it('should accept element:record_picker component', () => { expect(() => PageComponentSchema.parse({ type: 'element:record_picker', - properties: { object: 'account', displayField: 'name' }, + properties: { object: 'account', labelField: 'name' }, })).not.toThrow(); }); it('should parse record_picker props with defaults', () => { const props = ElementRecordPickerPropsSchema.parse({ object: 'account', - displayField: 'name', + labelField: 'name', }); expect(props.object).toBe('account'); - expect(props.displayField).toBe('name'); - expect(props.multiple).toBe(false); + expect(props.labelField).toBe('name'); }); it('should accept full record_picker props', () => { const props = ElementRecordPickerPropsSchema.parse({ object: 'account', - displayField: 'name', - searchFields: ['name', 'email'], + labelField: 'name', + valueField: 'id', + label: 'Account', filter: { status: 'active' }, - multiple: true, targetVariable: 'selected_account', placeholder: 'Search accounts...', + emptyText: 'No accounts', }); - expect(props.multiple).toBe(true); expect(props.targetVariable).toBe('selected_account'); - expect(props.searchFields).toEqual(['name', 'email']); + expect(props.labelField).toBe('name'); + expect(props.valueField).toBe('id'); + expect(props.label).toBe('Account'); + expect(props.emptyText).toBe('No accounts'); }); - it('should reject record_picker without required fields', () => { + it('should reject record_picker without its one required field', () => { expect(() => ElementRecordPickerPropsSchema.parse({})).toThrow(); - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'account' })).toThrow(); + }); + + // #5775 — `object` is the ONLY required prop. `labelField` is optional + // because the renderer defaults it to `name` (`props.labelField ?? 'name'`), + // so omitting it is a working picker, not a broken one. This is the half of + // the ruling that lets the showcase's `page-variables` page stop reporting + // `component-props-invalid` (a required key it had no reason to write). + it('accepts a picker with `object` alone — labelField defaults in the renderer', () => { + const props = ElementRecordPickerPropsSchema.parse({ object: 'account' }); + expect(props.object).toBe('account'); + expect(props.labelField).toBeUndefined(); + }); + + it('accepts the showcase picker shape verbatim (page-variables.page.ts:59)', () => { + const props = ElementRecordPickerPropsSchema.parse({ + label: 'Project', + labelField: 'name', + placeholder: 'Choose a project…', + object: 'showcase_project', + }); + expect(props.labelField).toBe('name'); + expect(props.label).toBe('Project'); + }); + + // #5775 tombstones — the prescription IS the payload. `displayField` was a + // REQUIRED declaration no renderer read; `searchFields` / `multiple` were + // capability claims the single-select control never kept (ADR-0049). + it('rejects the retired `displayField` with the rename prescription', () => { + expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', displayField: 'title' })) + .toThrow(/displayField.*removed.*use `labelField`|displayField.*removed.*`labelField`/s); + }); + + it('rejects the retired `searchFields` with its prescription', () => { + expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', searchFields: ['name'] })) + .toThrow(/`searchFields`.*removed.*Delete the key/s); + }); + + it('rejects the retired `multiple` with its prescription', () => { + expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', multiple: true })) + .toThrow(/`multiple`.*removed.*Delete the key/s); + }); + + it('does not materialize the retired keys on a clean parse', () => { + const props = ElementRecordPickerPropsSchema.parse({ object: 'a' }); + expect(props).not.toHaveProperty('displayField'); + expect(props).not.toHaveProperty('searchFields'); + expect(props).not.toHaveProperty('multiple'); }); }); @@ -814,7 +942,7 @@ describe('ComponentPropsMap interactive elements', () => { it('should parse element:record_picker props', () => { const result = ComponentPropsMap['element:record_picker'].parse({ object: 'account', - displayField: 'name', + labelField: 'name', }); expect(result.object).toBe('account'); }); @@ -825,6 +953,38 @@ describe('ComponentPropsMap interactive elements', () => { }); }); +// #5775 — `stages[].terminal` is honoured FIRST by the record-path renderer, +// ahead of the token heuristic that guesses "won"/"lost" from the value/label. +// The showcase's `done` stage is exactly the case the heuristic cannot read, so +// without this key there is no way to declare the terminus at all. +describe('RecordPathProps stages[].terminal (#5775)', () => { + it('accepts the showcase stage shape verbatim (task-detail.page.ts:40)', () => { + const result = RecordPathProps.parse({ + statusField: 'status', + stages: [ + { value: 'todo', label: 'To Do' }, + { value: 'done', label: 'Done', terminal: 'won' }, + ], + }); + expect(result.stages![1]!.terminal).toBe('won'); + }); + + it('leaves terminal undefined when unauthored (no default materialized)', () => { + const result = RecordPathProps.parse({ + statusField: 'status', + stages: [{ value: 'todo', label: 'To Do' }], + }); + expect(result.stages![0]!.terminal).toBeUndefined(); + }); + + it('rejects a terminal outside won|lost rather than silently stripping it', () => { + expect(() => RecordPathProps.parse({ + statusField: 'status', + stages: [{ value: 'x', label: 'X', terminal: 'closed' }], + })).toThrow(); + }); +}); + // --------------------------------------------------------------------------- // Enhanced RecordActivityProps (Unified Timeline) // --------------------------------------------------------------------------- diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 30315c0b20..779ca96253 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -96,6 +96,20 @@ import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; // authors and declared `hideFields`, so wiring the gate no longer turns three // showcase pages and the `sys_user` platform page into hard parse errors. // +// #5775 closed the rest of that inventory in both directions, on the same #5611 +// rule (the delivered, authorized shape is the contract): nine keys the +// renderers honour were DECLARED (`element:record_picker` `labelField` / +// `valueField` / `label` / `emptyText`, `record:path` `stages[].terminal`, +// `page:tabs` `items[].value` / `items[].count`, `page:card` `children`, and +// `children` on the three thin containers that were declared `EmptyProps`), +// and four that nothing read were RETIRED with tombstones + ADR-0087 D2 +// conversions (`displayField` → `labelField`, `page:card.body` → `children`, +// `searchFields`, `multiple`). What is deliberately NOT closed here is +// `page:card.visible`: a component-level visibility predicate written into +// `properties` and hoisted by `SchemaRenderer`. The canonical spelling is the +// component-level `visibleWhen` (ADR-0089) — that one is a page to rewrite, not +// a key to declare. +// // ── #5068: THE GATE IS WIRED — read the flip precisely ───────────────────── // // `packages/lint/src/validate-component-props.ts` dispatches on the component's @@ -131,6 +145,9 @@ import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; // pages against an `I18nLabelSchema` that is a plain `z.string()` (#5728), and // keys objectui's renderers honour that this file does not declare. The // warning-period inventory is the acceptance baseline for the error upgrade. +// #5775 cleared this file's half of that inventory; #5728 (the label maps) and +// the page rewrites (`page:card.visible`, #5776's tab `key`) are what remain +// before the upgrade to error. // // The verdict is pinned in `component.test.ts` and in the `ui/` tables of // `docs/audits/2026-07-unknown-key-strictness-ledger.md` — change all three @@ -143,8 +160,37 @@ import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; */ import { lazySchema } from '../shared/lazy-schema'; import { ExpressionInputSchema } from '../shared/expression.zod'; +import { retiredKey } from '../shared/retired-key'; const EmptyProps = z.object({}); +/** + * The composition slot every thin container renders: `page:section`, + * `page:footer`, `page:sidebar`. + * + * All three were declared `EmptyProps` — "this component takes zero props" — + * while their renderers have always rendered a child list + * (`renderChildren(schema.children || schema.body)` in objectui's + * `containers.tsx`, one per registered renderer). Declaring zero props for a + * container that renders children is the ADR-0078 shape from the schema side: + * the #5068 gate reports every authored `children` as an unknown key, and a + * `.strict()` batch would reject the only thing these components are for. + * + * `children` is the canonical spelling — it is what `grid`, `flex`, + * `page:accordion` items and `page:tabs` items already use, and what the + * renderers read FIRST. `body` is deliberately NOT declared here (#5775): one + * composition key, not two (Prime Directive #12). The renderers keep reading + * `body` as a back-compat fallback for stored documents; that fallback is + * objectui's to retire on its own schedule, and it is not a second authorable + * spelling. + * + * Shared by all three entries rather than copied: they are the same contract, + * and three identical defs would be three places for it to drift. + */ +export const PageContainerProps = z.object({ + children: z.array(z.unknown()).optional().describe('Child components rendered inside this container, in order'), +}); +export type PageContainerProps = z.infer; + /** * ---------------------------------------------------------------------- * 1. Structure Components @@ -180,6 +226,21 @@ export const PageTabsProps = z.object({ visibleWhen: ExpressionInputSchema.optional().describe( 'Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Binds `record`, `current_user`, `page.`. ADR-0089 canonical name (`visibility`/`visibleOn` aliases are not accepted here).', ), + /** + * Stable URL token for this tab — the value `?tab=` carries and the + * renderer restores on reload. Omitted, the renderer derives `tab-`, + * which silently points at a DIFFERENT tab as soon as the item list + * changes; that is why a durable link needs a semantic value here + * (`details`, `related:task`, …). Declared for #5776: the showcase authors + * this slot as `key`, which is neither spelling the renderer reads. + */ + value: z.string().optional().describe('Stable `?tab=` URL token for this tab (default: index-derived `tab-`, which is not durable across item-list changes)'), + /** + * Badge count rendered next to the label. Omitted, the renderer derives it + * by probing the `record:related_list` descendants of this tab's children, + * so an explicit value is only needed when the count is not that sum. + */ + count: z.number().int().min(0).optional().describe('Badge count shown next to the tab label (default: derived from `record:related_list` descendants)'), children: z.array(z.unknown()).describe('Child components') })), /** ARIA accessibility */ @@ -190,8 +251,29 @@ export const PageCardProps = z.object({ title: I18nLabelSchema.optional(), bordered: z.boolean().default(true), actions: z.array(z.string()).optional(), - /** Slot for nested content in the Card body */ - body: z.array(z.unknown()).optional().describe('Card content components (slot)'), + /** + * Card content, in order — the canonical composition slot, matching every + * other container (`grid`, `flex`, `page:section`, `page:tabs` items). + * + * This spelling was authored by the showcase and rendered by objectui long + * before it was declared (`schema.body ?? schema.children`, with the + * renderer's own comment saying authors expect `children` to work here); the + * declaration was `body` alone. #5775 converges the two on `children` rather + * than declaring both — one composition key, not two de-facto contracts + * (Prime Directive #12). `footer` is a genuinely distinct slot and stays. + */ + children: z.array(z.unknown()).optional().describe('Card content components, in order (the card body slot)'), + /** + * REMOVED (#5775). `body` was the declared spelling of the slot every other + * container calls `children`; the two are the same slot, and the renderer + * already reads both. The live mechanism is `children`. + */ + body: retiredKey( + '`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.', + ), /** Slot for footer content */ footer: z.array(z.unknown()).optional().describe('Card footer components (slot)'), /** ARIA accessibility */ @@ -385,6 +467,16 @@ export const RecordPathProps = z.object({ stages: z.array(z.object({ value: z.string(), label: I18nLabelSchema, + /** + * Declare this stage a terminus and say WHICH one. The renderer classifies + * every stage to decide whether it stays in the forward chevron path (won) + * or breaks out into the separated alt group (lost); an explicit `terminal` + * is honoured FIRST, ahead of the token heuristic that guesses from the + * value/label (`closed_won`, `失败`, …). Authors whose stage names the + * heuristic cannot read — the showcase's `done` — have no other way to get + * the right treatment. + */ + terminal: z.enum(['won', 'lost']).optional().describe('Mark this stage a terminus and its kind — overrides the renderer\'s value/label token heuristic'), })).optional().describe('Explicit stage definitions (if not using field metadata)'), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), @@ -530,14 +622,78 @@ export const ElementFormPropsSchema = lazySchema(() => z.object({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), })); +/** + * The record picker — a single-select over one object, writing the picked + * record's id into a page variable. + * + * ⚠️ #5775 rewrote this shape end to end, and the reason is worth keeping: the + * declaration and the renderer had drifted into two different contracts. The + * schema required `displayField` that no renderer has ever read, and declared + * `searchFields` / `multiple` that no renderer implements — while the renderer + * honoured `labelField`, `valueField`, `label` and `emptyText`, none of which + * were declared. An author following the schema got a picker rendered by + * `name` with zero diagnostics (ADR-0078), and the #5068 gate reported the + * showcase's own correct page as broken. + * + * The maintainer's ruling (2026-08-06, direction A) is the #5611 rule applied + * again: the delivered, authorized shape is the contract. `labelField` is the + * spelling — the renderer reads it, the component registry publishes it as a + * designer input, and the showcase authors it — so `displayField` retires as + * its synonym. `searchFields` and `multiple` retire under ADR-0049 + * enforce-or-remove: the control is a single-select `Select` with no search + * box, so both were capability claims nothing kept (#5021 / #4988 precedent). + * Either may return the day it is implemented; a declaration is not a roadmap. + */ export const ElementRecordPickerPropsSchema = lazySchema(() => z.object({ object: z.string().describe('Object to pick records from'), - displayField: z.string().describe('Field to display as the record label'), - searchFields: z.array(z.string()).optional().describe('Fields to search against'), + /** + * Field rendered as each row's text. Defaults to `name`, which is what the + * renderer falls back to (`props.labelField ?? 'name'`) — so this is + * optional, not required: omitting it is a working picker, not a broken one. + */ + labelField: z.string().optional().describe("Field rendered as each row's text (default `name`)"), + /** Field whose value is written into the bound page variable (default `id`). */ + valueField: z.string().optional().describe('Field whose value is written into the bound page variable (default `id`)'), + /** Control label rendered above the select. */ + label: I18nLabelSchema.optional().describe('Control label rendered above the select'), filter: FilterConditionSchema.optional().describe('Filter criteria for available records'), - multiple: z.boolean().optional().default(false).describe('Allow multiple record selection'), targetVariable: z.string().optional().describe('Page variable to bind selected record ID(s)'), placeholder: I18nLabelSchema.optional().describe('Placeholder text'), + /** Shown in place of the row list when the query returns nothing. */ + emptyText: I18nLabelSchema.optional().describe('Text shown when the query returns no records (default "No records")'), + /** + * REMOVED (#5775). A synonym of `labelField` — the same concept in two + * spellings, of which only `labelField` was ever read. + */ + displayField: retiredKey( + '`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.', + ), + /** + * REMOVED (#5775). ADR-0049 enforce-or-remove: the control has no search + * box, so this narrowed nothing. + */ + searchFields: retiredKey( + '`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.', + ), + /** + * REMOVED (#5775). ADR-0049 enforce-or-remove: the control is a single-select + * `Select`, and a page variable binds one record id. + */ + multiple: retiredKey( + '`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 accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), })); @@ -580,10 +736,12 @@ export const ComponentPropsMap = { 'page:header': PageHeaderProps, 'page:tabs': PageTabsProps, 'page:card': PageCardProps, - 'page:footer': EmptyProps, - 'page:sidebar': EmptyProps, + // The three thin containers: one shared `children` contract (#5775). They + // were `EmptyProps` while their renderers rendered a child list. + 'page:footer': PageContainerProps, + 'page:sidebar': PageContainerProps, 'page:accordion': PageAccordionProps, - 'page:section': EmptyProps, + 'page:section': PageContainerProps, // Record 'record:details': RecordDetailsProps, diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index fb413d9653..ce1e1086e3 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -879,7 +879,7 @@ which contain components. | `type` | Use | |:---------------------|:----| | `page:header` | Title + subtitle + breadcrumb + inline `actions: Action[]` | -| `page:card` | Bordered/un-bordered card with `body: Component[]` | +| `page:card` | Bordered/un-bordered card with `children: Component[]` (plus an optional `footer: Component[]` slot) | | `flex` | Generic styleable box (`properties.children`) — the workhorse for custom layout; style via `responsiveStyles` (see Styling below) | | `element:text` | Text node — `properties.content`; style via `responsiveStyles` | | `element:button` | Button — `properties.label` + `variant`/`size` + optional `action` |