@object-ui/i18n 16.1.0 → 17.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1202 @@
1
1
  # @object-ui/i18n
2
2
 
3
+ ## 17.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a889e31: A record's approval band now shows the quorum / per-group tally the server already computes.
8
+
9
+ The showcase's `showcase_committee_quorum` node declares `behavior: 'quorum'` with
10
+ `minApprovals: 2` over three approvers, and even ships a pre-rendered
11
+ `"Committee Sign-off (2 of 3)"` label; `showcase_expense_signoff` declares
12
+ `per_group` (会签) with named manager / finance groups. On the business record
13
+ the approval band rendered none of it — the lock badge, the recall button and
14
+ the approve/reject actions were all correct, but a two-of-three committee step
15
+ looked exactly like a one-approver step. An approver could not see whether their
16
+ own click finalized the node or was one of three, which is the single fact a
17
+ quorum node exists to express (objectstack#4478).
18
+
19
+ Nothing was wrong on the wire, and nothing here papers over the server. The
20
+ framework computes `decision_progress` — `{ behavior, got, need, groups? }`,
21
+ derived from the node's own `node_config_json` snapshot, so the count a client
22
+ shows is the count the engine will enforce. **It attaches that block in
23
+ `getRequest` only**: `listRequests` deliberately skips it, because the
24
+ `sys_approval_action` tally it costs is per row and a list read may return
25
+ hundreds. The record header's `useRecordApprovals` reads
26
+ `GET /approvals/requests?object=…&recordId=…` — the list route — so the
27
+ enrichment was never in the payload it had. The hook now follows up with one
28
+ single read for the ONE pending row and folds the result onto it; a failed or
29
+ mismatched follow-up leaves the row exactly as the list sent it, so a display-only
30
+ enrichment can never take the approval panel down and no tally is ever invented.
31
+
32
+ `InlineEditProvider` carries the block through as `approvalProgress`, and the
33
+ DetailView approval band renders it beside the existing badge: a labelled
34
+ `role="progressbar"` with one tick per required approval for `quorum` /
35
+ `unanimous`, and for `per_group` a chip per group marking which have signed
36
+ (`finance 1/1` ✓, `manager 0/1`). Group names come from the flow author's own
37
+ config, so they need no locale strings; the three new label keys are added to all
38
+ ten packs. `first_response` nodes carry no `decision_progress` and are unchanged —
39
+ one decision is the whole step there, and a "1 of 1" bar would be noise.
40
+
41
+ Scored `minor` rather than `patch`: this is new observable rendering plus a new
42
+ public `approvalProgress` prop / `ApprovalProgress` type on `@object-ui/react`,
43
+ not a behavior correction inside an existing surface.
44
+
45
+ ### Patch Changes
46
+
47
+ - 4b470b9: Localize the environment entitlement dialog and read cloud's nested error envelope.
48
+
49
+ The free-plan "Development environments are a paid feature" prompt was built from
50
+ English string literals in `entitlements.ts` — including the lowercase `your free
51
+ plan` sentence users reported (cloud#959). Both spec builders now take a translator
52
+ and resolve `environment.entitlement.*`; all ten locale packs carry the strings.
53
+ `entitlements.ts` stays dependency-free: `t` is passed in, not imported, and
54
+ defaults to the English copy with local `{{token}}` interpolation.
55
+
56
+ The dialog now renders the Console's own copy rather than the server's prose — a
57
+ control plane upgrades independently and only localizes these messages from
58
+ cloud#959 on, so preferring the server string left the reactive path English
59
+ against every older deployment.
60
+
61
+ Also fixes the reactive dialog not firing at all: cloud#948 moved coded errors into
62
+ a nested envelope (`{ success, error: { code, … } }`), and
63
+ `entitlementDialogFromError` read `code` off the top level — returning `null` for
64
+ every entitlement 403, so the upgrade dialog degraded to a generic red error toast.
65
+ Both shapes are read now.
66
+
67
+ ## 17.1.0
68
+
69
+ ### Minor Changes
70
+
71
+ - 1cf0de7: fix(detail): finish the approval-lock story, and warn on silently stripped fields (framework#3794)
72
+
73
+ The Console reported record writability wrong in both directions during an
74
+ approval, so a user had nothing to go on: what they _could_ edit said "locked",
75
+ and what they _couldn't_ said "updated successfully".
76
+
77
+ **The lock band told the truth; the Edit button did not.** objectui#2902 split
78
+ the band into "in approval · editable" vs locked, but the header **Edit** CTA
79
+ still keyed off nothing at all — on a genuinely locked record it stayed live, so
80
+ the user opened the form, filled a screen, and got `RECORD_LOCKED` back on Save.
81
+ It is now `disabled` on a locked record: visible-but-off, with the band beside it
82
+ saying why. This is the LOCK, not the mere presence of an approval — a
83
+ `lockRecord: false` node keeps Edit live, which is the point of that setting.
84
+
85
+ **And the band could still re-lock itself.** `DetailView` OR-ed the record's own
86
+ `approval_status` mirror into `isLocked` unconditionally. That mirror is written
87
+ on submit by any flow configuring an `approvalStatusField`, _regardless of_
88
+ `lockRecord` — so on a `lockRecord: false` node the host correctly resolved "not
89
+ locked" from the request's `lock_record` while the mirror dragged the band back
90
+ to "Locked for approval", with the pencils live and saves landing underneath it.
91
+ The host is now authoritative whenever it threads `approvalPending`; the mirror
92
+ is consulted only for bare/legacy `DetailView` hosts that thread nothing, where
93
+ it still reads as locked (no node granularity — the safe direction).
94
+
95
+ Recall's tooltip no longer promises to unlock a record the node never locked
96
+ (`detail.cancelApprovalTooltipUnlocked`).
97
+
98
+ **Silently stripped fields now surface on the record form's save path.** The
99
+ adapter emitted a write-warning for `create`/`update` responses carrying
100
+ `droppedFields`, but not for `batchTransaction` — which is how the record form
101
+ saves a master-detail record, i.e. the one surface where a user actually edits a
102
+ `readonlyWhen`-locked field. `batchTransaction` now emits one warning per event,
103
+ resolving each back to its operation via the response's `index`.
104
+
105
+ The toast itself was hardcoded English and called every strip "read-only". It is
106
+ now localized (`detail.writeStripped*`, ten locales) and worded by reason:
107
+ `readonly_when` says the field is not editable _in this record's current state_,
108
+ which is what actually happened — the field is editable in other states and the
109
+ form rendered it as an ordinary input, so "read-only" sent the user hunting for a
110
+ permission problem that does not exist.
111
+
112
+ **And it stopped crying wolf.** `createObjectStackUserStateAdapter` hand-stamped
113
+ the server-managed `updated_at` on every recents/favorites write, which the
114
+ server strips and reports — so the console popped "Some fields were not saved"
115
+ about a field no user ever touched, on page loads, drowning the signal the toast
116
+ exists for. It no longer sends the column; the server stamps it anyway.
117
+
118
+ - d21794c: fix(list,i18n): a 400 from the server no longer reads as "check your connection"
119
+
120
+ `classifyLoadError` was written because a 403 rendered the same
121
+ "check your connection and try again" panel as a genuine outage — its own doc
122
+ comment says users "were told to debug their network when the server had
123
+ (correctly) denied them access." It made that distinction for 401 and 403 and
124
+ then sent **everything else**, including 4xx, to the network branch.
125
+
126
+ A **400** is the server saying it understood the request and will never accept
127
+ it. Retrying resends the identical bad request, so "check your connection and
128
+ try again" is advice that cannot work — the same mistake the function exists to
129
+ prevent, one status code over.
130
+
131
+ This became reachable from ordinary stored metadata with
132
+ objectstack-ai/objectstack#4121: a `$filter` array that is not a filter AST is
133
+ now rejected at the protocol with `400 INVALID_FILTER`, where it previously
134
+ reached a driver (and, for a lone `['and']`, silently returned every row). A
135
+ view saved with such a filter now answers 400 on every load.
136
+
137
+ Adds a fourth classification, `rejected`, for `status === 400` and for the
138
+ server's 400-class codes (`INVALID_FILTER`, `UNSUPPORTED_QUERY_PARAM`,
139
+ `INVALID_QUERY`). Its copy points at the filter rather than the network, and
140
+ says who can fix it when the view is saved that way. 403/401 keep priority, so a
141
+ permission denial can never read as a bad request — pinned by a test.
142
+
143
+ The two new strings are added to **all ten locale packs**, not just `en`: the
144
+ neighbouring panels are translated, and `fallbackLng: 'en'` would have rendered
145
+ this one in English beside them. The full-parity gate
146
+ (`all-locales-key-parity.test.ts`) caught the pack I missed.
147
+
148
+ Verified: 5 new tests — numeric status, error code without a status, a status
149
+ embedded in the message text, and the 403/401 ordering guard. Reverting the
150
+ branch fails four of them. `plugin-list` + `i18n`: **403 tests across 29 files**,
151
+ green.
152
+
153
+ - f8a95e5: fix(fields): the sharing-criteria builder stops calling an empty criteria "All records" (objectstack#3896)
154
+
155
+ `FilterConditionField` renders `sys_sharing_rule.criteria_json`. With no
156
+ criteria it displayed **"All records"**, and `filterGroupToMongo` carried a
157
+ matching `// empty = match all` comment. That was describing a bug as a
158
+ feature: a sharing rule with no predicate was stored as `criteria_json: null`
159
+ and evaluated as `find(object, { filter: {} })` under the system context —
160
+ every record of the object, granted to the recipient. `SharingRuleSchema` had
161
+ always forbidden the shape ("never seeded as a permissive match-all",
162
+ ADR-0049); the REST and data-API entries just never checked.
163
+
164
+ objectstack#3896 closes those entries: the server now refuses to save a rule
165
+ whose criteria would match everything, and one already stored shares nothing.
166
+ This is the renderer catching up.
167
+
168
+ - **The empty read-only state now says the rule shares nothing**, in
169
+ `destructive` styling — key renamed `fields.filterCondition.allRecords` →
170
+ `fields.filterCondition.noCriteria`, retranslated across all ten locales.
171
+ Nothing else read the old key.
172
+ - **A new `fields.filterCondition.criteriaRequired` hint** renders under the
173
+ builder (and the JSON editor) while the criteria is empty. The server's
174
+ rejection is precise but only arrives as a toast _after_ Save; this says it
175
+ while the admin is still looking at the empty builder.
176
+ - **`isMatchAllCriteria` is exported** — a client-side mirror of the server
177
+ predicate covering `{}`, `[]`, and the vacuous combinators (`{ $and: [] }`,
178
+ `{ $or: [{}] }`), conservative in the same direction. The server stays
179
+ authoritative; this only decides whether to show the hint.
180
+
181
+ Unparsable JSON keeps its own `invalidJson` message and does **not** also
182
+ collect the empty-criteria hint.
183
+
184
+ Note for anyone wiring this end-to-end: the Criteria field is not marked
185
+ `required` in the object metadata, deliberately — `sys_sharing_rule.criteria_json`
186
+ is nullable in deployed tenants, so `required: true` would only produce a
187
+ destructive `NOT NULL` migration that those nulls block. The invariant lives in
188
+ the server's write guards; this change makes the UI stop contradicting it.
189
+
190
+ ### Patch Changes
191
+
192
+ - 752e18f: fix(console,app-shell): readable reassign hand-off + "System" label for svc:* audit actors — objectstack#4365 / objectstack#4366
193
+
194
+ - **Approvals inbox** (`ApprovalsInboxPage`): a reassign timeline entry now
195
+ renders "from A to B" from the structured
196
+ `reassign_from`/`reassign_to` fields (and their server-resolved
197
+ `*_name` companions) that objectstack#4365 added to
198
+ `sys_approval_action`, instead of relying on the old default comment that
199
+ baked two raw user ids into user-facing text. Legacy rows without the
200
+ structured fields keep the comment fallback. New i18n key
201
+ `approvalsInbox.reassignFromTo` across all ten locales.
202
+ - **Record history** (`RecordDetailView`): an audit row attributed to a
203
+ service principal (`svc:*` on the `actor` column — e.g. a
204
+ `runAs:'system'` flow's `svc:flow:<name>` label from objectstack#4366) now
205
+ renders the localized "System" label instead of the raw principal string;
206
+ the raw value stays on the entry for tooling.
207
+
208
+ - c785740: fix(detail): record Attachments become their own tab (with count badge) and their copy is translated — objectstack#4358
209
+
210
+ Two defects on `enable.files: true` record detail pages:
211
+
212
+ 1. **Buried placement.** `RecordDetailView` appended `RecordAttachmentsPanel`
213
+ AFTER the schema-rendered page tree, whose synthesized default embeds
214
+ `record:discussion` as the last main component — so the panel always
215
+ landed below an ever-growing feed timeline, undiscoverable without
216
+ scrolling to the very bottom, with no metadata knob to move it.
217
+
218
+ `buildDefaultTabs` now emits a peer **Attachments** tab (a new
219
+ `record:attachments` node rendered by an app-shell registration wrapping
220
+ the existing panel via RecordContext) between Related and
221
+ Activity/History. `PageTabsRenderer` derives the tab's count badge from a
222
+ `sys_attachment` probe scoped to `(parent_object, parent_id)`, riding the
223
+ same RelatedCountStore cache/invalidation bus as related-list badges — so
224
+ uploads and deletes update the badge live. A `hideAttachments` synthesizer
225
+ option suppresses the tab; RecordDetailView keeps its legacy bottom append
226
+ only as the fallback for authored pages without the node
227
+ (`hasExplicitAttachments`).
228
+
229
+ 2. **Untranslated copy.** The panel's eleven `detail.*` keys (`attachments`,
230
+ `uploadAttachment`, `loadingAttachments`, `noAttachments`,
231
+ `downloadAttachment`, `deleteAttachment`, and the five
232
+ `attachment*Denied/Required` friendly errors) existed only as inline
233
+ English `defaultValue`s — no locale bundle carried them, so non-English
234
+ consoles always showed English. All ten locales now define them; the tab
235
+ label rides the existing well-known-label dictionary (→ 附件 etc.).
236
+
237
+ - d61efd1: fix(grid): a bulk action's `visible` is evaluated per selected record — objectui#3067
238
+
239
+ The selection bar evaluated a def's `visible` against the ambient scope with no
240
+ record bound. That does not fail open, it answers wrongly: with no `record` in
241
+ scope the lenient evaluator returned `true` for **every** row-scoped predicate,
242
+ including the ones that should be false — `${record.done}` and
243
+ `${record.owner == user.id}` both came back `true`. An authored gate was not
244
+ weakened, it was inverted for half its inputs, and nothing distinguished that
245
+ from a real verdict.
246
+
247
+ `visible` is now evaluated **once per selected record, with that record in
248
+ scope**, fail-closed per record and warning once on a fault — the same contract
249
+ the row kebab uses. One evaluation answers both questions:
250
+
251
+ - **Is the button offered?** When at least one selected record passes. A
252
+ record-free predicate (`features.x`, `current_user.y`) returns the same
253
+ verdict for every row, so it still behaves as a plain button-level gate — no
254
+ syntactic sniffing for `record` references is involved.
255
+ - **Which records does it run on?** The ones that passed. The confirm step
256
+ states how many were skipped, so a run over fewer records than the user
257
+ ticked says so instead of quietly shrinking the selection.
258
+
259
+ Eligibility is re-applied to the EXPANDED set after "select all N matching",
260
+ not just the page selection the button could see.
261
+
262
+ The mechanism predates objectui#3002, but only inline-authored
263
+ `bulkActionDefs[].visible` used to reach it — written by authors who knew there
264
+ was no record. #3031 began promoting object actions into the bar, and their
265
+ `visible` is typically written for a row/record surface, which is what put
266
+ row-scoped predicates in front of a record-free evaluation.
267
+
268
+ - 9eb932b: fix(console): three real-user console failures — 403 blamed on the network, ⌘K search capped at 8 objects, nav gating fields inert
269
+
270
+ 1. **List error panel classifies the failure** (`plugin-list`, `i18n`): a 403/401 from the data source used to render the same "check your connection" copy as a genuine outage, sending users to debug their network while the server was correctly denying access. The panel now classifies by `httpStatus`/`status`/`statusCode`, the `PERMISSION_DENIED`/`UNAUTHORIZED` error codes, or an `HTTP <status>` message prefix, and renders dedicated permission-denied / sign-in-required copy (all nine locales).
271
+
272
+ 2. **⌘K / full-page search scope is no longer truncated** (`react`): `maxObjectsQueried` caps the per-object fanout fallback, not the search scope — it used to slice the candidate pool itself, so the `objects` whitelist sent to the platform's `/api/v1/search` only ever named the first 8 nav objects. Which sidebar group came first decided which records were findable; everything later in the nav was unsearchable no matter what the user typed.
273
+
274
+ 3. **Nav gating fields finally gate** (`app-shell`): `evaluateVisibility` only evaluated `${…}` template strings, so the `{ dialect: 'cel', source }` envelopes the spec normalizes every authored `visible` predicate into fell through to a blanket "visible" — a constant-false predicate still rendered for everyone. It now delegates to `ExpressionEvaluator.evaluateCondition`, which routes CEL envelopes to the canonical `@objectstack/formula` engine. And the sidebars' `requiredPermissions` check treats a bare name as an ADR-0066 system capability (union of the user's permission-set `systemPermissions` from `/me/permissions`) — the same subset rule the server applies to `AppSchema.requiredPermissions` — instead of misreading it as `can(<name>, 'read')`, which had degraded `requiredPermissions` into a hide-from-everyone switch (admins included). The `object:action` form and the legacy object-read fallback keep working.
275
+
276
+ - 3cb9646: fix(app-shell,i18n): record forms no longer render the developer-voiced default subtitle
277
+
278
+ Every create/edit record form (both the console dialog in `AppContent` and the
279
+ full-page `RecordFormPage`) hardcoded a platform default description under the
280
+ title: "Add a new {{object}} to your database." / "Update details for
281
+ {{object}}" (zh: 「向数据库添加新的{{object}}。」/「更新{{object}}的详情」).
282
+
283
+ The copy is developer-tooling voice leaking into end-user business apps — a
284
+ scheduling clerk filling in a 排班计划 has no business being told about "the
285
+ database", and the phrasing came straight from admin-panel boilerplate. The
286
+ line carried no information the form title didn't already have, and neither
287
+ call site let a form view override it.
288
+
289
+ The default subtitle is now gone: both call sites stop passing `description`,
290
+ and the unused `form.createDescription` / `form.editDescription` keys are
291
+ removed from all ten locale bundles (the `workspace.createDescription` key is
292
+ unrelated and stays).
293
+
294
+ - b5b97e2: fix(types,layout): nav item type `component` joins `NavigationItemType` and its zod enum — objectui#2918
295
+
296
+ The renderers have carried a full `type: 'component'` implementation (Phase 3b:
297
+ `componentRef` colon-split to `/component/<ns>/<name>`, `params` serialised as
298
+ querystring, `metadata:*` special-cases) — but the vocabulary never gained the
299
+ member, and `@objectstack/spec` has had `ComponentNavItem` all along. The zod
300
+ enum was the part that bit: `NavigationItemTypeSchema` rejected
301
+ `type: 'component'` at validation time, so authors could not declare one and
302
+ the renderer half was unreachable — dead on arrival rather than dead code.
303
+
304
+ - `NavigationItemType` and `NavigationItemTypeSchema` gain `'component'`;
305
+ `NavigationItem` gains the fields the renderer consumes, `componentRef` and
306
+ `params` (also used by `type: 'page'`), mirroring spec's `ComponentNavItem` —
307
+ declared in zod too, so parse no longer strips them.
308
+ - The `(item as any).componentRef` / `params` casts in `NavigationRenderer`
309
+ and `AppSchemaRenderer` become typed access.
310
+ - `NavigationDesigner`'s exhaustive type-meta map gains a `component` badge
311
+ (new `appDesigner.navTypeComponent` key in all 10 locales).
312
+ - `@object-ui/layout` gains `type-check` (src + tests) with the #2915 `paths`
313
+ override; its DEBT entry in `check-type-check-coverage.mjs` is deleted.
314
+
315
+ - 2d5d594: fix(list,detail): sorting a lookup column no longer orders by an invisible key — #3096
316
+
317
+ A relational column (`lookup` / `master_detail` / `user` / `tree`) never holds
318
+ the string its cell shows: it holds the `$expand`-ed record, or a raw foreign-key
319
+ id whose label was resolved separately. Every sort path took that raw value as
320
+ its key, so the column of names came back in an order with no relation to the
321
+ names — sorting looked broken, with nothing saying the key was something else.
322
+
323
+ The two halves are fixed differently, because they can order by different things:
324
+
325
+ - **Client-side sorts** (grid column headers, any `data-table`, a non-windowed
326
+ related list) now key off the label the cell renders, via the new
327
+ `getSortValue` / `compareSortValues` in `@object-ui/core` — which resolves an
328
+ expanded record through `getRecordDisplayName` (ADR-0079), so the sort key and
329
+ the lookup cell agree on which field names a record. This replaces two broken
330
+ comparators: `a[col] < b[col]` is always false between two objects (the
331
+ comparator collapsed to a constant and permuted the rows), and
332
+ `String(a[col])` is `"[object Object]"` (every row compared equal, so the sort
333
+ silently did nothing).
334
+ - **Server `$orderby` sorts** cannot be fixed here — the key is the stored id by
335
+ construction, and `objectstack#4256` settled that no relation join is coming.
336
+ So those entry points stop offering the illusion: the ListView toolbar sort
337
+ picker withholds relational fields and explains why (pointing at a formula
338
+ field as the supported way to sort by a related name), and a windowed related
339
+ list renders no sort button for them.
340
+
341
+ A relational field the view's CURRENT sort already uses stays listed, labelled
342
+ `(by ID)`, so view metadata authored or saved with such a sort round-trips
343
+ instead of rendering a blank row and losing the sort on the next edit.
344
+
345
+ ## 17.0.0
346
+
347
+ ### Minor Changes
348
+
349
+ - 952b978: fix(detail): the approval band honors the node's `lockRecord` instead of assuming every approval locks (#2902)
350
+
351
+ A record detail page treated "a pending approval request exists" as "this
352
+ record is locked". An approval node declares `lockRecord` (default `true`), and
353
+ on `lockRecord: false` the server keeps accepting writes for the whole time
354
+ that node waits — so the console was asserting a lock the backend did not
355
+ enforce.
356
+
357
+ The label was the smaller half of it. The same conflated signal fed `canEdit`,
358
+ so the record-level inline-edit session was suppressed too: no pencils,
359
+ `enter()` a no-op. On a single-approver step — a department head or plant
360
+ manager, exactly the case `lockRecord: false` exists for, where the approver is
361
+ meant to fill in the missing detail before deciding — the capability was
362
+ unreachable from the UI. And a flow chaining nodes with different policies drew
363
+ one identical band for "edit freely" and "the server will reject your save with
364
+ `RECORD_LOCKED`", so the two states were indistinguishable until Save failed.
365
+
366
+ Approval state is now two signals:
367
+
368
+ - **`approvalPending`** — an approval is running. Drives the band and the recall
369
+ button, both meaningful whether or not the record is editable.
370
+ - **`locked`** — that approval also forbids edits, from the pending node's
371
+ `lock_record` (framework#3814, read off the same `node_config_json` snapshot
372
+ the server's record-lock hook reads).
373
+
374
+ The band renders two states: amber lock + "Locked for approval", or sky clock +
375
+ "In approval · editable", each with its own tooltip. Recall moved out of the
376
+ locked branch — an editable pending approval is just as recallable. Inline
377
+ editing stays live in the editable state.
378
+
379
+ `InlineEditProvider` takes a new optional `approvalPending` prop, defaulting to
380
+ `locked`, so a host that threads only `locked` renders exactly as before. The
381
+ record's `approval_status` field remains the fallback for backends with no
382
+ approvals API; it carries no node granularity, so it still reads as locked — as
383
+ does a pending request from a backend too old to report the policy.
384
+
385
+ New `detail.approvalPendingEditable` / `detail.approvalPendingTooltip` keys are
386
+ translated in all ten locales.
387
+
388
+ - 2735de6: feat: render the server's effective API operation set (#3391 PR-4)
389
+
390
+ The frontend now consumes the per-object **effective API operation set** the
391
+ server resolves (from `/me/permissions` `apiOperations`, framework #3391) —
392
+ never the raw `apiMethods` — so Import/Export/New/Edit/Delete buttons match what
393
+ the server will actually admit, and a 405 import refusal shows a dedicated
394
+ message instead of silently falling back.
395
+
396
+ - **core** `resolveCrudAffordances(obj, effectiveApiOperations?)` — new optional
397
+ second argument intersects each affordance bit with its API operation
398
+ (create/import→create/import, edit→update, delete→delete, exportCsv→export).
399
+ Omitting it (old backend / no effective set) leaves affordances unchanged.
400
+ - **permissions** — `/me/permissions` response carries per-object
401
+ `apiOperations`; `PermissionContextValue.getObjectApiOperations(object)`
402
+ exposes it (undefined when absent → callers keep current behavior); `check()`
403
+ maps `import→allowCreate`, `export→allowRead`.
404
+ - **app-shell** `ObjectView` intersects its toolbar affordances with the object's
405
+ effective operations (Import); the platform-admin identity-import bypass is
406
+ unaffected.
407
+ - **plugin-list** `ListView` / **plugin-grid** `ObjectGrid` gate the Export
408
+ button (and export handler) on effective `export`; `plugin-grid` gains the
409
+ `@object-ui/permissions` workspace dependency.
410
+ - **plugin-grid** `ImportWizard` — a 405 / `OBJECT_API_METHOD_NOT_ALLOWED`
411
+ import refusal is detected by a new `isImportNotAllowed` predicate at every
412
+ catch site (async, sync, dry-run) and STOPS with a dedicated
413
+ `grid.import.notAllowed` message (10 locales + fallback dict) — it never falls
414
+ back to the sync/legacy path (which 405s too), distinct from the 404
415
+ route-absent fallback.
416
+
417
+ Backward-compatible: a missing effective set (unrestricted object, older
418
+ backend, or no permission provider) preserves the current default-allow
419
+ behavior everywhere.
420
+
421
+ ### Patch Changes
422
+
423
+ - 0b3be01: fix(app-shell): give inline `lookup` action params a real record picker (#3405)
424
+
425
+ An action parameter declared inline as `{ name: 'inspector', type: 'lookup',
426
+ reference: 'sys_user' }` always rendered as a plain text input asking the user
427
+ to paste a record id (UUID) — a supervisor assigning an inspector had to go
428
+ find that person's UUID by hand, while the same reference field picks records
429
+ by name in the create/edit dialog.
430
+
431
+ `paramToField()` degrades a picker param to text when it has no `referenceTo`
432
+ target, and `referenceTo` was only ever populated on the field-backed branch of
433
+ `resolveActionParams()`. The inline branch dropped the authored `reference`
434
+ key entirely (as did the spec schema, which stripped it as unknown), so an
435
+ inline picker could never reach `<LookupField>` no matter how it was authored.
436
+
437
+ - `resolveActionParam()` now maps an inline `reference` onto `referenceTo` — on
438
+ the inline branch, on the missing-field fallback branch, and as an override
439
+ on the field-backed branch (matching how every other inline value overrides
440
+ the resolved field).
441
+ - The text degradation now warns in dev naming the offending param, since with
442
+ `@objectstack/spec` rejecting a targetless inline picker at parse time it
443
+ means the metadata is broken, not merely partial.
444
+ - The fallback's placeholder and help text no longer claim "a picker is coming
445
+ soon" — the picker has shipped, and the message now says the parameter has no
446
+ reference object configured. Updated across all 10 locales.
447
+
448
+ - 3c4d935: fix(i18n): compose the AI-model diagnostics summary client-side instead of rendering the server's English string (objectui#2886)
449
+
450
+ `CloudAiModelStatus` rendered `report.summary` verbatim — the most prominent
451
+ line on the panel, in English for every locale.
452
+
453
+ Reading `objectstack-ai/cloud` settled how to fix it. The server **cannot**
454
+ localize that string as currently built:
455
+
456
+ - `service-ai/src/effective-model.ts:117` assembles it as a hard-coded English
457
+ template literal, with no locale parameter;
458
+ - `service-ai/src/routes/ai-routes.ts:395` declares `handler: async () => …` —
459
+ it takes **no request argument**, so it cannot read `Accept-Language` even
460
+ though `createAuthenticatedFetch` has been sending it since objectui#1319.
461
+
462
+ But no server change is needed, because every ingredient of the sentence is
463
+ already in the structured payload: `conversational.model`,
464
+ `conversational.source`, `structured.model`, `structured.pinned`, and
465
+ `routing.{free,paid}`. The issue proposed "return structured data instead of a
466
+ sentence" as the better fix — the server was already doing that; the client
467
+ just wasn't using it.
468
+
469
+ The panel now composes the line from those fields. `sourceLabel()` already
470
+ produced exactly the two clauses the server hand-rolls — "pinned by X" /
471
+ "code default (no env override)", and "same as build/ask" for an unpinned
472
+ structured model — so no new source vocabulary was required.
473
+
474
+ **A dropped diagnostic, not just untranslated text.** The client's
475
+ `EffectiveModelReport` never declared `routing`, which the server has always
476
+ sent conditionally. Its only appearance anywhere was inside the English summary,
477
+ so non-English admins could not see the plan→model routing policy **at all**.
478
+ It is now declared and surfaced.
479
+
480
+ Also fixed: `attributeSource` emits the bare token `'unknown'` when the adapter
481
+ cannot report a model, and `sourceLabel` fell through to rendering it raw.
482
+
483
+ Four keys added to all ten packs (`summary`, `summaryRouting`, `modelUnknown`,
484
+ `sourceUnknown`), so the full-parity guard from objectui#2909 stays green.
485
+
486
+ The panel had **no test coverage at all**; it now has five, mutation-tested by
487
+ restoring `<p>{report.summary}</p>` — which fails four of them.
488
+
489
+ - 4b60d2d: fix(console): make the approval timeline attachment chip show its name and open (#2820)
490
+
491
+ A decision attachment in the approval inbox timeline (审批动态) rendered a
492
+ nameless "附件" chip that did nothing when clicked. Three separate bugs:
493
+
494
+ - **No filename.** The chip resolved its label by fetching `/data/sys_file/{id}`
495
+ — a system object a regular approver cannot read — and silently fell back to a
496
+ generic label when that was denied. The name now comes from the attachment
497
+ descriptor the server returns (framework #3266), so no `sys_file` access is
498
+ needed and the real filename shows for every approver.
499
+ - **Dead click.** `openAttachment` called `window.open` _after_ an `await`, so
500
+ it was no longer a user gesture and the browser blocked the popup. It now opens
501
+ the tab synchronously up front, then points it at the signed URL once fetched.
502
+ - **Wrong origin.** The signed URL from the local storage adapter is
503
+ server-relative; `window.open` resolved it against the console origin. It is
504
+ now resolved against the API origin.
505
+ - Every open failure was swallowed silently. The user now gets a toast on
506
+ failure — new `approvalsInbox.attachmentOpenFailed` string across all 10
507
+ locales.
508
+
509
+ - de5e40c: fix(approvals): Approval Center UX pass — badge nowrap, approve confirm, decision progress bar, localized declared actions (#2762)
510
+
511
+ - **Badge no longer stacks CJK text vertically (P0-1)** — `Badge` gains
512
+ `whitespace-nowrap` in its base variants (a badge is a single-line pill by
513
+ definition), and the inbox 状态 column gets a minimum width, so 待审批 can
514
+ never render as 待/审/批.
515
+ - **Quick Approve now confirms (P0-2)** — the row's right-edge ✓, the mobile
516
+ card button and the `a` keyboard shortcut all route through a confirmation
517
+ dialog before executing, mirroring the Reject flow; an irreversible decision
518
+ can no longer fire on a stray click.
519
+ - **Decision progress is visualized (P1-1)** — the drawer renders a segmented
520
+ progress bar (ARIA `progressbar`) for `decision_progress`, per-group chips
521
+ get an explicit unsatisfied ○ state next to the satisfied ✓, the eligible
522
+ approver count is spelled out, and the drawer pager now reads
523
+ "Request N of M" so it can't be misread as approval progress.
524
+ - **Declared action labels localize (P0-3)** — `DeclaredActionsBar` resolves
525
+ label / confirmText / successMessage through the `_actions.<name>.*`
526
+ translation convention (metadata literals as fallback), matching
527
+ ObjectView/RecordDetailView; with the `@objectstack/plugin-approvals`
528
+ bundle, the drawer shows 通过 / 拒绝 / 转签 instead of English in a zh-CN
529
+ workspace. New `approvalsInbox` keys shipped in all ten locales.
530
+
531
+ - 1a03af6: fix(approvals): Approval Center triage + drawer readability pass (#2762 P1-2/P1-3/P1-4/P1-5/P2)
532
+
533
+ - **Decision-relevant data in the queue (P1-3)** — list rows and mobile cards
534
+ now surface the request's amount/total inline (detected from the snapshot,
535
+ preferring the server-formatted `payload_display` value), so a reviewer can
536
+ triage without opening each request. A sort control adds "Oldest first" and
537
+ "Amount (high→low)" alongside the default newest-first.
538
+ - **Empty applicant column (P1-4)** — flow-/system-initiated requests (no human
539
+ submitter) now read "Flow-initiated" with a workflow icon instead of a bare
540
+ person icon + "—", in the desktop table, mobile card, and drawer.
541
+ - **Approver chips deduped (P1-2)** — a person filling more than one approver
542
+ slot rendered as N identical "Waiting on" chips; they collapse to one chip
543
+ with a ×N count, the tooltip keeping every underlying id.
544
+ - **Action hierarchy (P1-5)** — `DeclaredActionsBar` maps the spec action
545
+ `variant` enum onto the Button variants (`primary` → filled default,
546
+ `danger` → destructive), so the drawer's Approve stands out and Reject reads
547
+ as destructive once `@objectstack/plugin-approvals` declares them.
548
+ - **Label polish (P2)** — `owner_id`-style resolved lookup keys render as
549
+ "Owner", not the awkward "Owner Id", in the drawer summary.
550
+
551
+ New `approvalsInbox` keys (`flowOrigin`, `sortBy`/`sortRecent`/`sortOldest`/
552
+ `sortAmount`) added to all ten locales.
553
+
554
+ - 3e886eb: fix(i18n): localize FileField upload widget + approvals snapshot field labels
555
+
556
+ - `FileField` (the shared upload widget) hard-coded every visible string
557
+ ("Drag & drop files here", "or click to browse", "Take photo", "Uploading…",
558
+ size/upload validation messages, …). They now route through
559
+ `useObjectTranslation` with new `fields.file.*` keys, translated across all
560
+ 10 locale bundles. This is why the approvals Approve/Reject dialog's
561
+ attachment dropzone was English in a Chinese console.
562
+ - The approvals inbox record-snapshot summary title-cased raw machine keys
563
+ instead of the target object's field labels. It now consumes the
564
+ server-sent `payload_labels` in `payloadSummary`/`decisionAmountEntry`,
565
+ falling back to the prettified key when absent; `approvalsApi`'s row type
566
+ gains `payload_labels`.
567
+
568
+ - cfc675e: fix(i18n): unconditional Chinese in the chatbot confirm card and the field inspector (objectui#2884, objectui#2885)
569
+
570
+ Two issues split out of the objectui#2871 survey because neither is a language
571
+ _branch_ — both are copy that renders in Chinese for every user regardless of
572
+ locale.
573
+
574
+ **objectui#2884 — the confirm-before-change card.** Heading, buttons, hint and
575
+ the verb column of each change row were Chinese literals, so an English user
576
+ read the whole confirm gate in Chinese. They now follow the same
577
+ prop-with-English-default convention the plan card already uses
578
+ (`changesTitleLabel`, `changesConfirmLabel`, `changeVerbLabels`, …), with the
579
+ console passing translated values from `console.ai.*`.
580
+
581
+ The serious half was the outbound message. Clicking Confirm sent
582
+ `'确认修改,应用你刚才提议的改动。'` unconditionally — an English user's click
583
+ told the agent, in Chinese, to apply the changes, and the agent answered in
584
+ Chinese for the rest of the thread. That message now routes through the same
585
+ `convZh` (conversation-language) switch as `planApproveMessage`, so it matches
586
+ the language actually being spoken rather than the UI or a hard-coded literal.
587
+
588
+ Note this is deliberately _not_ "always send English": the repo already decided
589
+ outbound agent text follows the CONVERSATION, and the cloud confirm gate
590
+ (`service-ai-studio` `confirm-gate.ts` `APPROVAL_RE`) matches on approval
591
+ keywords. The Chinese string is unchanged, so that path is byte-for-byte what
592
+ the gate already accepted; `i18n.test.ts` now pins it against the mirrored gate
593
+ regex alongside the two plan messages.
594
+
595
+ Also in this component: the error banner's `Response failed` / `Details` /
596
+ `Retry` were hard-coded English, and both it and the quota banner used a bare
597
+ `t(key)` that renders the raw key when the chat is mounted without an
598
+ `I18nProvider`. Both now use `useSafeTranslate`, so they degrade to English
599
+ instead of to `chatbotError.title`. The `「…」` corner brackets around the
600
+ target-app name are now neutral quotes.
601
+
602
+ **objectui#2885 — the draft-field suffix.** `ObjectFieldInspector` appended a
603
+ bare `(草稿)` to draft objects in the lookup picker — the only Chinese literal
604
+ in a 1500-line file where the other 101 strings all go through `t(key, locale)`.
605
+ It now reads `engine.inspector.draftSuffix` from the Studio catalog.
606
+
607
+ The 18 new keys were added to all ten locale packs, so the objectui#2872 part
608
+ (a) gap held at 469/471 rather than widening.
609
+
610
+ - 20df08c: fix(cloud-connection): localize the Cloud Connection panel (objectstack#3589 follow-up)
611
+
612
+ `CloudConnectionPanel` — the `cloud-connection:panel` SDUI widget that is the
613
+ entire body of the Cloud Connection Setup page — had no i18n at all: no
614
+ `@object-ui/i18n` import, and no `cloudConnection` namespace in any of the ten
615
+ built-in locale packs. Its siblings on neighbouring pages
616
+ (`marketplace:installed-list`, `mcp:connect-agent`) were already fully
617
+ localized, so this one page rendered a translated header above an English body
618
+ once the framework-side `page:header` resolution landed.
619
+
620
+ - New `cloudConnection` namespace in all ten packs (en, zh, ja, ko, de, fr, es,
621
+ pt, ru, ar), matching the coverage its sibling namespaces already had. Covers
622
+ every phase of the device-code flow: checking, error + retry, waiting
623
+ (approval prompt, user code, copy), bound (connection detail labels), and
624
+ unbound (call to action).
625
+ - The three hard-coded failure messages (expired request, bind failure, device
626
+ code request failure) are translated where they are raised, not where they
627
+ are rendered, since they are stored in component state.
628
+ - The "code is pre-filled…" line was one sentence stitched together across JSX
629
+ with a conditional tail and a bare `'.'`. It is now two self-contained
630
+ strings, so a translator never receives a dangling clause whose word order
631
+ they cannot change.
632
+ - The `bound_at` timestamp now formats with the active UI language rather than
633
+ the browser default, matching the surrounding copy.
634
+
635
+ Also adds a locale-parity test asserting the `cloudConnection` key set is
636
+ identical across all ten packs — partial coverage degrades quietly, because
637
+ i18next falls back to `en` and the result merely looks half-translated.
638
+
639
+ - af705b9: feat(i18n): complete the locale backfill — all ten packs reach full key parity (objectui#2872)
640
+
641
+ Translates the remaining **275 keys × 8 packs = 2,200 strings**, closing
642
+ objectui#2872. The largest namespaces are `grid` (101, mostly the import
643
+ wizard), `gantt` (58) and `dashboard` (25), plus a long tail across `list`,
644
+ `auth`, `fields`, `marketplace`, `capability` and nine others.
645
+
646
+ Every pack is now at parity with `en`: **2,495 of 2,499 keys**, zero keys that
647
+ `en` lacks. The four-key remainder is the outbound-message set, absent by
648
+ design so `t()` falls through to English and the cloud confirm gate keeps
649
+ recognising it — `outbound-agent-messages.test.ts` owns that invariant.
650
+
651
+ **P3 is now enforceable.** `high-frequency-namespace-parity.test.ts` was scoped
652
+ to four namespaces because full parity would have been a permanently red build.
653
+ That restriction is obsolete, so it is replaced by
654
+ `all-locales-key-parity.test.ts`, which asserts:
655
+
656
+ - every pack defines every `en` key;
657
+ - no pack defines a key `en` lacks (objectui#2872 part b was 74 keys of exactly
658
+ this, hidden behind a component-private fallback);
659
+ - **placeholders match `en` per string** — both `{{count}}` and the single-brace
660
+ `{count}` form, which two `gantt.autoScheduleDlg.*` keys use on purpose
661
+ because their call site does a literal `.replace('{count}', …)` rather than
662
+ i18next interpolation. A translation that drops a placeholder renders a
663
+ sentence with a hole in it and no error, so this is checked mechanically
664
+ rather than by eye.
665
+
666
+ All three assertions were mutation-tested, including the single-brace form.
667
+
668
+ ### A bug the test suite could not have caught
669
+
670
+ The first merge pass produced **duplicate keys** in four packs: the key list is
671
+ the union of what is missing across all eight, but the insert ran
672
+ unconditionally, so packs that already had `detail.created` / `detail.updated`
673
+ got a second copy. Every test still passed — at runtime the later property
674
+ simply wins, so the parity check saw a perfectly consistent object.
675
+
676
+ `tsc` caught it as TS1117 during `turbo build`. ESLint does not flag it, and a
677
+ runtime test _cannot_ — the duplicate is already collapsed before JS sees the
678
+ object. The compiler is the only possible guard here, and CI runs it. The merge
679
+ script now filters per pack against what that pack actually defines.
680
+
681
+ ### Translation quality
682
+
683
+ Model-generated, and dense domain terminology (Gantt dependency types, the
684
+ import wizard's upsert/match-field vocabulary) is exactly where that is
685
+ weakest. This was raised before starting and the work was requested anyway, so
686
+ it ships as a **reviewable first draft, not a finished localization** — native
687
+ review is still worthwhile. What _is_ verified mechanically: key parity in both
688
+ directions, placeholder shape per string, and that no outbound agent message
689
+ was translated.
690
+
691
+ - 0502a7c: fix(i18n): the change card's Confirm button sent text the cloud gate does not accept
692
+
693
+ The English `console.ai.changesConfirmMessage` was
694
+ `"Confirm the changes — apply what you just proposed."`. The cloud confirm gate
695
+ (`service-ai-studio` `confirm-gate.ts` `APPROVAL_RE`) recognises
696
+ `apply (this|the) change` — **not** "apply what". So the message failed the
697
+ gate, and failing the gate is silent: the agent re-proposes instead of applying,
698
+ and the Confirm button on the change card simply looks inert.
699
+
700
+ This affected English conversations **and all eight locales that fall back to
701
+ English** for that key. It is now
702
+ `"Confirm — apply the change you just proposed."` — singular "the change", so it
703
+ still matches if the gate ever tightens to a word boundary. The Chinese string
704
+ was always fine (`确认修改` hits the 确认-anchored clause) and is unchanged.
705
+
706
+ The same literal lives in four places — the locale pack, the
707
+ `ChatbotEnhanced` prop default, its doc comment, and the `AiChatPage`
708
+ `defaultValue` — and all four are updated together.
709
+
710
+ **Why the existing guard missed it.** `i18n.test.ts` mirrored only the _Chinese_
711
+ clause of `APPROVAL_RE`; the English half was reduced to "starts with Confirm,
712
+ contains apply" because nothing in this repo could see the real pattern. That
713
+ weaker assertion passed against a string the gate rejected — the guard was
714
+ green and the feature was broken.
715
+
716
+ The mirror is now **verbatim, both clauses**, and drives an `it.each` over every
717
+ outbound approval message in both `zh` and `en`. Two supporting tests keep it
718
+ honest: one asserting the gate stays narrow (a plain build request like
719
+ "帮我搭建一个 CRM" must NOT read as approval), and one asserting
720
+ `planAnswerMessage` does _not_ match — it answers a structure question and must
721
+ never read as blanket approval.
722
+
723
+ The mirror is duplicated across a repo boundary by necessity (objectui cannot
724
+ import from cloud); the comment says so, so the next person changing
725
+ `APPROVAL_RE` knows to update it here too.
726
+
727
+ - c6fd752: fix(app-shell): localize the two `DeclaredActionsBar` strings that bypassed i18n (objectui#2762 P0-3)
728
+
729
+ The declared action _labels_ resolve through `useObjectLabel`, so a zh-CN
730
+ workspace got 通过 / 拒绝 buttons — sitting inside a toolbar whose accessible
731
+ name was the English literal `'Actions'`, above decision-output fields whose
732
+ help text read `Handed to the flow as a decision output.` Both strings are
733
+ authored by the bar itself rather than by metadata, and both skipped the locale
734
+ bundle entirely.
735
+
736
+ - `aria-label` now uses the existing `common.actions` key (a host-supplied
737
+ `label` still wins).
738
+ - The decision-output help text moves to new `actions.decisionOutput.help` /
739
+ `.helpMultiValue` keys, added across all ten shipped locales.
740
+
741
+ Worth being precise about why the help text needed fixing at all, since the
742
+ runtime _does_ localize action params: `useConsoleActionRuntime` runs every
743
+ param through `actionParamText`, but these params are synthesized here from the
744
+ record's `decision_output_defs`, so their key path (`outputs.<key>`) is dynamic
745
+ and no `_actions.<action>.params.*` bundle entry can ever match it. The
746
+ fallback is not a rare path — it is the only path, which is why the English
747
+ survived.
748
+
749
+ Not fixed, and deliberately: a decision output that arrives without a `label`
750
+ still renders a title-cased version of its machine key. That derived text
751
+ mirrors the framework's `humanizeFieldPath` convention, and the real fix is the
752
+ backend declaring the label — a client-side bundle cannot key off a dynamic
753
+ output name.
754
+
755
+ - e05f052: feat(i18n): translate the four highest-traffic namespaces into the eight trailing locales (objectui#2872 part a)
756
+
757
+ Backfills `console`, `home`, `topbar` and `layout` — 193 keys × 8 packs, 1,544
758
+ strings — so a ja/ko/de/fr/es/pt/ru/ar admin sees the AI console, the home
759
+ screen, the top bar and the system navigation in their own language instead of
760
+ silently falling back to English.
761
+
762
+ The gap in those eight packs drops from **469–471 keys to 277–279**. `en` and
763
+ `zh` remain at exact parity (2499 : 2499, zero difference in both directions).
764
+
765
+ This is the "high-frequency namespaces only" strategy from the objectui#2872
766
+ discussion, not a full backfill: `grid` (101), `gantt` (58), `dashboard` (25)
767
+ and the long tail stay on English fallback and remain tracked there.
768
+
769
+ **Four keys are deliberately left untranslated**, and that is the load-bearing
770
+ part of this change:
771
+
772
+ ```
773
+ console.ai.planApproveMessage
774
+ console.ai.planApproveDefaultsMessage
775
+ console.ai.planAnswerMessage
776
+ console.ai.changesConfirmMessage
777
+ ```
778
+
779
+ These are not labels. They are the text a button _transmits to the agent_, and
780
+ the cloud confirm gate (`service-ai-studio` `confirm-gate.ts` `APPROVAL_RE`)
781
+ decides whether that text reads as approval. It recognises Chinese and English
782
+ — nothing else. `AiChatPage` therefore selects them by the language of the
783
+ CONVERSATION rather than of the UI, and the `t()` call is _expected_ to miss in
784
+ every non-Chinese pack and fall through to its English `defaultValue`.
785
+
786
+ Translating them would be an outright regression: a German user's "Build it"
787
+ would start sending German, the gate would stop matching, and the agent would
788
+ re-propose instead of building — the button looks inert while nothing visibly
789
+ errors.
790
+
791
+ objectui#2900 shipped precisely that bug for `changesConfirmMessage`, which had
792
+ been added to all ten packs. **This change removes it from the eight**,
793
+ restoring the English fallback. A new guard,
794
+ `packages/i18n/src/__tests__/outbound-agent-messages.test.ts`, pins the
795
+ invariant in both directions: the four keys must be absent from the eight packs
796
+ AND present in `en`/`zh`, while every _other_ `console.ai` label must be
797
+ translated — so the narrow fix can't be over-applied into an excuse for leaving
798
+ surrounding labels in English.
799
+
800
+ Translations are model-generated and would benefit from native review; the
801
+ placeholder set of every string was verified programmatically against the
802
+ English source.
803
+
804
+ - 0502a7c: test(i18n): ratchet the four backfilled namespaces so they cannot silently erode
805
+
806
+ objectui#2903 translated `console`, `home`, `topbar` and `layout` into all ten
807
+ packs. Nothing stopped that from decaying: `fallbackLng: 'en'` means dropping a
808
+ key from `de` renders English, which reads as "not translated yet" rather than
809
+ "we lost this", and the missing-key handler is dev-only so CI never sees it.
810
+
811
+ This is objectui#2872's P3 (full parity test) applied **only to the namespaces
812
+ that are actually complete**. Full parity would fail today by ~277 keys per
813
+ pack with no action attached to it, which is a broken build rather than a
814
+ guard. Widen `RATCHETED_NAMESPACES` as each remaining namespace is translated —
815
+ not before.
816
+
817
+ Asserts both directions, because the packs have drifted both ways before:
818
+
819
+ - every ratcheted `en` key exists in all nine other packs;
820
+ - no pack defines a ratcheted key that `en` lacks — objectui#2872 part (b) was
821
+ exactly this failure, 74 keys deep, hidden behind a component-private
822
+ fallback so English "happened to" render.
823
+
824
+ The four outbound agent messages are excluded, since they are deliberately
825
+ absent from the eight non-gate packs; `outbound-agent-messages.test.ts` owns
826
+ that invariant and the two guards would otherwise contradict each other.
827
+
828
+ A non-vacuity assertion pins the ratchet at >300 keys and requires every named
829
+ namespace to contribute, so a rename can't quietly reduce the whole file to a
830
+ no-op.
831
+
832
+ - faad45e: fix(fields): render `image` fields consistently and add click-to-zoom (#2836)
833
+
834
+ An `image` field rendered differently — and wrongly — on three surfaces:
835
+
836
+ - **Edit form showed broken thumbnails.** A record read back its `image` value
837
+ as a bare `sys_file` id (the reference form), but `readFileValue` returned an
838
+ id with no URL — the comment assumed the read path expands it, which the
839
+ edit-form data path does not. The result was `<img src="">`. `file-value` now
840
+ derives the stable download URL (`/api/v1/storage/files/:id`, which
841
+ 302-redirects to a signed URL and works directly as `<img src>`) for a bare
842
+ id or an id-only object, so every widget and cell renderer resolves one.
843
+ - **Inline edit leaked the raw storage URL.** `InlineFieldInput` had no branch
844
+ for file-backed types and fell through to a plain text input showing
845
+ `/api/v1/storage/files/…`. It now renders the same upload widgets the form
846
+ uses (`image`/`avatar`/`signature`/`file`/`video`/`audio`).
847
+ - **Hard-coded English.** `ImageField`'s upload/crop/remove/alt strings now go
848
+ through `t('fields.image.*')` (en + zh added).
849
+
850
+ Also adds an `ImageLightbox` — click a read-only thumbnail (detail or list cell)
851
+ to open a full-screen preview; multiple images get prev/next navigation, a
852
+ position counter and arrow-key support, a single image just the image. In a
853
+ grid cell the click is `stopPropagation`-guarded so enlarging doesn't also open
854
+ the row.
855
+
856
+ - 09c6a17: fix(grid): localize import result errors (objectstack#3566)
857
+
858
+ The import completion screen rendered the raw English server message verbatim —
859
+ e.g. `Row 6 (position): position: "装配工" matches more than one
860
+ os_tianshun_ehr_position — use a unique value or the record id` — with the field
861
+ name twice, an internal object api-name, all in English, while the dry-run panel
862
+ already localized the same errors.
863
+
864
+ - The result list now runs through the same `formatDryRunError` path (driving
865
+ off the structured error `code`, resolving the api-name to its field label,
866
+ dropping the duplicated `<api-name>:` prefix). Threaded the error `code`
867
+ through `ImportResult.errors` to make this possible.
868
+ - Added code-driven translations for the remaining structured import errors —
869
+ `invalid_boolean` / `invalid_number` / `invalid_date` / `invalid_option` /
870
+ `required` / `AMBIGUOUS_MATCH` — with Chinese (`zh`) copy in `@object-ui/i18n`
871
+ alongside the existing reference errors.
872
+
873
+ - ba73a02: fix(kanban): surface off-column records in an "Uncategorized" lane instead of dropping them (#2792)
874
+
875
+ Records whose `groupBy` value matched no declared column were bucketed and then silently discarded — the board rendered empty while the list footer still counted the rows, so it read as data loss (a status the board doesn't render, an edited/removed picklist option, imported legacy data, or an empty value all triggered it). They now land in a trailing "Uncategorized" lane so no record is invisible and the visible card total reconciles with the record count. Dragging a card out of that lane into a real column repairs its status; the drag handler refuses to persist a move _into_ the lane (its sentinel id is not a real option). Adds `kanban.uncategorized` to the en/zh bundles.
876
+
877
+ - 9b4b952: fix(i18n): make `en` the complete source of truth for grid import and set-password (objectui#2872 b/c)
878
+
879
+ The `en` and `zh` packs had drifted in both directions, silently, because
880
+ `fallbackLng: 'en'` degrades a missing key into English rather than an error and
881
+ the missing-key handler only fires in dev.
882
+
883
+ - **74 keys existed only in `zh`.** `grid.import.*` and `auth.setPassword.*` had
884
+ never been added to `en`, so no other locale could translate them: the English
885
+ text came from call-site `defaultValue:` args and a private map inside
886
+ `ImportWizard`. They now live in `en`, which is what translators and
887
+ `os i18n extract` read.
888
+ - **4 `en` keys were missing from `zh`** (`console.commandPalette.title`,
889
+ two `console.ai.suggestions.metadataAssistant.*`, `help.keyboardShortcuts`),
890
+ so Chinese users saw English.
891
+
892
+ `grid.import` in particular had three disagreeing sources — the `en` pack (62
893
+ keys), `zh` (130) and `ImportWizard`'s own fallback map (133), union 134, no two
894
+ the same set. All three are now aligned on 134.
895
+
896
+ The wizard's fallback map is kept, not deleted: it is what lets the wizard render
897
+ with no `I18nProvider` mounted (standalone embedding, unit tests). It is instead
898
+ pinned to the `en` pack by a new test, so the two can no longer drift.
899
+
900
+ `SetPasswordPage` drops its now-redundant inline `defaultValue:` args; the text
901
+ is byte-identical, it just comes from the pack now.
902
+
903
+ Adds two guards, both mutation-verified:
904
+ - `en` ↔ `zh` full key parity, asserted in both directions. The other eight
905
+ packs are still ~357 keys behind and are tracked separately (objectui#2872
906
+ part a), so they are deliberately not asserted yet.
907
+ - `IMPORT_DEFAULT_TRANSLATIONS` ↔ `en.grid.import`, same keys and same text.
908
+
909
+ - 7d46648: fix(hooks): stop calling translation hooks inside try/catch (objectui#2879)
910
+
911
+ Eleven call sites wrapped a React hook in `try`/`catch` to make it
912
+ "provider-safe". `useObjectTranslation` and `useObjectLabel` already are — they
913
+ read context optionally and fall back to react-i18next's global instance, and
914
+ never throw. The `catch` bought nothing and cost correctness: a throw _after_
915
+ the hook ran desyncs hook order on the next render, because React matches hooks
916
+ positionally. objectui#2595/#2596 fixed exactly this in `@object-ui/i18n`'s
917
+ `createSafeTranslation`; nine plugin-local re-implementations kept their own
918
+ copy of the bug, and two more (`ObjectTimeline`, `ObjectView`) were found by the
919
+ new lint rule below — `ObjectView` had even suppressed
920
+ `react-hooks/rules-of-hooks` inline to keep it.
921
+
922
+ - Six exact re-implementations now delegate to `createSafeTranslation`:
923
+ `plugin-detail`, `plugin-timeline`, `plugin-list`, `plugin-calendar`,
924
+ `plugin-grid`'s `ObjectGrid`, `plugin-designer`.
925
+ - `components`' `data-table` also delegates; `createSafeTranslation` now
926
+ returns `language` alongside `t` so consumers that localize dates don't need
927
+ a second hook call. Purely additive.
928
+ - `plugin-gantt` and `plugin-grid`'s `ImportWizard` keep their local hooks —
929
+ they fall back _per key_, which a single-probe factory cannot express and
930
+ which their comments justify (a host dictionary that covers common keys but
931
+ lags on newer ones). Only the `try`/`catch` is removed.
932
+ - `ObjectTimeline` and `ObjectView` call the hook directly and probe the
933
+ returned value, mirroring `useSafeFieldLabel`.
934
+
935
+ Adds `object-ui/no-try-catch-around-hook` (error) so a twelfth copy fails CI.
936
+ It only matches `use*` names, accepts member calls solely on `React` (so
937
+ `vi.useRealTimers()` is not a hook), and resets its try-depth inside nested
938
+ functions (so `renderHook(() => useThing())` inside a `try` is fine) — both
939
+ false positives were real code in this repo and are pinned in the rule's tests.
940
+
941
+ `eslint-rules/**/*.test.js` matched no vitest project glob, so the local
942
+ plugin's specs had never run in CI. They are now included; all three pass.
943
+
944
+ `ObjectTimeline`'s test mock of `@object-ui/react` omitted `useObjectLabel` —
945
+ the removed `try`/`catch` had been silently absorbing that gap. The mock is now
946
+ complete.
947
+
948
+ - bb4aa25: fix(i18n): apply globalActions label overlays to actions surfaced on a record-detail action bar (objectui#3372)
949
+
950
+ On a record-detail action bar the caller passes `objectDef.name` for **every**
951
+ action, so a `globalAction` surfaced there (e.g. `log_call`) looked up
952
+ `objects.<obj>._actions.<action>.label`, missed, and leaked the English
953
+ metadata literal ("Log a Call") instead of its `globalActions.<action>.label`
954
+ overlay ("记录通话"). Object-owned actions on the same bar translated fine,
955
+ which is what made the gap visible.
956
+
957
+ `useObjectLabel()`'s action resolvers now mirror the canonical
958
+ `@objectstack/spec` resolver (`system/i18n-resolver.lookupActionField`): when an
959
+ action is object-scoped, the object key still wins, but `globalActions.<action>.*`
960
+ is consulted as a fallback before returning the literal. This applies uniformly
961
+ to `actionLabel`, `actionConfirm`, `actionSuccess`, `actionDescription`,
962
+ `actionResultDialog`, `actionParamText`, and `actionParamOptionLabel`, so a
963
+ globalAction resolves the same on a record-detail action bar as it does
964
+ everywhere else. App-namespace discovery also recognises a `globalActions`-only
965
+ bundle (one with no object/field entries).
966
+
967
+ - 75f1cdf: fix(auth): localize the ADR-0069 remediation gate and the auth split-panel (#2870)
968
+
969
+ `RemediationOverlay` had no i18n at all. It is the full-screen gate mounted
970
+ unconditionally at `ConsoleShell` (`fixed inset-0 z-[200]`) that a user hits
971
+ when the backend returns `PASSWORD_EXPIRED` or `MFA_REQUIRED` — there is no
972
+ route around it, so a user who could not read English could not get back into
973
+ the product. That makes it a usability block rather than a cosmetic gap.
974
+
975
+ - New `auth.remediation.*` namespace in all ten locale packs, covering both
976
+ branches of the gate: expired-password (title, three field labels, submit /
977
+ submitting, mismatch and failure messages) and MFA enrolment (password step,
978
+ QR scan copy, backup-code disclosure, code entry, verify / verifying, and the
979
+ enrolment and invalid-code failures), plus the shared "sign out instead" exit.
980
+ - Validation and failure messages are translated where they are raised, since
981
+ they are held in component state and rendered later.
982
+ - The server-provided `remediationRequired.message` is left untouched; only the
983
+ empty-message fallback is localized.
984
+ - `AuthPageLayout`'s two marketing strings move to `auth.layout.*`. The forms it
985
+ wraps were already localized, so the split-panel had been rendering half in
986
+ the user's language and half in English.
987
+
988
+ Adds a locale-parity test over both namespaces, asserting an identical key set
989
+ across all ten packs, a non-empty string at every leaf, and that prose differs
990
+ from English (short labels like "Continue" legitimately collide). i18next falls
991
+ back to `en` silently and its missing-key handler is dev-only, so a key added to
992
+ one pack and forgotten elsewhere is invisible in whichever locales get tested by
993
+ hand.
994
+
995
+ - 53642d4: fix(core,fields): a string `$orderby` is a clause, not a character array — and localize the sharing-rule widgets (objectstack#3821)
996
+
997
+ **The recipient picker listed nothing, ever.** `QueryParams['$orderby']` was
998
+ typed as `Record | string[] | SortObject[]`, so `queryParamsToRecord` sent any
999
+ non-array value through `Object.entries`. Handed the clause string `'name asc'`
1000
+ — which callers do build by hand — it walked the string index by index and
1001
+ emitted `$orderby=0 n,1 a,2 m,3 e,4 ,5 a,6 s,7 c`. The server sorted by columns
1002
+ that don't exist and every row was filtered out, so
1003
+ `sys_sharing_rule.recipient_id` rendered "No matches" for every recipient type
1004
+ and no sharing rule could be created from the Console. `ObjectGrid` builds the
1005
+ same shape from a schema-level `sort` in three places, so grids with a string
1006
+ sort silently showed an empty table.
1007
+
1008
+ A string `$orderby` is now passed through verbatim (the server's OData
1009
+ normalizer has always parsed `'name asc'`), and the type admits `string`.
1010
+ `RecipientPickerField` additionally switched to the structured
1011
+ `{ name: 'asc' }` form so it can't regress this way against any data source.
1012
+
1013
+ **The three sharing-rule authoring widgets never had translations.**
1014
+ `ObjectRefField`, `RecipientPickerField` and `FilterConditionField` hardcoded
1015
+ their English copy — a Chinese Console showed "Select an object", "Select a
1016
+ user", "Search…", "No matches", "Edit as JSON". They now go through
1017
+ `useFieldTranslation` like every other widget, with keys added under `fields.*`
1018
+ in all ten locales.
1019
+
1020
+ The recipient placeholder was the interesting one: it read
1021
+ `` `Select a ${recipientType.replace(/_/g,' ')}` ``, interpolating the enum
1022
+ value into an English sentence — a shape no locale can translate. It is now a
1023
+ per-type key (`fields.recipient.selectUser`, `…selectBusinessUnit`, …), so
1024
+ "选择业务单元" and "Select a business unit" no longer have to share a structure.
1025
+
1026
+ **Editing a rule silently dropped its recipient.** The picker resets the stored
1027
+ id when `recipient_type` changes, because an id valid for a user is meaningless
1028
+ for a team. It treated the edit form's `'' → 'user'` hydration as such a change:
1029
+ opening any saved rule blanked the recipient, and saving persisted the blank.
1030
+ Only a non-empty predecessor now counts as a type switch.
1031
+
1032
+ **Building a filter submitted the surrounding form.** None of `FilterBuilder`'s
1033
+ controls declared `type="button"`, and a bare `<button>` inside a `<form>`
1034
+ defaults to `type="submit"`. Adding, removing or clearing a condition therefore
1035
+ submitted the sharing-rule dialog — firing validation mid-edit, and on an
1036
+ already-valid form saving the record before the admin was done.
1037
+
1038
+ **A rejected write showed the user raw server diagnostics.** The form rendered
1039
+ `error.message` verbatim, so a sharing / RLS denial reached the dialog and the
1040
+ toast as `FORBIDDEN: insufficient privileges to update showcase_private_note
1041
+ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and the
1042
+ record id to whoever hit it. Permission failures now render localized copy
1043
+ (`form.noPermissionToSave`, added in all ten locales), with the server text kept
1044
+ on the console for debugging; other failures still show the server's message,
1045
+ which is the useful part, and fall back to `form.submitFailed` when there is
1046
+ none — replacing the previously hardcoded English "An error occurred during
1047
+ submission".
1048
+
1049
+ **The detail header offered "Edit" on records the user may only read.** Object
1050
+ permissions can't express "this one record is read-only" — a read-only sharing
1051
+ grant sits inside an object the user may otherwise edit — so the header showed
1052
+ the primary Edit CTA, opened the form, and let the user retype a field before
1053
+ the server rejected the save. `DetailView` now gates Edit / Delete on the
1054
+ object-level check AND on the explain engine's record-grained verdict
1055
+ (`POST /api/v1/security/explain` with a `recordId`, ADR-0090 D6 / ADR-0095 C2 —
1056
+ the same pipeline the enforcement middleware runs, so button and server cannot
1057
+ disagree). Explaining oneself needs no special permission. The probe is one
1058
+ cached request per record, skipped entirely when the object-level check already
1059
+ says no, and **fails open** on every uncertainty — an unanswered hint must never
1060
+ be the reason a permitted user cannot act; the server stays the authority
1061
+ (ADR-0057 D10).
1062
+
1063
+ **A long option rendered straight past the combobox border.** `Combobox`'s
1064
+ trigger pinned itself to the component's `w-[200px]` default while the fields
1065
+ around it ran the full form column, and the selected label was a bare text child
1066
+ of a flex button — flex items need `truncate` AND `min-w-0` to clip, and it had
1067
+ neither. So "成员 (showcase_project_membership)" in the object picker overflowed
1068
+ the control and collided with the field beside it. The label now truncates, the
1069
+ trigger can shrink, the dropdown matches the trigger's width instead of a
1070
+ hardcoded 200px (a widened combobox used to clip its own options), and the two
1071
+ sharing-rule pickers ask for `w-full` so they line up with every other input.
1072
+
1073
+ Hardens `evaluatePermission` while there: a role config carrying only
1074
+ `fieldPermissions` (no `actions`) made `check()` throw a TypeError that
1075
+ propagated out of the render. A permission check must not be able to crash a
1076
+ view.
1077
+
1078
+ Browser-verified against the framework showcase Console in Chinese: object /
1079
+ criteria / recipient copy is fully localized, the recipient dropdown lists real
1080
+ users, business units and positions, a saved rule reopens with its recipient and
1081
+ criteria intact, editing the filter no longer submits, and a rule created
1082
+ end-to-end stores a real record id rather than free text. The criteria authored
1083
+ in the builder is honored by the evaluator: `{"pinned":true}` on an owner-private
1084
+ object granted the recipient exactly the matching records and nothing else.
1085
+
1086
+ - c6aaed8: fix(i18n): retire four hand-rolled zh/en branches (objectui#2871, part 1)
1087
+
1088
+ Four surfaces decided their language with a hand-written `startsWith('zh')`
1089
+ check instead of the locale packs, so the other eight shipped languages
1090
+ silently rendered English and the strings could never be translated without a
1091
+ code change.
1092
+
1093
+ - **`RecordTitleChip`** carried a private zh-CN/zh-TW dictionary behind a
1094
+ comment claiming "components is i18n-free". That is not true —
1095
+ `@object-ui/components` declares `@object-ui/i18n` and its sibling
1096
+ `containers.tsx` already uses it. All four of its keys (`detail.copied`,
1097
+ `detail.copyRecordId`, `detail.addToFavorites`, `detail.removeFromFavorites`)
1098
+ already existed in **all ten packs**, so this deletes ~35 lines and fixes ten
1099
+ locales with zero new translations. It renders on every record detail page.
1100
+ - **`EnvironmentListToolbar`**'s three state-aware CTA labels move to a new
1101
+ `environment.*` namespace. This surface had already regressed once for the
1102
+ same reason (#844) and was fixed then with inline `{en,zh}` pairs.
1103
+ - **`StudioAiCopilot`**'s dock title moves to the Studio catalog as
1104
+ `engine.studio.aiCopilot`.
1105
+ - **`StudioHomePage.relativeTime`** now uses `Intl.RelativeTimeFormat` with
1106
+ `numeric: 'auto'` instead of five `zh ? … : …` ternaries. This is strictly
1107
+ better than adding ten catalog keys: it covers every locale, applies the
1108
+ correct plural rules, and yields "yesterday" / 「昨天」 rather than "1d ago".
1109
+ Arabic gets its dual form («أسبوعين») — something a ternary cannot express.
1110
+
1111
+ The new `environment.*` keys are added to all ten packs, so this does not widen
1112
+ the gap tracked by objectui#2872 part (a).
1113
+
1114
+ `EnvironmentListToolbar`'s tests now render inside a real `I18nProvider` pinned
1115
+ to `en`. Without one, `t()` returns the raw key, so the previous assertions on
1116
+ literal English would have been asserting nothing.
1117
+
1118
+ - 263f885: fix(i18n): delete the four `pick({en,zh})` clones (objectui#2871, part 2)
1119
+
1120
+ Four files each carried an identical private resolver:
1121
+
1122
+ ```ts
1123
+ function pick(label: I18n): string {
1124
+ const lang = document.documentElement.getAttribute("lang") || "en";
1125
+ return lang.toLowerCase().startsWith("zh") ? label.zh : label.en;
1126
+ }
1127
+ ```
1128
+
1129
+ Only Chinese was ever handled, so ja/ko/de/fr/es/pt/ru/ar silently rendered
1130
+ English — and because the copy was baked into the components as inline
1131
+ `{en, zh}` pairs, no translator could reach it. All four copies are deleted
1132
+ along with their `I18n` type alias.
1133
+
1134
+ Migrated to the locale packs, **all ten languages**:
1135
+
1136
+ - `excelImport.*` (8 keys) — `ExcelImportBar`. The completion toast becomes a
1137
+ proper `{{count}}` / `{{object}}` interpolation instead of a template literal
1138
+ baked into both language variants.
1139
+ - `cloudOnboarding.*` (5 keys) — `CloudOnboardingNext`, the Cloud welcome page.
1140
+ - `aiModelStatus.*` (11 keys) — `CloudAiModelStatus`, including the
1141
+ `sourceLabel()` enum→prose helper (now `t`-driven with a `{{source}}`
1142
+ placeholder) and the three `ModelRow` labels. The conditional
1143
+ `(HTTP nnn)` fragment becomes two whole sentences rather than a string
1144
+ spliced mid-clause, which is not translatable into every word order.
1145
+ - `chatbotQuota.*` (4 keys) — the AI quota banner in `ChatbotEnhanced`.
1146
+
1147
+ The chatbot banner keeps choosing between the server's `quota.message` (zh) and
1148
+ `quota.messageEn` — that pair is server-owned — but now decides using the
1149
+ console's active language instead of `navigator.language`, which had ignored
1150
+ the in-app locale switcher entirely.
1151
+
1152
+ `CloudOnboardingNext`'s tests now render inside a real `I18nProvider`; without
1153
+ one `t()` returns the raw key, so the previous assertions on literal English
1154
+ were asserting nothing.
1155
+
1156
+ This completes the `pick()` cluster from #2871. The remaining
1157
+ `startsWith('zh')` sites are the ones that classification marked KEEP —
1158
+ `LoadingScreen` (bootstrap, selects real locale packs before i18next is up),
1159
+ `conversationLanguage` (detects the chat's language for the agent, not UI
1160
+ copy), `containers.tsx` (normalises author-supplied schema data; its `'与'`
1161
+ separator is a CJK typography rule), and the Studio catalog / `field-types.ts`
1162
+ data catalog.
1163
+
1164
+ - dc334da: fix(i18n): close the last three zh-branch gaps (objectui#2871, part 3)
1165
+
1166
+ The three items the #2871 classification marked as real but _not_ a
1167
+ migrate-the-copy fix. Each needed a different remedy.
1168
+
1169
+ **`LoadingScreen` — ten languages collapsed to two.** The boot splash already
1170
+ selected real locale packs (not inline copy), but through
1171
+ `lang.startsWith('zh') ? zh : en`, so a ja/ko/de user watched the whole startup
1172
+ in English. It now indexes `builtInLocales` by the two-letter prefix.
1173
+
1174
+ Each field falls back to `en` **individually**, which matters: `console.*` is
1175
+ one of the namespaces that trails in the non-`zh` packs (objectui#2872 part a),
1176
+ so a whole-object swap would have rendered `undefined` on the splash rather
1177
+ than English. `console.loadingHint` was in fact missing from all eight — added
1178
+ here, since a blank line under the progress list is worse than an English one.
1179
+
1180
+ **`containers.tsx` — two language sources that could disagree.** The tab-label
1181
+ call sites resolved `language` from `useObjectTranslation()`, then handed the
1182
+ string to `translateLabel`, which called `detectLocale()` and read
1183
+ `document.documentElement.lang` on its own. Those update independently, so an
1184
+ in-app language switch could leave a tab label and its surrounding chrome in
1185
+ different languages until the next reload. `language` is now threaded in, and
1186
+ `detectLocale` is deleted so nothing reaches for the DOM again.
1187
+
1188
+ **`field-types.ts` — a two-language data catalog.** `FieldTypeMeta` carried a
1189
+ `labelZh` column beside `label`, which capped the field-type picker at English
1190
+ or Chinese by construction. The 46 type names and 9 category names move into
1191
+ the Studio catalog as `engine.fieldType.<id>` / `engine.fieldCategory.<cat>`,
1192
+ generated from the existing values so no wording changes. This removes the
1193
+ `isZh` helper from **both** `ObjectFieldInspector` and `ObjectFormCanvas` — the
1194
+ two files the classification listed as "keep the component, fix the catalog".
1195
+
1196
+ The picker's search filter previously matched `id`, the English label, and
1197
+ `labelZh` — so searching in Japanese or German matched nothing. It now matches
1198
+ the label as the user actually sees it.
1199
+
3
1200
  ## 16.1.0
4
1201
 
5
1202
  ### Minor Changes