@object-ui/components 17.2.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1853 @@
1
1
  # @object-ui/components
2
2
 
3
+ ## 17.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - dc2aa3e: The action renderers publish the modern `UIActionSchema`, and every `forwardRef` renderer's props parameter is annotated so its declared types survive
8
+
9
+ **Breaking semantics (declared `minor` per the repo's version-alignment rule — objectui#4403 precedent — never `major`).** Six exported declarations in `@object-ui/components` change the action type they name, from the `@deprecated` legacy `ActionSchema` (`crud.ts`) to `UIActionSchema` (`ui-action.ts`):
10
+
11
+ - `ActionBarSchema.actions`, `ActionBarSchema.systemActions`
12
+ - `ActionMenuSchema.actions`
13
+ - `ActionGroupSchema.actions`
14
+ - `ActionButtonProps.schema`, `ActionIconProps.schema`
15
+
16
+ The two types are not interchangeable in either direction. `UIActionSchema` requires `name`, which legacy inherits as optional from `BaseSchema`; legacy pins `type: 'action'` where these renderers serve `'script' | 'url' | 'modal' | 'flow' | 'api'`; and only the modern type declares `locations`, `target`, `endpoint`, `bodyExtra`, `bodyShape` and a `variant` union containing `'primary'` — all of which the implementations already read. objectui#4417 measured four compiler errors proving the VALUES were modern while the DECLARATIONS said legacy; this moves the declarations to match, so the contract and the implementation finally agree.
17
+
18
+ No runtime behaviour changes, and no published surface is involved: none of the six declarations is re-exported from the package index, and the sweep found zero type-checked consumers outside each declaration's own file. Metadata that renders today renders identically — the renderers read the same keys through the same paths.
19
+
20
+ Separately, all fifteen `schema`-reading `forwardRef` renderers in the package now annotate their render function's first parameter directly, and carry the pass-through index signature on that annotation rather than on the `forwardRef` type argument. `forwardRef` routes its type argument through `PropsWithoutRef`, whose `Omit` collapses a props type carrying `[key: string]: any` down to the bare index signature — every declared property erased, silently, with `noImplicitAny` reporting clean because the `any` is supplied explicitly by the index signature. That is what hid the declaration/implementation drift above for as long as it lasted. Thirteen renderers recover a real declared type for `schema` (the two raw-tag factories keep `any`, which is what they genuinely declare), and a new structural guard, `forwardref-props-annotation.guard.test.ts`, fails on any future `forwardRef` that reintroduces either half of the trap.
21
+
22
+ - cb13400: One fullscreen long-text editor, hoisted to the package both render paths may import
23
+
24
+ The "expand to a full-height dialog" interaction had two independent implementations. `FullscreenTextarea` lived inside the form renderer's built-in (unregistered) `textarea` branch in `@object-ui/components`; `FullscreenFieldEditor` lived in `@object-ui/fields` and served the registered `TextAreaField` / `RichTextField` widgets. They exist because ONE form-level promise — `ObjectFormSchema.mobile.fullscreenLongText`, projected onto every long-text field as `mobile_fullscreen` — is honoured on two render paths, and each path grew its own answer.
25
+
26
+ Two copies of a state machine drift, and these did, in both directions: objectui#3400 measured a read-only long-text field that was fully editable through the built-in branch's dialog (and "Done" wrote the edit into form state), objectui#3402 measured the same write-back hole for `disabled` on the registered path, and objectui#3393 (the dialog title needs the field label) and objectui#3272 (the copy needs i18n) each landed on one side before the other. Every repair was correct and none of them scaled.
27
+
28
+ `@object-ui/components` now exports `FullscreenEditor`, a single primitive owning the affordance, the dialog, the draft/commit state machine and the copy. The direction follows the measured import graph rather than fighting it: `@object-ui/fields` depends on `@object-ui/components`, and `components` declares no dependency on `fields` in either `dependencies` or `peerDependencies`, so the shared code can only live in `components`. `FullscreenFieldEditor` becomes a thin wrapper over it and keeps its name, its props and its test-id namespaces, so both hosts and their pins are unchanged.
29
+
30
+ The load-bearing part of the merge is that the primitive DEFINES `readOnly` and `disabled` instead of inheriting them by accident. Neither copy defined both: the built-in one grew them under objectui#3400, while the fields one declared only `disabled` and was shielded from `readonly` by its hosts' early return — a single implementation cannot be shielded by one caller's control flow. So both are answered once, and both call paths inherit the same answers: `readOnly` renders no affordance at all (it means "shown plainly", so advertising an expand button the user cannot use is worse than showing none), `disabled` leaves an inert one (it means "not interactive, muted"). Neither relies on the toggle alone, because `disabled` also carries the form's `isSubmitting` and can flip to true while the dialog is already open — so opening refuses independently of the attribute, the injected editor is told, "Done" is disabled, and `onCommit` is gated as the single point where a value leaves for host state.
31
+
32
+ No copy changed and no locale pack needed an edit: the primitive consumes the same `form.fullscreen.*` / `common.cancel` keys both copies already read, through `createSafeTranslation` with English defaults byte-identical to the literals, so provider-less hosts render exactly what they did. The now-unread `form.fullscreen.*` defaults are dropped from `useFieldTranslation`, where they would have re-created in the defaults map precisely the duplication this change removes from the components.
33
+
34
+ `toggleClassName` is not carried into the new primitive. It was declared on `FullscreenFieldEditorProps` and written by nobody — zero producers repo-wide — and `FullscreenFieldEditor` is not exported from the `@object-ui/fields` barrel, so no consumer outside the package could ever have set it. Minting it as part of a NEW public export in `@object-ui/components` would have published a prop with no producer, the shape objectui#3232/#3233 keeps deleting.
35
+
36
+ - 38ab505: Retire the `global_nav` Studio designer surfaces, and track the `@objectstack` family at `17.0.0-rc.6` (objectstack#7100 / objectstack#6888).
37
+
38
+ ## The retirement
39
+
40
+ `global_nav` was an `ACTION_LOCATIONS` member no running-app surface ever rendered. The console's ⌘K palette (`app-shell/src/chrome/CommandPalette.tsx`) builds its groups from nav items, objects, dashboards, pages, reports, recent items, record search and theme; it holds no reference to `global_nav`, to `actionRendersAt`, or to any action-metadata source. An action declaring `locations: ['global_nav']` therefore never reached a user.
41
+
42
+ The Studio designer previewed it anyway — a mock frame reading `⌘K · Command palette` with the author's button inside it. That is the sharp edge the maintainer's 2026-08-09 ruling on objectstack#6888 named: an authoring tool promising a surface the product does not have teaches authors, and every AI copying this corpus, to declare dead metadata. `@objectstack/spec` `17.0.0-rc.6` retired the member (7 members → 6) with a named rejection message; this release removes the designer surfaces that outlived it.
43
+
44
+ - `metadata-admin/previews/ActionPreview.tsx` — the mock command-palette placement frame is gone. The metadata strip above it still ECHOES whatever `locations` the draft declares, deliberately: reporting what a (possibly stale) draft says is honest, whereas the frame CLAIMED the platform renders it.
45
+ - `metadata-admin/inspectors/ActionDefaultInspector.tsx` — the `global_nav` entry is gone from `LOCATION_LABELS`. That map is typed `Record< ActionLocation, string >`, so the retirement reached it as a compile error rather than as a silently stale dropdown — the mechanism objectui#3017 installed, firing as designed.
46
+ - `metadata-admin/previews/block-config.ts` — the `record:quick_actions` location dropdown no longer offers it, and both locale tables drop the now-orphaned `…option.location.global_nav` key.
47
+ - `@object-ui/components`' `action:bar` doc comment is aligned. The component's published enum is `[...ACTION_LOCATIONS]`, so it followed the retirement on its own; only the prose was stale.
48
+
49
+ `@object-ui/core`'s `ActionEngine.getActionsForLocation` is **unchanged and still answers a literal string match**. Narrowing it to the six live members would put a second rejection point beside the schema's — the tolerant-consumer shape the strict-contract rule forbids, inverted. Enforcement stays where it belongs: the parameter type is now six-membered so no type-correct caller can spell the retired value, and `ActionLocationSchema` rejects it by name at authoring and publish time.
50
+
51
+ ## The dependency move
52
+
53
+ All 37 `@objectstack/*` declarations across 30 `package.json` files move from `^17.0.0-rc.5` to `^17.0.0-rc.6`, and `pnpm-lock.yaml` resolves one copy of each family package at rc.6. The siblings move with `spec` because `client` / `formula` / `lint` pin it **exactly** — leaving them behind would keep two copies of the spec in the tree, the split brain objectui#3560 called out.
54
+
55
+ Bumping the pin and repairing the fallout cannot be split: at rc.5 the `Record< ActionLocation, string >` above is missing a key, at rc.6 it has an excess one.
56
+
57
+ ## Breaking, in FROM → TO form
58
+
59
+ - **`@object-ui/types`' `Theme` now binds the spec's `Theme`, not `ThemeInput`.** rc.6 retired every `…Input` alias and moved the bare name onto the `z.input` side (`X` = `z.input`, `XParsed` = `z.infer`). The runtime shape and this package's exported name are unchanged — `Theme` was, and still is, the AUTHORING shape where `mode` is optional. Re-pointing at `ThemeParsed` would have been the silent swap.
60
+ - **`SpecReport` / `SpecReportChart` re-point to `ReportParsed` / `ReportChartParsed`, and `SpecReportInput` / `SpecReportChartInput` to `Report` / `ReportChart`.** Same rename, same rule: each local alias keeps the SIDE it had at rc.5.
61
+ - **`@object-ui/types` no longer re-exports `I18nObject`, `LocaleConfig`, `PluralRule`, `DateFormat` or `NumberFormat`** — all five were retired by rc.6. They were dead re-exports here: nothing in this repo imported them from `@object-ui/types` (`@object-ui/i18n`'s formatter vocabulary in `utils/spec-formatters.ts` is locally declared and never bound the spec symbols). `I18nLabel` survives and is unchanged as a name.
62
+ - **`I18nLabel` itself widened from `string` to `string | Record< string, string >`** — rc.6 folded the retired `I18nObject`'s per-locale map into it and ships `resolveI18nLabel(label, locale)` as the shared resolver. Every read in this repo that lands in a text slot now goes through that resolver, so an inline map renders its locale instead of `[object Object]`. Reads the compiler cannot see are audited separately in objectui#4163.
63
+ - **`@object-ui/types`' `GlobalFilterSchema` derives via `.safeExtend`, not `.extend`.** rc.6's `GlobalFilterSchema` carries a refinement and zod 4 refuses `.extend()` on a refined object outright, which threw at module load. `.safeExtend` is zod's prescribed replacement and KEEPS the refinement, so the spec's cross-field rule now also runs on this package's dialect — which is the intended behaviour, since the pinned divergences widen individual fields and were never meant to switch off a whole-object rule.
64
+
65
+ - c1d939f: One `SchemaNode`, and one label vocabulary — the union wins, and labels resolve where the locale lives
66
+
67
+ Two packages published a type called `SchemaNode` and they were not the same type. `@object-ui/core` hand-declared `interface SchemaNode { type: string; … [key: string]: any }`; `@object-ui/types` exported `type SchemaNode = BaseSchema | string | number | boolean | null | undefined`, whose own doc comment names `'Plain string'` a valid node. Both were exported under one name from packages the same consumers import together, so which declaration a call site got depended on which package it happened to import from — #4548's canary measured 19 of 35 errors as exactly that collision. Core's declaration is now a re-export of types', so there is one declaration left to disagree with. Core's entry surface is unchanged: `dist/index.d.ts` is byte-identical across the change.
68
+
69
+ Reconciling it exposed a real defect rather than a mechanical narrowing, which is why the first attempt was withdrawn instead of forced. The spec bridges write `spec.label` — the spec's `I18nLabel`, an INLINE locale map like `{ en: 'Owner', 'zh-CN': '负责人' }` — into `node.label`, and `BaseSchema.label` declared `string`. Under core's old index signature that assignment was invisibly `any`; under one honest `SchemaNode` it is a type error. `BaseSchema.label` and `.description` therefore now accept `string | I18nLabel`, and the two bridge assignments compile with their expressions untouched.
70
+
71
+ Resolution happens at READ time, in the renderer, against the display locale — not at the bridge. Resolving at the bridge was measured unimplementable: it is a plain class method that cannot call a hook, `BridgeContext` declares no locale, and `updateContext()` has zero callers, so a bridge-resolved label would freeze one audience's language into the node tree with no re-translation channel. React's own invalidation re-translates for free at the read site.
72
+
73
+ The widening turned every blind `schema.label`-as-string read into a named compiler error, and that inventory is the audit: it named four sites repo-wide, all one class — the label reaching a React child position, where a map does not render as `[object Object]` but THROWS `Objects are not valid as a React child`, failing the whole subtree. Three are `@object-ui/components` renderers (`filter-builder`, `sidebar-group`, `dropdown-menu`), which now resolve with the spec's own `resolveI18nLabel` against `useDisplayLocale()`. The fourth is `plugin-dashboard`'s `DashboardGridLayout` heading, which resolves with `pickLocalized` against the active UI language — matching the widget-title resolution already in that same component rather than putting two resolvers and two disagreeing locale channels in one render; the two resolvers are limb-for-limb twins with a parity test pinning them.
74
+
75
+ One interface now carries both label vocabularies two properties apart — `label`/`description` are the spec's INLINE map, `ariaLabel` is the KEYED bundle reference — and each accepts the other's shape vacuously. That confusability is objectui#4167's known hazard, inherent to the spec's `I18nLabel` design; both shapes are named with cross-referenced doc comments stating which resolver owns which slot, and a pin asserts the two unions do not collapse into each other.
76
+
77
+ Finally, the spec bridges declare their return type as `BaseSchema` instead of the union. Both bridges end in a single `return node` on an object literal, so the union described nothing real while forcing a narrowing at every read — 272 mechanical errors across five suites in the first round. That change is a type annotation only; the emitted JavaScript is byte-identical.
78
+
79
+ - bb68488: `element:record_picker` publishes `sort`, `limit` and `emptyText` as authoring
80
+ inputs (objectui#4167).
81
+
82
+ All three were already READ by the renderer and declared by the contract — the
83
+ renderer has passed `sort` into `$orderby` and `limit` into `$top` since the
84
+ block existed, and `emptyText` decides the no-rows message — but none of them
85
+ appeared in `inputs`, so every layer that reads a manifest said they did not
86
+ exist. `packages/components/src/renderers/layout/page.tsx` builds the JSX-page
87
+ compiler's prop whitelist from `getKnownTypes()` plus these `inputs`, so writing
88
+ any of the three on a JSX page drew an `unknown-prop` warning from
89
+ `sdui-parser/src/validate.ts` on a key the renderer then went on to honour.
90
+
91
+ That is objectui#3407's shape — honoured, undiscoverable — and this is the same
92
+ repair objectui#3808 made for `record:details.hideFields` and objectui#3830 made
93
+ for `element:record_picker.filter`. `@objectstack/spec` 17.0.0-rc.6 is what made
94
+ it actionable: objectstack#5775 declared the three upstream, and the reverse
95
+ direction of the console's registry parity gate went red demanding them the
96
+ moment the pin moved — a red the previous exemption had predicted in writing and
97
+ called "correct and wanted".
98
+
99
+ Each description documents the renderer's real behaviour rather than restating
100
+ the schema, because that is the half an author cannot read off the contract:
101
+
102
+ - **`sort`** and **`limit`** are both overridden OUTRIGHT by a node-level
103
+ `dataSource` binding (`dataSource.sort ?? sort`), not merged with it — so a
104
+ node that carries a `dataSource` silently ignores them.
105
+ - **`limit`** defaults to 50 in the renderer, not in the schema, and a record
106
+ outside the limit cannot be picked at all with nothing in the control to say
107
+ more exist.
108
+ - **`emptyText`** is published as `string` against a contract of
109
+ `string | Record< string, string >`: rc.6 widened it to `I18nLabel`, and this
110
+ renderer passes the value straight into a text node with no locale resolution,
111
+ so only the plain-string form renders today. The description says so rather
112
+ than advertising a shape the renderer drops — the narrowed-type treatment
113
+ objectui#3832 describes, with the render-site gap tracked in objectui#4163.
114
+
115
+ The console's `registry-inputs-spec-parity` suite also drops all twelve of its
116
+ off-spec exemptions, which rc.6 obsoleted at once (objectstack#6776 declared
117
+ `page:header.recordChrome` / `showStar` / `showCopyId`, `page:accordion.variant`
118
+ and `page:tabs.tabStyle`; objectstack#5775 declared the `element:record_picker`
119
+ trio and `children` on the four page containers). The forward direction of that
120
+ gate now runs with no cover of any kind.
121
+
122
+ - bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
123
+ (objectui#4167, objectstack#4115).
124
+
125
+ The rc.6 bump published nine names this repo already declared locally, on top of
126
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
127
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
128
+ triaged on its own rather than blanket-renamed, because the right answer differs
129
+ per symbol: five bind to the spec, three are renamed because the spec's
130
+ same-named export means something else, five arrive by derivation, and one is a
131
+ declared dialect with a written reason.
132
+
133
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
134
+ `@object-ui/types`** — three exported names changed, because the spec exports the
135
+ same name for a _different_ thing:
136
+
137
+ | package | was | now | what the spec's same-named export actually is |
138
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
139
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
140
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
141
+ | `types` | `DateRangePreset` | `FilterBuilderDateRangePreset` | the thirteen HISTORICAL dashboard filter-bar presets; this one is the filter-builder set, which adds eight FUTURE windows the dashboard schema rejects |
142
+
143
+ `resolveI18nLabel` is the one where the collision had already started costing
144
+ something. rc.6 widened `I18nLabel` from `string` to
145
+ `string | Record< string, string >`, so the same authored value now reaches
146
+ either resolver — and each answers wrongly, silently, for the other's input: the
147
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
148
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
149
+ tags. The rc.6 bump PR met this and aliased the spec's import as
150
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
151
+ them. That is a review convention, which is what objectstack#4115 exists to
152
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
153
+ name says which vocabulary it resolves at every call site.
154
+
155
+ **Eleven keep their names and are now imported or derived from the spec** instead
156
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
157
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
158
+ `GanttConfig`, plus the three renamed above at their new names.
159
+
160
+ **Four of the copies were losing information, not just duplicating it.**
161
+
162
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
163
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
164
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
165
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
166
+ `quickFilters`, `autoZoomToFilter` — are all read by
167
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
168
+ intersection that existed only because this type did not carry them. It now
169
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
170
+ genuinely local extension; the schema is `$loose` upstream, so that key is
171
+ legal metadata rather than a second dialect.
172
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
173
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
174
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
175
+ `formatted` were missing, under a comment already claiming to be "the part
176
+ names of `AddressSchema`". The widget still renders five inputs; binding the
177
+ type stops it from asserting the platform cannot store the other two, and makes
178
+ the `{ ...address }` write-through say so.
179
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
180
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
181
+ own doc comment names this module as one of the three. It is now the spec's
182
+ array by reference, and the local date-macro bounds table is pinned complete
183
+ against it with `satisfies`, so a preset the schema gains without bounds here
184
+ is a compile error rather than a filter that validates clean and then selects
185
+ nothing.
186
+
187
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
188
+ it is bound directly, with a both-directions type pin that it stays the same type
189
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
190
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
191
+ schemas and are now re-exports — "still exact" is the argument for binding them,
192
+ since a copy with nothing to protect can only drift.
193
+
194
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
195
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
196
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
197
+ moved a derivation the guard could see into an object literal it deliberately
198
+ does not descend into. The dialect is unchanged and its three divergences are
199
+ pinned; which side moves on the refinement itself is objectui#4165.
200
+
201
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
202
+ `@object-ui/layout`: its public type surface now references the spec.
203
+
204
+ ### Patch Changes
205
+
206
+ - ceccdcf: Action confirm dialogs and success toasts now honour the bundle's translated
207
+ `confirmText` / `successMessage`, not just `label` (objectui#4265).
208
+
209
+ A TranslationBundle entry for an action carries three keys under one
210
+ `_actions.<name>` node — `label`, `confirmText`, `successMessage` — and
211
+ `useObjectLabel()` has always exposed a resolver for each. What had drifted was
212
+ the call sites: `page:header` (authored record pages), `record:quick_actions`
213
+ and the related-list row menu resolved the button `label` only and dispatched
214
+ the authored `confirmText` / `successMessage` untouched. One bundle entry met
215
+ two fates: the button rendered the translation, the confirm dialog rendered the
216
+ authored English.
217
+
218
+ All action-rendering surfaces now go through one resolver,
219
+ `useActionTextLocalizer()` (new, exported from `@object-ui/react`), which
220
+ applies the existing `actionLabel` / `actionConfirm` / `actionSuccess`
221
+ resolvers over the three keys together. Fallback is unchanged: with no bundle
222
+ entry — or an entry lacking a key — the authored text renders. A bundle cannot
223
+ introduce a `confirmText` or `successMessage` the metadata never declared.
224
+
225
+ - d6e5124: An action rendered in the overflow menu, as an icon or inside a group now reaches the runner carrying the same authored keys as the same action rendered inline — `action:menu`, `action:icon` and `action:group` forward `label` and `description`, and the two group/icon surfaces also forward `resultDialog`.
226
+
227
+ Every action renderer hands the `ActionRunner` an explicit key WHITELIST rather than the action itself. That is deliberate — a key no renderer honours must not look wired — but the whitelists had drifted, and which renderer a given action gets is decided by `action:bar`'s `maxVisible` split (3 on desktop, 1 on mobile) and by `systemActions`, which are always in the overflow menu. So the same declared action behaved differently depending on the viewport.
228
+
229
+ `label` and `description` are what the console's param-collection handler titles its dialog from (`title: action?.label || action?.title`, `description: actionDescription(…, action?.description)`). Dropped, an action with declared `params` opened a dialog titled "Action parameters" while the SAME declaration rendered inline named itself "Create Environment". `resultDialog` is the one-shot reveal spec (a fresh 2FA code, a newly minted OAuth secret): dropped, the runner falls back to the success toast and the value the user was meant to copy is gone — the objectui#3646 defect, still live on two of the four declared surfaces.
230
+
231
+ `undoable` and `recordIdField` are deliberately NOT added. Both are read only under a `rowRecord` guard, and `rowRecord` is `params._rowRecord`, written exclusively by the spread-based hosts (`DeclaredActionsBar`, `RelatedRecordActionsBridge`, `ObjectGrid`, `page:header`), none of which dispatch through these renderers. They are unreachable on this path rather than dropped — `action:button` forwards them here inertly — so forwarding them would have added a second inert copy instead of restoring an affordance.
232
+
233
+ A new repo gate, `pnpm check:action-forward-parity`, now derives each surface's owed key set (`authorable ∩ runtime-read − retired`) from the spec's own schemas and the consumers' ASTs and fails when a renderer drops one, so the seventh instance of this class fails on the pull request that introduces it rather than shipping green.
234
+
235
+ - debad27: An `autoTrigger` action that spills past `action:bar`'s `maxVisible` now still runs — `action:menu` consumes the flag instead of dropping it.
236
+
237
+ `autoTrigger` is the client-composed "run this action as soon as a renderer receives it" flag behind deep links like the welcome page's "Create your environment" CTA (#844). It was consumed only by `action:button`. `action:bar` splits its post-gate list at `maxVisible` (3 on desktop, 1 on mobile) and hands the tail to `action:menu`, which had no `autoTrigger` handling at all — so an auto-triggered action that happened to sort past that threshold was rendered as an ordinary "More" menu entry and never ran, while the caller had already spent the one-shot signal it stood for. The `?runAction=create_environment` deep link is consumed by stripping it from the URL, so the measured end state was `urlParam=null execute=0`: no dialog, and no URL left to retry from. Which actions lost their auto-trigger was partly a function of viewport width, since `maxVisible` drops to 1 on mobile, and `systemActions` — always in the overflow menu, whatever the viewport — could never fire one at all.
238
+
239
+ The flag's contract is now stated and enforced as "execute once on mount by whichever renderer receives the action". `action:menu` consumes it by EXECUTING, through the same path a click on that item takes; it does not open the dropdown, so a transport flag never moves what the user sees. Consumption happens where the action provably arrives — the menu renderer receiving it — not in the menu items, which Radix mounts only once the dropdown opens and which would therefore have waited on the very click the flag exists to avoid.
240
+
241
+ Once-ness has one implementation (`renderers/action/auto-trigger.ts`), now shared by both renderers rather than written twice: a guard ref per rendered action, so re-renders never re-fire it and a flag that flips true later still fires exactly once. Container visibility still governs mounting — a hidden `action:bar` or `action:menu` renders no children and auto-triggers nothing — while the action's own `visible` gate does not suppress the trigger, matching `action:button`'s long-standing behaviour so that a deep link cannot depend on where the bar happened to put the action.
242
+
243
+ The `action:bar` split, the inline `action:button` path and #4166's arming pins are unchanged.
244
+
245
+ - f650253: `BaseSchema.ariaLabel` declares the keyed i18n vocabulary the renderer actually
246
+ resolves, `.disabled` accepts the predicate string it actually evaluates, and the
247
+ keyed shape finally has a name (objectui#4581)
248
+
249
+ Three slots on one base type had drifted from what the renderer does with them.
250
+ PR #4593 fixed `visible` and measured the rest; these are the rest.
251
+
252
+ `ariaLabel` was `string`, but `SchemaRenderer.tsx:111` resolves it with
253
+ `resolveKeyedI18nLabel`, whose input is the KEYED form
254
+ `{ key, defaultValue?, params? }` — a reference into a translation bundle. It is
255
+ now `string | KeyedI18nLabel`, and `KeyedI18nLabel` is a new exported type in
256
+ `@object-ui/types` rather than a fourth inline copy of one object literal: the
257
+ three that existed (`@object-ui/react`'s resolver, `@object-ui/layout`'s
258
+ `resolveLabel`, `@object-ui/app-shell`'s `t`-taking twin) were verified identical
259
+ in their object half first, and two of them now import the name.
260
+
261
+ The vocabulary matters more than the widening. `#4581` originally asked for
262
+ `string | I18nLabel`, and that spelling was withdrawn as measured-wrong: the
263
+ spec's `I18nLabel` is the INLINE LOCALE MAP (`string | Record<string, string>`),
264
+ a different vocabulary resolved against a BCP-47 locale by a different function
265
+ of a confusingly similar name. Under it the shipped keyed fixture type-checked
266
+ only vacuously — as a locale map whose "locales" are named `key` and
267
+ `defaultValue` — the same label carrying `params` was rejected outright, and a
268
+ genuine `{ en: 'Owner' }` compiled while rendering an EMPTY `aria-label`. Naming
269
+ the keyed shape is the declaration half of the fix objectui#4167 started on the
270
+ naming side; `@object-ui/app-shell`'s copy keeps its inline spelling for now
271
+ because an open PR has a pending change to that file, and the comment there says
272
+ so.
273
+
274
+ `disabled` was `boolean` on a key the renderer never reads as one:
275
+ `SchemaRenderer.tsx:466` evaluates it through the same `evaluateCondition` as
276
+ `visible`, and a `disabledOn?: string` sibling exists for the same reason. It is
277
+ now `boolean | string`. The asymmetry with `visible` was accidental rather than
278
+ deliberate.
279
+
280
+ Both are widenings on authored-input-dominant properties: authors gain a
281
+ spelling, nothing that type-checked before stops doing so, and readers already
282
+ coped with `any` through `BaseSchema`'s index signature. Three test fixtures that
283
+ had been casting past these declarations with `as unknown as BaseSchema` state
284
+ their values directly now, and the declared unions are pinned invariantly so
285
+ neither a missing widening nor an overshoot to `any` can pass unnoticed.
286
+
287
+ Declaring the vocabulary honestly also surfaced a real one: the `toggle`
288
+ renderer writes `aria-label` itself instead of going through SchemaRenderer's
289
+ resolver, and it forwarded the raw value. Invoked directly it emitted
290
+ `aria-label="[object Object]"` for a keyed label — announced verbatim by a
291
+ screen reader. It resolves now. Through `SchemaRenderer` the bug was invisible,
292
+ because SchemaRenderer injects its own resolved `aria-label` afterwards; a
293
+ downstream type-check sweep found it, not a test.
294
+
295
+ `BaseSchema.label` and `.description` are deliberately unchanged and pinned that
296
+ way. They receive the spec's inline `I18nLabel` from the view bridges, which is a
297
+ real defect, but resolving it belongs at the spec-to-schema boundary rather than
298
+ in this declaration — and that work is still blocked on a design question about
299
+ where the display locale enters, so it is not in this release.
300
+
301
+ - d0c3b26: Every plain `<button>` now declares its `type`. HTML defaults an untyped button to
302
+ `type="submit"`, so any of these buttons would submit the form it was composed into
303
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
304
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
305
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
306
+ DOM is otherwise unchanged.
307
+
308
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
309
+ rule (error), so the next untyped button fails CI at write time rather than being found
310
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
311
+
312
+ - 4dadf0d: `@object-ui/components` compiles under `noImplicitAny` — the workspace's last strict-relaxing package
313
+
314
+ `packages/components/tsconfig.json` carried `"noImplicitAny": false`, the only place in the workspace that relaxed a `strict` sub-flag, under a comment that explained the neighbouring `rootDir` removal rather than the flag itself. `tsconfig.test.json` mirrored the one flag deliberately, so that a test project could not become the compiler of record for a source strictness decision the build config owns. Both now simply inherit `strict: true` from the root config, and the mirror's reasoning is rewritten to record why the mirror is gone rather than deleted silently.
315
+
316
+ Turning the flag on reported 26 implicitly-`any` sites in five renderer source files and 2 in the package's own tests, all of which now have real types. Nothing about the runtime changed; every one of the package's 1077 tests passes untouched.
317
+
318
+ Two of those signatures were typed by measurement rather than by preference, and both are worth recording:
319
+
320
+ The ten `sidebar.tsx` entry points follow the convention the package's other registered renderers already use — an inline `{ schema: <X>Schema; [key: string]: any }` annotation naming the registered component's own schema type (21 occurrences across the renderer tree, against zero uses of `ComponentRendererProps`). Only `'sidebar'` itself has a schema type in the registry map; the other ten registrations are sidebar _parts_ with none of their own, so they take `BaseSchema`, the type every registered node satisfies. Annotating them `SidebarSchema` would have asserted `type: 'sidebar'` on a node whose type is `'sidebar-header'`.
321
+
322
+ The action renderers' callbacks are typed from `UIActionSchema`, not the legacy `ActionSchema` those three files import for their declarations. The legacy interface (`crud.ts`, already `@deprecated`) has no `locations`, so the shared `actionRendersAt` placement predicate rejects it outright; its `variant` union has no `'primary'`, the value the objectui#2339 ordering tie-break compares against; and its `type` is the literal `'action'`, while the actions actually flowing through these renderers carry `'form' | 'script' | 'url' | 'flow' | 'api' | 'modal'`. `action:bar`'s own documented example is a `UIActionSchema`. None of this was checkable before, because the props type never reached the callbacks at all: `forwardRef` routes props through `PropsWithoutRef`, whose `Omit` collapses a props type carrying `[key: string]: any` down to the bare index signature, so `schema` arrived as `any` and every callback under it inferred `any` too. The fix annotates each action list once where it enters and lets the `filter`/`some`/`map` chains below infer.
323
+
324
+ Graded `patch`: no declaration this package publishes changes shape. The three action schema interfaces and the leaf components whose props moved to `UIActionSchema` are internal — none is re-exported from `src/index.ts`. The `actions?: ActionSchema[]` keys those interfaces still declare remain on the legacy type; reconciling that declaration with the type the implementation actually receives reaches roughly 46 sites across 12 files and is filed separately.
325
+
326
+ - ae10a01: Console chrome reaches the bundle — the list switcher, the aggregate footer, the dialog a11y fallbacks and the whole Settings namespace screen stop being English on non-English consoles
327
+
328
+ Six strings on the two screens a user looks at most were hardcoded English literals rather than bundle lookups, so they stayed English on every non-English console with nothing an app could author to change them. They are not object, field, view or action labels — no key in `TranslationData` reaches them — while the console's own bundle already ships zh-CN, ja-JP, es-ES, de, fr, pt, ru, ko and ar and translates hundreds of neighbouring strings. Omissions from an otherwise complete bundle, not a missing capability.
329
+
330
+ **Two of the six needed no new keys at all, which is the more interesting half.** The list-view mode switcher named its nine visualizations from a private `VIEW_LABELS` table while `console.objectView.viewType*` — the same nine words — had been resolved through the bundle by the create-view picker for months; the switcher now reads those keys, so the picker's 「画廊」 and the switcher's 「画廊」 cannot drift apart in nine languages. The create/edit dialog's close button is the remainder of a fix that already landed: objectstack#5505 routed the `sr-only` close label through `common.close` for the two Shadcn-synced primitives, but `MobileDialogContent` is a hand-written wrapper outside that regeneration zone with its own close button, and it is exactly what `ModalForm` renders — so the dialog the report measured was the one place still announcing "Close" in English.
331
+
332
+ The aggregate footer is the one the original report singled out: the **number** was already locale-formatted and the **prefix** was a hardcoded `Avg: ` / `Sum: `. All eleven aggregation kinds now take their prefix from `grid.summary.*`, and the label/value join is its own key rather than a `': '` baked into the renderer — the separator is translatable content, so zh sets a fullwidth colon and fr the French space-before-colon. The numbers are untouched. The form dialog's `sr-only` description fallback joins the packs too; it is clipped, not visible, so the only way an app could displace it was to author a `description` and thereby put a visible subtitle on every dialog.
333
+
334
+ **The Settings namespace screen converts as one unit.** `SettingsView` routed zero framing copy through i18n — save/failure toasts, the env-lock and crypto refusals, the load-error card, the empty-route state, the navigation buttons, the unsaved-changes save bar — while its immediate sibling `SettingsHub`, in the same directory, resolved everything through `t('console.settingsHub.*')`. A zh-CN admin read correctly translated field labels sitting inside an English save bar, because `useSettingsLabel` translates a namespace's authored content but reaches none of the chrome around it. All of it now resolves through a `console.settingsView.*` namespace placed beside the hub's, including the crypto-refusal strings that objectui#4579 deliberately left in English rather than leave one translated string among a dozen literals.
335
+
336
+ The save-bar counter was an English plural rule executing in every locale (`change` plus an `s` when the count exceeds one). It is now a real i18next plural family — base key plus `_one` and `_other` in all ten packs — not the `(s)` spelling translated nine ways. The base key is the load-bearing part: i18next asks `Intl.PluralRules` for the one suffix a language needs and, finding no such slot, falls back to English, so without it Russian would read English at counts 2-20 and Arabic at 2-99. Russian and Arabic take the "noun: {count}" form their packs already use for this exact reason, and the counter is verified rendering in-language at 1, 2 and 5.
337
+
338
+ The Beta badge reuses the hub's existing key rather than minting a twin, and the refusal messages interpolate their subject through the bundle instead of concatenating a translated word onto an English prefix.
339
+
340
+ - 4b70d28: data-table row menu — the built-in Edit/Delete predicate parameters are derived from the authoring type, not hand-restated
341
+
342
+ One authoring shape (`DataTableSchema.rowEditPredicates` / `rowDeletePredicates`, objectui#2614) had grown four separate declarations in `renderers/complex/data-table.tsx`: the shared `isBuiltinRowActionVisible` gate and `planDataTableRowMenu` each hand-wrote `{ visibleWhen?: unknown }`, and the row-menu ITEM component hand-wrote the full `{ visibleWhen?: unknown; disabledWhen?: unknown }` pair. Nothing tied any of them to the type whose values they receive, so a rename in `@object-ui/types` would have left all four compiling against a shape that no longer existed — the objectui#3009 hand-copy family, in miniature.
343
+
344
+ Each one now derives. The planner keeps its deliberate visibility-only subset, `Pick`ed from the very key its caller passes (`Pick<NonNullable<DataTableSchema['rowEditPredicates']>, 'visibleWhen'>` and the delete twin), so the signature still tells the truth about what the function reads while becoming structurally unable to drift from what it subsets. The two consumers that serve both built-ins share one derived alias taken from the union of the twins, so they may only read keys both schema keys declare. Measured: with `visibleWhen` renamed in `@object-ui/types`, the previous hand-written declarations still type-check clean, the derived ones fail to compile.
345
+
346
+ No behavior change — no runtime code was touched, and the package's suite passes unchanged. Alongside it, the "a disabled item still counts toward the menu" rule gains the pin it never had where a user meets it: a row whose only action is `disabledWhen`-gated keeps its "⋮" trigger, and that trigger opens the item, present and `aria-disabled`. The two halves of that rule live in different functions, and each half's own test stayed green while the other regressed.
347
+
348
+ - b4d3c22: `element:button`'s action forward is excess-property checked again — a misspelled key on that payload is now a compile error, not silence
349
+
350
+ The payload `element:button` hands to `execute(…)` closed with `as any`. An assertion asks only for comparability, so it switched TypeScript's excess-property (freshness) check off for the whole literal: all sixteen keys the renderer forwards rode unchecked, and `ActionDef` being a closed type (objectui#4046) bought this surface nothing. A typo added to that list — `refreshAftr`, `confirmTxt` — would have compiled, published, and reached a runner that silently does nothing, which is the objectstack#2169 "Mark Done does nothing" shape the closed type exists to prevent.
351
+
352
+ The exemption that recorded this assumed the cast was load-bearing, on the reasoning that `element:button` receives a bare `action?: Record< string, any >` prop rather than a typed action, so removing the cast would be a contract change. Measured on TypeScript 6.0.3, that was wrong on the point that mattered: dropping the cast type-checks clean as-is, because every key the literal writes is already declared on `ActionDef`. The prop's type is a separate question and is deliberately untouched here — the forward literal never needed the cast to compile.
353
+
354
+ Two literals needed it, not one. The `execute(…)` argument is contextually typed by `execute(action: ActionDef)`, so dropping the cast is enough for the explicit keys. The `paramsPayload` binding spread into it is the second, easily-missed half: a spread source's own keys are not checked _through_ the spread, so `{ actionParams }` / `{ params }` could still invent a key while the payload around them was checked. That binding is now annotated `ActionDef` too.
355
+
356
+ Runtime behaviour is unchanged — the object reaching `execute` is identical, key order included, and the emitted `.d.ts` does not move. What changes is that the compiler now rejects an invented key here, verified in both directions: the same probe key produces `TS2353` after this change and no diagnostic at all before it.
357
+
358
+ With this surface fixed, `check:action-forward-parity` has no payloads exempt from its freshness rule: all five action-forwarding renderers write into a literal the compiler checks, and the gate's ratchet removed the exemption entry itself as designed.
359
+
360
+ - bc64bfe: A dependency-gated option list no longer deletes the field's stored value on mount
361
+
362
+ The four fixed-option widgets (`SelectField`, `MultiSelectField`, `CheckboxesField`, `RadioField`) end their cascade resolution with a "drop what is no longer offered" effect, and the form renderer runs an equivalent clear of its own over every option field. Both read `resolveCascadingOptions`, which returns an **empty** offered set whenever the list is _gated_ — a declared `dependsOn` parent is still empty. Nothing the field held could be "still offered" against an empty set, so both paths wrote the field empty **on mount, with no interaction**, while the control rendered "Select Country first" beside it: it told the user it could not offer anything, and deleted what they had.
363
+
364
+ Gated means **unknown**, not invalid. The cascade clear exists (ADR-0058) so a user-driven parent change prunes a now-invalid child; a withheld list on mount is missing information — the record simply arrived with its controlling field empty (a later-cleared parent, an import, a partially-migrated row) — and that is not a reason to destroy stored data. Both clears now skip while gated, reading the resolver's own `gated` flag rather than re-deriving it from an empty offered set, which would collide with the distinct never-configured case guarded separately in objectui#4220.
365
+
366
+ Convergence stays exactly where it belongs: once the parent **is** chosen and the resolved set genuinely excludes the stored value, the prune applies unchanged — including at the moment the gate lifts, so picking a parent whose list does not contain the old value still clears it on that transition. The three states are pinned apart (never-configured / gated / resolved-and-excludes) across all four widgets and the form host, so a future edit cannot collapse them back into one empty-set test.
367
+
368
+ Reachable on every host that mounts these widgets with a live record: the form renderer, the grid's inline cell editor, and the detail page's inline editor — where each `onChange` went straight into the record draft the save bar commits.
369
+
370
+ - 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
371
+
372
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
373
+
374
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
375
+
376
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
377
+
378
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
379
+
380
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
381
+
382
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
383
+
384
+ No behavior, no keys added or removed, no placeholder changed.
385
+
386
+ - 45e1949: Numbers render in the user's locale, and a `Field.number` year is no longer `2,026`
387
+
388
+ Every numeric field the console rendered went through an `Intl.NumberFormat` built with the locale hardcoded to `en-US` and `useGrouping` never set. Two defects rode in that one construction: a `zh-CN` or `de-DE` console still grouped and pointed decimals the US way, and a four-digit **year** stored as `Field.number({ scale: 0 })` rendered as `2,026` — in every locale, with no field property able to turn it off. Apps had been converting year columns to `Field.text` to escape it, permanently trading numeric comparison, range filters and dataset dimension types for a display detail.
389
+
390
+ The construction had been copied into five places — the number cell renderer, the currency cell renderer, the `CurrencyField` widget, the compact `formatNumber` helper, and the dashboard `MetricWidget` — so fixing any one surface never changed the answer. They now share one formatter, `formatDisplayNumber` in `@object-ui/i18n`, which owns the locale and the grouping policy together, plus one locale resolver, `useDisplayLocale`.
391
+
392
+ `useDisplayLocale` composes the two locale channels this repo already had rather than adding a third: the tenant's regional default (`useLocalization().locale`, ADR-0053) when an org has configured one, otherwise the active UI language (`useObjectTranslation().language`) so grouping and decimal marks follow a language switch. That second step is what covers the case the report was measured in — a fresh database, where the tenant localization endpoint has no locale to give.
393
+
394
+ Grouping is now suppressed when a field declares `scale: 0` and carries no currency, which is what makes years, fiscal periods and other ordinals render plainly. This is an **interim default** with an accepted cost: a large scale-0 _count_ loses its separators too. It holds only until the spec gains an authorable presentation hint, which is being specified separately, contract-first; when that lands it overrides this heuristic.
395
+
396
+ Three surfaces deliberately keep their separators, because a zero-decimal display there does not come from a field declaration: the dashboard `MetricWidget` (its decimals are parsed from a numeral.js format pattern, and its own contract calls the separators load-bearing — "`1,930,000` not `1930000`"), the `element:number` aggregate renderer, and every currency path including amounts whose currency code could not be resolved. An **undeclared** `scale` also keeps grouping — absent means "decimals unknown", not "integer".
397
+
398
+ `formatCurrency`, `formatCompactCurrency` and `formatNumber` each take a new optional trailing `locale` argument. Existing calls are unaffected; omitting it now follows the runtime default rather than forcing US conventions.
399
+
400
+ - a3ae404: fix(components,plugin-dashboard): a static-data `table` widget renders instead of crashing
401
+
402
+ A dashboard widget authored as `{ type: 'table', options: { data: [ … ] } }` fell into the
403
+ error boundary with "Maximum update depth exceeded" the moment its tile re-rendered, while
404
+ every chart family on the identical static surface rendered clean.
405
+
406
+ - `data-table` no longer re-renders itself to death. Its `columns` / `data` fallbacks are
407
+ module-scope empties instead of per-render array literals, and the prop→state column sync
408
+ re-seeds on a value change rather than on a new identity — so a consumer that derives its
409
+ columns each render (which both dashboard surfaces do) costs the table nothing.
410
+ - Both dashboard surfaces now give the static table the `columns` key `DataTableSchema`
411
+ requires, derived from the rows when the author declared none — the same derivation the
412
+ `provider: 'object'` half of the widget family already performed. Previously such a table
413
+ drew one empty row per record: no headers, no cells.
414
+ - `DashboardGridLayout` reads an authored `options.data` ARRAY for its static table, which
415
+ its `widgetData?.items` expression resolved to `[]`. `DashboardRenderer` had the arm all
416
+ along.
417
+
418
+ - bfdf3d4: `element:record_picker.filter` is now discoverable from the published `inputs`
419
+
420
+ The fourth A-class gap of objectui#3808's own list, and the one its three-way
421
+ triage dropped: `filter` appears in that issue's raw key dump for this block and
422
+ then in none of its A / B / C lists, so the change that added the repo-wide
423
+ parity gate exempted it by name instead of declaring it. It is the same shape as
424
+ the four #3808 fixed — `@objectstack/spec` declares
425
+ `ElementRecordPickerProps.filter`, the renderer has read it all along
426
+ (`composed?.filter ?? props.filter`, straight into the picker query's `$filter`),
427
+ and the registry `inputs` never mentioned it.
428
+
429
+ `element:record_picker` is not in the public tier ("record picking is a field
430
+ widget, not a page block"), so the gap was not in `sdui.manifest.json` — it was
431
+ in the JSX-page compiler's prop whitelist, which `renderers/layout/page.tsx`
432
+ builds from `getKnownTypes()` plus these same `inputs`. A JSX page writing
433
+ `filter` therefore got an `unknown-prop` warning from `sdui-parser`'s prop walk
434
+ on the very key that decided which records the picker offered, and the designer
435
+ panel gave an author no way to discover the key existed at all.
436
+
437
+ The description is derived from what the renderer does, not from restating the
438
+ spec's one-liner, because the one thing an author cannot read off the spec is
439
+ which of the two places they may write a filter wins: a node-level `dataSource`
440
+ filter (itself AND-combined with any saved `view` it names) is taken and this
441
+ top-level `filter` is DROPPED, not merged — so this key applies only when the
442
+ node carries no `dataSource` filter.
443
+
444
+ `type` is `'object'`, taken from the spec's actual shape on the resolved pin
445
+ rather than the `'array'` the issue's landing sketch guessed:
446
+ `FilterConditionSchema` is `z.record(z.string(), z.unknown())` intersected with
447
+ the `$and` / `$or` / `$not` group, so a rule array is rejected. This is the one
448
+ key in the family where `ComponentInput`'s coarse typing costs nothing —
449
+ `sdui-parser`'s `checkType` accepts exactly the values the spec accepts here, so
450
+ unlike `element:text_input.defaultValue` there is no narrowing to disclose.
451
+
452
+ The parity gate's explicit exemption for this key is deleted in the same change
453
+ (its own `carries no stale unpublished-key exemption` assertion demands it), and
454
+ the key joins #3808's four in the by-name "declared, not merely not-failing" pin.
455
+
456
+ - b1e42d0: Conditional required (`requiredWhen`) now decides at SUBMIT time too — the star and the validator can no longer disagree
457
+
458
+ A `requiredWhen` predicate that flipped to FALSE after the dialog mounted updated only half the form. The display layer re-evaluated correctly — the asterisk and `aria-required` both disappeared — while submit stayed refused with "<field> is required" and no write was ever issued. The user saw an optional field and a form that would not save, with nothing on screen naming the field it was still waiting on (objectui#4161).
459
+
460
+ The cause is not a mount-time snapshot, which is what the symptom looks like. The renderer hands react-hook-form its per-field rules as a `<Controller rules>` prop, and RHF _merges_ that object into the field descriptor it already holds — `_f: { ...previous._f, ...options }`. A rule key that stops being spelled is therefore never removed. Rules could be ADDED live (a predicate flipping TRUE after mount did start enforcing, correctly) but never withdrawn: the `validate.required` entry installed the first time the predicate evaluated TRUE outlived every later FALSE verdict. The validation layer was append-only, latched on the first TRUE the field ever produced.
461
+
462
+ The `validate.required` entry is now registered unconditionally and decides required-ness when it _runs_, reading the live verdict the renderer publishes on every render — the same single `resolveFieldRuleState` result that draws the asterisk, not a second evaluation of the predicate with its own copy of the record assembly. Both directions are pinned: a predicate flipping FALSE re-opens submit, a predicate flipping TRUE starts enforcing, and statically required fields are unaffected.
463
+
464
+ - 3f5f87c: `SchemaRenderer` states its real contract — a typed, required `schema` and a deliberate forwarding surface
465
+
466
+ `SchemaRenderer` is the renderer loop: every registered SDUI component is rendered through it. It handed `forwardRef` a props type of `{ schema: SchemaNode } & Record<string, any>`, which puts `string` into `keyof Props`, so `'ref' extends keyof Props` was always true, React's `PropsWithoutRef` took its `Omit` branch, and `Omit` over a type carrying a string index signature keeps only the index signature. Every declared prop was erased. Measured on the pre-fix source: `keyof ComponentProps<typeof SchemaRenderer>` was `string` and `ComponentProps<typeof SchemaRenderer>['schema']` was `any`, while the type argument went on declaring `SchemaNode`. The other half is the same defect seen from the call site — `<SchemaRenderer />` with no schema at all, `<SchemaRenderer schema={12345} />`, and an arbitrary misspelled prop each type-checked in silence. This is objectui#4422 / PR #4438's trap in the most central component in the repo, spelled `Record<string, any>` rather than `[key: string]: any`, which is why every previous sweep's grep and both shipped guards' detector reported the site as clean.
467
+
468
+ Graded **minor, not major**, on objectui#4528's reasoning: the type argument has always DECLARED `schema`; the index signature erased it from the resolved type, and restoring what the declaration documents is a fix to the published contract rather than a contract break.
469
+
470
+ **The forwarding surface is kept, deliberately.** This component forwards every prop it does not read to the component the schema names, resolved at runtime from a plugin-extensible registry — `packages/react/README.md` documents exactly that, and `@object-ui/components`' form renderer consumes the `onSubmit` it shows being forwarded. Closing that surface would state a false contract and would force every leaf plugin's props into this package. So the two halves are separated: the `forwardRef` type argument is the honest `SchemaRendererProps`, with no index signature for `PropsWithoutRef` to collapse, and the open surface is stated once in an explicit export annotation, which nothing routes through `Omit`. The published `.d.ts` shows the erasure disappearing: `ForwardRefExoticComponent<Omit<{ schema: SchemaNode } & Record<string, any>, "ref"> & RefAttributes<any>>` becomes `ForwardRefExoticComponent<SchemaRendererProps & Record<string, any> & RefAttributes<any>>`.
471
+
472
+ `SchemaRendererProps.schema` is declared as `BaseSchema | string | null | undefined` — what this component actually handles. It previously declared `@object-ui/core`'s `SchemaNode` interface, which requires `type: string` and so contradicted the component's own early returns for strings and nullish, while every caller held `@object-ui/types`' wider union. The erasure hid that mismatch completely.
473
+
474
+ **One declared behaviour change.** A non-object, non-string primitive schema now renders as its own text. It previously fell through to the shallow copy `{ ...schema }`, which spreads a primitive to an empty object, lost the `type` the renderer then looked up, and surfaced the red "Unknown component type: undefined" box — an accident of the spread rather than a decision. The declared props type excludes `number` / `boolean` so no author is invited to pass them; the runtime handling is defence-in-depth for untyped callers and stored metadata. Strings, `null`, `undefined`, `0` and `false` render exactly as before, and an object naming an unregistered type still gets the error box; all four are pinned.
475
+
476
+ Latent defects the erasure had been hiding, each surfaced by the repo-wide type-check and fixed at its call site: `DashboardRenderer` cast its widget schema to `Record<string, any>`, dropping the `type` every branch of `getComponentSchema` sets; `DashboardGridLayout`'s equivalent now states its return type instead of inferring a union that admitted a shape with no `type`; and `ReportViewer` handed a section's `content` array to the renderer whole, so a multi-node section rendered the unknown-component box instead of its content — arrays are mapped rather than widened into the renderer's declared input.
477
+
478
+ A repo-wide structural guard replaces the two per-package siblings' blocked direction: it judges every `forwardRef` in `packages/*/src` (219 sites) and its detector resolves `Record<string, …>` and `string`-keyed mapped types in addition to literal index signatures — the spelling the previous detector went blind on. It judges the type argument only, where an index signature is an accidental eraser, and never an export annotation, where one is a stated contract.
479
+
480
+ - f5e1143: A collapsed sidebar now survives a reload — `SidebarProvider` reads the `sidebar_state` cookie it has always written
481
+
482
+ The cookie half of this feature only ever ran in one direction. `setOpen` wrote `sidebar_state` on every toggle with a 7-day max-age, and nothing ever read it back: `SidebarProvider` seeded its state from `defaultOpen` (default `true`), so a sidebar you collapsed came back expanded on the next load with the correct cookie sitting right there, unread. QA measured it at 255px and `data-state=expanded` at +2s, +4s and +8s after load, reproduced three times.
483
+
484
+ Upstream Shadcn closes this loop in a **server component** — it reads the cookie there and passes the value down as `defaultOpen`. A pure SPA like the console has no such step, which is why nothing downstream could paper over it: passing a cookie-derived `defaultOpen` from one shell would have fixed that shell and left every other consumer of the primitive broken. The read therefore happens client-side, in the provider, as a lazy `useState` initialiser rather than a mount effect — the state has to be right on the first render, since a post-mount correction would still flash an expanded sidebar at the user.
485
+
486
+ Precedence is now pinned, in this order: a controlled `open` prop, then the cookie, then `defaultOpen`, then `true`. The cookie overrides the _default_, never a controlled usage. With no cookie present the behaviour is exactly what it was before, which is what keeps explicit `defaultOpen={false}` call sites — the marketing demos in `apps/site` — rendering unchanged; those cases are controls in the new test file and are green on both sides of the change.
487
+
488
+ Only the two values the writer produces are honoured (`"true"` / `"false"`), matched on an exact cookie name; anything else, including an absent or malformed value, falls through to `defaultOpen` rather than inventing a preference the user never expressed. The reader is SSR-safe, which `apps/site` needs: those primitives are `"use client"`, and Next still renders them on the server for the initial HTML, where there is no `document`.
489
+
490
+ Because `packages/components/src/ui/**` is regenerated from the Shadcn registry, the primitive itself only gains two anchored one-liners. All of the parsing lives in `packages/components/src/lib/sidebar-cookie.ts`, which the sync never touches, and the two edits are declared in `scripts/shadcn-local-patches.mjs` so `pnpm shadcn:update` re-applies them instead of silently reverting the fix — the same mechanism already used for the translated `Sheet`/`Dialog` close labels.
491
+
492
+ - 5bf09fd: `ActionParamDialog`'s `select` branch no longer renders a hardcoded English `Select...` placeholder. The fallback used when an action param declares no `placeholder` of its own now reads the existing `common.select` pack key, so it is translated in all ten locales and carries the typographic ellipsis (U+2026) that #3878 converged the packs on. Authored `placeholder` metadata keeps priority, and no locale pack changed — the key was reused from `LookupField`'s identical select-trigger use.
493
+ - Updated dependencies [0e67b53]
494
+ - Updated dependencies [ceccdcf]
495
+ - Updated dependencies [ee66e2e]
496
+ - Updated dependencies [ee26e65]
497
+ - Updated dependencies [5900ac5]
498
+ - Updated dependencies [932cbcd]
499
+ - Updated dependencies [734d186]
500
+ - Updated dependencies [f650253]
501
+ - Updated dependencies [3d9769a]
502
+ - Updated dependencies [8f85f8b]
503
+ - Updated dependencies [d0c3b26]
504
+ - Updated dependencies [3fc2971]
505
+ - Updated dependencies [aca27fa]
506
+ - Updated dependencies [dde7283]
507
+ - Updated dependencies [f7c6430]
508
+ - Updated dependencies [ae10a01]
509
+ - Updated dependencies [92876f0]
510
+ - Updated dependencies [f279deb]
511
+ - Updated dependencies [eb7f586]
512
+ - Updated dependencies [e901131]
513
+ - Updated dependencies [d9d3463]
514
+ - Updated dependencies [2a40f69]
515
+ - Updated dependencies [bec3e14]
516
+ - Updated dependencies [613b167]
517
+ - Updated dependencies [1f9b905]
518
+ - Updated dependencies [828549a]
519
+ - Updated dependencies [e1ade8f]
520
+ - Updated dependencies [abb0f81]
521
+ - Updated dependencies [38ab505]
522
+ - Updated dependencies [3e19fe7]
523
+ - Updated dependencies [bb58d1d]
524
+ - Updated dependencies [5cc847c]
525
+ - Updated dependencies [fa21254]
526
+ - Updated dependencies [33c32bf]
527
+ - Updated dependencies [66fb4fa]
528
+ - Updated dependencies [b953a97]
529
+ - Updated dependencies [d7f3e30]
530
+ - Updated dependencies [6d641c9]
531
+ - Updated dependencies [7e4f0e5]
532
+ - Updated dependencies [a84385b]
533
+ - Updated dependencies [45e1949]
534
+ - Updated dependencies [92250d6]
535
+ - Updated dependencies [c1d939f]
536
+ - Updated dependencies [58bebf6]
537
+ - Updated dependencies [405e808]
538
+ - Updated dependencies [49ae9f4]
539
+ - Updated dependencies [c0f9a4b]
540
+ - Updated dependencies [2459a3e]
541
+ - Updated dependencies [ac853ce]
542
+ - Updated dependencies [fa51109]
543
+ - Updated dependencies [d6aa172]
544
+ - Updated dependencies [fe52a04]
545
+ - Updated dependencies [d46f9b8]
546
+ - Updated dependencies [3f5f87c]
547
+ - Updated dependencies [2fea4d2]
548
+ - Updated dependencies [7f1cb33]
549
+ - Updated dependencies [f148a64]
550
+ - Updated dependencies [bb68488]
551
+ - Updated dependencies [2e3b0c0]
552
+ - Updated dependencies [9461dd3]
553
+ - Updated dependencies [78fa331]
554
+ - Updated dependencies [47f551b]
555
+ - Updated dependencies [31ab1ac]
556
+ - Updated dependencies [0082db8]
557
+ - Updated dependencies [ab04728]
558
+ - Updated dependencies [06915b0]
559
+ - Updated dependencies [ff84b05]
560
+ - @object-ui/i18n@17.5.0
561
+ - @object-ui/react@17.5.0
562
+ - @object-ui/core@17.5.0
563
+ - @object-ui/types@17.5.0
564
+ - @object-ui/sdui-parser@17.5.0
565
+ - @object-ui/react-runtime@17.5.0
566
+
567
+ ## 17.4.0
568
+
569
+ ### Minor Changes
570
+
571
+ - ecae400: Retire the `capability-multiselect` field widget name, which existed only on the docs-site registration path and which nothing ever stamped (objectui#3308, ADR-0049 enforce-or-remove)
572
+
573
+ `field:capability-multiselect` was registered by `registerFields()` and only there. That function's sole caller is the docs site, so the key never existed on the live path (`registerAllFields()`, run at module import, iterates `fieldWidgetMap` — which never listed it). A field authored with `widget: 'capability-multiselect'` therefore resolved to nothing in every real application, while the comment above the registration described it as usable from a record form: a code comment promising a capability that does not exist, which is the worst direction for a metadata renderer AI-authored apps read as authority.
574
+
575
+ Nothing stamped the hint either. ADR-0056 P1 stamps `permission-facet-link` on all six `sys_permission_set` facets — `system_permissions` included — through the single `ObjectStackAdapter.getObjectSchema` choke point, and P2 put the capability editor in Studio. The widget name was a leftover from an intermediate iteration of that rollout.
576
+
577
+ Removed, with a tombstone at each site:
578
+
579
+ - `@object-ui/fields` — the `field:capability-multiselect` registration and the comment that advertised it. **Breaking in name only**: the key was unreachable outside the docs site, so no application could have resolved it. A field still carrying the hint now degrades to its declared `type` renderer, the defined behavior for an unregistered widget.
580
+ - `@object-ui/plugin-detail` — `InlineFieldInput`'s `widget === 'capability-multiselect'` branch, the hint's last honoring surface. Leaving one consumer for a name no producer emits and no form resolves is the same declared-vs-enforced split, inverted. The sibling `permission-facet-link` branch is untouched and pinned.
581
+ - `@object-ui/components` — the dead `capability-multiselect` entry in the form renderer's `DATA_SOURCE_FIELD_TYPES` set, which could never match a resolvable widget.
582
+ - `@object-ui/plugin-form` — a comment naming `capability-multiselect` as the widget stamped onto `sys_permission_set.system_permissions`; it names `permission-facet-link` now, which is what is actually stamped.
583
+
584
+ `CapabilityMultiSelectField` itself is **unchanged and still exported**: Studio's `PermissionMatrixEditor` imports and renders it directly, which is ADR-0056 P2's design. Only the widget name is retired — the component is not a registry field widget and its doc comment now says so.
585
+
586
+ `registerFields()` is also **kept**, with its `@deprecated Use registerAllFields() instead` note corrected. The two are not interchangeable: it registers `createFieldRenderer(widget)`, which synthesizes the label, description and the local `value`/`onChange` state that lets a bare field node (`{ type: 'currency', label: 'Amount' }`) render standalone in the docs demos. Retiring it needs a decision about where that demo chrome goes; the note now records that instead of implying a drop-in replacement.
587
+
588
+ ### Patch Changes
589
+
590
+ - 794c497: `action:bar` member actions declaring `visible: false` are now hidden instead of rendered
591
+
592
+ `action:button` and `action:icon` carried the same truthiness gate objectui#3812
593
+ removed from the member-action leaves — `if (schema.visible && !isVisible)
594
+ return null` — so `visible: false`, the most explicit way an author can say
595
+ "never show this", fell into the "no gate declared" branch and the action
596
+ rendered anyway.
597
+
598
+ objectui#3812's triage judged the five component-level `schema.visible` gates a
599
+ dormant defensive layer, because `packages/react`'s `SchemaRenderer` evaluates
600
+ `newSchema.visible !== undefined` and hides the node before the component ever
601
+ mounts. Two of the five are not dormant, and this is the difference:
602
+
603
+ `action:bar` does not route through `SchemaRenderer`. It resolves each member's
604
+ renderer from the `ComponentRegistry` itself and spreads the whole member action
605
+ onto that renderer's schema, so an author's `visible` on a member arrives as the
606
+ child's own `schema.visible` and lands on the child's gate. `action:bar` is also
607
+ the only gate on that path by design — its `filteredActions` deliberately
608
+ filters on `requiredPermissions` and `actionRendersAt` only, leaving `visible` to
609
+ the member renderer. The path is reachable end-to-end and is now pinned that way
610
+ (registry-mounted `action:bar`, member declaring `visible: false`), so the
611
+ reachability does not have to be argued again.
612
+
613
+ Both gates now read the same named definition as the rest of the family,
614
+ `hasDeclaredVisibilityGate` (`!= null && !== ''`) — the invariant objectui#3492
615
+ established for the selection bar and objectui#3758 applied to the row-action
616
+ surfaces. The evaluation entry is untouched and already short-circuits a boolean
617
+ rather than handing it to the CEL engine, which `actionPredicate.parity` pins for
618
+ both the engine and the renderer path.
619
+
620
+ Behaviour change surface, deliberately narrow: only an `action:button` /
621
+ `action:icon` whose `visible` is the literal boolean `false` (or another falsy
622
+ non-empty value) changes — from rendered to hidden, which is what the
623
+ declaration asked for. `visible: true` still renders, `''` and an absent
624
+ `visible` are still no gate at all, and no expression-valued `visible` changes
625
+ verdict. `ActionSchema.visible` is `ExpressionInputSchema` with no boolean
626
+ member, so `objectstack build` cannot emit this shape; hand-written view JSON and
627
+ in-process callers constructing action defs can.
628
+
629
+ The remaining three component-level gates (`action:group`, `action:menu`,
630
+ `action:bar`'s own) stay as they are — they only ever mount through
631
+ `SchemaRenderer`, which resolves `visible` first, and the overflow `action:menu`
632
+ that `action:bar` synthesizes carries no `visible` at all.
633
+
634
+ - 993336f: An action declaring `disabled: ''` is no longer greyed out forever (objectui#3842)
635
+
636
+ The "is a `disabled` gate declared?" test stopped at `!= null`, missing the
637
+ `!== ''` half of the invariant the `visible` family converged on
638
+ (`hasDeclaredVisibilityGate`, objectui#3492 / #3758 / #3812 / #3823 / #3835). So
639
+ `disabled: ''` counted as a declared gate, and the verdict went to the evaluation
640
+ entry — which reads an empty predicate as "no condition → `true`"
641
+ (`toPredicateInput('')` is `undefined`, `evaluateCondition(undefined)` is `true`).
642
+
643
+ The direction is why this half is a defect and the `visible` half was not. On
644
+ `visible`, that `true` means SHOW, so an over-broad "declared" test and a
645
+ permissive empty predicate cancel out and `visible: ''` renders either way. On
646
+ `disabled`, the same `true` means DISABLE — the two mistakes compound, and an
647
+ empty predicate stopped meaning "no gate" and started meaning "permanently
648
+ greyed out". One empty predicate, opposite treatment under two keys.
649
+
650
+ Two gates now ask the shared definition instead:
651
+
652
+ - `@object-ui/app-shell`'s `DeclaredActionsBar` — the hot one. Its actions are
653
+ SERVER-declared (`objectDef.actions[]`) and its hosts are the approvals inbox's
654
+ record sections, so a `disabled: ''` arriving from metadata (an authoring form
655
+ left empty, a template that rendered to an empty string) produced an Approve /
656
+ Reject button nobody could click, indistinguishable from deliberate metadata.
657
+ objectui#3835 was this same surface failing the other way.
658
+ - `@object-ui/components`' `action:button` — verified to be the same shape before
659
+ it was changed (the issue inferred it from the identical spelling but did not
660
+ probe it): with `disabled: ''` the rendered button carried `disabled=""`.
661
+
662
+ **Behaviour change surface, deliberately narrow.** Only `disabled: ''` changes —
663
+ from disabled to clickable, which is what "no predicate" asked for. `disabled:
664
+ true` still disables, `disabled: false` and an absent `disabled` still do not, and
665
+ no expression-valued `disabled` changes verdict. One consequence worth naming: on
666
+ `action:button`, an empty `disabled` now falls THROUGH to the legacy non-spec
667
+ `enabled` fallback instead of short-circuiting on the empty predicate, so an
668
+ action spelling both (`disabled: ''` + `enabled: true`) becomes clickable.
669
+
670
+ The legacy `enabled` leg of `action:button` was routed through the same
671
+ definition for consistency, and that part is behaviour-preserving by derivation
672
+ rather than a fix: the leg is negated (`disabled = !isEnabled`), so an empty
673
+ predicate's `true` already arrived as "not disabled" — the same verdict "no gate"
674
+ produces. All four shapes are identical under either test; the derivation table
675
+ and the reason no test can distinguish them are written down next to the pins.
676
+
677
+ `hasDeclaredVisibilityGate` keeps its historic name at both call sites (the
678
+ objectui#3842 dispatch ruling): the predicate is key-neutral, and one
679
+ implementation behind two names is how a repo grows dialects. Each call site says
680
+ so in a comment.
681
+
682
+ - f0a625a: `disabled: ''` no longer greys out the remaining five action surfaces (objectui#3849)
683
+
684
+ objectui#3842 / PR #3851 fixed the "is a `disabled` gate DECLARED?" test on
685
+ `action:button` and app-shell's `DeclaredActionsBar`. Five same-shaped sites were
686
+ outside that PR's scope and stayed on `!= null`, so within one component the
687
+ `visible` gate asked `hasDeclaredVisibilityGate` while the `disabled` gate on the
688
+ next line asked `!= null` — two spellings of one question:
689
+
690
+ - `@object-ui/components` — `action:icon`, `action:group`'s inline button
691
+ (`InlineActionButton`) and dropdown item (`DropdownActionItem`), and
692
+ `action:menu`'s item (`ActionMenuItem`).
693
+ - `@object-ui/plugin-detail` — `record:quick_actions`' `QuickActionButton`.
694
+
695
+ Why the missing `!== ''` half is a defect on this key and not on `visible`:
696
+ `toPredicateInput('')` is `undefined` and `evaluateCondition(undefined)` is
697
+ `true`. On `visible` that `true` means SHOW, so an over-broad "declared" test and
698
+ a permissive empty predicate cancel out. On `disabled` it means DISABLE, so they
699
+ compound — `disabled: ''` (an empty predicate: nothing declared) rendered a
700
+ permanently greyed-out control, with nothing the author could write to un-grey
701
+ it. Unlike #3842's approvals inbox, these five are the general action face
702
+ (toolbars, dropdowns, record quick actions), so the reach is wider even though no
703
+ single high-value host owns them.
704
+
705
+ **Behaviour change surface, deliberately narrow.** Only `disabled: ''` changes —
706
+ from disabled to clickable, which is what "no predicate" asked for. `disabled:
707
+ true` still disables, `disabled: false` and an absent `disabled` still do not, and
708
+ no expression-valued `disabled` changes verdict. On the four sites that also carry
709
+ the legacy non-spec `enabled` fallback, one consequence follows: an empty
710
+ `disabled` now falls THROUGH to that leg instead of short-circuiting on the empty
711
+ predicate, so an action spelling both (`disabled: ''` + `enabled: true`) becomes
712
+ clickable. `record:quick_actions` has no `enabled` leg, so its chain is the single
713
+ gate.
714
+
715
+ Routing those legacy `enabled` legs through the same definition is
716
+ behaviour-preserving by derivation rather than a fix: the leg is negated
717
+ (`disabled = !isEnabled`), so an empty predicate's `true` already arrived as "not
718
+ disabled" — the verdict "no gate declared" produces. #3842's four-shape derivation
719
+ table is reproduced next to the new pins, together with the statement that no
720
+ `enabled` case can go red by reverting that leg.
721
+
722
+ `hasDeclaredVisibilityGate` keeps its historic name (the objectui#3842 ruling): the
723
+ predicate is key-neutral, and one implementation behind two names is how a repo
724
+ grows dialects. The three `@object-ui/components` sites import it relatively;
725
+ `record:quick_actions` takes it from the package barrel, the cross-package route
726
+ objectui#3835 opened. Every call site says so in a comment.
727
+
728
+ - b5980f4: Action-face member actions declaring `visible: false` are now hidden instead of rendered
729
+
730
+ The three member-action gates on the action face asked truthiness —
731
+ `if (action.visible && !isVisible) return null` — so `visible: false`, the most
732
+ explicit way an author can say "never show this", fell into the "no gate
733
+ declared" branch and the action rendered anyway:
734
+
735
+ - `action:group` in `display: 'inline'` mode (`InlineActionButton`);
736
+ - `action:group` in `display: 'dropdown'` mode (`DropdownActionItem`);
737
+ - `action:menu`'s items (`ActionMenuItem`).
738
+
739
+ These leaves `.map()` the component's own `actions` array, so neither
740
+ `SchemaRenderer`'s node-level `visible` handling nor
741
+ `ActionEngine.getActionsForLocation` (whose boolean `visible` was always
742
+ correct) is in the path — the truthy gate was the only gate.
743
+
744
+ All three now read one named definition, `hasDeclaredVisibilityGate`
745
+ (`!= null && !== ''`), and let the declaration itself decide. This is not a new
746
+ decision: objectui#3492 established the invariant for the selection bar, whose
747
+ `hasVisibilityGate` spells out why truthiness cannot answer the question, and
748
+ objectui#3758 applied it to both row-action surfaces. The evaluation entry is
749
+ untouched and already short-circuits a boolean rather than handing it to the CEL
750
+ engine, which `actionPredicate.parity` pins for both the engine and the renderer
751
+ path.
752
+
753
+ Behaviour change surface, deliberately narrow: only a member action whose
754
+ `visible` is the literal boolean `false` (or another falsy non-empty value)
755
+ changes — from rendered to hidden, which is what the declaration asked for.
756
+ `visible: true` still renders, `''` and an absent `visible` are still no gate at
757
+ all, and no expression-valued `visible` changes verdict.
758
+ `ActionSchema.visible` is `ExpressionInputSchema` with no boolean member, so
759
+ `objectstack build` cannot emit this shape; hand-written view JSON and
760
+ in-process callers constructing action defs can.
761
+
762
+ - 8aad9fd: Action-face predicates written against the canonical `record.` root now evaluate
763
+
764
+ `action:button`, `action:icon`, `action:menu` and `action:group` gated their
765
+ actions on `useCondition(pred, context)`, which evaluates on
766
+ `new ExpressionEvaluator({ ...scope, ...context })` — and the context each of
767
+ them passed was the row spread flat, or nothing at all. Only the shorthand
768
+ spelling resolved:
769
+
770
+ | predicate | verdict, before |
771
+ | ---------------------------- | -------------------------------- |
772
+ | `status == "pending"` | evaluates (`action:button` only) |
773
+ | `record.status == "pending"` | throws `record is not defined` |
774
+ | `data.status == "pending"` | throws `data is not defined` |
775
+
776
+ `record.` is not a mistaken spelling — it is the canonical one. It is what
777
+ `ExpressionEvaluator`'s CEL path binds (`bag.record` as the record namespace),
778
+ what `evalRowPredicate` binds on the record header, list rows, the row kebab
779
+ and conditional formatting (`record.status` / bare `status` / `data.status`),
780
+ and what the server enforces with. A `visible` that fails CLOSED turns the throw
781
+ into "hidden", so a correctly-authored predicate deleted its own button —
782
+ indistinguishable from the gate having said no. On the fail-soft legs the same
783
+ throw lands the other way: `disabled` greyed a control out for everyone.
784
+
785
+ Live rather than theoretical: every declared action on framework's
786
+ `sys_approval_request` gates on `record.viewer.*`, so the whole server-declared
787
+ approval decision set was invisible wherever the declared-action bar rendered
788
+ until objectui#4077 fixed that bar. These four generic renderers carried the
789
+ same binding.
790
+
791
+ What changed:
792
+
793
+ - all four bind the row the three canonical ways, through one named helper
794
+ (`usePredicateRecordContext`, exported from `@object-ui/react` beside
795
+ `useCondition`), so the action face and the row surfaces answer an author's
796
+ `visible:` the same way;
797
+ - `action:icon` reads the row at all. It evaluated against an empty bag, so not
798
+ even the bare-field shorthand resolved — and its `data` prop was landing in
799
+ the props spread onto the DOM button;
800
+ - `action:menu`'s items and `action:group`'s two leaves receive the row from
801
+ their host, which they previously never got;
802
+ - `action:bar` forwards the row into the overflow menu it builds, not just to
803
+ its inline members. An action's predicate had been answering a different
804
+ question purely because it spilled past `maxVisible` — which on mobile
805
+ defaults to 1, making the verdict a function of the viewport.
806
+
807
+ Deliberately unchanged: the evaluation entry and each site's error policy. A
808
+ predicate that genuinely faults still fails closed on `action:button` /
809
+ `action:menu` `visible` and still fails soft on the other legs, exactly as
810
+ before; `toPredicateInput`, `hasDeclaredVisibilityGate` and the empty-predicate
811
+ rules keep their pinned semantics. Binding the row is a separate question from
812
+ what to do when the predicate faults.
813
+
814
+ A surface with no row of its own binds nothing rather than an empty record, so
815
+ a host that supplies the row through the ambient predicate scope is not blanked
816
+ out; a row passed explicitly still wins over the scope.
817
+
818
+ - 0cbdca8: Built-in `select` fields: the form's label, validation message and required state now reach the control
819
+
820
+ A hand-written form field `{ name: 'status', label: 'Status', type: 'select', options: [...] }`
821
+ rendered a visible "Status" label that pointed at nothing. Measured before the fix:
822
+ `<label for="…-form-item">` resolved to no element at all, `getByLabelText(/status/i)`
823
+ found zero matches, and the trigger button carried no `id`, no `aria-describedby`, no
824
+ `aria-invalid` and no `aria-required`. Clicking the label did nothing and a screen reader
825
+ announced an anonymous combobox with no field name, no error link and no required state.
826
+
827
+ Cause: the built-in `select` branch spread its whole DOM pass-through onto Radix's
828
+ `Select.Root`. Root renders no DOM element of its own, so everything it does not
829
+ recognise is silently dropped — and that is exactly where `<FormControl>`'s Slot puts the
830
+ field's `id` / `aria-describedby` / `aria-invalid` and the renderer puts `aria-required`.
831
+ The pass-through now lands on `SelectTrigger`, the focusable `button[role=combobox]` the
832
+ user and their assistive tech actually operate, with the same two keys deliberately kept
833
+ on Root as in the widget-side fix: `name` (the one key Root consumes — it forwards it to
834
+ the hidden native `select` that takes part in form submission) and `disabled` (Root
835
+ disables trigger, items and hidden select together, so the raw prop must not gain a second
836
+ author). `ref` rides the pass-through too, so react-hook-form can finally focus a built-in
837
+ select.
838
+
839
+ This is the built-in half of the same mechanism objectui#3306 fixed on the widget side.
840
+ The two halves diverged because `'select'` is a `BUILTIN_FIELD_TYPES` member: an
841
+ object-driven `field:select` resolves to the registered `SelectField` (fixed since #3306),
842
+ while a hand-written bare `type: 'select'` never consults the registry and kept the
843
+ defect. Both paths are now pinned side by side — the registered path as a positive control
844
+ — so they cannot drift apart again. Which component renders a bare `select` is unchanged;
845
+ only where its host props land.
846
+
847
+ Authored `className` on a built-in `select` now reaches the trigger (it previously reached
848
+ no element), composed with the branch's touch-target height rather than replacing it.
849
+
850
+ - d3e738a: Export `hasDeclaredVisibilityGate` from the package barrel (objectui#3835)
851
+
852
+ `hasDeclaredVisibilityGate(visible)` — "did this action DECLARE a visibility gate
853
+ at all?", i.e. `!= null && !== ''`, with the verdict left to the evaluation entry
854
+ — is the single definition objectui#3492 established and PR #3816 / #3825 / #3836
855
+ applied to every member-action gate in this package and in `@object-ui/plugin-grid`.
856
+ It lived module-private in `src/renderers/action/visibility-gate.ts`.
857
+
858
+ The family turned out to have a member outside these packages:
859
+ `@object-ui/app-shell`'s `DeclaredActionsBar` gates server-declared actions with
860
+ the same question and had the same truthiness bug (objectui#3835). Exporting the
861
+ one definition is what keeps that fix from becoming a fifth hand-spelled copy of
862
+ it — the drift shape objectui#3142 already had to unpick for `locations` in these
863
+ same files.
864
+
865
+ Additive only: one `export` line, no behaviour change in this package. The
866
+ function is pure and dependency-free.
867
+
868
+ - c3b01a7: Give composite and grouped field widgets a real accessible name: the form renderer now associates its label by IDREF for widgets that declare `labelling: 'group'`, instead of emitting a `<label for>` that nothing labelable answers (objectui#3961).
869
+
870
+ Six widgets rendered a visible group label that named **nothing** in the accessibility tree. Measured in a real form, one field per row, reading each label's `for` against the DOM:
871
+
872
+ ```
873
+ address for=…-form-item -> MISSING byLabelText=0 role+name=0
874
+ geolocation for=…-form-item -> MISSING byLabelText=0 role+name=0
875
+ checkboxes for=…-form-item -> div byLabelText=0 role+name=0
876
+ radio for=…-form-item -> div[radiogroup] byLabelText=0 role+name=0
877
+ rating for=…-form-item -> div byLabelText=0 role+name=0
878
+ file for=…-form-item -> div[role=button] byLabelText=0 role+name=0
879
+ ```
880
+
881
+ Two shapes, one outcome. `address` / `geolocation` spread the host's id onto their first sub-input and then replaced it with that input's own unique id (objectui#3343, correct in itself), so the `for` named an id no element carried — clicking "Shipping Address" did nothing and the group label was absent from the accessibility tree entirely. `checkboxes` / `radio` / `rating` / `file` kept the id, but on a `div`: a `<label for>` on a non-labelable element is inert HTML — `HTMLLabelElement.control` is `null`, so it activates nothing and contributes no name. A screen reader heard "Street Address", "City", "Alpha", "Beta" — never which group they belonged to.
882
+
883
+ The fix is the WAI-ARIA group pattern, driven by a DECLARATION rather than by the host guessing at widget DOM:
884
+
885
+ - `@object-ui/core` — `ComponentMeta` gains `labelling?: 'control' | 'group'`. Additive and optional; absent means `'control'`, which is every existing component's behaviour.
886
+ - `@object-ui/components` — the form renderer reads it. For a `'group'` field the `<FormLabel>` publishes an `id` and drops its `for`, and the widget receives `aria-labelledby`. The single-control path is unchanged down to the attribute: no id on the label, no `aria-labelledby` key on the widget, so no field acquires a second naming channel. `ui/form.tsx` is untouched (Shadcn no-touch) — both halves travel as ordinary props, since `<FormLabel>` spreads props after its own `htmlFor`.
887
+ - `@object-ui/fields` — the six audited widgets declare `labelling: 'group'`. `address` / `geolocation` move the host id (and only the id) from their first sub-input to the group container; `checkboxes` / `rating` answer a host-supplied `aria-labelledby` with `role="group"`; `radio` keeps Radix's more specific `radiogroup`; `file` takes the name on its dropzone with no invented group layer, because it has exactly one control that merely happens not to be labelable.
888
+
889
+ No new key in the widget props contract: `aria-*` is already declared on it and forwarded by `toDomProps`, the same channel `aria-required` (objectui#3290) travels.
890
+
891
+ Deliberately unchanged: sub-labels keep naming their own inputs (`aria-labelledby` overrides `<label for>`, so putting the group name on the first sub-input would have replaced "Street Address" with the field name — the concatenated-name outcome this issue rejected), `aria-describedby` stays on the first focusable sub-input where focus can reach it (objectui#3318), the sub-input ids of objectui#3343 do not move, and standalone rendering — the inline grid editor, a bare SDUI node, where nobody hands down an id and there is no host label to point at — emits no role and no IDREF at all.
892
+
893
+ A widget that does not declare itself keeps the old `for`, which the label-association tests report as an association resolving to a non-labelable element. Silence was the failure mode being fixed; the default path stays loud.
894
+
895
+ - 7ed3360: fix(data-table): don't render a row overflow ("⋮") trigger that opens an empty menu
896
+
897
+ The row overflow trigger was gated on whether row-action **handlers** were
898
+ supplied (`onRowEdit` / `onRowDelete` / `rowActionDefs`), while the menu's items
899
+ were filtered a second time — per item, per record — against
900
+ `rowEditPredicates` / `rowDeletePredicates` and a custom action's `visible`. On a
901
+ row where every item was predicate-suppressed the trigger still rendered and
902
+ opened an empty box, which reads as a broken page.
903
+
904
+ The trigger is now decided by the items that will actually render for that row,
905
+ resolved through the same visibility rule the items gate themselves on, so the
906
+ two cannot disagree. The decision is per row: within one table a row that keeps
907
+ an action keeps its trigger while a row with nothing left renders none. The
908
+ actions cell itself is unchanged, so the column stays aligned with its header.
909
+
910
+ - 0fa5e4d: The `div` deprecation notice is now reported once per module load, not once per render (objectui#3965)
911
+
912
+ `DivRenderer` called `console.warn` on every render. That is invisible on a page
913
+ with one `div` and destructive on a page with many: the docs schema-catalog index
914
+ renders 400+ example thumbnails and emitted ~190 byte-identical notices, burying
915
+ the page's real console errors underneath — the two nested-button errors fixed in
916
+ objectui#3903 / PR #3964 had to be fished out of that flood, and it cost a
917
+ browser-verification run its signal twice.
918
+
919
+ The deprecation itself is unchanged: dev builds still report it, the message and
920
+ its migration guidance are byte-identical, and production builds are still
921
+ silent. Only the repetition is gone. The guard is a module-level `Set` keyed by
922
+ type, and the production early-return happens _before_ the set is marked, so a
923
+ production render cannot suppress a later dev-build notice.
924
+
925
+ - 5bfaabd: `PageComponentSchema.dataSource` now reaches every object-bound block, not just
926
+ `list-view` — and `element:record_picker` stops discarding `view`
927
+ (objectstack#6953).
928
+
929
+ objectstack#5576 wired the spec's per-element data binding
930
+ (`dataSource: { object, view?, filter?, sort?, limit? }`) to `list-view` and left
931
+ the same declaration inert on every other page component. Two gaps remained, and
932
+ both were silent:
933
+
934
+ - **`element:record_picker` read four of the five keys and dropped `view`.** So
935
+ `dataSource: { object: 'account', view: 'hot' }` — the spec's own example —
936
+ built a picker over EVERY account instead of the rows the saved view selects.
937
+ Nothing threw and nothing rendered an error; the option list was simply wider
938
+ than what was authored, which also means a user could select a record the page
939
+ said was out of scope.
940
+ - **`object-grid` / `object-form` / `object-kanban` / `object-calendar` /
941
+ `object-chart` / `object-metric` / `record:related_list` read none of it.**
942
+ Each gates its fetch on its own `objectName`, and nothing mapped
943
+ `dataSource.object` onto it, so a page written the way the spec documents
944
+ rendered an empty grid / a field-less form / a board with no cards / an empty
945
+ month / an empty chart / a static metric number — with no request and no
946
+ diagnostic anywhere. Spec-valid metadata rendering nothing is the
947
+ objectstack#4413 shape.
948
+
949
+ Composition follows objectstack#5576's landed semantics unchanged on every block:
950
+ a named saved view supplies the baseline, a key written on the component itself
951
+ overrides it, an explicit binding key overrides both, `filter` AND-combines
952
+ ("additional filter criteria" — a binding can narrow a view, never widen it), and
953
+ a `view` name that does not resolve renders a configuration error instead of
954
+ degrading to the object's full scope.
955
+
956
+ - `@object-ui/react` — new `useElementDataSourceSchema(schema, mapping, dataSource?)`
957
+ and `ElementDataSourceGate` apply a resolved binding to the schema keys a given
958
+ block reads, plus `ElementDataSourceErrorPanel` / `ElementDataSourceLoadingPanel`
959
+ for the two non-final states. One precedence table for all blocks rather than
960
+ one copy per block — that copy is how "additional filter criteria" would have
961
+ become two dialects.
962
+ - A mapping names **only** keys its block genuinely reads. A composed value
963
+ written onto a key the block ignores would be accepted and dropped, which is
964
+ the defect being removed, one layer deeper — so a kanban's swimlane `columns`
965
+ never receive a view's field list, and a block with no row cap leaves `limit`
966
+ unmapped. The per-block coverage table, including two residual gaps that are
967
+ named rather than papered over, is in `content/docs/guide/data-source.md`.
968
+
969
+ No behaviour changes for a block that carries no `dataSource`: the binding-free
970
+ path returns the schema by reference, so nothing remounts and nothing refetches.
971
+
972
+ - ab3ad4f: An empty predicate is no longer a declared gate anywhere (objectui#3850, objectui#3862)
973
+
974
+ "Is a gate DECLARED on this key — is there a condition to reach a verdict on?" was
975
+ answered three times in this repo, with three different scopes, and the widest
976
+ answers sat on `disabled`, where the mistake is not benign:
977
+
978
+ - `hasDeclaredVisibilityGate` (the action face) asked `!= null && !== ''`, so every
979
+ OBJECT counted — including `{ dialect: 'cel', source: '' }`. That envelope is not
980
+ a hand-written curiosity: `@objectstack/spec`'s `ExpressionInputSchema` normalizes
981
+ every authored predicate into one, so "the author left the predicate empty"
982
+ compiles to exactly it. The verdict path normalized the same value back to
983
+ `undefined`, and `evaluateCondition(undefined)` answers `true` — "no condition, so
984
+ visible/enabled". On `visible` that `true` means SHOW, so the two mistakes
985
+ cancelled; on `disabled` it means GREY, so they compounded: a button disabled
986
+ forever that no author asked to disable (objectui#3850, the residue objectui#3842
987
+ left behind).
988
+ - `SchemaRenderer` asked `disabled !== undefined` inline, one notch wider again, so
989
+ `disabled: null` greyed out too — on the GENERIC rendering path, since that block
990
+ runs for every node type, and not as an internal flag either: `_disabled` is
991
+ forwarded to the component as a real `disabled` prop (objectui#3862).
992
+ - `ActionRunner`'s execution gates asked "does this normalize to something
993
+ evaluable?" — the scope that turned out to be right (objectui#3848 / objectui#3872).
994
+
995
+ There is now ONE definition, `hasDeclaredPredicate`, exported from
996
+ `@object-ui/core` (`evaluator/declaredPredicate.ts`, beside the `toPredicateInput`
997
+ normalizer it is derived from): a gate is declared when normalization still leaves a
998
+ condition to evaluate. `''`, a whitespace-only string, an empty-`source` envelope
999
+ and any non-predicate value (`0`, `{}`) are NOT declared; `false` IS (a verdict is
1000
+ not a missing gate — objectui#3812). `hasDeclaredVisibilityGate` keeps its name as a
1001
+ re-export of it, so the five member-action renderer call sites, `DeclaredActionsBar`
1002
+ and `record-quick-actions` are unchanged and inherit the scope;
1003
+ `SchemaRenderer`'s `disabled` / `disabledOn` chain and `ActionRunner`'s two gates
1004
+ read the same function. No consumer got a local "and also check for empty" test —
1005
+ that fourth dialect is what objectui#3842 / objectui#3849 spent two PRs merging away.
1006
+
1007
+ Measured behaviour change, `action:button` and the generic path, before → after:
1008
+
1009
+ | value | `visible` | `disabled` | `enabled` | `SchemaRenderer` `disabled` prop |
1010
+ | --------------------------------------------------------- | -------------- | ---------- | --------- | -------------------------------- |
1011
+ | `''` | shown → shown | on → on | on → on | forwarded → absent |
1012
+ | `null` | shown → shown | on → on | on → on | forwarded → absent |
1013
+ | `{ dialect: 'cel', source: '' }` | shown → shown | GREY → on | on → on | forwarded → absent |
1014
+ | `{ source: '' }` | shown → shown | GREY → on | on → on | forwarded → absent |
1015
+ | `' '` (whitespace) | HIDDEN → shown | on → on | GREY → on | forwarded → absent |
1016
+ | `0` / `{}` (not predicates) | shown → shown | GREY → on | on → on | forwarded → absent |
1017
+ | `true` / `false` / bare CEL / `${…}` / non-empty envelope | unchanged | unchanged | unchanged | unchanged |
1018
+
1019
+ Every row moves toward "there is no gate here", never away from it, and no value
1020
+ that HAS a verdict changes it — the verdict is still read from the raw value, only
1021
+ the gate in front of it narrowed. Two rows are behaviour changes rather than the
1022
+ equivalence the ruling expected, and are pinned as such: the whitespace string moves
1023
+ on `visible` / `enabled` (it used to normalize to `'${ }'`, which evaluates falsy,
1024
+ so a predicate that says nothing HID the action from everyone), and non-predicate
1025
+ junk stops greying controls out (fail-open, the posture `ActionRunner` already
1026
+ committed to).
1027
+
1028
+ One blank spelling is knowingly still outside the scope: an envelope whose `source`
1029
+ is blank but not EMPTY (`{ dialect: 'cel', source: ' ' }`) — the normalizer folds a
1030
+ `source` of `''` and does not trim, so the string spelling of a blank predicate is
1031
+ trimmed and the envelope spelling is not, and `disabled` still greys out for that one
1032
+ value. The ruling enumerated three empty spellings; this is a fourth, measured and
1033
+ filed as objectui#3960 rather than widened in here.
1034
+
1035
+ One chain is deliberately untouched: `SchemaRenderer`'s `visible` / `visibleWhen` /
1036
+ `visibleOn` / `visibility` / `hidden` / `hiddenOn` legs keep `!== undefined`, because
1037
+ narrowing them would change ALIAS PRECEDENCE, not just emptiness. The `hidden` legs
1038
+ are not negated and therefore carry this same defect with the polarity that makes the
1039
+ node vanish — measured, out of this ruling's scope, filed as objectui#3955.
1040
+
1041
+ - c2fd122: fix(actions): forward `bodyShape` end-to-end so a declared body wrap is honoured
1042
+
1043
+ Sibling of the `bodyExtra` fix, same failure shape one key over. `bodyShape` is
1044
+ the spec's body-WRAPPING declaration for a `type: 'api'` action — `'flat'` (the
1045
+ default) or `{ wrap: key }` to nest the collected params under `key`, the shape
1046
+ better-auth's `organization/update` needs. The console `apiHandler` read it
1047
+ unconditionally while **no** action renderer forwarded it, so an author who
1048
+ declared `bodyShape: { wrap: 'data' }` on an `action:button` / `:group` / `:icon`
1049
+ / `:menu` action got a FLAT body on the wire: the endpoint received the params at
1050
+ the top level, and the declaration read as honoured because it parsed and
1051
+ published.
1052
+
1053
+ The four declared-action renderers now forward the key, and `ActionSchema`
1054
+ declares it (typed by derivation from the spec, so the union cannot drift).
1055
+ `ActionRunner.executeAPI` — the fallback path taken when no host registered an
1056
+ `api` handler — now reads it too, closing a second asymmetry in which the same
1057
+ action changed body shape depending on which host executed it. The wrap covers
1058
+ the collected params only; `bodyExtra` and other top-level keys stay flat, which
1059
+ is the spec's own wording for the key and what both console read-sites already
1060
+ did.
1061
+
1062
+ `element:button` deliberately does **not** forward it: its whitelist mirrors
1063
+ spec's `InlineActionSchema` pick list field for field, and that pick list does
1064
+ not include `bodyShape` — it is not inline vocabulary.
1065
+
1066
+ - e24d767: Record page header action predicates now speak CEL, like every other action surface
1067
+
1068
+ `visible` / `hidden` / `disabled` on a `page:header` action were handed to
1069
+ ObjectUI's legacy JS evaluator, while the row kebab, the selection bar and
1070
+ conditional formatting have evaluated the identical metadata on the canonical
1071
+ CEL engine since objectui#1584 / ADR-0058. Every construct that exists only in
1072
+ CEL therefore worked in a list row and threw on the record page — where the
1073
+ throw fail-closed hid the button, leaving nothing on screen to notice:
1074
+
1075
+ - method calls — `record.f_tags.size() > 0`, `record.f_textarea.contains("x")`
1076
+ - the `in` operator — `'"red" in record.f_multiselect'` (a parse error)
1077
+ - stdlib functions — `record.f_date < today()` (`today is not a function`)
1078
+
1079
+ Both header evaluation sites now go through `evalRowPredicate`: the same entry,
1080
+ the same bindings (`record.*` + bare field names + `data.*` + the host scope,
1081
+ with relations bound as the stored foreign key), and the same fail-closed +
1082
+ warn-once semantics as the row surfaces. One predicate on one record now reaches
1083
+ the same show/hide verdict in the row menu, the selection bar and the record
1084
+ header.
1085
+
1086
+ Legacy-dialect strings are unaffected: `${…}`, `===`/`!==`, `?.`, `??` and
1087
+ JS-only methods such as `.includes()` still route to the legacy evaluator (with
1088
+ its existing one-time deprecation warning), so authored pages keep working. A
1089
+ `${…}` template predicate, which the header previously could not evaluate at
1090
+ all, now resolves through that fallback instead of hiding the button. A
1091
+ predicate that genuinely cannot be evaluated still hides its action, and now
1092
+ reports itself once in the same words the other surfaces use, naming the
1093
+ surface, the action and the predicate.
1094
+
1095
+ - aca561a: Four spec keys the renderers already honoured are now discoverable from the published `inputs`
1096
+
1097
+ `record:details.hideFields`, `record:related_list.relationshipValueField`,
1098
+ `record:related_list.add` and `element:text_input.defaultValue` were declared by
1099
+ `@objectstack/spec` and read by their renderers, while the registry `inputs` —
1100
+ the surface `gen-manifest.ts` serializes into `sdui.manifest.json` and
1101
+ `sdui-intrinsics.d.ts` — never mentioned them. Nothing anywhere reported the
1102
+ mismatch, and every layer that reads a manifest said the opposite of the
1103
+ runtime: the keys were in no designer panel and no generated `.d.ts`,
1104
+ `sdui-parser`'s prop walk returned `unknown-prop` for an author who wrote one,
1105
+ and the renderer honoured it regardless. That is objectui#3407's original
1106
+ complaint (`readonly` was enforced and honoured, the description just never said
1107
+ so) on four more keys.
1108
+
1109
+ Each description is derived from what the renderer actually does, not from
1110
+ restating the spec's one-liner, because the two can differ and the published
1111
+ text is what an AI author reads:
1112
+
1113
+ - `hideFields` documents bare field names only — the renderer tolerates
1114
+ `{name}` / `{field}` entries but the spec is `z.array(z.string())` and rejects
1115
+ them, so teaching that spelling would publish a dialect the contract refuses;
1116
+ - `relationshipValueField` publishes the renderer's `'id'` default and says that
1117
+ the resolved value drives the list filter, the Add-picker link value and the
1118
+ pre-filled create form together;
1119
+ - `add` publishes its member shape in prose (`ComponentInput` is flat and has no
1120
+ member-shape slot) with each default taken from the renderer — including
1121
+ `picker.labelField`, where the renderer defaults to `name` while the spec's
1122
+ own wording says "the object title field". It also names `picker.filter` as a
1123
+ KNOWN GAP rather than documenting it as a restriction: the spec declares it
1124
+ and nothing reads it, so an author would otherwise believe their picker is
1125
+ scoped when it offers every record (objectui#3831);
1126
+ - `defaultValue` distinguishes the two behaviours an author can get — seeding a
1127
+ bound page variable once while it is still empty, versus the native
1128
+ uncontrolled initial value with no variable bound.
1129
+
1130
+ `element:text_input` is not in the public tier, so its gap was not in
1131
+ `sdui.manifest.json` at all — it was in the JSX-page compiler's prop whitelist,
1132
+ which `renderers/layout/page.tsx` builds from `getKnownTypes()` plus these same
1133
+ `inputs`, making the undeclared `defaultValue` a live `unknown-prop` warning.
1134
+
1135
+ The repo-wide parity gate now runs in both directions over one covered set and
1136
+ one exemption discipline, so neither direction can be forgotten again the way
1137
+ the reverse half was after PR #3806. Nine spec keys stay deliberately
1138
+ unpublished, each with a written reason and a tracking issue: two the renderers
1139
+ do not read at all (objectui#3829), three retired upstream by ADR-0087
1140
+ tombstones, `page:tabs.type` (a carrier collision, objectstack#6776), two
1141
+ `targetVariable` declarative hints (objectui#3834), and
1142
+ `element:record_picker.filter` (objectui#3830).
1143
+
1144
+ - 0ef9dfd: `page:card` publishes `children` instead of the retired `body`, and `page:section` / `page:footer` / `page:sidebar` publish the `children` slot they render
1145
+
1146
+ `inputs` is the published authoring surface, not documentation: the Studio block
1147
+ designer builds its panel from it, `sdui-parser`'s `gen-manifest.ts` serializes
1148
+ it into `sdui.manifest.json` and `sdui-intrinsics.d.ts`, and the JSX-page
1149
+ compiler builds its prop whitelist from it. Two of the `page:*` containers had
1150
+ drifted from the contract in opposite directions.
1151
+
1152
+ `page:card` published `{ name: 'body', type: 'slot' }`. `@objectstack/spec`
1153
+ retired `PageCardProps.body` in objectstack#5775 (PR objectstack#6281, merged
1154
+ 2026-08-07, ADR-0087 D2) and declared `children` in its place — one composition
1155
+ slot with one spelling, the same one `grid`, `flex`, `page:section` and
1156
+ `page:tabs` items already use. The designer was teaching a key the contract now
1157
+ rejects by name.
1158
+
1159
+ `page:section`, `page:footer` and `page:sidebar` declared no `inputs` at all, so
1160
+ the designer could not authorize the child list those three components exist to
1161
+ render. The same upstream PR replaced their `EmptyProps` declaration with the
1162
+ shared `PageContainerProps`, whose single key is `children`; all three now
1163
+ publish that one slot from one shared literal, mirroring the spec's own single
1164
+ definition.
1165
+
1166
+ Rendering is unchanged in both directions. `PageCardRenderer` still READS `body`
1167
+ first (`schema?.body ?? schema?.children`) and the three thin containers still
1168
+ read `schema?.children || schema?.body`, so documents stored under the old
1169
+ contract keep rendering until the ADR-0087 D2 conversion rewrites the key at
1170
+ load time — a back-compat read is not a second authorable spelling, the same
1171
+ split the `page-header-subtitle-alias` sequencing established. No validation
1172
+ verdict moves either: `children` is already in `sdui-parser`'s `BASE_PROPS` (so
1173
+ it was never an `unknown-prop`), `isContainer: true` was already set on all
1174
+ four, and `codegen.ts` filters `slot` inputs out of the generated `.d.ts` where
1175
+ `SduiBaseProps.children` types it.
1176
+
1177
+ What changes for an author is the designer surface: `body` is no longer offered
1178
+ on `page:card`, `children` is, and the three thin containers gained an
1179
+ authorable content slot.
1180
+
1181
+ - 7e5bb5d: fix(actions): forward `bodyExtra` end-to-end through the action chain
1182
+
1183
+ An action's static request body (`bodyExtra`) was dropped one hop before the
1184
+ `ActionRunner`: every action renderer forwards an explicit whitelist of keys, and
1185
+ none of them listed `bodyExtra`. Since `@objectstack/spec` 17 made it the only way
1186
+ a `type: 'api'` action can carry a payload (`params` keeps its single meaning as
1187
+ the parameter definition array), and the ADR-0087
1188
+ `inline-action-api-params-to-body-extra` conversion rewrites older object-form
1189
+ `params` pages onto it at load, a previously-working published page validated,
1190
+ published and then POSTed an empty body.
1191
+
1192
+ `element:button`, `action:button`, `action:group`, `action:icon` and `action:menu`
1193
+ now forward the key; `ActionRunner.executeAPI` merges it into the request body
1194
+ **last** (so a constant always overrides a same-named user param, matching the
1195
+ console `apiHandler`); `ActionSchema` declares it; and a non-array `params` on a
1196
+ `type: 'api'` action keeps working for one version window with a dev-mode
1197
+ deprecation warning naming `bodyExtra`.
1198
+
1199
+ - 54233b1: Record detail pages: a header ⟳ that refreshes the record, its related lists and its tab counts in place — no browser reload
1200
+
1201
+ Concurrent-editing scenario from the shop floor (MES work orders): operator A sits on a record's detail page while operator B starts or reports the same order. A had no way to see the new state except F5, which throws away the open tab, the scroll position and any in-progress inline edit along with the stale data.
1202
+
1203
+ The pipeline for this already existed — the objectui#2269 invalidation bus refetches every mounted reader in place, and `RecordContext.refresh` had been declared for it — but nothing produced that field and no UI reached for it. Three changes give it a trigger:
1204
+
1205
+ - **`RecordDetailView` produces `RecordContext.refresh`**, publishing `notifyDataChanged({ objectName: '*' })`. The wildcard is deliberate: a user reaches for refresh because of a write made by SOMEONE ELSE, which this client never saw and therefore cannot attribute to particular objects. `'*'` marks everything mounted as stale, so the main record, every related child list and the tab-count badges all refetch — no remount, so tab / scroll / draft state survive. First phase covers the standalone record route; embedded hosts (list drawer, split-pane preview) keep their existing chrome unchanged.
1206
+ - **`page:header` renders the ⟳** at the far end of the header row when — and only when — the host provides `refresh`. It is page chrome rather than a header action, so its position is the same on every record page regardless of which business actions the object declares, and it can never be collapsed into the `⋯` overflow. Styled as that `⋯` trigger's twin so the row reads as one button family. Its accessible name and tooltip come from the existing `common.refresh` key, so the icon-only button is not English-only in the other nine locales. The icon spins for a short floor after a click, because the bus is fire-and-forget and a warm backend would otherwise finish before the click looked like it landed.
1207
+ - **`RelatedList` accepts the `'*'` wildcard** on the legacy `objectui:related-changed` event, matching what `dataChangeMatches` already does for the bus's own readers. This listener compared the payload's object name to its own, so a wildcard invalidation reached everything on the page except the related lists — a concrete foreign object name is still ignored.
1208
+
1209
+ Hosts that provide no `refresh` render exactly as before.
1210
+
1211
+ - 97b63d7: Row actions declaring `visible: false` are now hidden instead of rendered
1212
+
1213
+ A custom row action's visibility **gate** was detected by truthiness, so
1214
+ `visible: false` — the most explicit way an author can say "never show this" —
1215
+ fell into the "no gate declared" branch and the action rendered for every row.
1216
+ Both surfaces of the ObjectGrid row cell (the "⋮" overflow item and the inline
1217
+ `variant:'primary'` button) and the data-table's row overflow menu read the same
1218
+ gate, so all three rendered it; the `#3562` emptiness guard counts with that same
1219
+ gate, so a row whose only action was `visible: false` also grew a "⋮" it could
1220
+ not fill.
1221
+
1222
+ The gate now detects a **declared** gate by `!= null && !== ''` and lets the
1223
+ declaration itself decide — a boolean short-circuits to its own verdict rather
1224
+ than being handed to the CEL engine. This is the invariant objectui#3492 already
1225
+ established for the selection bar, whose `hasVisibilityGate` spells out why
1226
+ truthiness cannot answer the question, and the same `!= null` posture the
1227
+ built-in `visibleWhen` gate has always had. `visible: true` still renders,
1228
+ `''` and an absent `visible` are still no gate at all, and no expression-valued
1229
+ `visible` changes verdict.
1230
+
1231
+ Behaviour change surface, deliberately narrow: only an action whose `visible` is
1232
+ the literal boolean `false` (or another falsy non-empty value) changes — it goes
1233
+ from rendered to hidden, which is what the declaration asked for.
1234
+ `ActionSchema.visible` is `ExpressionInputSchema` with no boolean member, so
1235
+ `objectstack build` cannot emit this shape; hand-written view JSON and
1236
+ in-process callers constructing defs can, and did. The three row surfaces now
1237
+ reach the same verdict as the selection bar and the record page header for every
1238
+ non-expression shape, which `predicate-surface-parity` pins.
1239
+
1240
+ - Updated dependencies [8aad9fd]
1241
+ - Updated dependencies [6719877]
1242
+ - Updated dependencies [56ff091]
1243
+ - Updated dependencies [7864f03]
1244
+ - Updated dependencies [d229dfa]
1245
+ - Updated dependencies [4bc6c23]
1246
+ - Updated dependencies [c3b01a7]
1247
+ - Updated dependencies [f5f8744]
1248
+ - Updated dependencies [69becd2]
1249
+ - Updated dependencies [5e52495]
1250
+ - Updated dependencies [b750823]
1251
+ - Updated dependencies [5bfaabd]
1252
+ - Updated dependencies [e06810e]
1253
+ - Updated dependencies [ab3ad4f]
1254
+ - Updated dependencies [c2fd122]
1255
+ - Updated dependencies [ac2139c]
1256
+ - Updated dependencies [b14ab3a]
1257
+ - Updated dependencies [8c60819]
1258
+ - Updated dependencies [e64a52e]
1259
+ - Updated dependencies [844d17f]
1260
+ - Updated dependencies [48132f7]
1261
+ - Updated dependencies [4dcd52a]
1262
+ - Updated dependencies [42ae5c6]
1263
+ - Updated dependencies [1d723e3]
1264
+ - Updated dependencies [0109f54]
1265
+ - Updated dependencies [7e5bb5d]
1266
+ - Updated dependencies [fbc23e0]
1267
+ - Updated dependencies [6d762da]
1268
+ - Updated dependencies [d11996e]
1269
+ - Updated dependencies [e6fdbdc]
1270
+ - Updated dependencies [f9faa7d]
1271
+ - Updated dependencies [6bb454a]
1272
+ - Updated dependencies [523be48]
1273
+ - Updated dependencies [7e2b7e9]
1274
+ - Updated dependencies [33526fd]
1275
+ - Updated dependencies [32413ec]
1276
+ - Updated dependencies [c1e1e6b]
1277
+ - @object-ui/react@17.4.0
1278
+ - @object-ui/core@17.4.0
1279
+ - @object-ui/i18n@17.4.0
1280
+ - @object-ui/types@17.4.0
1281
+ - @object-ui/react-runtime@17.4.0
1282
+ - @object-ui/sdui-parser@17.4.0
1283
+
1284
+ ## 17.3.0
1285
+
1286
+ ### Minor Changes
1287
+
1288
+ - 3889ffb: Console chrome i18n gaps (objectstack#5407).
1289
+
1290
+ - A dependency-gated lookup now names its controlling field by its **label**
1291
+ instead of its raw API name. The sentence was localized but the interpolated
1292
+ name was not, so every locale — English included — read `Select crm_account
1293
+ first`. The form renderer passes a new `dependsOnLabels` widget prop (the
1294
+ lookup-side counterpart of `emptyHint`, which it already resolves to labels
1295
+ for the fixed-option widgets); a name the host does not cover still falls
1296
+ back to itself.
1297
+ - The page-header overflow trigger's `More actions` accessible name now reads
1298
+ `detail.moreActions`, the same key `action:menu`'s own overflow trigger uses,
1299
+ so the two cannot diverge per locale.
1300
+ - The activity-feed reaction button's `Add reaction` accessible name is now a
1301
+ bundle key (`detail.addReaction`, added to all ten packs).
1302
+ - The "check the highlighted fields" toast joins field names with a per-locale
1303
+ separator (`validation.formInvalidJoiner`) instead of a hardcoded `、`
1304
+ (U+3001) — right for zh/ja by accident, wrong in English and every Latin
1305
+ locale. Latin packs use `, `, CJK `、`, Arabic `، `.
1306
+ - The Spanish `validation.required` / `validation.unique` templates gained
1307
+ their own masculine head noun (`El campo {{field}} es obligatorio`) so the
1308
+ adjective agrees for feminine field labels too — `Cuenta es obligatorio` was
1309
+ ungrammatical.
1310
+
1311
+ - 56409c2: Field widgets are finally told when their field fails validation, and the props
1312
+ slot that carries it takes the name the published contract gives it
1313
+ (objectui#3222).
1314
+
1315
+ **Breaking** for anyone implementing a field widget (see migration below). The
1316
+ repo version policy keeps this a `minor` — objectui's major tracks
1317
+ `@objectstack`'s — so read the bump as "breaking within objectui".
1318
+
1319
+ ## The a11y defect this fixes
1320
+
1321
+ `@objectstack/spec/ui`'s `FieldWidgetPropsSchema` — the published contract that
1322
+ third-party and AI-authored field widgets are written against — has always
1323
+ declared `error?: string`. `@object-ui/fields` declared its own slot as
1324
+ `errorMessage`. That looked like a naming split; it was worse:
1325
+
1326
+ ```
1327
+ producers of `errorMessage` anywhere in packages/ + apps/ : 0
1328
+ reads of `errorMessage` in packages/fields/src : 15 (7 widgets)
1329
+ reads of `props.error` : 0
1330
+ ```
1331
+
1332
+ The slot was dead under BOTH spellings. No host ever passed it: the form
1333
+ renderer showed validation text through its own `<FormMessage/>` and never
1334
+ forwarded the prop. So `EmailField`, `CurrencyField`, `UrlField`,
1335
+ `RichTextField`, `PercentField`, `TextAreaField` and `PhoneField` each computed
1336
+ `aria-invalid={!!errorMessage}` from a value that was `undefined` forever —
1337
+ **`aria-invalid` had never once been set, and a screen reader was never told
1338
+ the field had failed validation.**
1339
+
1340
+ Worse than "never set": `<FormControl>` is a Radix `Slot` that hands its child a
1341
+ CORRECT `aria-invalid`, but a widget's own attribute is written after the props
1342
+ spread, so it wins. Those seven widgets were actively overwriting the right
1343
+ answer with `false`.
1344
+
1345
+ FROM: `renderFieldComponent` received no validation state, and the widget props
1346
+ type declared `errorMessage?: string`, which nothing produced.
1347
+ TO: the form renderer passes react-hook-form's `fieldState.error?.message` down
1348
+ as `error` when it renders a registered widget, and the props type declares
1349
+ `error?: string`. Both ends of the contract are live for the first time; a
1350
+ rename alone would only have swapped one dead key for another.
1351
+
1352
+ ## Migration for widget authors
1353
+
1354
+ ```diff
1355
+ -export function MyField({ value, onChange, field, readonly, errorMessage }: FieldWidgetComponentProps< string >) {
1356
+ - return <Input value={value} aria-invalid={!!errorMessage} />;
1357
+ +export function MyField({ value, onChange, field, readonly, error }: FieldWidgetComponentProps< string >) {
1358
+ + return <Input value={value} aria-invalid={!!error} />;
1359
+ ```
1360
+
1361
+ No alias is kept. `errorMessage` was retained nowhere on purpose — a tolerant
1362
+ second spelling is exactly the de-facto second contract AGENTS.md #0.1 forbids,
1363
+ and it is what would let a missed call site go quiet again. Because
1364
+ objectui#3221 had already removed the type's `[key: string]: any`, every missed
1365
+ site is a compile error rather than a silent `any`, so the compiler — not grep
1366
+ — validated this rename.
1367
+
1368
+ ## Responsibilities are split, not duplicated
1369
+
1370
+ The widget consumes `error` **only** to drive `aria-invalid` on the control it
1371
+ renders (which only it can do — `aria-invalid` has to sit on the input element).
1372
+ The message TEXT stays with `<FormMessage/>` in the form renderer. A widget that
1373
+ also renders the text double-displays it, and the docs, the agent prompt and the
1374
+ tests all now say so.
1375
+
1376
+ For the same reason `required` — also declared by the spec, also never delivered
1377
+ — is deliberately NOT lowered into widget props: the required marker has exactly
1378
+ one author, the renderer's `<FormLabel>`, and giving widgets the flag invites a
1379
+ second asterisk. The a11y state a widget could legitimately carry is
1380
+ `aria-required`, which needs no contract change at all (`AriaAttributes` is
1381
+ already part of the type and widgets already forward it).
1382
+
1383
+ Builtin field types are unaffected: they render inside `<FormControl>`, whose
1384
+ Slot already supplies `aria-invalid`, so `error` is stripped there rather than
1385
+ leaking into the DOM as a stray attribute.
1386
+
1387
+ Docs updated to match: `content/docs/guide/plugin-development.md`,
1388
+ `skills/objectui/guides/plugin-development.md` and
1389
+ `.github/prompts/component.prompt.md` — the last of which additionally used the
1390
+ spec's non-generic type alias as a generic (`FieldWidgetProps< number >`) and
1391
+ destructured a `mode` prop that exists on neither type.
1392
+
1393
+ - 042e09d: **BREAKING (v17)** — field widgets receive their metadata on ONE key, `field`.
1394
+ `schema` is removed from the widget contract (objectui#3233).
1395
+
1396
+ ## What changed
1397
+
1398
+ `schema` was a second carrier for what `field` already means. Two producers fed
1399
+ it: `SchemaRenderer` passed the authored node as `schema`, and the form
1400
+ renderer's `renderFieldComponent` passed `schema={props.field || props.schema ||
1401
+ props}` _alongside_ `field`. The predictable result was ~30 widgets resolving
1402
+ their config as `field || schema` — one concept, two spellings, a de-facto
1403
+ second contract (AGENTS.md #0.1).
1404
+
1405
+ - `FieldWidgetComponentProps` no longer declares `schema`. Reading
1406
+ `props.schema` is now a compile error, not a silent `any`.
1407
+ - Both producers converged. The form renderer passes `field` only. The SDUI node
1408
+ → `field` translation happens exactly once, in a new registration adapter
1409
+ (`withFieldCarrier`), which every built-in field widget is registered through.
1410
+ - All `field || schema` reads in `@object-ui/fields` are now plain `field` reads.
1411
+
1412
+ ## Migrating a widget you wrote
1413
+
1414
+ **Reading the metadata** — replace the fallback with the single key:
1415
+
1416
+ ```diff
1417
+ -const config = field || (props as any).schema;
1418
+ +const config = field;
1419
+ ```
1420
+
1421
+ **Registering a widget** — if your widget can be rendered from a schema node
1422
+ (anything `SchemaRenderer` dispatches, not just forms), wrap it once so it still
1423
+ gets `field`:
1424
+
1425
+ ```diff
1426
+ +import { withFieldCarrier } from '@object-ui/fields';
1427
+ +
1428
+ -ComponentRegistry.register('color', ColorField, { namespace: 'field' });
1429
+ +ComponentRegistry.register('color', withFieldCarrier(ColorField), { namespace: 'field' });
1430
+ ```
1431
+
1432
+ `withFieldCarrier` forwards the node **by reference** — nothing is copied,
1433
+ narrowed or renamed — and consumes `schema` so it cannot reach the DOM through a
1434
+ widget's `...props` spread.
1435
+
1436
+ A third-party widget that still reads `props.schema` and is **not** re-registered
1437
+ through the adapter will read `undefined` in v17 and silently render an empty /
1438
+ default state. That is the deliberate cost of a major boundary: one contract
1439
+ beats N dialects, and a widget that picks the wrong spelling should fail at
1440
+ compile time rather than work under one host and not another.
1441
+
1442
+ ## What did NOT change
1443
+
1444
+ - **Host metadata (SDUI JSON) is untouched.** No authored schema changes; this is
1445
+ a change to how widgets are _written_, not to what apps declare.
1446
+ - **`schema` is still the universal SDUI prop** every registered component
1447
+ receives from `SchemaRenderer` (`element:*`, `page:*`, grids, reports). Only
1448
+ the _field-widget_ contract retired it. In particular `renderFieldComponent`
1449
+ still passes `schema` when a form field type resolves to a plain component
1450
+ through the bare-name fallback (e.g. `type: 'text'` reaching the display text
1451
+ widget) — that component's contract is the node, and dropping it there would
1452
+ render `undefined.className`.
1453
+
1454
+ ## Payload equivalence
1455
+
1456
+ Every path that used to deliver a payload through `schema` now delivers the
1457
+ identical object through `field`, and both halves are pinned by tests asserting
1458
+ **object identity**, not shape:
1459
+
1460
+ - form path — `packages/components/src/renderers/form/__tests__/form-field-carrier.test.tsx`
1461
+ - SDUI path — `packages/fields/src/__tests__/field-carrier-sdui.test.tsx`
1462
+
1463
+ ### Patch Changes
1464
+
1465
+ - 532cf8b: Deliver the required state to the control in the five renderers outside the object form that still painted it as an asterisk only (objectui#3299 — the same defect #3290/#3298 fixed in `form.tsx`).
1466
+
1467
+ Each site converges on the reference shape (`EmbeddableForm.tsx`): the control carries `aria-required={required || undefined}` and the asterisk is `aria-hidden="true"`, so assistive tech announces required once, as a state — instead of hearing a bare "asterisk" folded into the accessible name, or nothing at all.
1468
+
1469
+ - `@object-ui/app-shell` — `ActionParamDialog` (both the boolean row and the default branch, delivered through the real field widgets' `toDomProps` whitelist) and `CreateViewDialog` (display label, machine name, and every type-specific required-field selector).
1470
+ - `@object-ui/components` — the custom `ActionParamDialog` (all five typed branches, including the Radix select trigger) and `FieldContainer`, whose existing Slot injection (`id` / `aria-describedby` / `aria-invalid`) now also injects `aria-required`, covering every consumer in one place.
1471
+ - `@object-ui/plugin-detail` — `InlineCreateRelated`'s create-tab inputs.
1472
+
1473
+ Deliberately NOT the native `required` attribute (#3290 ruling): each of these hosts runs its own validation, and native `required` would arm the browser's constraint-validation bubble beside it. The SDUI controls that already use native `required` (`renderers/form/{input,textarea,select,checkbox}.tsx`, `basic/text-input.tsx`) are unchanged — they don't have a second validator, so their channel is already correct.
1474
+
1475
+ - 680080a: The required state now reaches the input control as `aria-required`, instead of
1476
+ existing only as part of the control's accessible name (objectui#3290).
1477
+
1478
+ The form renderer has always computed a correct `required` — the static
1479
+ `required` flag merged with the `requiredWhen` CEL verdict — and then spent it
1480
+ on exactly one thing: the red asterisk in `<FormLabel>`. That asterisk carried
1481
+ `aria-label="required"`, so the only path from the computed state to assistive
1482
+ tech was `<label for>` folding it into the control's **accessible name**: the
1483
+ field was announced as "Title required".
1484
+
1485
+ A state smuggled through a name is broken three ways:
1486
+
1487
+ - it is read in name order rather than announced as a state, and "list the
1488
+ required fields" style navigation cannot see it at all;
1489
+ - a field rendered without a `label` (compact layouts, inline grid editing)
1490
+ draws no asterisk, so the signal disappears entirely;
1491
+ - `requiredWhen` makes required **dynamic**, and a state channel can express
1492
+ the flip where a name cannot.
1493
+
1494
+ ## What changed
1495
+
1496
+ - Every field control — built-in (`input` / `textarea` / `checkbox` / `switch` /
1497
+ `select`) and registered widget alike — now receives `aria-required="true"`
1498
+ when the field resolves required, and **no attribute at all** when it does
1499
+ not. Absence rather than `aria-required="false"` is deliberate: it is what
1500
+ the `requiredWhen`-turns-false case has to produce.
1501
+ - The red asterisk is now `aria-hidden="true"` and no longer carries
1502
+ `aria-label="required"`. It renders exactly as before for sighted users; it
1503
+ simply stops being announced, so the state is reported once (as a state) and
1504
+ not twice.
1505
+
1506
+ **No field widget needed a change.** `aria-required` is already declared and
1507
+ typed on the widget props contract (`FieldWidgetComponentProps &
1508
+ AriaAttributes`) and every widget forwards its leftover props to the control it
1509
+ renders, so all 48 widgets pick it up unmodified.
1510
+
1511
+ Two non-changes, both deliberate:
1512
+
1513
+ - **No native `required` attribute.** That would arm the browser's own
1514
+ constraint-validation bubble alongside react-hook-form's `<FormMessage/>` —
1515
+ two validators, two UIs, one field. `aria-required` reports the state without
1516
+ triggering native validation.
1517
+ - **No `required` boolean in the widget props contract.** A boolean would give
1518
+ the required marker a second author, and the next widget draws its own
1519
+ asterisk next to the renderer's — the double-display failure objectui#3222
1520
+ already declined for the validation message.
1521
+
1522
+ If you select the asterisk in a test, it is now
1523
+ `span[data-required-marker]` — an explicit locator, rather than an
1524
+ accessibility attribute doubling as a test hook.
1525
+
1526
+ - a7651e6: The form renderer's built-in `select` branch stops saying "No options available"
1527
+ in English to non-English sessions (objectui#3263).
1528
+
1529
+ FROM: the inline branch that renders a `type: 'select'` field — the one taken
1530
+ whenever the field is a `BUILTIN_FIELD_TYPES` member, i.e. before the `field:`
1531
+ registry is consulted — rendered `{emptyHint || 'No options available'}`. TO:
1532
+ `{emptyHint || t('fields.options.empty')}`, the same i18n key the registered
1533
+ option widgets fall back to (objectui#3231, all ten locale packs).
1534
+
1535
+ This was the last hardcoded copy of that sentence in `form.tsx`, and the file was
1536
+ half-translated in a way a user could see inside one widget: the dependency-gate
1537
+ sentence next to it already went through `t()`
1538
+ (`fields.options.selectFirst`), so under a `zh` session a gated select read
1539
+ "请先选择 Country" and the same select — one keystroke later, when the parent
1540
+ value matched no option — flipped to English.
1541
+
1542
+ `fields.options.empty` is added to `useSafeFormTranslation`'s defaults map, the
1543
+ pattern `fields.options.selectFirst` already follows there, so a form rendered
1544
+ with no `I18nProvider` (standalone widget, test, embedded form) produces the
1545
+ byte-identical English string it produced before. Both halves are pinned by
1546
+ tests: the Chinese rendering in one file, the no-provider English fallback in
1547
+ another (mounting a provider installs it as react-i18next's global default,
1548
+ which would erase the state the second one observes).
1549
+
1550
+ The box moved from an inline `<div>` into a small `BuiltinSelectEmptyState`
1551
+ component in the same file, because `renderFieldComponent` is a plain helper that
1552
+ early-returns on the registered-widget path — a hook called there would run
1553
+ conditionally. It forwards its rest props, since `<FormControl>` is a Radix
1554
+ `Slot` that supplies the control's `id` / `aria-describedby`; a test pins that
1555
+ the field's `<label for>` still resolves to this box.
1556
+
1557
+ Deliberately NOT unified with `@object-ui/fields`' `OptionsEmptyState`: different
1558
+ package, different render path (inline branch vs. registry). What the two share
1559
+ is the i18n key, not a component — merging them would impose one path's markup
1560
+ and props on the other.
1561
+
1562
+ - b71fc92: Localize the last untranslated console-chrome accessible names (objectstack#5430)
1563
+
1564
+ Four icon-only controls still carried hardcoded English accessible names, so
1565
+ under a non-English session they were the only English left in the record
1566
+ chrome — and because the controls have no visible label, that literal _is_ the
1567
+ control to a screen reader and to the hover tooltip.
1568
+
1569
+ - `page:header`'s `role="toolbar"` — now `detail.pageHeaderActions` (its `⋯`
1570
+ overflow trigger eight lines below was fixed in #5407; the toolbar was missed)
1571
+ - `ReactionPicker`'s `role="listbox"` popup — now `detail.emojiPicker`
1572
+ - `ReactionPicker`'s per-reaction chip, which built its name by concatenation
1573
+ with English pluralization baked in (`reaction${count !== 1 ? 's' : ''}`) —
1574
+ now `detail.reactionCount` / `detail.reactionCountOne`
1575
+ - `NavigationOverlay`'s drawer close and split-panel close — now `common.close`
1576
+ (the key the rest of the console already uses) and `common.closePanel`
1577
+
1578
+ The pluralized label follows this repo's **two-key** convention
1579
+ (`detail.relatedRecords`/`relatedRecordOne`, `lookup.recordCount`/`recordCountOne`)
1580
+ rather than an i18next `_one`/`_other` pair: zh/ja/ko have no separate singular
1581
+ form, so those packs would legitimately omit the `_one` half and
1582
+ `all-locales-key-parity` would read that as a lost key.
1583
+
1584
+ All five new keys are added to all ten locale packs.
1585
+
1586
+ - 34595eb: The Combobox trigger now declares `type="button"` explicitly, so it can never
1587
+ submit an enclosing `<form>` (objectui#3344). The current Radix
1588
+ `PopoverTrigger` happens to supply `type="button"` through its Slot, but that
1589
+ form-safety guarantee was an upstream implementation detail — it is now a
1590
+ locally declared, regression-tested contract, matching the explicit style of
1591
+ LookupField / MultiSelectField / RatingField. The default sits before the
1592
+ trigger pass-through spread (objectui#3318), so a consumer who explicitly
1593
+ passes `type` (e.g. `type="submit"`) still wins.
1594
+ - 9cbcbf4: The form renderer's built-in `textarea` branch reads the fullscreen long-text
1595
+ flag on one spelling (objectui#3303).
1596
+
1597
+ FROM: the branch resolved the affordance as `mobile_fullscreen || fullscreen`,
1598
+ and both prop strips (`stripRendererOnlyProps`, `stripRegisteredFieldProps`)
1599
+ carried a matching entry discarding a `fullscreen` key. TO: a single read of
1600
+ `mobile_fullscreen`, with no strip entry left for the alias.
1601
+
1602
+ No runtime behaviour changes for anything that exists, because the second term
1603
+ was permanently `undefined`. `fullscreen` had **no producer**: a repo-wide grep
1604
+ plus `objectstack`'s `packages/spec` turns up only the unrelated
1605
+ feedback/loading overlay property of the same name, never a form field. The one
1606
+ real producer is `ObjectForm`, which stamps `mobile_fullscreen` onto long-text
1607
+ fields from `ObjectFormSchema.mobile.fullscreenLongText` — the same single
1608
+ carrier `TextAreaField` and `RichTextField` read.
1609
+
1610
+ This closes the last member of the convergence run objectui#3232 / #3233 /
1611
+ #3245 / #3301 started. The changeset for #3232 named this branch explicitly as
1612
+ "a separate live path" still accepting two spellings; it is now single-read like
1613
+ the two widgets, so the same authored metadata behaves the same way whether a
1614
+ field type resolves to a registered widget or falls through to the built-in
1615
+ branch.
1616
+
1617
+ Why a no-producer alias is worth removing rather than leaving as harmless
1618
+ insurance: it is not insurance, it is a contract that never held. The renderer
1619
+ advertised a spelling to whoever reads it next — very much including an AI
1620
+ writing form metadata — and that spelling silently does nothing, with no error
1621
+ and nowhere to look. That is the lenient consumer fallback AGENTS.md #0.1
1622
+ forbids, and the identical mechanism behind #3245 and #3301. Dropping the strip
1623
+ entries matters for the same reason: a key nobody produces should not get a
1624
+ dedicated discard, it should be in the ordinary unrecognised-key class, so a
1625
+ typo is as visible as any other typo instead of being quietly swallowed.
1626
+
1627
+ Pinned by tests in both places the alias lived: the built-in branch renders the
1628
+ expand affordance for `mobile_fullscreen` and not for `fullscreen` (the
1629
+ canonical case is asserted alongside the alias case, so the negative cannot pass
1630
+ for the empty reason of the affordance having disappeared altogether), and the
1631
+ strips are shown to own `mobile_fullscreen` — stripped from the top-level props,
1632
+ delivered on `field` — while `fullscreen` is now indistinguishable from an
1633
+ arbitrary unknown key.
1634
+
1635
+ - 85c4c9c: 表单内置 `textarea` 的全屏编辑对话框现在能拿到字段自己的 label:对话框标题显示字段名而不是恒定的通用词「编辑文本」,同一张表单上多个长文本字段的展开按钮也终于有了互不相同的无障碍名(objectui#3393)。
1636
+
1637
+ `renderFormField` 在解构字段配置时把 `label` 单独取走了(它要渲染 `<FormLabel>`),而下游 `renderFieldComponent` 唯一调用点重建 props 对象时显式补回了 `field` / `inputType` / `options` / `placeholder` / `emptyHint` / `dependsOnLabels` 等等,唯独漏了 `label`。于是内置 `textarea` 分支里的 `label` 恒为 `undefined`,`FullscreenTextarea` 中两条依赖它的分支从写下那天起就没走到过:
1638
+
1639
+ - 对话框标题 `label ?? t('form.fullscreen.title')` 永远落到通用词——一个叫「备注」的字段点开全屏编辑,标题不会说自己是「备注」;
1640
+ - 展开按钮的无障碍名永远插值通用名词,一张有三个长文本字段的表单上三个按钮读屏完全一样。读屏用户无法判断自己要展开的是哪个字段,这是可达性缺陷而不是观感问题。
1641
+
1642
+ ## 改了什么
1643
+
1644
+ - 调用点显式转发 `label`(与 `placeholder` / `emptyHint` 同法),这是唯一的行为改动。
1645
+ - `label` 属于 renderer-only:`stripRendererOnlyProps` 与 `stripRegisteredFieldProps` 各加一条丢弃项,所以它既不会变成 DOM 上的 `label="备注"` 杂属性(每个内置分支都会把剩余 props 直接摊到 DOM 节点上),也不会成为注册型 widget 新收到的 prop——自 v17 起 `field` 是它们唯一的元数据载体(objectui#3233),label 一直在那里读。
1646
+ - 内置 `textarea` 分支里那句 `const { label: _label, ...rest }` 随之删除。它本想拦住 label 落到 DOM,但既然从来没有 label 送进来,它拦的是不存在的东西(ESLint 一直报着 `'_label' is assigned a value but never used`),而且只护住了这一个分支。现在这件事由 strip 统一负责,所有分支同等受护。
1647
+
1648
+ 十个语言包零改动:#3272 把 `form.fullscreen.toggle` 做成了带 `{{label}}` 插值的整句(zh 插在句尾、ja 插在句首),label 一通,十个语言的句子直接就对。字段没有 label 时仍回落到被翻译的通用词。
1649
+
1650
+ - fd54c3e: The form renderer's built-in `textarea` branch now honours `readonly` / `disabled` on its fullscreen exit, which previously bypassed both (objectui#3400). `renderFieldComponent` destructures `readonly` off its props, so it is absent from the `...rest` each branch spreads; the plain exit put it back as `readOnly={readonly}` plus the read-only tint, the `mobile_fullscreen` exit put back neither. `FullscreenTextarea` then renders three controls — the inline textarea, the expand button, and the dialog's own textarea — of which only the first ever saw a spread prop. So a read-only long-text field was editable in place without even opening the dialog, and a disabled one looked correctly greyed out while its expand button stayed live, its dialog accepted any edit, and "Done" wrote that edit back into form state. Neither is an exotic combination: `ObjectForm` stamps `mobile_fullscreen` onto every long-text field when `mobile.fullscreenLongText` is set without consulting either flag, `readonly` is also resolved at runtime from a `readonlyWhen` CEL rule, and `disabled` is additionally true for the whole form while a submit is in flight — so a submit in progress did not stop a long-text edit either. A read-only field now renders no expand button at all, matching the registered `TextAreaField` path so both renderers give the same metadata the same behaviour; a disabled field keeps the button but disables it. Both states also lock the dialog's textarea and its "Done" button independently, because `disabled` can flip to true while the dialog is already open. Editable long-text fields are unaffected.
1651
+ - 4eeb932: The form renderer's last user-visible English literals now go through i18n (#3272). The fullscreen long-text editor (`mobile_fullscreen`) was an entire untranslated dialog — title, screen-reader description, `Cancel` / `Done` footer buttons, and the expand trigger's accessible name — rendering English inside an otherwise translated zh/ja/ar form; it now reads the new `form.fullscreen.*` keys, shipped in all ten locale packs.
1652
+
1653
+ **Behaviour change worth reading if you author forms:** `submitLabel` and `cancelLabel` no longer default to the literals `'Submit'` and `'Cancel'` in the renderer. They default to _unset_, and the action bar falls back at render time to `common.submit` / `common.cancel`, so a form that declares no button copy now follows the session language instead of being silently frozen to English. A label you DO declare still wins verbatim in every locale — including an English one under a zh session, and including an explicit empty string (the fallback uses `??`, so `submitLabel: ''` renders a blank button rather than being overwritten). The only forms whose rendered text changes are those that never declared the labels and are viewed in a non-English session — which is the bug. `FormSchema.submitLabel` / `cancelLabel` stay optional strings; no spec or type change.
1654
+
1655
+ Also removed the built-in `select` branch's second `|| 'Select an option'` fallback. The single call site already supplies `t('common.selectOption')`, so the literal was reachable only through an authored `placeholder: ''` — where it replaced the author's deliberate blank with an untranslated English word.
1656
+
1657
+ - 53811d1: Associate the label with its control at the two form surfaces where the two were never programmatically connected (objectui#3341 — found while implementing #3299/PR #3340, and deliberately left out of that PR's scope fence as a different class of defect).
1658
+
1659
+ `aria-required` reaching the control (#3299) only fixes the required _state_; at these two sites the control's accessible _name_ was still wrong, because the label pointed at nothing:
1660
+
1661
+ - `@object-ui/plugin-detail` — `InlineCreateRelated`'s create-tab fields rendered a `<label>` with no `htmlFor` beside an `<Input>` with no `id`, and the two were siblings rather than wrapper/child. The field label was unreachable for assistive tech, and clicking the label did not focus the input. The ids are namespaced with `React.useId`, because `field.name` alone is unique only within one instance and a detail page mounts one of these per related list.
1662
+ - `@object-ui/components` — the custom `ActionParamDialog`'s `select` branch rendered `<Label htmlFor={param.name}>` but never put the matching `id` on its Radix `SelectTrigger`, so the reference dangled. The textarea / number / date / text branches already set `id={param.name}`; select was the only one that did not.
1663
+
1664
+ `SelectTrigger` renders a `<button role="combobox">`, and `button` is a labelable element, so the plain `htmlFor`/`id` pair is the correct association there — no `aria-labelledby` required. No spec change and no widget-props contract change.
1665
+
1666
+ - d915c47: Relation fields (`lookup` / `master_detail` / `user` / `tree`) are now usable in action and conditional-formatting predicates: they bind as the stored foreign key on every surface, and the fields a predicate reads are included in the query projection (#3501).
1667
+
1668
+ Before this, one predicate over one relation field had four different fates, decided by things its author does not control. `$expand` **replaces** the id in place with the whole related record, and a view expands exactly the relations it shows as COLUMNS — so `record.owner == "U1"` was **true** where the column was absent, **false** where it was displayed, and a **fault** where the field was neither displayed nor projected (a list's `$select` was built from its columns alone, and CEL treats an absent key as a fault, not as null). A fault is fail-CLOSED on the row kebab and the selection bar and fail-OPEN on the lenient paths, so the same authoring mistake hid the button from everyone on one surface and showed it to everyone on the next, with nothing on screen to point at either. The server, meanwhile, only ever sees the id — so client and server could not agree, which is the one thing ADR-0036 / ADR-0058 exist to guarantee.
1669
+
1670
+ Two changes close it. `toPredicateRecord` (new, `@object-ui/core`) collapses expanded relation values back to their ids when a record is bound for evaluation — driven by the object's own field types, not by sniffing for an `id` key, so a `json` field that happens to carry one is untouched. It is threaded through `evalRowPredicate` / `resolveConditionalFormatting` (via a new `fields` option), `useRowPredicate`, `partitionBulkRows`, and both `page:header` evaluators, with the object schema supplied by `ObjectGrid` / `ListView` / `ObjectKanban` / the record context. Kanban card formatting is threaded the same way, so a rule cannot match on the grid view of a list and silently never match on its board. Display is unaffected — a detail-page title still renders the related record's name, and the schema-only `kanban-ui` entry point (which has no object schema to offer) keeps using the payload verbatim. `collectPredicateFieldRefs` / `listViewPredicates` (new) harvest the `record.x` / `data.x` references out of a view's conditional formatting, row-action defs, bulk-action defs, promoted object actions and `userActions` overrides, and add them to `$select` — intersected with the object's declared fields plus the platform columns every object carries (`isProjectableField`), because an unknown key is not ignored by every backend. No `$expand` is added: a predicate wants the foreign key, which is what an unexpanded relation already is.
1671
+
1672
+ - 825bbe3: The option widgets' "this list cannot be filled" message now has one source, and
1673
+ it is translated (objectui#3231).
1674
+
1675
+ FROM: `SelectField`, `MultiSelectField`, `RadioField` and `CheckboxesField` each
1676
+ carried their own copy of the empty/gated state, each destructured the declared
1677
+ `emptyHint` prop into `_emptyHint` and dropped it, and each rendered a hardcoded
1678
+ English literal (`'No options available'`, `` `Select ${…} first` ``) even in a
1679
+ Chinese or Japanese session. TO: one shared `OptionsEmptyState` — the host's
1680
+ `emptyHint` when it supplied one, otherwise a translated fallback
1681
+ (`fields.options.empty` / `fields.options.selectFirst`, added to all ten locale
1682
+ packs).
1683
+
1684
+ `emptyHint` was declared, produced by the form renderer and transported, then
1685
+ lost three times over — so no registered widget could ever render it. All three
1686
+ breaks are fixed, because closing only the last one delivers nothing:
1687
+
1688
+ - `isOptionField` compared the raw resolved type against `'select'` /`'radio'` /
1689
+ `'multiselect'` / `'checkboxes'`. Object-derived forms emit
1690
+ `mapFieldTypeToFormType`'s prefixed ids (`field:select`), which matched none of
1691
+ them, so for every option field coming from an object schema — the normal case
1692
+ in the console — the whole cascade block was skipped and no hint was computed
1693
+ at all. It now normalizes the `field:` prefix, the same normalization
1694
+ `stripRegisteredFieldProps` already applied a few lines below.
1695
+ - `stripRegisteredFieldProps` then removed the `emptyHint` key from what was
1696
+ left. It is now forwarded to the four cascade option types, alongside
1697
+ `dependentValues`. This stays an allow-list rather than a blanket
1698
+ pass-through: every other registered widget spreads its leftover props onto a
1699
+ DOM node, where an unknown `emptyHint` attribute is a React warning.
1700
+ - the widgets themselves discarded it. Keeping it out of the `...props` spread
1701
+ was correct; not using it afterwards was not.
1702
+
1703
+ User-visible effect: a dependency-gated option list now prompts with the
1704
+ controlling field's **label** ("Select Country first") instead of its raw
1705
+ metadata name, in the session's language; an unconfigured list says so in the
1706
+ session's language too. The gate sentence is one i18n key shared by the renderer
1707
+ and the widget fallback, so the two sides cannot word it differently.
1708
+
1709
+ Untouched: the built-in (unregistered) `select` branch of the form renderer,
1710
+ which already consumed `emptyHint`. That is a separate live path.
1711
+
1712
+ - 5dd0127: Localize the record-overlay and tab-badge chrome that #5430's sweep left behind (objectstack#5506)
1713
+
1714
+ Four more console-chrome strings were still hardcoded English literals. Unlike
1715
+ #5430's set they are not all accessible names — one is visible copy, and one was
1716
+ a component **default** that only the console happened to override.
1717
+
1718
+ - `page:tabs`' count badge built its `aria-label` by template literal,
1719
+ `` `${formatTabCount(count)} items` ``. The badge renders digits only, so that
1720
+ label _is_ the badge to a screen reader — and the English plural was baked in
1721
+ with no singular branch at all, so a related list with one row announced
1722
+ "1 items". Now `common.itemCount` / `common.itemCountOne`.
1723
+ - `NavigationOverlay`'s drag-resize handle (`role="separator"`, no visible label)
1724
+ — now `common.resizeDrawer`.
1725
+ - `NavigationOverlay`'s `expandLabel` **default**. Hosts may override it and the
1726
+ console does, but the default is what every other host ships — and it feeds
1727
+ both `aria-label` and `title` of an icon-only button. Now
1728
+ `detail.openAsFullPage`, still overridable by the prop.
1729
+ - `NavigationOverlay`'s `resolvedTitle` fallback, `'Record Detail'` — **visible**
1730
+ overlay heading, not just an a11y name. Now `detail.recordDetail`.
1731
+ - The sr-only `SheetDescription`/`DialogDescription` prose
1732
+ `Record detail overlay for {title}.`, which existed in three copies
1733
+ (drawer / modal / popover) — now one `detail.recordDetailOverlay` key with a
1734
+ `{{title}}` placeholder.
1735
+
1736
+ The count badge follows this repo's **two-key** plural convention
1737
+ (`detail.reactionCount`/`reactionCountOne`, `detail.relatedRecords`/`relatedRecordOne`)
1738
+ rather than an i18next `_one`/`_other` pair: zh/ja/ko have no separate singular
1739
+ form, so those packs would legitimately omit the `_one` half and
1740
+ `all-locales-key-parity` would read that as a lost key. The formatted count
1741
+ (`1.2k`, not `1200`) is interpolated so the accessible name and the visible
1742
+ digits never disagree — and because i18next skips its own plural resolution when
1743
+ `count` is a string, the two-key scheme stays in charge of the choice.
1744
+
1745
+ Both touched components moved from `useSafeTranslate` to `createSafeTranslation`,
1746
+ which carries an options bag (two of the new keys interpolate) and an English
1747
+ defaults map. That map is what keeps the provider-less path English, which
1748
+ consumers outside this package depend on — `plugin-view`'s `ObjectView.test.tsx`
1749
+ and `e2e/live/inline-edit-polish-2572.spec.ts` address this chrome by English
1750
+ accessible name with no `I18nProvider` mounted.
1751
+
1752
+ All six new keys are added to all ten locale packs.
1753
+
1754
+ - 06632e9: `PageRenderer` no longer renders its own `<h1>` when the page authors a titled `page:header`, so a page has exactly one level-1 heading. Every non-record page used to render the page `title`/`label` as an `h1` _and_ let the `page:header` block render a second one — on the showcase master-detail page both said "New Project + Tasks", producing a broken document outline, a page title a screen reader announces twice, and the same string printed twice on screen. Record pages already delegated the whole title block to `page:header`; that rule now holds for `app` / `home` / `utility` pages too, and it is what the live e2e was reporting as a Playwright strict-mode violation (`getByRole('heading', { name })` resolving to 2 elements, objectui#3434).
1755
+
1756
+ Delegation is deliberately conservative: only a `page:header` whose title renders literal text takes the heading over. A header with no title — or one whose title interpolates to nothing (e.g. `title: '{name}'` with no record in scope) — renders no heading of its own, so the page keeps its implicit `h1` rather than ending up with none. The page-level `description` is unaffected; it is the page's own prose, not a duplicate of the header `subtitle`.
1757
+
1758
+ Author-visible effect: on a page carrying both a `label` and a titled `page:header`, only the header's title is shown (e.g. app-crm's welcome page shows "Welcome to the CRM", not also "CRM Welcome"). Pages without a `page:header` are unchanged.
1759
+
1760
+ - a4cff5b: Conditional-rule predicates that fail to evaluate are no longer silent
1761
+ (objectstack#5149, appeal 2). `evalFieldPredicate` — the canonical funnel for
1762
+ `visibleWhen` / `readonlyWhen` / `requiredWhen`, view-level `visibleOn`, legacy
1763
+ `condition`, per-option `visibleWhen`, screen-field predicates and list
1764
+ conditional formatting — now logs **one `console.warn` per predicate text**
1765
+ when evaluation fails (parse error, unbound identifier, engine fault), carrying
1766
+ the predicate source, the engine's failure reason, and the field/rule locator
1767
+ the call site provides. Renderer call sites thread that locator
1768
+ (`visibleWhen of field 'amount'`), so a broken predicate identifies itself in
1769
+ the browser console instead of being indistinguishable from an absent one.
1770
+
1771
+ Verdicts are unchanged: evaluation still fails open to the caller's safe
1772
+ default (flipping that default is objectstack#5149 appeal 1, tracked
1773
+ separately). Fault-probing callers (`evalRowPredicate`'s fail-closed path,
1774
+ `ExpressionEvaluator`'s `throwOnError`) opt out via the new
1775
+ `diagnostic.warn: false` and keep their own single diagnostic, so no broken
1776
+ predicate ever warns twice.
1777
+
1778
+ - 71be406: The close button that the `Sheet` and `Dialog` primitives auto-render now announces itself in the session locale instead of always in English. Both buttons are icon-only (a lucide `X`), so their `sr-only` span is not decoration — it IS the control's accessible name, and upstream Shadcn ships it as a hardcoded English literal. Under zh/ja/es every drawer and modal in the console (~20 `SheetContent` consumers plus every `DialogContent` consumer — ChatDock, ActivityFeed, metadata-admin, AiChatPage, BuildDebugDrawer, PeoplePicker, RecordDetailDrawer, …) announced "Close" to a screen reader. The span now renders `<CloseSrLabel />`, which resolves `common.close` (present in all ten locale packs since objectstack#5430) and falls back to English when no `I18nProvider` is mounted, so existing suites and e2e specs that address these controls by their English name are unaffected (objectstack#5505).
1779
+
1780
+ Because `packages/components/src/ui/**` is regenerated from the Shadcn registry, the edit is not a hand patch that the next `pnpm shadcn:update` would silently revert: it is declared as data in `scripts/shadcn-local-patches.mjs`, re-applied automatically on every sync (including `--force`), and enforced in both directions — `pnpm shadcn:check` now exits non-zero if a declared patch is missing from the file on disk or can no longer be re-applied to current upstream, and an offline test gates the same invariant on every PR.
1781
+
1782
+ - 8d8094a: 20 more registered field widgets now announce a failed validation to assistive
1783
+ tech: `multiselect`, `radio`, `checkboxes`, `tags`, `lookup`, `master_detail`,
1784
+ `user`, `owner`, `file`, `image`, `location`, `object`, `color`, `rating`,
1785
+ `code`, `avatar`, `address`, `geolocation`, `qrcode` and `object-ref` carry
1786
+ `aria-invalid="true"` on their real focusable control after a validation
1787
+ failure, where before the red message rendered while a screen reader was told
1788
+ nothing (objectui#3318, the registry-wide gap objectui#3306's sweep measured).
1789
+
1790
+ Each widget follows the objectui#3222/#3306 pattern: the `toDomProps(props)`
1791
+ whitelist spread goes onto the control the user actually focuses — the input,
1792
+ the lookup trigger button, the radiogroup (`role="radiogroup"` is the
1793
+ ARIA-designated carrier for a set of radios), every chip/checkbox/star of the
1794
+ composite option widgets, the upload dropzone/button — followed by an explicit
1795
+ `aria-invalid={!!error}` computed from the published `error` slot. Wrapper
1796
+ `<div>`s never carry the state, and `name` is withheld from non-form-control
1797
+ elements (the objectui#3291 leak class).
1798
+
1799
+ `Combobox` (`@object-ui/components`) now accepts standard button attributes
1800
+ and forwards them to its focusable `role="combobox"` trigger, giving
1801
+ combobox-based widgets an element to deliver `aria-invalid` /
1802
+ `aria-describedby` to — the same seam objectui#3306 opened on
1803
+ `SelectTrigger`.
1804
+
1805
+ Nine types remain on the objectui#3318 ratchet ledger with their blockers
1806
+ documented there (`formula`/`summary`/`auto_number`/`vector` render no
1807
+ focusable control; `grid`, `slider`, `signature` need component-level design;
1808
+ `filter-condition`/`recipient-picker` deliver in their editable states but
1809
+ render a dependency-gate hint with no control in a fresh form).
1810
+
1811
+ - Updated dependencies [18cd432]
1812
+ - Updated dependencies [d915c47]
1813
+ - Updated dependencies [b71fc92]
1814
+ - Updated dependencies [65516ba]
1815
+ - Updated dependencies [94c5b7c]
1816
+ - Updated dependencies [ca0fa8f]
1817
+ - Updated dependencies [3889ffb]
1818
+ - Updated dependencies [5781fb1]
1819
+ - Updated dependencies [7e2406a]
1820
+ - Updated dependencies [9e9e9a9]
1821
+ - Updated dependencies [4eeb932]
1822
+ - Updated dependencies [5c856ec]
1823
+ - Updated dependencies [23018cc]
1824
+ - Updated dependencies [68b6a28]
1825
+ - Updated dependencies [0554e88]
1826
+ - Updated dependencies [d915c47]
1827
+ - Updated dependencies [f44d872]
1828
+ - Updated dependencies [28b2e65]
1829
+ - Updated dependencies [509104a]
1830
+ - Updated dependencies [825bbe3]
1831
+ - Updated dependencies [6195841]
1832
+ - Updated dependencies [5dd0127]
1833
+ - Updated dependencies [a415684]
1834
+ - Updated dependencies [a4cff5b]
1835
+ - Updated dependencies [175bd79]
1836
+ - Updated dependencies [5af2852]
1837
+ - Updated dependencies [f833d3a]
1838
+ - Updated dependencies [a6ec93d]
1839
+ - Updated dependencies [2a9513d]
1840
+ - Updated dependencies [d22ae31]
1841
+ - Updated dependencies [c7ed4c3]
1842
+ - Updated dependencies [2409e1d]
1843
+ - Updated dependencies [789fe3e]
1844
+ - @object-ui/core@17.3.0
1845
+ - @object-ui/types@17.3.0
1846
+ - @object-ui/i18n@17.3.0
1847
+ - @object-ui/react@17.3.0
1848
+ - @object-ui/sdui-parser@17.3.0
1849
+ - @object-ui/react-runtime@17.3.0
1850
+
3
1851
  ## 17.2.0
4
1852
 
5
1853
  ### Minor Changes
@@ -2095,6 +3943,7 @@ overflow-auto` region (bottom on-screen, within the viewport) and the table can
2095
3943
  objects+fields were editable in Studio; this reworks both surfaces.
2096
3944
 
2097
3945
  **Setup (assign + read-only):**
3946
+
2098
3947
  - The six facets (`object_permissions`, `field_permissions`, `system_permissions`,
2099
3948
  `row_level_security`, `tab_permissions`, `admin_scope`) now render read-only on
2100
3949
  the `sys_permission_set` record page as a compact summary (counts, or capability
@@ -2109,6 +3958,7 @@ overflow-auto` region (bottom on-screen, within the viewport) and the table can
2109
3958
 
2110
3959
  **Studio (design every facet):** the permission matrix editor gains structured
2111
3960
  editors for the facets that were JSON-only —
3961
+
2112
3962
  - **System Capabilities**: a multi-select over the live `sys_capability` registry
2113
3963
  (scope-grouped, labelled chips).
2114
3964
  - **Row-Level Security**: per-policy rows (object · operation · enabled) with CEL
@@ -2845,6 +4695,7 @@ overflow-auto` region (bottom on-screen, within the viewport) and the table can
2845
4695
  platform primitive for "no records / no data" states. Two new props
2846
4696
  keep it flexible enough to absorb the hand-rolled variants that lived
2847
4697
  in `plugin-list`, `plugin-kanban`, and `plugin-dashboard`:
4698
+
2848
4699
  - `showIcon?: boolean` — drops the icon container entirely. Used by the
2849
4700
  kanban board-level empty banner, which is a status banner rather than
2850
4701
  a true empty-state.
@@ -2853,6 +4704,7 @@ overflow-auto` region (bottom on-screen, within the viewport) and the table can
2853
4704
  empty state, which uses a large standalone glyph).
2854
4705
 
2855
4706
  Adopters:
4707
+
2856
4708
  - `plugin-list` (`ListView` grid empty-state) — preserves the existing
2857
4709
  large icon, title, message, add-record button and `data-testid`s,
2858
4710
  but delegates the structural markup to `DataEmptyState`.
@@ -2915,6 +4767,7 @@ overflow-auto` region (bottom on-screen, within the viewport) and the table can
2915
4767
  The renderer now passes `schema.action` through `SchemaRenderer` to turn it
2916
4768
  into a real React element, and explicitly strips `action`/`icon` from the
2917
4769
  spread so schema-shaped objects don't reach DOM attributes.
4770
+
2918
4771
  - @object-ui/types@5.1.1
2919
4772
  - @object-ui/core@5.1.1
2920
4773
  - @object-ui/i18n@5.1.1
@@ -2937,17 +4790,20 @@ overflow-auto` region (bottom on-screen, within the viewport) and the table can
2937
4790
  - bd8447d: Three platform-wide detail polish items.
2938
4791
 
2939
4792
  **Tighter page rhythm**
4793
+
2940
4794
  - Outer `PageRenderer` padding `p-4 md:p-6 lg:p-8` → `p-3 md:p-4 lg:p-6`
2941
4795
  and outer body wrap `space-y-8` → `space-y-6` so list / detail / home
2942
4796
  pages share the same edge rhythm. Cuts ~16px of edge slack on lg.
2943
4797
 
2944
4798
  **Highlights KPI treatment**
4799
+
2945
4800
  - `HeaderHighlight` now renders numeric / currency / percent / decimal
2946
4801
  values as KPI numbers (`text-xl md:text-2xl font-semibold tabular-nums`)
2947
4802
  instead of the uniform `text-sm font-semibold`, so amount / probability
2948
4803
  / count fields read as headline stats — Salesforce-style key facts.
2949
4804
 
2950
4805
  **Discussion footer upgrade**
4806
+
2951
4807
  - `RecordActivityTimeline` now uses `RichTextCommentInput` (bold / italic /
2952
4808
  list / code, `@`-mention autocomplete, preview toggle, Send) instead of
2953
4809
  a bare `<textarea>`.
@@ -2962,6 +4818,7 @@ overflow-auto` region (bottom on-screen, within the viewport) and the table can
2962
4818
  accordion in flush mode dropped all per-item borders so the collapsed
2963
4819
  "Quotes / Products / Open Tasks" triggers stacked with zero visual
2964
4820
  separation.
4821
+
2965
4822
  - `@object-ui/plugin-detail` `DetailSection`: override the `CardTitle`
2966
4823
  className to `text-base font-semibold tracking-tight`, slim down
2967
4824
  `CardHeader` padding (`py-3 px-4 sm:py-4 sm:px-6`) and `CardContent`
@@ -2976,6 +4833,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
2976
4833
  CRM-style related-list lists).
2977
4834
 
2978
4835
  - d51a577: feat(platform): Discussion attachments + @mention directory + Reference Rail aside
4836
+
2979
4837
  - **Discussion attachments** — `RichTextCommentInput` now accepts an `extraSlot`
2980
4838
  and a `canSubmitEmpty` flag so hosts can mount the existing
2981
4839
  `CommentAttachment` composer beneath the editor without forking the toolbar.
@@ -2995,6 +4853,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
2995
4853
  - d1ec6a2: Fold inline-edit into the page-header overflow menu (HubSpot/Lightning
2996
4854
  pattern) and remove the orphan "Edit fields" toolbar row that previously
2997
4855
  floated between the tab strip and the first detail section.
4856
+
2998
4857
  - `@object-ui/app-shell` `RecordDetailView`: injects a new `sys_inline_edit`
2999
4858
  system action that appears in the ⋯ overflow menu and dispatches a
3000
4859
  `objectui:record:inline-edit-toggle` window CustomEvent (filtered by
@@ -3011,6 +4870,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
3011
4870
  in `page:accordion` / `page:tabs` items.
3012
4871
 
3013
4872
  - d548d6b: Unify empty-state visuals across timeline + registered `empty` renderer.
4873
+
3014
4874
  - `RecordActivityTimeline` and `ActivityTimeline` now use `DataEmptyState`
3015
4875
  instead of a bare `<p>` so empty timelines match list/related-list visuals
3016
4876
  (muted icon badge + centered copy).
@@ -3061,6 +4921,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
3061
4921
  not only contacts of that account).
3062
4922
 
3063
4923
  Two coupled fixes:
4924
+
3064
4925
  1. `RelatedList` now requires `parentId` + `referenceField` to auto-
3065
4926
  fetch. When both are present it calls `dataSource.find(api,
3066
4927
  { $filter: { [referenceField]: parentId } })`. When either is
@@ -3089,6 +4950,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
3089
4950
  default detail view. They were missing cross-cutting affordances and
3090
4951
  shipped with English-only tab labels and heavy bordered section cards
3091
4952
  even when the host locale was Chinese. Track 1 closes the visible gap:
4953
+
3092
4954
  - **app-shell `RecordDetailView`**: the `assignedPage` branch now wears
3093
4955
  the same chrome as the default branch — lifecycle managed-by badge
3094
4956
  and presence avatars in the top-right, `MetadataPanel` debug panel,
@@ -3117,6 +4979,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
3117
4979
  record detail page (lead, opportunity, future account/contact/case).
3118
4980
  Before this change it had two problems that bled into every custom
3119
4981
  page across the product:
4982
+
3120
4983
  1. **Quadruple registration**: `@object-ui/layout` registered both
3121
4984
  `page-header` and `page:header`, and `@object-ui/components`
3122
4985
  independently registered `page:header` (and `page:section`).
@@ -3127,6 +4990,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
3127
4990
  than the default detail view it was meant to supersede.
3128
4991
 
3129
4992
  This commit:
4993
+
3130
4994
  - Removes the `@object-ui/layout` `page:header` registration. The
3131
4995
  layout package keeps the legacy kebab-cased `page-header` alias only.
3132
4996
  The canonical renderer now lives in `@object-ui/components` and is
@@ -3152,6 +5016,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
3152
5016
  opportunity highlights strip looks coherent with the chip above it.
3153
5017
 
3154
5018
  - ddb08a7: feat(page:header,page:tabs): title fallback + single-tab strip auto-hide (Phase G slice 3 polish)
5019
+
3155
5020
  - `page:header.resolvedTitle` now honors `objectSchema.titleFormat`
3156
5021
  (e.g. `{first_name} {last_name}`) and falls back through `name →
3157
5022
  full_name → title → subject → display_name → label` before degrading
@@ -3198,6 +5063,7 @@ full_name → title → subject → display_name → label` before degrading
3198
5063
  muted badge on every empty Activity/History tab).
3199
5064
 
3200
5065
  - b14fe09: Phase P.0 + P.5: tighten record-detail header chrome.
5066
+
3201
5067
  - `RecordTitleChip` collapses the title row to a single baseline-aligned line — H1, eyebrow object label, copy-id, favorite star — instead of the previous two-row title + subtitle layout.
3202
5068
  - `record:details` extends the highlight-field dedup set to also exclude the title field resolved from `objectSchema.primaryField` (or the standard `name`/`full_name`/`title`/`subject`/`display_name`/`label` fallbacks). Removes the duplicate row that previously echoed the H1 (e.g. "客户名称: Acme Corporation") inside the field grid.
3203
5069
 
@@ -3214,6 +5080,7 @@ full_name → title → subject → display_name → label` before degrading
3214
5080
  underline when active. 'card' and 'pill' variants are unchanged.
3215
5081
 
3216
5082
  - 74962b0: feat(detail): record:discussion schema component + flush accordion variant
5083
+
3217
5084
  - New `record:discussion` schema type lets authors place the record
3218
5085
  chatter feed anywhere in a custom Page schema. Wired through a
3219
5086
  shared `DiscussionContext` provider on the `assignedPage` branch
@@ -3240,6 +5107,7 @@ full_name → title → subject → display_name → label` before degrading
3240
5107
  accordion, discussion slot) that custom Lightning pages already enjoy.
3241
5108
 
3242
5109
  Changes:
5110
+
3243
5111
  - `buildDefaultPageSchema` now emits `page:tabs.items` (correct shape
3244
5112
  for the renderer) rather than `tabs`.
3245
5113
  - `PageHeaderRenderer.resolvedTitle` honors `objectSchema.primaryField`
@@ -3353,6 +5221,7 @@ full_name → title → subject → display_name → label` before degrading
3353
5221
  For every tab item whose `count` is not set explicitly, the renderer walks the tab's children (depth-first) to find the first `record:related_list` schema node and issues a `limit:1` find through the active `dataSource` to read the matching `total`. The badge appears in the tab strip without spec authors having to wire counts manually.
3354
5222
 
3355
5223
  Behavior:
5224
+
3356
5225
  - Explicit `count` in the spec always wins.
3357
5226
  - Probe is filtered by the parent record id via `relationshipField` when present (skipped until the parent record is loaded).
3358
5227
  - Best-effort: a failed probe just omits the badge — no error surface.
@@ -3393,6 +5262,7 @@ full_name → title → subject → display_name → label` before degrading
3393
5262
  ### Patch Changes
3394
5263
 
3395
5264
  - 6b683c8: fix(detail): clean up record page rendering
5265
+
3396
5266
  - Drop `ai:chat_window` from the protocol-component placeholder list. The
3397
5267
  floating chat overlay (plugin-chatbot) is the canonical AI entry point;
3398
5268
  inline page schemas that still reference `ai:chat_window` now surface
@@ -3420,6 +5290,7 @@ full_name → title → subject → display_name → label` before degrading
3420
5290
  The report configuration panel is now safe to open on any spec-shape `Report` and only exposes fields that are actually persisted by `@objectstack/spec`.
3421
5291
 
3422
5292
  `@object-ui/plugin-report`:
5293
+
3423
5294
  - Add a bidirectional `SpecFilterAdapter` so `ReportConfigPanel` can edit
3424
5295
  spec `FilterCondition` filters (`{field: value}`, `{field: {$op: value}}`,
3425
5296
  top-level `$and`/`$or`). Complex / nested filters fall back to a
@@ -3441,15 +5312,18 @@ full_name → title → subject → display_name → label` before degrading
3441
5312
  - 18 new unit tests cover the filter adapter round-trip.
3442
5313
 
3443
5314
  `@object-ui/components`:
5315
+
3444
5316
  - `FilterBuilder` now guards against malformed external `value` props.
3445
5317
  Previously a spec-shape filter (`{is_active: true}`) would crash the
3446
5318
  component on first render; the builder now falls back to an empty
3447
5319
  AND group whenever `value` is not a valid `FilterGroup`.
3448
5320
 
3449
5321
  `@object-ui/i18n`:
5322
+
3450
5323
  - Add `report.editor.*` strings to `en` and `zh`.
3451
5324
 
3452
5325
  - 8442c05: Improve report editor panel usability based on real-user browser testing:
5326
+
3453
5327
  - **Wider config panel** — the report editor now defaults to a `--config-panel-width`
3454
5328
  of 440px (up from 280px), driven by a new optional `style` prop on
3455
5329
  `ConfigPanelRenderer`. Long field labels, report titles, type labels, and filter
@@ -3572,6 +5446,7 @@ full_name → title → subject → display_name → label` before degrading
3572
5446
  detail view (`TypeError: titleFormat.replace is not a function`) and printed
3573
5447
  `Failed to evaluate expression: ${[object Object]}` for every action visibility
3574
5448
  predicate.
5449
+
3575
5450
  - `@object-ui/core`: `ExpressionEvaluator.evaluate` / `evaluateCondition` now
3576
5451
  unwrap Expression envelopes transparently.
3577
5452
  - `@object-ui/react`: new `toPredicateInput()` helper to safely normalize
@@ -3654,6 +5529,7 @@ full_name → title → subject → display_name → label` before degrading
3654
5529
  plugin-designer, plugin-detail, plugin-editor, plugin-form, plugin-gantt,
3655
5530
  plugin-grid, plugin-kanban, plugin-list, plugin-map, plugin-markdown,
3656
5531
  plugin-report, plugin-timeline, plugin-view, plugin-workflow.
5532
+
3657
5533
  - @object-ui/types@4.0.5
3658
5534
  - @object-ui/core@4.0.5
3659
5535
  - @object-ui/i18n@4.0.5
@@ -3668,6 +5544,7 @@ full_name → title → subject → display_name → label` before degrading
3668
5544
  Library builds (vite lib mode) now externalize every non-relative import instead of bundling third-party CJS dependencies into the published dist. This avoids inlined `require("react")` / `require("react-dom")` calls that cause `Calling \`require\` for "react" in an environment that doesn't expose the \`require\` function` runtime errors when consumer apps re-bundle the published dist.
3669
5545
 
3670
5546
  Specifically fixes:
5547
+
3671
5548
  - `@object-ui/plugin-dashboard` no longer inlines `react-grid-layout` (and its transitive `react-draggable` / `react-resizable` CJS bundles). `react-grid-layout` is now declared as a peer dependency so consumers install a single ESM-friendly copy.
3672
5549
  - `@object-ui/components`, `@object-ui/plugin-calendar`, `@object-ui/plugin-charts`, `@object-ui/plugin-designer` no longer inline `react-i18next` / `i18next` / `use-sync-external-store` CJS shims.
3673
5550
  - All plugin packages now use a unified `external: (id) => !/^[./]/.test(id) && !id.startsWith(__dirname)` rule, ensuring future additions of CJS deps are automatically externalized.
@@ -3724,6 +5601,7 @@ full_name → title → subject → display_name → label` before degrading
3724
5601
  contract through the renderer end-to-end:
3725
5602
 
3726
5603
  **`@object-ui/types`** — `DataSource` gains four optional methods:
5604
+
3727
5605
  - `createExportJob(resource, request)` → `{ jobId, status, estimatedRecords, createdAt }`
3728
5606
  - `getExportJobProgress(jobId)` → `{ status, processedRecords, totalRecords, percentComplete, downloadUrl, … }`
3729
5607
  - `cancelExportJob(jobId)` (optional)
@@ -3733,6 +5611,7 @@ full_name → title → subject → display_name → label` before degrading
3733
5611
  remain dependency-free.
3734
5612
 
3735
5613
  **`@object-ui/components`** — new public API:
5614
+
3736
5615
  - `useExportJob({ dataSource, pollIntervalMs, onComplete, onError })` — owns the
3737
5616
  full polling loop, terminal-state handling, cancel, and download.
3738
5617
  - `<ExportProgressDialog open onOpenChange job filename closeAfterDownloadMs />` —
@@ -3751,24 +5630,29 @@ full_name → title → subject → display_name → label` before degrading
3751
5630
  - a2d7023: End-user feature batch — forms, designer history, import/export, and PWA offline sync.
3752
5631
 
3753
5632
  **Forms (`@object-ui/fields`, `@object-ui/providers`)**
5633
+
3754
5634
  - `FileField`: native `<input capture="environment">` camera capture for mobile devices, plus a uploading-progress indicator driven by `UploadProvider`.
3755
5635
  - `ImageField`: per-image inline crop/rotate via the lazy-loaded `ImageCropperDialog` (canvas-based, zero new deps).
3756
5636
  - New `UploadProvider` in `@object-ui/providers` with pluggable adapters for S3 and Azure Blob (plus the default object-URL adapter for local previews). XHR-based with progress, abort, and retry.
3757
5637
  - `LookupField`: `lookup.dependsOn: string | string[]` to chain dependent lookups (e.g. State depends on Country); the trigger is gated until parent values are present and the OData `$filter` is built automatically.
3758
5638
 
3759
5639
  **Container-aware widget widths (`@object-ui/components`)**
5640
+
3760
5641
  - New `useResizeObserver(ref)` hook exposing `{ width, height }` of any element. SSR-safe; reads the initial size via `getBoundingClientRect`.
3761
5642
  - `plugin-gantt` and `plugin-kanban` now react to their container size instead of `window.innerWidth`, so they behave correctly inside split panels and dashboards.
3762
5643
 
3763
5644
  **Designer history (`@object-ui/plugin-designer`)**
5645
+
3764
5646
  - `useUndoRedo` (and therefore `useDesignerHistory`) gains `persistKey` + `storage` options to round-trip the undo/redo stack through `sessionStorage`, plus a `clearPersisted()` cleanup helper. Drafts now survive accidental tab refreshes.
3765
5647
  - New `<HistoryPanel>` component renders the timeline visually with one-click jump-to-checkpoint via the new `jumpTo(index)` API.
3766
5648
 
3767
5649
  **Import wizard (`@object-ui/plugin-grid`)**
5650
+
3768
5651
  - Saved column-mapping templates: name, save, re-apply, and delete via a new template bar in the mapping step. Persisted under `objectui:import-templates:${objectName}` (override via `templateStorageKey` / `templateStorage`).
3769
5652
  - Inline validation correction: cells with errors in the preview step are now editable; corrections feed straight into the import without requiring a re-upload, with green-bar status indicators for fixed rows.
3770
5653
 
3771
5654
  **PWA offline sync (`@object-ui/mobile`)**
5655
+
3772
5656
  - New `MemoryOfflineQueue` / `IndexedDbOfflineQueue` (`createOfflineQueue()` picks the best backend) backed by IndexedDB.
3773
5657
  - `createOfflineDataSource(inner, { queue })` wraps any DataSource so mutations issued while offline (or that fail with a network-style error) are queued and replayed in order on reconnect. Includes `replay()`, `drop()`, `clear()`, `pending()`, an `onChange` notifier, and an opt-in `resolveConflict` hook for stale-write conflicts.
3774
5658
  - New `useOfflineSync(source)` hook exposes `{ isOnline, pending, isReplaying, replay, drop, clear }` and auto-replays on the browser's `online` event.
@@ -3796,11 +5680,13 @@ full_name → title → subject → display_name → label` before degrading
3796
5680
  `FormSchema.mobileStickyActions` (new) is the lower-level escape hatch — applied automatically when `mobile.stickyActions` is set on `ObjectFormSchema`.
3797
5681
 
3798
5682
  **`@object-ui/plugin-form`** — `ObjectForm` now:
5683
+
3799
5684
  - propagates `mobile.fullscreenLongText` to every textarea/markdown/html field as `mobile_fullscreen: true`,
3800
5685
  - sets `mobileStickyActions` on the inner form schema and adds `pb-20` padding so content isn't covered by the fixed bar,
3801
5686
  - when `mobile.stepper === true` (or `'auto'` + `useIsMobile()` + > `stepperMinFields` fields), routes the flat field list through the existing `WizardForm` with synthetic single-field "steps" — keeping per-step validation and the existing `Next`/`Back`/`Submit` flow.
3802
5687
 
3803
5688
  **`@object-ui/components`** — the registered `form` renderer adds:
5689
+
3804
5690
  - a `mobileStickyActions` opt-in that turns the action row into a `position: sticky; bottom: 0` bar on small viewports, and
3805
5691
  - an inline `FullscreenTextarea` wrapper used when no field-package widget is registered, providing the same expand-button + edit-dialog UX so the feature works even in lighter setups.
3806
5692
 
@@ -3839,6 +5725,7 @@ full_name → title → subject → display_name → label` before degrading
3839
5725
  `@object-ui/app-shell` and `@object-ui/components` have been updated to
3840
5726
  point at the new paths; no runtime behaviour changed. A new
3841
5727
  `examples/README.md` provides a "which example should I use?" selector.
5728
+
3842
5729
  - @object-ui/types@3.3.1
3843
5730
  - @object-ui/core@3.3.1
3844
5731
  - @object-ui/i18n@3.3.1
@@ -3983,6 +5870,7 @@ full_name → title → subject → display_name → label` before degrading
3983
5870
  - New plugin-object and ObjectQL SDK updates
3984
5871
 
3985
5872
  **Added:**
5873
+
3986
5874
  - New Plugin: @object-ui/plugin-object - ObjectQL plugin for automatic table and form generation
3987
5875
  - ObjectTable: Auto-generates tables from ObjectQL object schemas
3988
5876
  - ObjectForm: Auto-generates forms from ObjectQL object schemas with create/edit/view modes
@@ -3991,6 +5879,7 @@ full_name → title → subject → display_name → label` before degrading
3991
5879
  - ObjectQL Integration: Enhanced ObjectQLDataSource with getObjectSchema() method using MetadataApiClient
3992
5880
 
3993
5881
  **Changed:**
5882
+
3994
5883
  - Updated @objectql/sdk from ^1.8.3 to ^1.9.1
3995
5884
  - Updated @objectql/types from ^1.8.3 to ^1.9.1
3996
5885
 
@@ -4006,6 +5895,7 @@ full_name → title → subject → display_name → label` before degrading
4006
5895
  - Patch release: Add automated changeset workflow and CI/CD improvements
4007
5896
 
4008
5897
  This release includes infrastructure improvements:
5898
+
4009
5899
  - Added changeset-based version management
4010
5900
  - Enhanced CI/CD workflows with GitHub Actions
4011
5901
  - Improved documentation for contributing and releasing