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