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