@object-ui/components 16.1.0 → 17.2.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,1671 @@
1
1
  # @object-ui/components
2
2
 
3
+ ## 17.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4ae0ac4: One placement rule for action `locations` (objectui#3142).
8
+
9
+ **Breaking for metadata**: an action that declares no `locations` (missing key
10
+ or `[]`) no longer renders in a located surface. FROM: omitting `locations`
11
+ made an action appear on the list toolbar, the record header, and every
12
+ metadata-admin toolbar. TO: declare where it belongs —
13
+ `locations: ['record_header']` for the record header, `['list_toolbar']` for
14
+ the list toolbar, and so on. Nothing else changes; actions that already
15
+ declare a location are untouched.
16
+
17
+ Four renderers each answered "where does an action with no `locations` go?"
18
+ differently — `action:bar` and metadata-admin showed it EVERYWHERE,
19
+ `page:header` showed it on the header, `action:group` showed it for
20
+ `undefined` but hid it for `[]` — while `ActionEngine`, `RecordDetailView`,
21
+ `DeclaredActionsBar`, the related-list bridge and the environment toolbar all
22
+ showed it NOWHERE. The same action therefore appeared or vanished depending on
23
+ which component happened to draw it. All eight now go through one exported
24
+ predicate, `actionRendersAt(action, location)` from `@object-ui/types`: an
25
+ action renders at a location only if it declares that location.
26
+
27
+ The strict reading is the platform's own — ADR-0078 lists "an `action` with no
28
+ `locations`" as a verified inert shape, and the detail-page synthesizer already
29
+ documented "must include `locations: ['record_header']` to render". The
30
+ leniency contradicted both, and it is what let an aggregate-only bulk action
31
+ (objectui#3139) — one with no single-record placement by construction — mint a
32
+ list-toolbar button whose dispatch could only fail.
33
+
34
+ Two placements are declared elsewhere and need no `locations`, both unchanged:
35
+ host-injected chrome in the `systemActions` / `headerSystemActions` slot (now
36
+ consistently exempt on `page:header` too, where it used to be filtered), and an
37
+ action named in a view's `bulkActions` / `bulkActionDefs`.
38
+
39
+ Authoring side: Studio seeds `locations: ['record_header']` on a new action
40
+ instead of minting one that renders nowhere, and the action inspector says so
41
+ when no placement is ticked. The `ActionSchema.locations` JSDoc claimed a
42
+ `['record_header']` default that no renderer ever implemented — corrected.
43
+
44
+ - 09d30a4: Stop declaring 18 `@object-ui/auth` / `@object-ui/components` / `@object-ui/react`
45
+ symbols under names `@objectstack/spec` owns (objectui#3159, objectstack#4115
46
+ batch 5).
47
+
48
+ **Breaking for importers of all three packages** — six exported names changed,
49
+ because the spec exports the same name for a _different_ thing:
50
+
51
+ | package | was | now | what the spec's same-named export actually is |
52
+ | :----------- | :--------------------------- | :----------------------- | :----------------------------------------------------------------------------- |
53
+ | `auth` | `AuthSession` | `AuthClientSession` | the SERVER's session record (`{ id, userId, expiresAt: ISO string, token? }`) |
54
+ | `auth` | `AuthProviderConfig` | `AuthProviderOptions` | an OAuth/OIDC provider registration (`{ id, clientId, clientSecret, scope? }`) |
55
+ | `components` | `FilterCondition` | `FilterBuilderCondition` | the recursive ObjectQL predicate AST (`$and`/`$or`/`$not`) |
56
+ | `components` | `Field` | `FieldContainer` | an object FIELD's metadata and its builder namespace |
57
+ | `react` | `ConflictResolutionStrategy` | `ConflictResolution` | the metadata-MERGE policy (`error \| priority \| first-wins \| last-wins`) |
58
+
59
+ The `react` rename is the odd one out: the new name is the **spec's own** name
60
+ for the union that hook always used, so it is a re-export rather than a dialect.
61
+
62
+ Eleven more keep their names and are now **imported or derived from the spec**
63
+ instead of re-declared: `TenancyPosture`, `DelegableScope` (+`DelegableAdminScope`),
64
+ `AuthUser`, `ShareLinkPermission`, `ShareLinkAudience`, `ShareLink`, `SortItem`,
65
+ `OfflineStrategy`, `OfflineCacheConfig`, `OfflineSyncConfig`, `OfflineConfig`,
66
+ `NavigationConfig`.
67
+
68
+ **Three of the copies were losing information, not just duplicating it.**
69
+
70
+ - `AuthUser` never declared the spec's `positions` or `tenantId` — the
71
+ authorization inputs. Its `[key: string]: unknown` index signature meant the
72
+ omission was invisible at every call site _and_ to any structural comparison
73
+ (the objectstack#4075 mechanism). It now `extends` the spec principal, so the
74
+ display-only fields (`image`, `role`, `roles`, `emailVerified`) are the delta
75
+ and the spec's keys arrive on their own.
76
+ - `useNavigationOverlay`'s copy carried the note _"inline … to avoid importing
77
+ from `@object-ui/types` (which may not be a direct dependency of
78
+ `@object-ui/react`)"_. The vocabulary belongs to `@objectstack/spec`, which
79
+ **is** a direct dependency — the same expired "kept local to avoid a
80
+ dependency" comment objectui#3169 found in `@object-ui/app-shell`.
81
+ - `useOffline` and `usePerformance` both opened with _"Types aligned with
82
+ `@objectstack/spec` v2.0.7"_. The installed spec is 17.0.0-rc.1.
83
+
84
+ `ShareLink` derives from the spec row **minus `password_hash`** — omitted rather
85
+ than optional, because it is the credential itself and typing it in a browser
86
+ package is an invitation to render it. `password_protected` (the boolean the UI
87
+ needs in its place) is the one local addition.
88
+
89
+ The config types derive from each schema's **input** side, not `z.infer`.
90
+ `useOffline(config: OfflineConfig = {})` defaults to the empty object, which the
91
+ output type — every `.default()`ed key required — would reject outright.
92
+
93
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
94
+ `@object-ui/components`: its public type surface now references the spec.
95
+
96
+ Scored `minor`, not `major`, per this repo's fixed-group rule — objectui's major
97
+ tracks `@objectstack`, so breaking changes of our own ship as minor with the
98
+ semantics spelled out above (see AGENTS.md §版本号策略). A `major` here would carry
99
+ all 39 packages of the fixed group to `18.0.0` and off objectstack's 17.x line.
100
+
101
+ - 726b89c: `@object-ui/types` stops declaring sixteen symbols under names `@objectstack/spec` owns (objectui#3156, objectstack#4115).
102
+
103
+ Seven are now **derived** from the spec, nine are **renamed** to the local
104
+ dialect they always were. Both halves remove the same hazard: a local
105
+ declaration under a spec export's name reads as the spec's own definition to
106
+ the next reader, so a copy that is merely _correct today_ is a planted premise
107
+ tomorrow.
108
+
109
+ **Derived** — the spec now supplies the keys, by reference:
110
+
111
+ | symbol | derivation |
112
+ | :----------------------- | :-------------------------------------------------------------------------------- |
113
+ | `ActionParam` | `z.input<typeof ActionParamSchema>`, `type` widened to the local legacy spellings |
114
+ | `CreateExportJobRequest` | `Omit<CreateExportJobInput, 'object'>` (`object` is the method argument) |
115
+ | `CreateExportJobResult` | re-export from `@objectstack/spec/contracts` |
116
+ | `ImportRowResult` | re-export from `@objectstack/spec/api` |
117
+ | `NavigationArea` | spec keys, with `navigation` / `visible` pinned locally |
118
+ | `NavigationAreaSchema` | `specFieldsExcept(NavigationAreaSchema.shape, …)` |
119
+ | `Theme` | re-export of the spec's `ThemeInput` (the authoring shape) |
120
+ | `ExportJobFormat` | re-export of the spec's `ExportFormat` |
121
+
122
+ Four of these close real gaps rather than tidy names. `ActionParam` never
123
+ declared `reference` — the key `resolveActionParams()` actually reads for an
124
+ inline lookup target — nor `defaultFromRow`, which the metadata designer's own
125
+ inspector writes; it also narrowed `visible` to a bare string although the
126
+ resolver has always accepted the `{ dialect, source }` envelope too.
127
+ `CreateExportJobResult.createdAt` and `ImportRowResult.action` were optional
128
+ here and required by the server, leaving every consumer a branch that could
129
+ never run. And `NavigationArea`'s `id` now carries the spec's own length rule
130
+ instead of accepting any string.
131
+
132
+ **Renamed** — same word, different concept:
133
+
134
+ | was | now | why |
135
+ | :----------------- | :----------------------- | :----------------------------------------------------------------------------------------------------------------------------- |
136
+ | `FileMetadata` | `UploadedFileMetadata` | field-VALUE payload (`url`, `original_name`), not the storage file record |
137
+ | `GestureType` | `TouchGestureType` | direction-fused (`swipe-left`), not the spec's type+direction pair |
138
+ | `GestureConfig` | `TouchGestureConfig` | gesture→`action` binding, not per-gesture tuning |
139
+ | `OfflineConfig` | `PWAOfflineConfig` | service-worker route caching, not the offline data/sync model |
140
+ | `PageRegion` | `PageNodeRegion` | region of the renderer page NODE, holding `SchemaNode`s |
141
+ | `PageRegionSchema` | `PageNodeRegionSchema` | zod twin of the above |
142
+ | `ResponsiveConfig` | `MobileResponsiveConfig` | mobile box config, not the spec's SDUI grid contract |
143
+ | `WidgetManifest` | `RuntimeWidgetManifest` | SDUI component manifest, not the field-widget plugin manifest |
144
+ | `WidgetSource` | `RuntimeWidgetSource` | `module`/`inline`/`registry` loader union — and its `inline` carries a resolved component where the spec's carries source code |
145
+
146
+ **Migration**: the old names are gone, not deprecated — an alias would preserve
147
+ exactly the ambiguity being removed. Import the new name; nothing about the
148
+ shapes changed. `@object-ui/types` already re-exports the spec's own
149
+ `SpecResponsiveConfig`, and `@object-ui/react`'s `useOffline` config remains the
150
+ spec-shaped `OfflineConfig`, so both concepts stay reachable under
151
+ distinguishable names.
152
+
153
+ Each rename carries a bidirectional tripwire
154
+ (`packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts`): it fails if
155
+ the spec ever claims the new name, and also if the spec retires the old one —
156
+ at which point the natural name can be taken back rather than the workaround
157
+ outliving its reason.
158
+
159
+ ### Patch Changes
160
+
161
+ - cb82705: A standalone grid's search box searches the list, not the page you can see (objectui#3118).
162
+
163
+ Under server-side pagination a standalone `ObjectGrid` rendered `data-table`'s
164
+ built-in search box, and that box filtered the rows the table was holding —
165
+ which is one page. The user read "2 results for X in this list" while 3075 rows
166
+ never participated, with the pager beside it still reading `1 / 63`. Every piece
167
+ was individually correct: `searchable` defaults to true, `manualPagination` is
168
+ true, and the two are declared next to each other in the same object literal.
169
+
170
+ This is objectui#3106 one axis over — sort there, filter here — and it takes the
171
+ same shape. `DataTable` gains `manualSearch` + a controlled `search` +
172
+ `onSearchChange`. In that mode it filters nothing, reports the typed term, and
173
+ renders `search` as the box's value, holding **no** term of its own: a private
174
+ copy beside a controlled prop is the shape the defect had. `ObjectGrid` turns
175
+ that term into a `$search` on the refetch — the server picks the matching fields
176
+ from the object's metadata (ADR-0061), the same channel the ListView toolbar has
177
+ always used — and returns to page 1, since a new term makes the old page index a
178
+ different set of rows (usually no rows at all). `$searchFields` rides along only
179
+ when the view declared `searchableFields`, which can narrow the server-resolved
180
+ set and never widen it.
181
+
182
+ Two things worth naming:
183
+
184
+ - Both paths are never live at once. The server's answer is the answer; a client
185
+ pass left running underneath would silently re-narrow it to whichever returned
186
+ rows happen to contain the term as _rendered text_, overruling the server's
187
+ own notion of which fields are searchable.
188
+ - Under `manualSearch` a table with no `onSearchChange` renders **no** search
189
+ box. The sort axis could degrade to inert headers; here there is no honest
190
+ local behaviour to fall back to, because the rows to search are not in the
191
+ browser.
192
+
193
+ Client-paginated grids are untouched: inline, bound and grouped grids hold every
194
+ row they display, so their box keeps filtering in memory, where the count it
195
+ produces is true. The ListView path was never affected — it passes
196
+ `showSearch: false` and searches from its own toolbar.
197
+
198
+ - f6e8d78: Lookup search inside a create/edit modal is typeable again (objectui#3183).
199
+
200
+ In every production console build, the search input of a lookup field's
201
+ quick-select popover — and the nested Record Picker dialog — could not take
202
+ focus while the form modal was open: every click/focus was synchronously
203
+ yanked back to the field trigger, so a lookup could not be searched while
204
+ creating a record.
205
+
206
+ Root cause is a race in stock `@radix-ui/react-focus-scope@1.1.16`: the
207
+ focus-scopes stack effect's cleanup schedules `focusScopesStack.remove(scope)`
208
+ in a `setTimeout(0)`. When the effect re-runs for a still-mounted scope (a
209
+ `container` ref flicker), the re-run re-`add`s the scope and the stale timeout
210
+ then evicts it — the dialog's trap listeners stay active but its scope is no
211
+ longer in the stack, so an opening popover pauses nothing and the trap yanks
212
+ focus out of the popover forever.
213
+
214
+ Fixed via `patches/@radix-ui__react-focus-scope.patch`: an effect re-run for a
215
+ live scope cancels the pending eviction; a real unmount still runs the full
216
+ delayed cleanup (autofocus-on-unmount + stack removal). Regression-tested in
217
+ `packages/components` with a deterministic reproduction of the race.
218
+
219
+ - a8ad6c0: A required boolean must be savable in its UNCHECKED state — `false` and `0` are values.
220
+
221
+ Reported against an AI-built task tracker whose 任务 object has a required
222
+ `是否完成` boolean: the create form showed the switch OFF, answered "是否完成不能
223
+ 为空", and saved instantly once the switch was turned ON. The app could only ever
224
+ create ALREADY-DONE tasks — the one state the control shows by default was the
225
+ one value it refused to save (cloud#972).
226
+
227
+ Two defects stacked, and either alone is enough to break it:
228
+
229
+ **The `required` verdict read truthiness, not presence.** `@objectstack/spec`
230
+ FieldSchema.required (ADR-0113) is "an insert must provide a NON-NULL value",
231
+ and objectql's record validator implements exactly that. react-hook-form's
232
+ built-in rule instead fails whenever `isBoolean(value) && !value` — its
233
+ accept-the-terms checkbox heritage — silently redefining every required boolean
234
+ as "must be TRUE", including a select whose chosen option value is `false`. It
235
+ also disagreed the other way, letting a whitespace-only string through for the
236
+ server to reject with a 400. The form renderer no longer hands RHF its own
237
+ `required`: the check is now a `validate` entry keyed `required` (so the error
238
+ still surfaces as `type: 'required'`, which the conditional-required cleanup
239
+ keys on) backed by a new shared `isMissingForRequired` in `@object-ui/core`, a
240
+ deliberate mirror of objectql `record-validator.isMissing` — `undefined`,
241
+ `null`, blank-after-trim string, empty array. Deleting the inherited rule also
242
+ stops a `required` that rode in on `validation` from outliving a `requiredWhen`
243
+ that resolved to FALSE.
244
+
245
+ **A boolean field held `undefined` while displaying "off".** A two-state control
246
+ has no third state, but a field with no entry in `defaultValues` rendered an OFF
247
+ switch backed by nothing: the create payload omitted the column (it lands null,
248
+ which reads as unchecked but isn't) and the presence check above would still
249
+ refuse it. The form renderer now folds `false` into `defaultValues` for every
250
+ boolean-widget field the caller left unset — in `defaultValues` itself, not
251
+ per-Controller, because that object is also the dirty-check baseline and what
252
+ the defaults-reset window replays. Every surface gets it, including the
253
+ modal/drawer create dialogs that start from a bare `{}`. An authored default
254
+ (or a loaded record, `null` included) still wins.
255
+
256
+ `WizardForm`'s cross-step gate had its own copy of the empty-value predicate; it
257
+ now imports the shared one so it cannot drift from the per-field verdict. And
258
+ the field-demo renderer read `schema.defaultValue || schema.value`, throwing
259
+ away an authored default of `false` / `0` / `''` — same falsy-as-empty class,
260
+ now `??`.
261
+
262
+ Verified end to end on a local stack against the exact metadata shape
263
+ `apply_blueprint` materializes (`{ type: 'boolean', required: true }`, no
264
+ default): a 是否完成 = 否 task with 工时 = 0 now creates and persists as
265
+ `{ hours: 0, is_done: false }`, turning the switch on still stores `true`, and a
266
+ blank required text is still refused.
267
+
268
+ - Updated dependencies [4ae0ac4]
269
+ - Updated dependencies [696e3c1]
270
+ - Updated dependencies [bca45cc]
271
+ - Updated dependencies [a889e31]
272
+ - Updated dependencies [09d30a4]
273
+ - Updated dependencies [4bf612c]
274
+ - Updated dependencies [335041c]
275
+ - Updated dependencies [b414983]
276
+ - Updated dependencies [256f8cc]
277
+ - Updated dependencies [d9668a7]
278
+ - Updated dependencies [4b470b9]
279
+ - Updated dependencies [cb82705]
280
+ - Updated dependencies [f572849]
281
+ - Updated dependencies [4a51e77]
282
+ - Updated dependencies [ea96284]
283
+ - Updated dependencies [d3584c6]
284
+ - Updated dependencies [cc70b8f]
285
+ - Updated dependencies [a8ad6c0]
286
+ - Updated dependencies [444457c]
287
+ - Updated dependencies [850033c]
288
+ - Updated dependencies [022e4c3]
289
+ - Updated dependencies [009e25d]
290
+ - Updated dependencies [726b89c]
291
+ - @object-ui/types@17.2.0
292
+ - @object-ui/core@17.2.0
293
+ - @object-ui/react@17.2.0
294
+ - @object-ui/i18n@17.2.0
295
+ - @object-ui/sdui-parser@17.2.0
296
+ - @object-ui/react-runtime@17.2.0
297
+
298
+ ## 17.1.0
299
+
300
+ ### Minor Changes
301
+
302
+ - 9e7349e: **`target` is the only action handler slot — the `execute` alias is gone from the renderer (framework#3856).**
303
+
304
+ `ActionRunner.executeScript` read `action.target || action.execute`. That fallback
305
+ is unreachable against `@objectstack/spec` 17: `execute` is now a tombstoned key
306
+ (framework#3855) that the parser **rejects** with the rename prescription, so no
307
+ parsed action can carry it and the `||` could only ever yield `target`. Verified
308
+ against 17.0.0-rc.0 — an action declaring `execute` fails `ActionSchema.safeParse`,
309
+ and a `target` action's parsed output has no `execute` key at all.
310
+
311
+ Deleted rather than left as harmless residue: two handler slots is what let one
312
+ action run one script server-side and a different one client-side (framework#3713,
313
+ where this renderer preferred the alias while the spec transform preferred
314
+ `target`). A dead slot still reads as a live contract to the next maintainer.
315
+
316
+ `execute` is also **removed from the types**, which is the part that had never
317
+ landed. framework#3856 predicted a compile error here; there wasn't one, because
318
+ neither reader was typed against the spec's `z.infer`:
319
+
320
+ - `@object-ui/types` `ActionSchema` hand-declared `execute?: string`. Removed, so
321
+ `execute: '…'` now fails `tsc` at the authoring site (TS2353).
322
+ - `@object-ui/core` `ActionDef` hand-declared it too. Removed — but `ActionDef`
323
+ carries a `[key: string]: any` index signature, so stale hand-authored metadata
324
+ that never passed through the parser still compiles. For that path
325
+ `executeScript` now returns the rename prescription instead of a bare
326
+ "No script provided", matching the spec tombstone's rule that removing an
327
+ authorable key must be audible: silently binding no handler is the
328
+ "Mark Done does nothing" shape (framework#2169).
329
+
330
+ The four action renderers (`action:button`, `action:icon`, `action:menu`,
331
+ `action:group`) no longer forward `execute` into the runner, and Studio's
332
+ `ActionPreview` no longer falls back to it — previewing an alias-only draft as
333
+ "bound" contradicted the parse that rejects it on save.
334
+
335
+ Requires `@objectstack/spec` 17. Metadata still on the alias is rewritten by
336
+ `os migrate meta --from 16`.
337
+
338
+ - 38ca8be: refactor(fields): `requiredWhen` is the only required-predicate slot — drop the retired `conditionalRequired` alias
339
+
340
+ `@objectstack/spec` 17 (objectstack#3855) **retired** `Field.conditionalRequired`,
341
+ the long-deprecated alias of `requiredWhen`. ObjectUI carried a back-compat read
342
+ for it in seven places; all of them are removed.
343
+
344
+ The removal is safe because the spec did not merely _stop emitting_ the key — it
345
+ made authoring it **fail loudly**. `retiredKey()` declares the key as
346
+ `z.never()`, so:
347
+
348
+ - `z.input` types it as `never` — writing it is a `tsc` error at the authoring site;
349
+ - the parse **rejects** it (verified against `17.0.0-rc.0`), at both `FieldSchema`
350
+ and `ObjectSchema`, with the prescription as the message:
351
+
352
+ > `conditionalRequired` was removed in @objectstack/spec 17 (#3855) — use
353
+ > `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged.
354
+ > Run `os migrate meta --from 16` to rewrite it automatically.
355
+
356
+ So spec-parsed metadata cannot carry the key — an object declaring it fails to
357
+ load rather than loading with the rule silently dropped. Keeping a renderer-side
358
+ `requiredWhen ?? conditionalRequired` would have re-created exactly the second
359
+ de-facto contract the tombstone exists to prevent: the key would have kept
360
+ working in the UI while being rejected everywhere else, hiding the producer's bug
361
+ (AGENTS.md #0.1). "Backend-agnostic" (#1) does not argue for keeping it either —
362
+ `conditionalRequired` is an ObjectStack-spec-ism, so the only producers that ever
363
+ emit it are ObjectStack producers on ≤16, and the spec ships them a converter.
364
+
365
+ Removed from:
366
+
367
+ | package | site |
368
+ | :----------------------- | :-------------------------------------------------------------------------------------------------------- |
369
+ | `@object-ui/types` | the `conditionalRequired?:` member on `FormField` |
370
+ | `@object-ui/core` | the `??` fallback + rules-param member in `resolveFieldRuleState` |
371
+ | `@object-ui/components` | three pass-throughs in the form renderer |
372
+ | `@object-ui/plugin-form` | `ObjectForm`, `ModalForm`, `sectionFields`, `deriveMasterDetail` (×2) |
373
+ | `@object-ui/app-shell` | the field inspector's legacy read/auto-migrate, and the key's entry in `clientValidation`'s CEL lint list |
374
+
375
+ **Studio authors lose nothing.** The object designer's draft validation parses
376
+ against the spec's own `ObjectSchema`, so a draft carrying the key now surfaces
377
+ the tombstone's rename prescription under the same `fields.<name>.conditionalRequired`
378
+ path the CEL lint used to report — a better message than the inspector's silent
379
+ auto-migration, and one the server agrees with. That behavior is pinned by a test.
380
+
381
+ **Migrating:** rename the key to `requiredWhen` (the CEL value is unchanged), or
382
+ run `os migrate meta --from 16`.
383
+
384
+ - 07de839: fix(notifications): the config, `position` and action `variant` are read instead of forked or ignored (#3014 follow-up)
385
+
386
+ The last of the notification contract. After `displayType` (#3071) and `icon`
387
+ (#3076), four gaps of the same family were left:
388
+
389
+ - **the config was 3/4 inert** — only `defaultDuration` was ever read.
390
+ `maxVisible` and `stacking` were carried and ignored, while
391
+ `NotificationBanners` capped at a hard-coded `3` of its own;
392
+ - **its field names forked from `NotificationConfigSchema`** — `position` vs
393
+ `defaultPosition`, a renderer-local `stacking` boolean with no spec
394
+ counterpart, and no `pauseOnHover` at all;
395
+ - **a notification could not declare a `position`.** The #3008 parity guard
396
+ asserted the position _vocabulary_ matched the spec while nothing positioned
397
+ anything by it — a guard passing over an unused value;
398
+ - **`NotificationActionButton.variant` was the shadcn Button vocabulary**
399
+ (`default | destructive | outline`) under a spec-shaped name, forking
400
+ `NotificationActionSchema.variant` (`primary | secondary | link`).
401
+
402
+ **How positioning resolves now** — `notification.position ?? config.defaultPosition
403
+ ?? nothing`, and "nothing" is a real answer:
404
+
405
+ - **declared** → the surface pins itself there, always. `presentNotificationToast`
406
+ passes it per-toast so the contract wins over the container;
407
+ - **undeclared** → the surface keeps its own anchor (a snackbar's bottom edge) or
408
+ defers to the host's toast chrome.
409
+
410
+ That asymmetry is the design decision. The host's sonner container also serves
411
+ toasts that are _not_ spec notifications (the console action runtime's own
412
+ `toast.*` calls), so it stays the fallback authority for placement — never a
413
+ competing one. A declared position a component prop could silently override
414
+ would be the same "validates, then does nothing" shape this whole area is about.
415
+ Hence `defaultPosition` has no fabricated default: "the host didn't say" has to
416
+ be representable.
417
+
418
+ Also: `maxVisible` / `stackDirection` now drive every stacking surface through
419
+ one shared `visibleNotificationStack` (cap keeps the NEWEST, stack grows in the
420
+ declared direction); `pauseOnHover` holds a transient notification's timer and
421
+ resumes it with the time it had left, which needed the provider to track live
422
+ timers rather than fire-and-forget `setTimeout`s. Legacy spellings still resolve:
423
+ `position` folds into `defaultPosition`, and `stacking: false` reads as
424
+ `maxVisible: 1` rather than being ignored.
425
+
426
+ `onToast` now receives the resolved config as a second argument, so the delegate
427
+ can apply the parts of the contract only it can. Existing one-argument handlers
428
+ are unaffected. The spec-parity guard gained the action-variant vocabulary, the
429
+ one notification enum it did not cover.
430
+
431
+ - 2a40b5e: feat(notifications): each spec `displayType` gets its own presentation instead of a toast (#3014)
432
+
433
+ #3008 closed the **contract** half of this: `NotificationContext`'s union matched
434
+ `NotificationTypeSchema`, and `notify()` materialized the declared type so a
435
+ consumer _could_ branch on it. Nothing did. `NotificationProvider` handed every
436
+ item to the host's `onToast` delegate regardless of type, so an author picking
437
+ `banner` or `inline` got a transient overlay — plausible output, wrong output.
438
+
439
+ Each of the five spec types now has a presentation of its own:
440
+
441
+ | `displayType` | Presentation | Rendered by |
442
+ | ------------- | ------------------------------------------------------ | ---------------------------------- |
443
+ | `toast` | transient overlay (unchanged) | the host's `onToast` delegate |
444
+ | `snackbar` | bottom-anchored bar, one at a time, at most one action | `<NotificationSnackbar />` |
445
+ | `banner` | page-width strip **in the content flow** | `<NotificationBanners />` |
446
+ | `alert` | blocking acknowledgement dialog, FIFO queue | `<NotificationAlerts />` |
447
+ | `inline` | in place, at the raising surface | `<NotificationInline scope="…" />` |
448
+
449
+ The four surface components ship from `@object-ui/components` and subscribe via
450
+ `useNotificationsByPresentation(type, scope?)`.
451
+
452
+ **Answers to the three questions the issue left open:**
453
+
454
+ 1. **Banner/inline placement is the host's.** They are not overlays: a banner takes
455
+ space at the top of the content area and an `inline` notification belongs next to
456
+ the thing that raised it. So the context exposes the items and the surfaces
457
+ subscribe, rather than one `onToast`-style delegate positioning everything. An
458
+ `inline` notification carries a `scope` that pairs it with its outlet, so two
459
+ forms on one page don't show each other's messages.
460
+ 2. **`alert` is modal-ish but NOT the action system's `ModalHandler`.** That handler
461
+ resolves a page/object, renders it, and reports an `ActionResult` back to the
462
+ `ActionRunner`; a notification alert has no schema, no target and no result.
463
+ Routing it there would mean synthesizing a page just to say "OK". It renders
464
+ through the `AlertDialog` primitive instead — no second action-modal path.
465
+ 3. **`snackbar` earns its own component.** It supersedes rather than stacks, anchors
466
+ bottom regardless of the toast position config, and takes at most one action.
467
+ Making it a sonner variant is what "presents as a toast" means.
468
+
469
+ **Also fixed:** auto-dismiss now follows the presentation. `toast`/`snackbar` keep
470
+ the transient timer; `banner`/`alert`/`inline` are persistent unless the raiser sets
471
+ `duration` explicitly — a persistent banner used to evaporate on the shared 5s toast
472
+ timer. `dismissible` is honored on the persistent surfaces (an `alert` always keeps
473
+ its acknowledge button; `dismissible: false` only closes the Escape route).
474
+
475
+ `onToast` now receives **only** `toast` items. A provider with no `onToast` remains
476
+ the supported store-only mode (a bell reading `notifications`/`unreadCount`), but
477
+ raising one of the other four types with its surface unmounted warns in dev, naming
478
+ the component to mount — that failure used to be silent.
479
+
480
+ `NOTIFICATION_PRESENTATIONS` is typed `Record<NotificationPresentation, …>`, so a new
481
+ member in the spec enum fails type-check until its presentation is decided; a parity
482
+ test additionally asserts the table covers `NotificationTypeSchema` exactly and that
483
+ no two types share a surface.
484
+
485
+ - df613fa: fix(notifications): the spec `icon` is read instead of stored and ignored (#3014 follow-up)
486
+
487
+ `NotificationSchema.icon` — "Icon name override" — reached `NotificationItem` and
488
+ stopped there. Every surface drew the severity icon, so an author writing
489
+ `icon: 'rocket'` got the success checkmark. Same shape as the `displayType`
490
+ collapse #3071 fixed: a value that validates, is carried, and renders nothing.
491
+
492
+ All five presentations now resolve it through one rule (`notificationIcon`): a
493
+ declared Lucide name — kebab-case or PascalCase — replaces the severity icon;
494
+ anything else falls back to it. That includes the console's sonner toast, so the
495
+ override behaves identically on a toast, a banner, a snackbar, an alert and an
496
+ inline message.
497
+
498
+ **The fallback is the interesting part.** `getLazyIcon` degrades an unknown name
499
+ to a `Database` glyph, which is right for a data-shaped schema slot and wrong
500
+ here — on an error notification it swaps a meaningful icon for a meaningless one.
501
+ So the name is checked first, via a new `isLucideIconName` export, and a typo
502
+ costs the author their override and nothing more.
503
+
504
+ - 0ded602: fix(form): a server rejection that names fields now marks those fields (objectstack#3896)
505
+
506
+ The server has always said which field it rejected. `@objectstack/objectql`'s
507
+ validators throw `VALIDATION_FAILED` with `fields[]` — one entry per offending
508
+ field, each with a human `message` — and both the REST layer and the runtime
509
+ dispatcher serve that as a 400 with the entries intact.
510
+
511
+ Every form dropped them. The submit handler caught the rejection, ran the
512
+ message through `extractWriteErrorMessage`, and showed **one undirected toast**:
513
+ the user was told something was wrong but not _what_, on a surface that already
514
+ knows how to mark an input — and already does exactly that for client-side
515
+ validation. On a long form the offending field was often off-screen, so "创建"
516
+ appeared to do nothing.
517
+
518
+ **Now the two failures behave identically, because they share one
519
+ implementation.** The per-field marking, the toast naming the fields, and the
520
+ scroll-and-focus of the first offender (#2793) were extracted from the
521
+ client-side invalid handler; the server path calls the same function. As far as
522
+ the person filling in the form is concerned these are the same event — only the
523
+ referee differs.
524
+
525
+ Three layers, each of which was dropping the detail:
526
+
527
+ - **`@object-ui/react`** — new `extractFieldErrors(err)` (exported alongside
528
+ `extractWriteErrorMessage` / `isPermissionError`) normalises the three shapes
529
+ the error can arrive in: a typed `ValidationError` from the ObjectStack
530
+ adapter, the raw `@objectstack/client` error (whose `details` falls back to the
531
+ whole response body, which is where `fields[]` lands), and a hand-rolled error
532
+ carrying `fields` directly — the server duck-types that shape identically, so
533
+ the client must not be pickier than the server. Entries with no usable `field`
534
+ are **dropped rather than guessed at**: marking an innocent input is worse than
535
+ the generic toast.
536
+ - **`@object-ui/data-objectstack`** — `normaliseClientError` now maps a 400
537
+ `VALIDATION_FAILED` onto the `ValidationError` class that has sat in
538
+ `errors.ts` since the package was written, exported and **never once
539
+ constructed**. Its `validationErrors: Array<{ field, message }>` shape was
540
+ already exactly right. `create` also now normalises at all: only `update` did,
541
+ so a rejected insert reached callers as the raw client error — and a create is
542
+ the path that most often trips required-field validation.
543
+ - **`@object-ui/components`** — the form renderer maps the entries onto
544
+ `form.setError` and takes over the failure, **but only when every rejected
545
+ field has a visible input to carry it**. If the server also rejected something
546
+ the form does not render, it falls through to the banner, whose top-level
547
+ message concatenates every field's reason — so the part the user cannot see
548
+ inline is still said out loud instead of silently dropped.
549
+
550
+ This also removes the need for the client-side predicate mirroring added in
551
+ #2962: a form no longer has to guess what the server will reject in order to
552
+ warn about it beforehand, and mirrored predicates drift.
553
+
554
+ Non-field failures (403 / permission denials / anything without `fields[]`) take
555
+ exactly the path they took before.
556
+
557
+ - 24e0e0a: feat(components,grid,list): a column-header sort orders the whole list, not the page you can see — #3106
558
+
559
+ Clicking a column header under server pagination sorted **the current page**.
560
+ The user saw "sorted by this column" and got "these fifty rows are in order;
561
+ page 2 starts over". The sort was real — its scope was not the one the screen
562
+ implied — and it had no way out of `data-table` at all: the sort lived in two
563
+ `useState`s with no callback, so the layer that issues the request could not
564
+ see it even in principle.
565
+
566
+ `DataTable` gains `manualSorting` + a controlled `sort` + `onSortChange`. In
567
+ that mode it sorts nothing, reports what a header click asks for, and renders
568
+ `sort` as the indicator — keeping **no** sort state of its own, because a
569
+ private copy beside a controlled prop is the shape the defect had.
570
+
571
+ `ObjectGrid` turns that into a `$orderby` in both of its server modes (its own
572
+ fetch, and a parent-driven one), and `ListView` lands it in `currentSort` — the
573
+ same state the toolbar's sort builder writes. One sort, two controls: that is
574
+ what makes "does a header sort outrank the saved view's sort?" a non-question
575
+ rather than a precedence rule someone has to remember.
576
+
577
+ Three details that are decisions, not incidentals:
578
+
579
+ - **A header click replaces the order** instead of appending to it, so the
580
+ column under the cursor is the one the list is sorted by. Multi-key orders
581
+ still come from the sort builder, and the headers render them numbered.
582
+ - **It cannot ask for "no sort".** In client mode the third click clears, and
583
+ that is meaningful there — the rows return to the order they arrived in.
584
+ Across a server-paged collection there is no such order (objectstack#4363), so
585
+ a header offering it would hand the user a worse lie than the one being fixed.
586
+ Clearing stays with the sort builder, which can restore the view's default.
587
+ - **Relational columns render no sort affordance** under server sorting. A
588
+ `lookup` column shows a related record's name while `$orderby` can only order
589
+ by the stored id (objectstack#4256) — the same reason #3096 removed them from
590
+ the toolbar's sort picker. Client-side sorting keys off the rendered label, so
591
+ those headers stay live there.
592
+
593
+ Client-side tables are untouched: same three-state cycle, same local sort.
594
+
595
+ - 80edbd4: fix(view,components): the spec→FilterBuilder operator table covers the whole view vocabulary, and the dead write direction is gone
596
+
597
+ `view-config-utils`' `SPEC_TO_BUILDER_OP` resolved **10 of the spec's 19
598
+ canonical `VIEW_FILTER_OPERATORS`**. The nine it missed —
599
+ `not_equals`, `starts_with`, `ends_with`, `greater_than`, `less_than`,
600
+ `greater_than_or_equal`, `less_than_or_equal`, `is_null`, `is_not_null` — all
601
+ appear in stored view metadata (they are canonical; `ViewFilterRuleSchema`
602
+ validates against exactly this list), and each reached the FilterBuilder as a
603
+ raw spelling its operator dropdown cannot select.
604
+
605
+ Same defect and same cause as #2974, one table over: spellings were enumerated
606
+ by hand. That table is now derived from the spec's own canonical list and
607
+ `VIEW_FILTER_OPERATOR_ALIASES`, matched case- and separator-insensitively, so
608
+ `not_in` / `notIn` / `'not in'` / `NOT_IN` are one entry rather than four
609
+ chances to miss one.
610
+
611
+ Four canonical operators have no FilterBuilder equivalent —
612
+ `starts_with`/`ends_with` (absent from its vocabulary) and `is_null`/
613
+ `is_not_null` (distinct from the `is_empty`/`is_not_empty` it does have). They
614
+ are recorded as explicit `null`s and asserted, and deliberately left unmapped:
615
+ folding them onto a near-equivalent would silently rewrite the author's
616
+ operator on the next save, whereas an unmapped operator surfaces as a condition
617
+ row the author must complete.
618
+
619
+ Also retired `BUILDER_TO_SPEC_OP` and `toSpecFilter` — the write direction,
620
+ dead since the legacy `buildViewConfigSchema` engine was replaced by the
621
+ studio's spec-driven inspector (no caller anywhere in the repo, and not part of
622
+ `@object-ui/plugin-view`'s public exports). It was objectui's last emitter of
623
+ `'not in'` with a space, plus `before`/`after`, as _filter-AST_ operators —
624
+ spellings that reached the server outside `VALID_AST_OPERATORS` and were dropped
625
+ without an error (objectstack-ai/objectstack#3948).
626
+
627
+ `@object-ui/components` now exports `FILTER_BUILDER_OPERATORS` (and the
628
+ `FilterBuilderOperator` type), derived from the operators the FilterBuilder
629
+ actually renders, so tables mapping onto that vocabulary can assert against it
630
+ instead of restating it.
631
+
632
+ Refs objectstack-ai/objectui#2945, #2901.
633
+
634
+ ### Patch Changes
635
+
636
+ - fc0272a: fix(actions): apply the ADR-0066 D4 capability gate on every action surface (framework#3923)
637
+
638
+ An action declaring `requiredPermissions` is supposed to be one declaration with
639
+ two enforcement surfaces: 403 on the server, hidden button in the UI. The UI half
640
+ only ever ran inside `ActionEngine.getActionsForLocation` — and the surfaces
641
+ `record_header`, `record_more`, `list_item` and `list_toolbar` actually render on
642
+ do not go through the engine. They filter their own action lists. So a button
643
+ declaring a capability nobody holds rendered, live and clickable, on the record
644
+ header, in every grid row menu, and on the list toolbar. For a `type: 'api'`
645
+ action pointed at a self-authored endpoint, nothing else was checking either: the
646
+ platform's action route (which is where the 403 comes from) never sees that
647
+ request.
648
+
649
+ `page:header`, `action:bar` (business _and_ `systemActions`) and the grid's
650
+ `RowActionMenu` now apply the same gate, via a shared `useCapabilityGate()` so
651
+ the surfaces cannot drift apart. The rule is the engine's, unchanged: hide unless
652
+ the caller holds **all** declared capabilities; an empty held set is "holds
653
+ nothing" and gates; **unknown** — no action runtime, no resolved capabilities —
654
+ fails OPEN, because the server is the authority and hiding a permitted user's
655
+ button on missing client data is the worse failure.
656
+
657
+ The record surface was also feeding the gate nothing to work with.
658
+ `RecordDetailView` mounts its own `<ActionProvider>`, which shadows the shell's
659
+ for every action on that page, and seeded it with identity only — no
660
+ `systemPermissions`. Since unknown fails open, that alone un-gated every
661
+ `record_header` / `record_more` / `record_section` action on the one page those
662
+ locations exist on. It now forwards the caller's resolved capabilities (and only
663
+ once they have actually resolved, so a standalone embed without a
664
+ `PermissionProvider` keeps failing open rather than hiding everything).
665
+
666
+ `useRecordEditable`'s record-level explain probe went out on a bare
667
+ `fetch(..., { credentials: 'include' })`. A bearer-token session carries its
668
+ credential in the `Authorization` header, not a cookie, so the probe came back
669
+ 401 on a perfectly valid admin session and the verdict silently failed open —
670
+ the hook was inert in exactly the deployments it was written for. It now rides
671
+ the host's authenticated fetch (`SchemaRendererProvider`'s `apiFetch`), falling
672
+ back to the global one for standalone embeds.
673
+
674
+ - c785740: fix(detail): record Attachments become their own tab (with count badge) and their copy is translated — objectstack#4358
675
+
676
+ Two defects on `enable.files: true` record detail pages:
677
+
678
+ 1. **Buried placement.** `RecordDetailView` appended `RecordAttachmentsPanel`
679
+ AFTER the schema-rendered page tree, whose synthesized default embeds
680
+ `record:discussion` as the last main component — so the panel always
681
+ landed below an ever-growing feed timeline, undiscoverable without
682
+ scrolling to the very bottom, with no metadata knob to move it.
683
+
684
+ `buildDefaultTabs` now emits a peer **Attachments** tab (a new
685
+ `record:attachments` node rendered by an app-shell registration wrapping
686
+ the existing panel via RecordContext) between Related and
687
+ Activity/History. `PageTabsRenderer` derives the tab's count badge from a
688
+ `sys_attachment` probe scoped to `(parent_object, parent_id)`, riding the
689
+ same RelatedCountStore cache/invalidation bus as related-list badges — so
690
+ uploads and deletes update the badge live. A `hideAttachments` synthesizer
691
+ option suppresses the tab; RecordDetailView keeps its legacy bottom append
692
+ only as the fallback for authored pages without the node
693
+ (`hasExplicitAttachments`).
694
+
695
+ 2. **Untranslated copy.** The panel's eleven `detail.*` keys (`attachments`,
696
+ `uploadAttachment`, `loadingAttachments`, `noAttachments`,
697
+ `downloadAttachment`, `deleteAttachment`, and the five
698
+ `attachment*Denied/Required` friendly errors) existed only as inline
699
+ English `defaultValue`s — no locale bundle carried them, so non-English
700
+ consoles always showed English. All ten locales now define them; the tab
701
+ label rides the existing well-known-label dictionary (→ 附件 etc.).
702
+
703
+ - b41f401: **Authoring types are input types (framework#4074 steps 2–3): `ActionParam` takes the spec's declaration forms, `ListViewSchema` stops promising parse-output defaults, and `FormField.dependsOn` matches its runtime reader.**
704
+
705
+ Three public types said something different from what the platform accepts. All
706
+ three divergences were found by making `packages/types`' tests compile (#3009)
707
+ and then resolving the declared `p1-spec-alignment.test.ts` debt site-by-site
708
+ instead of papering over it.
709
+
710
+ **`ActionParam` is now the authoring shape, aligned with the spec's input.**
711
+ `name` / `label` / `type` become optional and `field` / `objectOverride` appear:
712
+ the spec's primary way to declare a param — a bare field reference that inherits
713
+ label/type/validation/options from an object field — was unrepresentable while
714
+ all three were required. The _resolved_ shape the dialog consumes (after
715
+ app-shell's `resolveActionParams()` inlines the reference) remains
716
+ `@object-ui/core`'s `ActionParamDef`, with all three required. Authoring and
717
+ resolved are different types on purpose. `label` and option labels take the
718
+ spec's `I18nLabel` by import — which the new compile-time guard promptly
719
+ revealed to be aliased to plain `string` in the current spec (the per-locale
720
+ record is the separate `I18nObject`), so this is not a behavioural widening
721
+ today; importing the alias means objectui tracks any future widening
722
+ automatically.
723
+
724
+ **Breaking:** code destructuring `param.name` / `param.label` / `param.type` as
725
+ guaranteed must now handle the field-backed form (or consume the resolved
726
+ `ActionParamDef` instead, which is what dialog-side code should be doing).
727
+
728
+ **`ListViewInferred` is `z.input`, not `z.infer`.** The spec sub-schemas that
729
+ flow into the list-view surface (`userActions`, `tabs` → `ViewTab`, `sharing`)
730
+ carry `.default()`s, so the inferred output type made fields like
731
+ `userActions.refresh` or a tab's `pinned`/`visible` _required_ — but nothing on
732
+ the render path ever runs `.parse()`: `normalizeListViewSchema` deliberately
733
+ applies no defaults ("an absent flag stays absent", its own suite). The output
734
+ type therefore rejected valid authored metadata (`userActions: { sort: true }`)
735
+ while promising renderers defaults that never arrive. Typing the surface as
736
+ input matches both the author and the runtime object. Code that _trusted_ those
737
+ phantom defaults now gets an optionality error — which is a latent bug surfacing,
738
+ not a regression: the value really could be absent.
739
+
740
+ **`FormField.dependsOn` is `DependsOnInput`.** The runtime reader
741
+ (`resolveCascadingOptions`) has always accepted a bare name, a list of names, or
742
+ lookup-parameter entries `{ field, param }` — its parameter type says so. The
743
+ public property said `string`, so array-authored metadata type-errored while
744
+ working, and the form renderer read the key through `(f as any).dependsOn` to
745
+ get past its own type. The shape now lives in `@object-ui/types` (single source
746
+ of truth next to `FormField`), `@object-ui/core` imports and re-exports it, and
747
+ the two `as any` reads in the components form renderer are typed.
748
+
749
+ **The `p1-spec-alignment.test.ts` exclusion is gone.** Its 14 errors resolved:
750
+ the two "sharing in ObjectUI format" tests and the legacy-ARIA-spelling fixture
751
+ are deleted/rewritten — those dialects are _normalizer input_, folded by
752
+ `normalizeListViewSchema` and asserted branch-by-branch in core's
753
+ `normalize-list-view.test.ts`, the seam where the fold actually runs; asserting
754
+ them on the canonical type only ever "passed" because nothing compiled the file.
755
+ One fixture claimed a shape no surface ever admitted (an ObjectQL triplet as a
756
+ spec `ViewTab.filter`) and was corrected to the rule-object form. Every test
757
+ file in `@object-ui/types` is now compiled, with no exclusions.
758
+
759
+ Discrimination-checked: reverting `ListViewInferred` to `z.infer`, `dependsOn`
760
+ to `string`, or `ActionParam.name` to required each produces the expected
761
+ compile error in the now-compiled test files (`TS2739` / `TS2322` / `TS2741`);
762
+ restored, all projects are clean.
763
+
764
+ - 68ef584: fix(test-setup): stop shadowing ten real registrations, and declare page:header's inputs
765
+
766
+ `vitest.setup.dom.tsx` re-registered `text`, `email`, `password`, `textarea`,
767
+ `image`, `html`, `avatar`, `select`, `slider` and `grid` by hand — ~380 lines of
768
+ renderer copied out of @object-ui/components — to undo bare-name fallbacks that
769
+ @object-ui/fields and the plugins claimed by loading after it.
770
+
771
+ Both sides now register under their own namespace with `skipFallback: true`, so
772
+ nothing overwrites the `ui:` originals and the workaround is obsolete. It was
773
+ not free: the copies carried no `inputs` and no `defaultProps`, so inside the
774
+ test environment four curated public blocks reported an empty configuration
775
+ surface while the real registrations declare one. `apps/console`'s contract
776
+ test reads that registry, so its picture of the contract was fiction for those
777
+ tags — a guard that measures a fixture instead of the product.
778
+
779
+ Deleting the block restores what the app actually boots with. Verified: the ten
780
+ tags keep their namespace and canonical type, and their declared surface comes
781
+ back — `text` 1 input, `email` 6, `password` 6, `textarea` 6, `image` 3,
782
+ `html` 1, `avatar` 4, `select` 6, `slider` 5, `grid` 7, plus `defaultProps`.
783
+ The heavy DOM setup also got roughly twice as fast (~545s to ~235s of setup
784
+ time across the suite), since every file in that project was paying to evaluate
785
+ the duplicated renderers.
786
+
787
+ With the shadowing gone, `page:header` was left as a genuine gap: a curated
788
+ public block whose renderer reads `title`, `subtitle`, `actions`, `breadcrumb`,
789
+ `recordChrome`, `showStar` and `showCopyId`, with none of them declared. Now
790
+ declared.
791
+
792
+ `element:divider` keeps zero inputs on purpose — its renderer reads only
793
+ `className`, so there is nothing to author.
794
+
795
+ - c769d3d: fix(form): a `defaultValues` change no longer discards the field the user is filling
796
+
797
+ The form renderer adopts a changed `defaultValues` with `form.reset()`, which
798
+ replaces the **whole** react-hook-form record — so it also blanks the fields the
799
+ incoming defaults say nothing about. And it runs in a **passive effect**, one
800
+ commit after those fields have been committed and painted, so input landing in
801
+ that window was silently dropped.
802
+
803
+ The caught case is the wizard (objectui#2982). It reuses ONE inner form across
804
+ steps and feeds it `defaultValues={formData}` — the merge of the steps submitted
805
+ **so far** — so at every step boundary the incoming defaults are missing exactly
806
+ the fields now on screen:
807
+
808
+ ```
809
+ RESET to {"name":"Alice"} (values before: {"name":"Alice","note":"hello"})
810
+ -> create POST {"name":"Alice"} — the last step is gone
811
+ ```
812
+
813
+ In a browser this needs a busy main thread plus typing on the first frame after
814
+ the new defaults arrive — unlikely by hand, but paste and autofill land in a
815
+ single tick. The same shape had already bitten once before, as a `reset()` on
816
+ `defaultValues` **identity** churn wiping input mid-interaction; comparing by
817
+ value fixed that, and this is the residual hole where the value genuinely did
818
+ change.
819
+
820
+ The reset now carries such a value across instead of dropping it. Deliberately
821
+ narrow: only a field the **caller has never carried** — absent from both the
822
+ outgoing and the incoming defaults — and whose value the user actually changed
823
+ is eligible. Wherever the caller has an opinion it stays authoritative, so the
824
+ load-bearing paths are unchanged:
825
+
826
+ - an edit-mode record landing after first paint still fills every field it names
827
+ (a field the user has NOT touched is empty-ish against the baseline, using the
828
+ same comparison the dirty check uses, so a widget normalizing its own empty
829
+ value on mount is not mistaken for input);
830
+ - a `recordId` swap still replaces the record outright — drawer/modal/split
831
+ forms re-fetch without re-entering their loading branch, so record B lands in
832
+ the still-mounted form and must not inherit an abandoned edit to record A;
833
+ - a field the caller withdraws from its defaults stops being the user's.
834
+
835
+ A reset that carries input now also reports the form as dirty (it is, against
836
+ the caller's defaults) instead of unconditionally announcing pristine, so a
837
+ host's discard guard keeps hearing the truth.
838
+
839
+ - 94e63ef: fix(form): the runtime `field` metadata slot is declared instead of smuggled, and importing the spec's FormField is a lint error — #3090
840
+
841
+ `FormField.field` — the slot where object-bound form paths stash the resolved
842
+ field-metadata **object** for widgets — rode through the index signature,
843
+ undeclared, readable only via `as any`. Same key, different layer: in the spec
844
+ form-view vocabulary `field` is a _string_ (the referenced object-field name),
845
+ and the undeclared slot kept that pun latent. The slot is now declared
846
+ (`field?: Record<string, any>`) with the invariant in its JSDoc: on a runtime
847
+ FormField it is never a string — the authored string form ends at the
848
+ `normalizeSectionField` chokepoint, and a tripwire test pins that across all
849
+ three input shapes. Assigning a string is now a compile error; the `as any`
850
+ casts at the read sites are gone.
851
+
852
+ A `no-restricted-imports` tripwire bans importing `FormField`/
853
+ `FormFieldSchema` from `@objectstack/spec/ui` inside this repo: the spec's
854
+ FormField TYPE erases to `any` in its dist (objectstack#4171), so the
855
+ misimport silently deletes type safety — tsc says nothing. The lint message
856
+ names the two layers and the correct import. The drift-guard parity test is
857
+ the one legitimate importer, exempted inline with its reason.
858
+
859
+ Ledger: `FormField` and `FormFieldSchema` move from untriaged DEBT to ALLOW
860
+ with the two-layer rationale written down (122 → 120).
861
+
862
+ - c735bf7: fix(form): a spec-vocabulary field no longer crashes the standalone form, and every surface now says which vocabulary you meant — #3090
863
+
864
+ Writing the regression test against the unfixed renderer proved the failure
865
+ was worse than the assumed silent drop: a `{ field: 'x' }` entry (spec
866
+ form-VIEW vocabulary) slipped past the `f?.name` guards into a
867
+ react-hook-form Controller with `name === undefined` and crashed the whole
868
+ standalone form on `name.split('.')`, with nothing naming the culprit entry.
869
+ The renderer now partitions such entries out — the rest of the form renders —
870
+ and surfaces them with an inline alert plus a console.error whose text is the
871
+ fix instruction (rename to `name`, or use an object-bound form whose sections
872
+ accept the spec shape).
873
+
874
+ `objectui validate` grows the same boundary awareness: on failure, a
875
+ `{ field: … }` entry in a standalone form gets a "likely cause" hint naming
876
+ the real fix instead of the bare `invalid_union` — the previous message read
877
+ as "bolt a `name` on", which converts spec metadata wrongly. On success,
878
+ mixed-vocabulary entries (`name` + string `field`) get a warning: they
879
+ validate, but the spec key is dead weight the renderer ignores.
880
+
881
+ `normalizeSectionField` warns (once per site) when an authored section field
882
+ mixes both identity keys — the spec branch derives the runtime name from
883
+ `field`, so an authored `name` was silently overwritten.
884
+
885
+ - 02aef0c: fix(sdui): a `kind:'html'` page can use lazily-registered blocks, and recovers when one registers late
886
+
887
+ objectui#2953 had a twin one tier over, unreported. The whitelist a
888
+ `kind:'html'` page's source compiles against was built from `getAllTypes()` +
889
+ `getConfig()` — both loaded-only — so any block registered via `registerLazy()`
890
+ was rejected as _"not an allowed component"_.
891
+
892
+ The blast radius is worse than the react tier's. There, a missing block cost one
893
+ identifier; here a compile diagnostic fails the **whole page**, so a single
894
+ `<object-kanban>` replaced the entire page with `HTML page failed to compile (2)`.
895
+ And it never recovered: `layoutElement` was memoised on `[schema, pageType]` with
896
+ no registry signal, so the cached error panel outlived the plugin actually
897
+ landing — permanently broken for the session.
898
+
899
+ `ComponentRegistry` gains three lazy-aware reads:
900
+
901
+ - `getKnownTypes()` — loaded registrations **plus** pending lazy stubs, deduped.
902
+ The set a whitelist or manifest should be built from. `getAllTypes()` keeps its
903
+ loaded-only meaning ("what can render right now") and now says so.
904
+ - `getMeta(type, namespace?)` — metadata from the loaded registration, else from
905
+ a pending stub. `getConfig()` stays loaded-only, since callers read
906
+ `.component` off it.
907
+ - `getVersion()` — monotonic counter of changes to the known set, bumped on
908
+ register / unregister / registerLazy. A cache key that a type _count_ cannot
909
+ substitute for: one registration plus one unregistration leaves the count
910
+ untouched while the set changed.
911
+
912
+ `getJsxManifest()` builds from those, and `PageRenderer` subscribes to the
913
+ registry so a page that could not compile retries when the registry grows.
914
+
915
+ A stub carries no `inputs` yet, so its props surface as `unknown-prop` warnings
916
+ rather than errors — the page compiles and renders, and the inner
917
+ `SchemaRenderer` triggers the loader and swaps in the real block. Authoring-time
918
+ prop validation is unaffected: `sdui.manifest.json` is generated with every
919
+ plugin eagerly loaded, and asserts as much.
920
+
921
+ - 9a04d25: fix(registry): prefix every namespaced key exactly once, in every namespace
922
+
923
+ objectui#3023 fixed eleven `record:*` blocks registered as
924
+ `register('record:x', …, { namespace: 'record' })` — an already-prefixed name
925
+ handed to a registry that prefixes it again, landing the block at
926
+ `record:record:x` — and guarded that namespace alone. Twenty-two more were
927
+ sitting in `action:` (5), `element:` (10) and `page:` (7), two of them
928
+ (`page:header`, `element:divider`) curated public blocks.
929
+
930
+ Checking one namespace is exactly what let them keep sitting there, so the
931
+ guard now asks the whole registry rather than a prefix of it.
932
+
933
+ Same fix as before: register the bare name and let `namespace` do the
934
+ prefixing, with `skipFallback: true` so the fallback does not claim that bare
935
+ name globally. It would otherwise take over `header`, `footer`, `sidebar`,
936
+ `tabs`, `card`, `accordion`, `section`, `text`, `image`, `button`, `icon` —
937
+ every one of which belongs to `ui:`. All 22 stay reachable exactly as
938
+ `<namespace>:<name>`; the registry goes 522 keys to 500, and the contract is
939
+ unchanged at 42/42.
940
+
941
+ Found while probing why six curated Tier B primitives report no `inputs`. They
942
+ do declare them — `vitest.setup.dom.tsx` registers simplified `text` / `image` /
943
+ `html` / `grid` stubs that shadow the real registrations inside the test
944
+ environment only. That shadowing is a separate question, left alone here; the
945
+ doubled keys it turned up are not test-environment artifacts.
946
+
947
+ - eb4b740: feat(page,element): declare inputs for the eight configurable page:\* / element:\* blocks
948
+
949
+ Same gap objectui#3027 closed for `record:*`, in the two namespaces next door:
950
+ renderers that read real props while every authoring surface reported "takes no
951
+ configuration". Declarations mirror what each renderer reads —
952
+
953
+ | block | inputs |
954
+ | ------------------------- | -------------------------------------------------------------------------------- |
955
+ | `page:tabs` | items, tabStyle (line/card/pill), position (top/left) |
956
+ | `page:card` | title, bordered, body (slot), footer (slot) |
957
+ | `page:accordion` | items, allowMultiple, variant (flush/card) |
958
+ | `element:text` | content, variant (heading/subheading/body/caption), align |
959
+ | `element:number` | object, aggregate (count/sum/avg/min/max), field, filter, format, prefix, suffix |
960
+ | `element:button` | label, action (inline ActionDef), variant, size, icon, iconPosition, disabled |
961
+ | `element:definition-list` | items, columns, inline |
962
+ | `element:repeater` | object, titleField, fields, filter, sort, limit, emptyText, divided |
963
+
964
+ `page:section`, `page:footer` and `page:sidebar` are left at zero inputs on
965
+ purpose: their renderers render `children`/`body` and nothing else — like
966
+ `element:divider`, they are genuinely prop-less containers, and inventing
967
+ inputs for them would be the opposite falsehood.
968
+
969
+ `aria` and `className` stay undeclared throughout, per the convention on
970
+ `record:details`: escape hatches and styling pass-throughs, not authoring
971
+ choices. `element:button`'s registration also documents the split against
972
+ `action:button` — inline ActionDef vs a declared action referenced by name —
973
+ so the two stop reading as duplicates.
974
+
975
+ Declaration only; no renderer behavior changes. Curation (which of these join
976
+ `PUBLIC_BLOCKS`) is a separate change.
977
+
978
+ - 5b084eb: fix(sdui): the react page's "no adapter yet" fallback stops churning its provider context
979
+
980
+ Audit of the remaining half of `ReactKindPage`'s scope memo, `[schema, adapter]`.
981
+ The `schema` half was the live bug fixed in objectui#2984; this is the adapter
982
+ half.
983
+
984
+ **The hosts are fine.** Both `AdapterCtx.Provider` call sites pass a stable
985
+ value — `AdapterProvider` from `useState`, the console preview from a module
986
+ constant — so there is no state loss in the shipped app.
987
+
988
+ **One real instance remained**, one layer down: `<SchemaRendererProvider
989
+ dataSource={adapter ?? {}}>` minted a fresh object on every render while the
990
+ adapter was still null (the window before the host connects). That is a context
991
+ value, and `SchemaRendererProvider` memoises on its identity, so every block
992
+ inside the page had its schema re-cloned and its expressions re-run on each
993
+ render of the page. Now a module constant, like the `SchemaRenderer` fallback
994
+ it mirrors.
995
+
996
+ **The `adapter` dependency itself must stay**, and is now pinned. It looks like
997
+ the obvious thing to optimise away — it is the last remaining trigger that can
998
+ recompile a page and cost its `useState`. But `ReactRunner` hands React the same
999
+ element object while `(code, scope)` hold, and React bails out on an identical
1000
+ element reference, so the page subtree never re-renders on its own: recompiling
1001
+ is the _only_ path by which a new adapter reaches the blocks inside the page.
1002
+ Removing the dependency strands every block on the first adapter forever — no
1003
+ error, just a dead data source. `react-page-adapter.test.tsx` pins both
1004
+ directions, so the tradeoff cannot be quietly re-litigated.
1005
+
1006
+ Docs: the react-pages guide now states the host-side requirement — an adapter
1007
+ constructed inline on every render resets every react page on every render.
1008
+
1009
+ - aa1240a: fix(sdui): lazily-registered public blocks reach a `kind:'react'` page's scope, and ReactRunner keeps the errors it catches
1010
+
1011
+ Two defects in the trusted `kind:'react'` page tier.
1012
+
1013
+ **objectui#2953 — the contract skipped lazy blocks.** `getPublicConfigs()`
1014
+ resolved every curated `PUBLIC_BLOCKS` tag through `getConfig()`, which reads
1015
+ loaded registrations only, so a block registered with `registerLazy()` was
1016
+ absent from the contract until its plugin chunk happened to be imported. In
1017
+ `apps/console` that silently dropped `object-kanban`, `object-calendar`,
1018
+ `object-gantt`, `object-timeline`, `object-map` and `markdown` from every react
1019
+ page's scope — writing `<ObjectKanban/>` threw `ReferenceError` even though the
1020
+ tag is a first-class contract member, and whether it threw depended on load
1021
+ order. `getPublicConfigs()` now resolves pending lazy stubs too, returning them
1022
+ with `lazy: true` and no `component` (new `PublicComponentConfig` type); the
1023
+ injected wrapper renders through `SchemaRenderer`, which triggers the loader and
1024
+ shows its placeholder. `getConfig()` stays loaded-only by design.
1025
+
1026
+ **objectui#2954 — ReactRunner discarded its own error state.**
1027
+ `getDerivedStateFromProps` re-transpiled and re-evaluated the page source on
1028
+ every render and unconditionally set `error: null`. React runs it before the
1029
+ re-render that follows `getDerivedStateFromError`, so the boundary threw away
1030
+ the error it had just caught, rebuilt an identical throwing element, and the
1031
+ throw escaped past its own `fallback` to the renderer's generic panel; `onError`
1032
+ was gated on state that had already been cleared and never fired for a
1033
+ compile-time error at all; and each compile minted a fresh page function — a new
1034
+ element type — that remounted the subtree and wiped the page's `useState`. The
1035
+ transpile+eval is now memoised on `(code, scope)`, errors persist until the
1036
+ inputs actually change, and `onError` reports each error exactly once.
1037
+
1038
+ - 2374a49: fix(sdui): a react page no longer loses its state to a memo that never held, and a source that exports nothing fails loudly
1039
+
1040
+ Writing the regression guard for objectui#2954's "latent hazard" found it was
1041
+ already real.
1042
+
1043
+ **`evaluatedSchema` was memoised on values rebuilt every render.**
1044
+ `SchemaRenderer` fell back to a fresh `{}` when no `SchemaRendererProvider` sat
1045
+ above it, and `usePageVariables()` returned a brand-new object literal outside a
1046
+ `PageVariablesProvider`. Both feed the `evaluatedSchema` memo's dependency list,
1047
+ so for any tree without those providers the memo never hit: the schema was
1048
+ re-cloned and the ExpressionEvaluator re-run on every render, and children got a
1049
+ new schema identity every time. A `kind:'react'` page memoises its compiled
1050
+ source on that identity, so the page was recompiled — a new page function, a new
1051
+ element type — and React remounted it, silently discarding the user's `useState`.
1052
+ Any registry notification (every lazy plugin's first load) triggered it. Both
1053
+ fallbacks are now module constants.
1054
+
1055
+ **A source that exports nothing now throws instead of rendering blank.**
1056
+ `generateElement` inserts the implicit `export default` only when the source
1057
+ _starts with_ JSX, a `function` declaration, `()` or `class` — so the very
1058
+ common `const Page = () => …` exported nothing, and the page rendered blank with
1059
+ no error reported anywhere. It now throws with a message naming the fix, which
1060
+ `ReactRunner`'s error panel surfaces. `export default null` still means "render
1061
+ nothing"; a default export that is not a component throws too.
1062
+
1063
+ **`PageSchema['kind']` matches `@objectstack/spec`.** It declared
1064
+ `'full' | 'slotted'` while the renderer had shipped `'react'` and
1065
+ `'html'`/`'jsx'` since ADR-0080 and read the field through a cast. The union now
1066
+ spells all five and the cast is gone.
1067
+
1068
+ Docs: new `content/docs/guide/react-pages.md` (choosing between the executed and
1069
+ parsed tiers, the capability gate, the injected scope, flat props, `Block`,
1070
+ `useAdapter`, source shapes, error handling) and a `@object-ui/react-runtime`
1071
+ README — the package had neither, while being the tier AI-authored pages target.
1072
+
1073
+ - 2d5d594: fix(list,detail): sorting a lookup column no longer orders by an invisible key — #3096
1074
+
1075
+ A relational column (`lookup` / `master_detail` / `user` / `tree`) never holds
1076
+ the string its cell shows: it holds the `$expand`-ed record, or a raw foreign-key
1077
+ id whose label was resolved separately. Every sort path took that raw value as
1078
+ its key, so the column of names came back in an order with no relation to the
1079
+ names — sorting looked broken, with nothing saying the key was something else.
1080
+
1081
+ The two halves are fixed differently, because they can order by different things:
1082
+
1083
+ - **Client-side sorts** (grid column headers, any `data-table`, a non-windowed
1084
+ related list) now key off the label the cell renders, via the new
1085
+ `getSortValue` / `compareSortValues` in `@object-ui/core` — which resolves an
1086
+ expanded record through `getRecordDisplayName` (ADR-0079), so the sort key and
1087
+ the lookup cell agree on which field names a record. This replaces two broken
1088
+ comparators: `a[col] < b[col]` is always false between two objects (the
1089
+ comparator collapsed to a constant and permuted the rows), and
1090
+ `String(a[col])` is `"[object Object]"` (every row compared equal, so the sort
1091
+ silently did nothing).
1092
+ - **Server `$orderby` sorts** cannot be fixed here — the key is the stored id by
1093
+ construction, and `objectstack#4256` settled that no relation join is coming.
1094
+ So those entry points stop offering the illusion: the ListView toolbar sort
1095
+ picker withholds relational fields and explains why (pointing at a formula
1096
+ field as the supported way to sort by a related name), and a windowed related
1097
+ list renders no sort button for them.
1098
+
1099
+ A relational field the view's CURRENT sort already uses stays listed, labelled
1100
+ `(by ID)`, so view metadata authored or saved with such a sort round-trips
1101
+ instead of rendering a blank row and losing the sort on the next edit.
1102
+
1103
+ - 379728f: fix(fields): a `select` no longer wipes itself when its value outruns its options (#2968)
1104
+
1105
+ Radix keeps a hidden native `<select>` mirror so a Select's value takes part in
1106
+ native form submission. Assigning a value that mirror has no `<option>` for is a
1107
+ no-op — the element stays on `''` — but Radix still dispatches the synthetic
1108
+ `change`, so `''` comes straight back out through `onValueChange` and lands in
1109
+ react-hook-form on top of the value the caller just set.
1110
+
1111
+ The window is not theoretical: `SelectContent` registers its native options a
1112
+ commit AFTER the trigger mounts, so a record that lands after first paint — an
1113
+ edit modal whose `findOne` is still in flight — resets the form into exactly
1114
+ that gap. Every rendered select came back empty while RHF's `_defaultValues`
1115
+ still held the right value. When one of the wiped fields is the one a
1116
+ `visibleWhen` predicate reads, the predicate flips back to false, the
1117
+ conditional fields hide again and the form **latches** in the broken state:
1118
+ pressing Update then fails validation, or submits an empty enum, on a form the
1119
+ user never touched. The wipe is also recorded as a user edit, so Cancel prompts
1120
+ "discard changes?" on an untouched form.
1121
+
1122
+ `SelectItem` rejects `value=""` outright, so `''` can never be a value the user
1123
+ actually picked — it is always the mirror talking. It is now dropped at the
1124
+ single `Select` chokepoint, which covers every surface that renders one (object
1125
+ form, inline grid editor, action param dialog). Clearing a select still goes
1126
+ through `undefined`, which is untouched — the `dependsOn` cascade-clear behaves
1127
+ exactly as before.
1128
+
1129
+ - 7f23cd0: fix(form): a numeric/boolean select option survives selection with its type intact — #3090
1130
+
1131
+ `SelectOptionSchema.value` has accepted `string | number | boolean` for as
1132
+ long as it has existed, but the Radix controls underneath speak strings:
1133
+ picking `{ value: 2 }` silently submitted `"2"` — a wrong-typed write into a
1134
+ number field that nothing on the client ever reported. (Display half-worked:
1135
+ a numeric default matched its numeric item; only SELECTION morphed the type.)
1136
+
1137
+ The renderers now stringify on the way into the control and map the selection
1138
+ back to the AUTHORED option value on the way out (`matchOptionValue`), across
1139
+ the in-form select, the standalone `type: 'select'` component, and the
1140
+ standalone `type: 'radio-group'` component. The TS types stop lying to match:
1141
+ `SelectOption.value` / `RadioOption.value` and the corresponding
1142
+ `value`/`defaultValue`/`onChange` channels widen to what the zod schemas
1143
+ always accepted — a call site treating `option.value` as `string` is now a
1144
+ compile error pointing at a real latent crash, not a false comfort.
1145
+
1146
+ The ripple the widening named, handled at each boundary: `@object-ui/core`'s
1147
+ `OptionLike.value` widens (the option engines compare by identity, so values
1148
+ flow opaquely; the option-lint's CEL-literal domain stringifies at its
1149
+ boundary), and the multi-value field widgets (checkboxes / multiselect /
1150
+ radio) stringify at theirs — multi-value fields store string arrays.
1151
+
1152
+ Round-trip pinned by real Radix interactions in jsdom: the in-form select
1153
+ submits `2` (number), the standalone select hands its handler `false`
1154
+ (boolean).
1155
+
1156
+ - aa35561: fix(form): a split create/edit form no longer loses the panel you are not submitting from (#2153)
1157
+
1158
+ `SplitForm` rendered one `SchemaRenderer` — one react-hook-form instance and one
1159
+ `<form>` element — **per section**, and its two groups of sections live in
1160
+ separate resizable panels. So each panel owned isolated form state: submitting
1161
+ from one panel's action bar sent only that section's fields and silently dropped
1162
+ everything the user had typed on the other side of the divider. Filling both
1163
+ panels and clicking Create persisted `{ subject }` alone.
1164
+
1165
+ The same isolation killed cross-panel field rules: a `visibleWhen` in the right
1166
+ panel referencing a left-panel field never saw that field in its record, so the
1167
+ predicate faulted and failed **open** — the field the author meant to hide was
1168
+ always shown.
1169
+
1170
+ Both panels are now ONE form. The panel group became a layout the form renderer
1171
+ owns, via a new `FormSchema.fieldPanes` (+ `fieldPanesOrientation`,
1172
+ `fieldPanesResizable`) that mirrors `fieldTabs` (#2959): the `<form>` wraps the
1173
+ whole `ResizablePanelGroup` and each pane holds only fields, which is what lets a
1174
+ single react-hook-form instance span the divider. Sections inside a pane render
1175
+ behind the inline `section-divider` header, each at its own declared column
1176
+ density within the form's shared grid.
1177
+
1178
+ One more fix falls out of moving the panels into the renderer: `splitResizable:
1179
+ false` now actually pins the divider. It previously only hid the grip — the
1180
+ separator stayed draggable, because nothing passed the panel library's
1181
+ `disabled`.
1182
+
1183
+ Each pane is its own `@container`, so a multi-column section collapses to fewer
1184
+ columns as its panel is dragged narrower instead of overflowing.
1185
+
1186
+ - 3c1f321: fix(form): a tabbed/sectioned create-edit form no longer loses the tabs you are not looking at (#2959, #2153)
1187
+
1188
+ The explicit-`sections` path rendered one `SchemaRenderer` — one react-hook-form
1189
+ instance and one `<form>` element — **per section**, all sharing the same
1190
+ `formId`. Two failures compounded:
1191
+
1192
+ 1. the footer submit button (`form={formId}`) can only be associated with the
1193
+ **first** of those forms, so section 2+ never reached the payload; and
1194
+ 2. in the `tabbed` variant Radix unmounted the inactive panel, destroying that
1195
+ tab's form state outright.
1196
+
1197
+ Reported flow (HotCRM, 3 tabs, required `description` on tab 3): fill tab 1 →
1198
+ submit → server 400 `description is required` → switch to tab 3, fill it →
1199
+ submit → the server now reports `subject; description; status; priority` **all**
1200
+ missing, because the second submit's body had lost every earlier value.
1201
+
1202
+ `ModalForm` (stacked and `contentLayout: 'tabbed'`) and `TabbedForm` now render
1203
+ ONE form for all sections, matching `ObjectForm` / `DrawerForm`. Stacked sections
1204
+ use the existing inline `section-divider` header (which now also renders the
1205
+ section's `description`); tabbed sections go through a new
1206
+ `FormSchema.fieldTabs` (+ `defaultFieldTab`, `fieldTabsPosition`) that the form
1207
+ renderer distributes into **force-mounted** Radix panels — CSS-hidden rather
1208
+ than unmounted, since react-hook-form skips validation for unmounted fields,
1209
+ which is how a required field on a tab nobody opened used to sail past the
1210
+ client and come back as a server 400.
1211
+
1212
+ Validation feedback now points at the tab: a rejected field activates its tab and
1213
+ every tab holding one is marked on its trigger, for client-side rules and server
1214
+ `fields[]` rejections alike.
1215
+
1216
+ - Updated dependencies [62311b6]
1217
+ - Updated dependencies [fc0272a]
1218
+ - Updated dependencies [9e7349e]
1219
+ - Updated dependencies [8864971]
1220
+ - Updated dependencies [1cf0de7]
1221
+ - Updated dependencies [752e18f]
1222
+ - Updated dependencies [c785740]
1223
+ - Updated dependencies [b41f401]
1224
+ - Updated dependencies [19e9fa0]
1225
+ - Updated dependencies [d61efd1]
1226
+ - Updated dependencies [95b7214]
1227
+ - Updated dependencies [7d9734d]
1228
+ - Updated dependencies [6ae818e]
1229
+ - Updated dependencies [9eb932b]
1230
+ - Updated dependencies [746dd00]
1231
+ - Updated dependencies [aebfa4f]
1232
+ - Updated dependencies [38ca8be]
1233
+ - Updated dependencies [3cb9646]
1234
+ - Updated dependencies [4952edf]
1235
+ - Updated dependencies [7f0252e]
1236
+ - Updated dependencies [c4d7b20]
1237
+ - Updated dependencies [7639a61]
1238
+ - Updated dependencies [94e63ef]
1239
+ - Updated dependencies [02aef0c]
1240
+ - Updated dependencies [6f29aa5]
1241
+ - Updated dependencies [d21794c]
1242
+ - Updated dependencies [c4db402]
1243
+ - Updated dependencies [5319bf1]
1244
+ - Updated dependencies [49e5671]
1245
+ - Updated dependencies [b5b97e2]
1246
+ - Updated dependencies [f59f2c1]
1247
+ - Updated dependencies [07de839]
1248
+ - Updated dependencies [2a40b5e]
1249
+ - Updated dependencies [4874117]
1250
+ - Updated dependencies [ad0183a]
1251
+ - Updated dependencies [ce08d55]
1252
+ - Updated dependencies [32462dd]
1253
+ - Updated dependencies [aa1240a]
1254
+ - Updated dependencies [2374a49]
1255
+ - Updated dependencies [390c071]
1256
+ - Updated dependencies [d10f526]
1257
+ - Updated dependencies [2d5d594]
1258
+ - Updated dependencies [ea7f477]
1259
+ - Updated dependencies [7f23cd0]
1260
+ - Updated dependencies [0ded602]
1261
+ - Updated dependencies [24e0e0a]
1262
+ - Updated dependencies [f8a95e5]
1263
+ - Updated dependencies [3a6cf24]
1264
+ - Updated dependencies [aa35561]
1265
+ - Updated dependencies [03bd53b]
1266
+ - Updated dependencies [3c1f321]
1267
+ - Updated dependencies [a045a32]
1268
+ - Updated dependencies [912496d]
1269
+ - Updated dependencies [9867281]
1270
+ - @object-ui/core@17.1.0
1271
+ - @object-ui/react@17.1.0
1272
+ - @object-ui/types@17.1.0
1273
+ - @object-ui/i18n@17.1.0
1274
+ - @object-ui/sdui-parser@17.1.0
1275
+ - @object-ui/react-runtime@17.1.0
1276
+
1277
+ ## 17.0.0
1278
+
1279
+ ### Patch Changes
1280
+
1281
+ - 7b21891: fix(action): honor the spec `disabled` predicate on every action-rendering surface (#1885 follow-through)
1282
+
1283
+ The spec Action field is `disabled` (boolean | CEL — disabled when TRUE); the
1284
+ schema has no `enabled` key. #1885 wired it in `action:button` only. Browser
1285
+ dogfooding against the showcase found FIVE more surfaces where a spec-authored
1286
+ `disabled` silently did nothing:
1287
+
1288
+ - **components** — the `action:group` leaves (inline + dropdown), `action:icon`
1289
+ and `action:menu` still read the legacy non-spec `enabled`. They now consume
1290
+ `disabled` as the primary control (evaluated in the same scope as `visible`),
1291
+ with `enabled` kept as a deprecated fallback.
1292
+ - **app-shell** — `DeclaredActionsBar` (server-declared action bar) read
1293
+ neither; it gains `disabled` (no legacy fallback: declared actions are
1294
+ spec-shaped and never carried `enabled`).
1295
+ - **plugin-detail** — `record:quick_actions` HAD a `disabled` implementation,
1296
+ but its `typeof === 'string'` split dropped the `{dialect:'cel', source}`
1297
+ envelope the server compiles authored CEL into (#2661 routes envelopes to the
1298
+ canonical formula engine), so the predicate never fired on real metadata. It
1299
+ now feeds `toPredicateInput`'s result to `useCondition` whole, like every
1300
+ other surface.
1301
+
1302
+ Pinned by new `DropdownActionItem` tests (disabled-when-TRUE, false-stays-
1303
+ clickable, disabled-wins-over-enabled, boolean literal) and browser-verified
1304
+ end-to-end against the showcase `showcase_archive_task` specimen: greyed on an
1305
+ in-progress task, clickable on a done one (with `visible` hiding Mark Done on
1306
+ the same screen — the hide-vs-grey contrast).
1307
+
1308
+ - de5e40c: fix(approvals): Approval Center UX pass — badge nowrap, approve confirm, decision progress bar, localized declared actions (#2762)
1309
+
1310
+ - **Badge no longer stacks CJK text vertically (P0-1)** — `Badge` gains
1311
+ `whitespace-nowrap` in its base variants (a badge is a single-line pill by
1312
+ definition), and the inbox 状态 column gets a minimum width, so 待审批 can
1313
+ never render as 待/审/批.
1314
+ - **Quick Approve now confirms (P0-2)** — the row's right-edge ✓, the mobile
1315
+ card button and the `a` keyboard shortcut all route through a confirmation
1316
+ dialog before executing, mirroring the Reject flow; an irreversible decision
1317
+ can no longer fire on a stray click.
1318
+ - **Decision progress is visualized (P1-1)** — the drawer renders a segmented
1319
+ progress bar (ARIA `progressbar`) for `decision_progress`, per-group chips
1320
+ get an explicit unsatisfied ○ state next to the satisfied ✓, the eligible
1321
+ approver count is spelled out, and the drawer pager now reads
1322
+ "Request N of M" so it can't be misread as approval progress.
1323
+ - **Declared action labels localize (P0-3)** — `DeclaredActionsBar` resolves
1324
+ label / confirmText / successMessage through the `_actions.<name>.*`
1325
+ translation convention (metadata literals as fallback), matching
1326
+ ObjectView/RecordDetailView; with the `@objectstack/plugin-approvals`
1327
+ bundle, the drawer shows 通过 / 拒绝 / 转签 instead of English in a zh-CN
1328
+ workspace. New `approvalsInbox` keys shipped in all ten locales.
1329
+
1330
+ - 8fb1295: fix(data-table): keep the right-pinned action column HEADER sticky on horizontal scroll (objectui#2784)
1331
+
1332
+ The row-actions column is pinned to the right edge by injecting `sticky right-0`
1333
+ into the column's `className`, which reaches both the body cells and the header
1334
+ cell. Body cells stayed pinned, but the header cell unconditionally appended a
1335
+ `relative` position utility (it anchors the column-resize handle) — and since
1336
+ `cn` is `tailwind-merge`, the later `relative` won over the injected `sticky`.
1337
+ So the "操作" title scrolled away while its body cells stayed frozen.
1338
+
1339
+ The header now detects a right-pinned column (its `className` carries
1340
+ `sticky` + `right-0`), skips `relative` for it (a sticky cell is already its own
1341
+ positioning context, so the `absolute` resize handle still anchors correctly),
1342
+ and re-asserts `sticky right-0 z-20` after `col.className` so tailwind-merge
1343
+ keeps the pin and it stacks above the body's pinned cells (z-10). Left-frozen
1344
+ columns, the resize handle, and non-pinned columns are unaffected.
1345
+
1346
+ - c19ac11: fix(form): scroll and focus the first errored field on an invalid submit (#2793)
1347
+
1348
+ Submitting a form with a missing required field already toasts the offending field names (#2329), but in a long form the field itself stays off-screen, so the user still hunts for it. react-hook-form's native focus-on-error only reaches fields whose registered ref is a focusable native input — it silently no-ops for custom widgets (lookup / select / master-detail), which is exactly the reported case. The form renderer now disables RHF's unreliable `shouldFocusError` and, in its `onInvalid` handler, scrolls the first errored field (in visual/declared order) into view via its `data-field` wrapper and focuses a focusable control inside it — working for every field type.
1349
+
1350
+ - 2f947e4: fix(page,field): consume the spec's `type`/`label`/`maxLength` keys (framework#1878 §3 naming-drift recheck)
1351
+
1352
+ Three forward-drifts where objectui read a different key than the spec
1353
+ declares, so authoring the documented key silently no-oped:
1354
+
1355
+ - **page `type` → `pageType`** (app-shell + components): `PageSchema` declares
1356
+ the page KIND as `type`, but `PageRenderer` reads `schema.pageType` and fell
1357
+ back to `'record'` — and nothing mapped between them. Every non-record page
1358
+ (`home`/`app`/`list`/`utility`) rendered with the record max-width, a wrong
1359
+ `data-page-type` attribute, and a suppressed header. `PageView` now passes
1360
+ `pageType` alongside the SchemaNode discriminator `type`.
1361
+ - **page `label` → `title`** (components): `PageSchema.label` is required but the
1362
+ region renderer read only `title`. Now dual-reads `title ?? label`, mirroring
1363
+ the fallback `DashboardRenderer` already uses. Coupled with the above — the
1364
+ header is gated on `pageType !== 'record'`, so both were needed for a title to
1365
+ appear.
1366
+ - **field `maxLength`/`minLength`** (plugin-form + fields): validation already
1367
+ dual-read these, but `ObjectForm`'s HTML-attribute pass and `TextAreaField`
1368
+ read `max_length` only, so a spec-authored `maxLength` gave no browser cap and
1369
+ no character counter. Both now dual-read, matching `buildValidationRules`.
1370
+
1371
+ Verified in the browser against the showcase: `capability_map` (`type: 'home'`)
1372
+ now renders `data-page-type="home"`, the `home` max-width and its page title;
1373
+ record pages are unchanged.
1374
+
1375
+ - 7d46648: fix(hooks): stop calling translation hooks inside try/catch (objectui#2879)
1376
+
1377
+ Eleven call sites wrapped a React hook in `try`/`catch` to make it
1378
+ "provider-safe". `useObjectTranslation` and `useObjectLabel` already are — they
1379
+ read context optionally and fall back to react-i18next's global instance, and
1380
+ never throw. The `catch` bought nothing and cost correctness: a throw _after_
1381
+ the hook ran desyncs hook order on the next render, because React matches hooks
1382
+ positionally. objectui#2595/#2596 fixed exactly this in `@object-ui/i18n`'s
1383
+ `createSafeTranslation`; nine plugin-local re-implementations kept their own
1384
+ copy of the bug, and two more (`ObjectTimeline`, `ObjectView`) were found by the
1385
+ new lint rule below — `ObjectView` had even suppressed
1386
+ `react-hooks/rules-of-hooks` inline to keep it.
1387
+
1388
+ - Six exact re-implementations now delegate to `createSafeTranslation`:
1389
+ `plugin-detail`, `plugin-timeline`, `plugin-list`, `plugin-calendar`,
1390
+ `plugin-grid`'s `ObjectGrid`, `plugin-designer`.
1391
+ - `components`' `data-table` also delegates; `createSafeTranslation` now
1392
+ returns `language` alongside `t` so consumers that localize dates don't need
1393
+ a second hook call. Purely additive.
1394
+ - `plugin-gantt` and `plugin-grid`'s `ImportWizard` keep their local hooks —
1395
+ they fall back _per key_, which a single-probe factory cannot express and
1396
+ which their comments justify (a host dictionary that covers common keys but
1397
+ lags on newer ones). Only the `try`/`catch` is removed.
1398
+ - `ObjectTimeline` and `ObjectView` call the hook directly and probe the
1399
+ returned value, mirroring `useSafeFieldLabel`.
1400
+
1401
+ Adds `object-ui/no-try-catch-around-hook` (error) so a twelfth copy fails CI.
1402
+ It only matches `use*` names, accepts member calls solely on `React` (so
1403
+ `vi.useRealTimers()` is not a hook), and resets its try-depth inside nested
1404
+ functions (so `renderHook(() => useThing())` inside a `try` is fine) — both
1405
+ false positives were real code in this repo and are pinned in the rule's tests.
1406
+
1407
+ `eslint-rules/**/*.test.js` matched no vitest project glob, so the local
1408
+ plugin's specs had never run in CI. They are now included; all three pass.
1409
+
1410
+ `ObjectTimeline`'s test mock of `@object-ui/react` omitted `useObjectLabel` —
1411
+ the removed `try`/`catch` had been silently absorbing that gap. The mock is now
1412
+ complete.
1413
+
1414
+ - 9b53d72: feat(charts): ObjectChart honors the spec `ChartConfig` author shape (objectui#2880 / framework#3729)
1415
+
1416
+ `ChartConfigSchema` is the chart protocol, but the renderer only ever read a
1417
+ Recharts-flavoured internal shape — `chartType`, `xAxisKey`, `series[].dataKey`.
1418
+ Everything an author wrote in the SPEC shape reached the renderer and was
1419
+ silently dropped, which is exactly what ADR-0078 forbids. framework#3725
1420
+ documented the gap by trimming the published contract down to the props that
1421
+ actually worked; this closes it the other way round.
1422
+
1423
+ **S1 — one normalization boundary.** `normalizeChartSchema` translates the
1424
+ author shape into the internal pipeline contract in a single place, rather than
1425
+ scattering `??` fallbacks through the render tree (framework PD #12: one
1426
+ translation is a contract mapping, N fallbacks are a second dialect):
1427
+
1428
+ - `type` → `chartType`, `xAxis: { field }` → `xAxisKey`, `series: [{ name }]` →
1429
+ `series: [{ dataKey }]`
1430
+ - the report surface's bare-string `xAxis`/`yAxis` resolve too
1431
+ - `yAxis: [{ field }]` alone plots, with no `series` declared
1432
+ - **internal props win**, so `DashboardRenderer`, `ObjectView` and the dataset
1433
+ path are byte-for-byte unaffected — there is no migration
1434
+
1435
+ **The `type` collision.** `ChartConfig.type` is the chart family, but on any
1436
+ surface that flattens chart config into a props bag `type` is already the SDUI
1437
+ envelope's component discriminator. Spreading props last let an author's
1438
+ `type="bar"` replace `object-chart` so the block stopped resolving; stamping the
1439
+ discriminator last ate the author's value instead. The react-page wrapper now
1440
+ keeps both: the discriminator wins the `type` slot and the author's value is
1441
+ preserved beside it as `specType`, which the normalizer reads back.
1442
+
1443
+ **S2 — axis presentation.** `ChartAxis.format` drives the tick formatter (via
1444
+ `Intl.NumberFormat`, no new dependency), `min`/`max` pin the domain,
1445
+ `logarithmic` swaps the scale, `title` labels the axis, and `showGridLines` is
1446
+ honored. A second `yAxis` entry (or `position: 'right'`) turns on the secondary
1447
+ axis that `series[].yAxis` binds to — in combo charts an explicit binding now
1448
+ beats the family-derived bar→left/line→right guess. `showLegend` is honored,
1449
+ and `title`/`subtitle` render above the plot instead of only titling the
1450
+ drill-down drawer.
1451
+
1452
+ **S3 — `series[].stack`, `annotations`, `interaction`.** Stacking passes the
1453
+ author's group name through as Recharts' `stackId`. Annotations render as
1454
+ `ReferenceLine` (`type: 'line'`) / `ReferenceArea` (`type: 'region'`) with the
1455
+ declared axis, colour, style and label. `interaction.tooltips: false` suppresses
1456
+ the hover card and `interaction.brush: true` adds the range selector;
1457
+ `showDataLabels` prints values on the marks. `interaction.zoom` has no Recharts
1458
+ primitive behind it and is deliberately still unimplemented rather than faked.
1459
+
1460
+ - 53642d4: fix(core,fields): a string `$orderby` is a clause, not a character array — and localize the sharing-rule widgets (objectstack#3821)
1461
+
1462
+ **The recipient picker listed nothing, ever.** `QueryParams['$orderby']` was
1463
+ typed as `Record | string[] | SortObject[]`, so `queryParamsToRecord` sent any
1464
+ non-array value through `Object.entries`. Handed the clause string `'name asc'`
1465
+ — which callers do build by hand — it walked the string index by index and
1466
+ emitted `$orderby=0 n,1 a,2 m,3 e,4 ,5 a,6 s,7 c`. The server sorted by columns
1467
+ that don't exist and every row was filtered out, so
1468
+ `sys_sharing_rule.recipient_id` rendered "No matches" for every recipient type
1469
+ and no sharing rule could be created from the Console. `ObjectGrid` builds the
1470
+ same shape from a schema-level `sort` in three places, so grids with a string
1471
+ sort silently showed an empty table.
1472
+
1473
+ A string `$orderby` is now passed through verbatim (the server's OData
1474
+ normalizer has always parsed `'name asc'`), and the type admits `string`.
1475
+ `RecipientPickerField` additionally switched to the structured
1476
+ `{ name: 'asc' }` form so it can't regress this way against any data source.
1477
+
1478
+ **The three sharing-rule authoring widgets never had translations.**
1479
+ `ObjectRefField`, `RecipientPickerField` and `FilterConditionField` hardcoded
1480
+ their English copy — a Chinese Console showed "Select an object", "Select a
1481
+ user", "Search…", "No matches", "Edit as JSON". They now go through
1482
+ `useFieldTranslation` like every other widget, with keys added under `fields.*`
1483
+ in all ten locales.
1484
+
1485
+ The recipient placeholder was the interesting one: it read
1486
+ `` `Select a ${recipientType.replace(/_/g,' ')}` ``, interpolating the enum
1487
+ value into an English sentence — a shape no locale can translate. It is now a
1488
+ per-type key (`fields.recipient.selectUser`, `…selectBusinessUnit`, …), so
1489
+ "选择业务单元" and "Select a business unit" no longer have to share a structure.
1490
+
1491
+ **Editing a rule silently dropped its recipient.** The picker resets the stored
1492
+ id when `recipient_type` changes, because an id valid for a user is meaningless
1493
+ for a team. It treated the edit form's `'' → 'user'` hydration as such a change:
1494
+ opening any saved rule blanked the recipient, and saving persisted the blank.
1495
+ Only a non-empty predecessor now counts as a type switch.
1496
+
1497
+ **Building a filter submitted the surrounding form.** None of `FilterBuilder`'s
1498
+ controls declared `type="button"`, and a bare `<button>` inside a `<form>`
1499
+ defaults to `type="submit"`. Adding, removing or clearing a condition therefore
1500
+ submitted the sharing-rule dialog — firing validation mid-edit, and on an
1501
+ already-valid form saving the record before the admin was done.
1502
+
1503
+ **A rejected write showed the user raw server diagnostics.** The form rendered
1504
+ `error.message` verbatim, so a sharing / RLS denial reached the dialog and the
1505
+ toast as `FORBIDDEN: insufficient privileges to update showcase_private_note
1506
+ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and the
1507
+ record id to whoever hit it. Permission failures now render localized copy
1508
+ (`form.noPermissionToSave`, added in all ten locales), with the server text kept
1509
+ on the console for debugging; other failures still show the server's message,
1510
+ which is the useful part, and fall back to `form.submitFailed` when there is
1511
+ none — replacing the previously hardcoded English "An error occurred during
1512
+ submission".
1513
+
1514
+ **The detail header offered "Edit" on records the user may only read.** Object
1515
+ permissions can't express "this one record is read-only" — a read-only sharing
1516
+ grant sits inside an object the user may otherwise edit — so the header showed
1517
+ the primary Edit CTA, opened the form, and let the user retype a field before
1518
+ the server rejected the save. `DetailView` now gates Edit / Delete on the
1519
+ object-level check AND on the explain engine's record-grained verdict
1520
+ (`POST /api/v1/security/explain` with a `recordId`, ADR-0090 D6 / ADR-0095 C2 —
1521
+ the same pipeline the enforcement middleware runs, so button and server cannot
1522
+ disagree). Explaining oneself needs no special permission. The probe is one
1523
+ cached request per record, skipped entirely when the object-level check already
1524
+ says no, and **fails open** on every uncertainty — an unanswered hint must never
1525
+ be the reason a permitted user cannot act; the server stays the authority
1526
+ (ADR-0057 D10).
1527
+
1528
+ **A long option rendered straight past the combobox border.** `Combobox`'s
1529
+ trigger pinned itself to the component's `w-[200px]` default while the fields
1530
+ around it ran the full form column, and the selected label was a bare text child
1531
+ of a flex button — flex items need `truncate` AND `min-w-0` to clip, and it had
1532
+ neither. So "成员 (showcase_project_membership)" in the object picker overflowed
1533
+ the control and collided with the field beside it. The label now truncates, the
1534
+ trigger can shrink, the dropdown matches the trigger's width instead of a
1535
+ hardcoded 200px (a widened combobox used to clip its own options), and the two
1536
+ sharing-rule pickers ask for `w-full` so they line up with every other input.
1537
+
1538
+ Hardens `evaluatePermission` while there: a role config carrying only
1539
+ `fieldPermissions` (no `actions`) made `check()` throw a TypeError that
1540
+ propagated out of the render. A permission check must not be able to crash a
1541
+ view.
1542
+
1543
+ Browser-verified against the framework showcase Console in Chinese: object /
1544
+ criteria / recipient copy is fully localized, the recipient dropdown lists real
1545
+ users, business units and positions, a saved rule reopens with its recipient and
1546
+ criteria intact, editing the filter no longer submits, and a rule created
1547
+ end-to-end stores a real record id rather than free text. The criteria authored
1548
+ in the builder is honored by the evaluator: `{"pinned":true}` on an owner-private
1549
+ object granted the recipient exactly the matching records and nothing else.
1550
+
1551
+ - c6aaed8: fix(i18n): retire four hand-rolled zh/en branches (objectui#2871, part 1)
1552
+
1553
+ Four surfaces decided their language with a hand-written `startsWith('zh')`
1554
+ check instead of the locale packs, so the other eight shipped languages
1555
+ silently rendered English and the strings could never be translated without a
1556
+ code change.
1557
+
1558
+ - **`RecordTitleChip`** carried a private zh-CN/zh-TW dictionary behind a
1559
+ comment claiming "components is i18n-free". That is not true —
1560
+ `@object-ui/components` declares `@object-ui/i18n` and its sibling
1561
+ `containers.tsx` already uses it. All four of its keys (`detail.copied`,
1562
+ `detail.copyRecordId`, `detail.addToFavorites`, `detail.removeFromFavorites`)
1563
+ already existed in **all ten packs**, so this deletes ~35 lines and fixes ten
1564
+ locales with zero new translations. It renders on every record detail page.
1565
+ - **`EnvironmentListToolbar`**'s three state-aware CTA labels move to a new
1566
+ `environment.*` namespace. This surface had already regressed once for the
1567
+ same reason (#844) and was fixed then with inline `{en,zh}` pairs.
1568
+ - **`StudioAiCopilot`**'s dock title moves to the Studio catalog as
1569
+ `engine.studio.aiCopilot`.
1570
+ - **`StudioHomePage.relativeTime`** now uses `Intl.RelativeTimeFormat` with
1571
+ `numeric: 'auto'` instead of five `zh ? … : …` ternaries. This is strictly
1572
+ better than adding ten catalog keys: it covers every locale, applies the
1573
+ correct plural rules, and yields "yesterday" / 「昨天」 rather than "1d ago".
1574
+ Arabic gets its dual form («أسبوعين») — something a ternary cannot express.
1575
+
1576
+ The new `environment.*` keys are added to all ten packs, so this does not widen
1577
+ the gap tracked by objectui#2872 part (a).
1578
+
1579
+ `EnvironmentListToolbar`'s tests now render inside a real `I18nProvider` pinned
1580
+ to `en`. Without one, `t()` returns the raw key, so the previous assertions on
1581
+ literal English would have been asserting nothing.
1582
+
1583
+ - dc334da: fix(i18n): close the last three zh-branch gaps (objectui#2871, part 3)
1584
+
1585
+ The three items the #2871 classification marked as real but _not_ a
1586
+ migrate-the-copy fix. Each needed a different remedy.
1587
+
1588
+ **`LoadingScreen` — ten languages collapsed to two.** The boot splash already
1589
+ selected real locale packs (not inline copy), but through
1590
+ `lang.startsWith('zh') ? zh : en`, so a ja/ko/de user watched the whole startup
1591
+ in English. It now indexes `builtInLocales` by the two-letter prefix.
1592
+
1593
+ Each field falls back to `en` **individually**, which matters: `console.*` is
1594
+ one of the namespaces that trails in the non-`zh` packs (objectui#2872 part a),
1595
+ so a whole-object swap would have rendered `undefined` on the splash rather
1596
+ than English. `console.loadingHint` was in fact missing from all eight — added
1597
+ here, since a blank line under the progress list is worse than an English one.
1598
+
1599
+ **`containers.tsx` — two language sources that could disagree.** The tab-label
1600
+ call sites resolved `language` from `useObjectTranslation()`, then handed the
1601
+ string to `translateLabel`, which called `detectLocale()` and read
1602
+ `document.documentElement.lang` on its own. Those update independently, so an
1603
+ in-app language switch could leave a tab label and its surrounding chrome in
1604
+ different languages until the next reload. `language` is now threaded in, and
1605
+ `detectLocale` is deleted so nothing reaches for the DOM again.
1606
+
1607
+ **`field-types.ts` — a two-language data catalog.** `FieldTypeMeta` carried a
1608
+ `labelZh` column beside `label`, which capped the field-type picker at English
1609
+ or Chinese by construction. The 46 type names and 9 category names move into
1610
+ the Studio catalog as `engine.fieldType.<id>` / `engine.fieldCategory.<cat>`,
1611
+ generated from the existing values so no wording changes. This removes the
1612
+ `isZh` helper from **both** `ObjectFieldInspector` and `ObjectFormCanvas` — the
1613
+ two files the classification listed as "keep the component, fix the catalog".
1614
+
1615
+ The picker's search filter previously matched `id`, the English label, and
1616
+ `labelZh` — so searching in Japanese or German matched nothing. It now matches
1617
+ the label as the user actually sees it.
1618
+
1619
+ - Updated dependencies [0b3be01]
1620
+ - Updated dependencies [3c4d935]
1621
+ - Updated dependencies [4b60d2d]
1622
+ - Updated dependencies [952b978]
1623
+ - Updated dependencies [de5e40c]
1624
+ - Updated dependencies [1a03af6]
1625
+ - Updated dependencies [3e886eb]
1626
+ - Updated dependencies [cfc675e]
1627
+ - Updated dependencies [20df08c]
1628
+ - Updated dependencies [1767124]
1629
+ - Updated dependencies [8ecf5a6]
1630
+ - Updated dependencies [af705b9]
1631
+ - Updated dependencies [0502a7c]
1632
+ - Updated dependencies [7b35e4b]
1633
+ - Updated dependencies [e16ed2d]
1634
+ - Updated dependencies [c6fd752]
1635
+ - Updated dependencies [f9bbddb]
1636
+ - Updated dependencies [dfd3705]
1637
+ - Updated dependencies [c77108c]
1638
+ - Updated dependencies [2735de6]
1639
+ - Updated dependencies [6dee2cb]
1640
+ - Updated dependencies [e05f052]
1641
+ - Updated dependencies [0502a7c]
1642
+ - Updated dependencies [faad45e]
1643
+ - Updated dependencies [09c6a17]
1644
+ - Updated dependencies [c7cff19]
1645
+ - Updated dependencies [ba73a02]
1646
+ - Updated dependencies [cd09a7b]
1647
+ - Updated dependencies [f1abf0e]
1648
+ - Updated dependencies [f05b84e]
1649
+ - Updated dependencies [9b4b952]
1650
+ - Updated dependencies [7d46648]
1651
+ - Updated dependencies [bb4aa25]
1652
+ - Updated dependencies [75f1cdf]
1653
+ - Updated dependencies [662bdf9]
1654
+ - Updated dependencies [059a052]
1655
+ - Updated dependencies [53642d4]
1656
+ - Updated dependencies [8aae006]
1657
+ - Updated dependencies [c6cfdf1]
1658
+ - Updated dependencies [d147a13]
1659
+ - Updated dependencies [c6aaed8]
1660
+ - Updated dependencies [263f885]
1661
+ - Updated dependencies [dc334da]
1662
+ - @object-ui/i18n@17.0.0
1663
+ - @object-ui/react@17.0.0
1664
+ - @object-ui/types@17.0.0
1665
+ - @object-ui/core@17.0.0
1666
+ - @object-ui/react-runtime@17.0.0
1667
+ - @object-ui/sdui-parser@17.0.0
1668
+
3
1669
  ## 16.1.0
4
1670
 
5
1671
  ### Patch Changes