@object-ui/i18n 17.3.0 → 17.6.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +1929 -1
  2. package/README.md +41 -12
  3. package/dist/i18n.d.ts +1 -1
  4. package/dist/i18n.js +1 -2
  5. package/dist/index.d.ts +23 -21
  6. package/dist/index.js +31 -21
  7. package/dist/locales/ar.d.ts +638 -369
  8. package/dist/locales/ar.js +744 -424
  9. package/dist/locales/de.d.ts +638 -369
  10. package/dist/locales/de.js +759 -446
  11. package/dist/locales/en.d.ts +673 -404
  12. package/dist/locales/en.js +899 -406
  13. package/dist/locales/es.d.ts +638 -369
  14. package/dist/locales/es.js +739 -427
  15. package/dist/locales/fr.d.ts +638 -369
  16. package/dist/locales/fr.js +738 -423
  17. package/dist/locales/index.d.ts +6476 -3786
  18. package/dist/locales/index.js +20 -20
  19. package/dist/locales/ja.d.ts +638 -369
  20. package/dist/locales/ja.js +732 -417
  21. package/dist/locales/ko.d.ts +638 -369
  22. package/dist/locales/ko.js +736 -424
  23. package/dist/locales/pt.d.ts +638 -369
  24. package/dist/locales/pt.js +737 -425
  25. package/dist/locales/ru.d.ts +638 -369
  26. package/dist/locales/ru.js +750 -424
  27. package/dist/locales/zh.d.ts +672 -403
  28. package/dist/locales/zh.js +720 -403
  29. package/dist/pickLocalized.d.ts +58 -0
  30. package/dist/pickLocalized.js +115 -7
  31. package/dist/provider.d.ts +84 -2
  32. package/dist/provider.js +305 -31
  33. package/dist/useDisplayLocale.d.ts +49 -0
  34. package/dist/useDisplayLocale.js +55 -0
  35. package/dist/useObjectLabel.d.ts +94 -14
  36. package/dist/useObjectLabel.js +134 -21
  37. package/dist/useSafeTranslation.js +51 -3
  38. package/dist/utils/index.d.ts +4 -3
  39. package/dist/utils/index.js +4 -3
  40. package/dist/utils/number-display.d.ts +32 -0
  41. package/dist/utils/number-display.js +32 -0
  42. package/dist/utils/spec-formatters.d.ts +39 -13
  43. package/dist/utils/spec-formatters.js +3 -3
  44. package/package.json +4 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1914 @@
1
1
  # @object-ui/i18n
2
2
 
3
+ ## 17.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - deb157a: Retire the field designer's `Indexed` toggle — the ObjectStack spec has no
8
+ field-level index flag
9
+
10
+ `indexed` was never a `FieldSchema` key. The field-level flag built no index
11
+ (objectstack#2377 removed it) and, since objectstack#4001 replaced silent
12
+ drops with loud rejection, `FieldSchema.safeParse` refuses it by name. Ticking
13
+ `Indexed` in Studio therefore made `PUT /api/v1/meta/object/:name` fail with
14
+ `422 INVALID_METADATA`, and — because the key was stored — every later save of
15
+ that object stayed blocked until the author found and cleared the toggle.
16
+
17
+ Both field designers stop offering the control and stop authoring the key
18
+ (`ObjectFieldInspector`'s Advanced section; `FieldDesigner`'s advanced
19
+ section, `MetadataFieldsPage`, `MetadataService`, `metadataConverters`), the
20
+ `designer.field.indexed` / `appDesigner.fieldDesigner.indexed` labels retire
21
+ with it across all ten locale packs, and `DesignerFieldDefinition.indexed` is
22
+ removed from `@object-ui/types`.
23
+
24
+ Drafts and objects that already carry the key are un-poisoned on load rather
25
+ than migrated, so an edit-and-save round-trip of previously blocked metadata
26
+ now succeeds. The strip is keyed to the retired key alone — every other
27
+ unknown key on a field definition still survives the round-trip.
28
+
29
+ Declare indexes on the object instead: `indexes: [{ name, fields, unique }]`.
30
+ - 9c60144: **Breaking (published API):** `NavigationRenderer` no longer accepts `resolveGroupLabel` or `resolveItemLabel`. If your build just broke on one of these props, delete the prop — it never did anything.
31
+
32
+ Both were id-keyed label resolvers on `NavigationRendererProps` (`@object-ui/layout`), typically wired to `useObjectLabel().navGroupLabel` from `@object-ui/i18n` to translate sidebar group and leaf labels from a client translation pack keyed `{ns}.apps.{appName}.navigation.{nodeId}.label`.
33
+
34
+ **They could never fire.** The renderer guards convention-based resolution with `isCustomized` — a case-insensitive "the authored label differs from this branch's comparison target" test. On the `object` / `dashboard` branches the target is the object or dashboard name, and the test is meaningful: it protects an author's custom label (`Projects`) from being overwritten by an `objects.project.label` translation. On the two id-keyed branches the target was the nav node's own **`id`** — `grp_workspace`, `group_sales` — while the label was its text — `Workspace`, `Sales`. Those never compare equal, so the guard was true for every real navigation entry and the resolver beneath it was unreachable. The only node that could have reached it is one whose label is literally its own id.
35
+
36
+ Nothing regresses when they go, because nothing was using them to begin with: **app-navigation localization is owned solely by the server-side `/meta` boundary.** `translateApp` (`@objectstack/spec`, `src/system/i18n-resolver.ts`) rewrites every navigation node's `label` by id, and `@objectstack/rest` applies it before the metadata reaches the client — so nav labels arrive already localized and the client-side path was never the one answering. One owner, not two.
37
+
38
+ **If you wired these hooks to localize navigation, your labels were never being localized by them.** Move the translation to the server side: add it to the app's i18n bundle that `translateApp` reads, keyed by navigation node id. A client-side pack keyed `apps.*.navigation.*.label` changes nothing in the sidebar.
39
+
40
+ Also in this change:
41
+
42
+ - `useObjectLabel().navGroupLabel` (`@object-ui/i18n`) is **kept**, but its docstring no longer promises `"Sales" → "销售"` for sidebar groups — that promise was false, and a live docstring describing an unreachable path is how an agent or a developer ends up wiring a translation pack and receiving silent non-localization. It is now documented for what it is: a plain reader for `{ns}.apps.{appName}.navigation.{groupId}.label` with no first-party caller, pointing at the server boundary for anything nav-related.
43
+ - `UnifiedSidebar` (`@object-ui/app-shell`) drops the two prop wirings, including a `studio` carve-out that existed only to stop `resolveItemLabel` from re-translating an already-translated "Package management" label.
44
+ - The `object` / `dashboard` / `viewName` branches — `resolveObjectLabel`, `resolveDashboardLabel`, `resolveViewLabel` — are untouched and keep both their resolvers and the `isCustomized` guard.
45
+ - b8ce7dc: Show the current organization in the console top bar for users with exactly one
46
+ membership.
47
+
48
+ `WorkspaceSwitcher` is a multi-membership affordance — with one organization
49
+ there is nothing to switch to, so it renders nothing — and it was also the only
50
+ place the console ever displayed the organization name. On a deployment whose
51
+ tenancy posture puts an organization wall in force (`isolated` or `group`), that
52
+ left a single-membership business user with no indication anywhere of which
53
+ organization they were looking at, while every list on the page was silently
54
+ scoped to it. Found in a downstream multi-tenant acceptance run.
55
+
56
+ The new `CurrentOrganizationIndicator` renders the active organization name
57
+ read-only — no trigger, no menu, no click target — in exactly the case the
58
+ switcher declines. The switcher's own visibility rule is unchanged, and
59
+ deployments without an organization wall (`single` posture, or a server that
60
+ reports no posture at all) render nothing new: the top bar stays
61
+ organization-silent where organizations are not a scope the user is inside.
62
+
63
+ Adds one translated string, `organization.current.label`, in all ten packs.
64
+
65
+ ### Patch Changes
66
+
67
+ - 69251bf: `AddressField` is translatable, shows no US example placeholders, and formats its readonly line in the reader's address order.
68
+
69
+ The five sub-labels ("Street Address", "City", "State / Province", "ZIP /
70
+ Postal Code", "Country") were English string literals with no i18n key. On a
71
+ non-English console every address field showed five English words in the middle
72
+ of an otherwise fully translated form, and an app had no way to reach them: the
73
+ parts are not fields on the object (`billing_address` is a single `address`
74
+ column), so a translation bundle had nothing to key on, there is no `subLabels`
75
+ property to declare, and the widget cannot be replaced from metadata. They now
76
+ resolve through `fields.address.street` / `.city` / `.state` / `.postalCode` /
77
+ `.country`, added to all ten locale packs. The `en` values are byte-identical to
78
+ the literals they replace, and `FIELD_DEFAULTS` carries the same five, so
79
+ English and provider-less rendering are unchanged.
80
+
81
+ The five input placeholders (`123 Main St`, `San Francisco`, `CA`, `94102`,
82
+ `United States`) are **removed** rather than keyed. They were untranslated and
83
+ US-specific — a zh/ja/ar user was shown an American address as the example of
84
+ what to type — and the right example is a function of the address's country,
85
+ not the reader's language, which no channel in the stored value can supply
86
+ today. Each box keeps the visible label that names it.
87
+
88
+ The readonly line's part order now follows the reader's display locale
89
+ (`useDisplayLocale()`): `zh`, `ja` and `ko` read largest-first (`Country, ZIP
90
+ State, City, Street`), every other locale keeps the unchanged small-to-large
91
+ order (`Street, City, State ZIP, Country`). The display cell renderer takes the
92
+ same locale through the same shared `formatAddress`, so a stored address reads
93
+ identically in a readonly form and in a grid cell.
94
+ - 57e668f: An admin override of an approval now looks like one — before the click, and in the timeline afterwards.
95
+
96
+ A platform/tenant admin who holds **no slot** in a request's pending-approver
97
+ slate saw the exact same filled **Approve** / **Reject** / **Reassign** a
98
+ designated approver sees: no distinct styling, no warning, nothing. One unmarked
99
+ click takes framework#3424's privileged branch, which is *authoritative* — it
100
+ finalises the node even under `per_group` / `unanimous` / `quorum`, silently
101
+ bypassing every co-sign group that has not voted. The measured consequence in a
102
+ real app project was a PM who clicked Approve *"to see if it was real"* and
103
+ finalised a stage for an approver who never acted, then filed it as "countersign
104
+ is broken". The backend was working as designed; the console gave the admin no
105
+ way to see that.
106
+
107
+ Two halves, both client-side:
108
+
109
+ - **Affordance.** A viewer the server reports as `can_act: false` **and**
110
+ `can_override: true` now gets a warning-styled action labelled as an override
111
+ (`Override Approve`), and the dialog it opens **names the pending approvers
112
+ being bypassed**. The wording is the fix, not decoration — a warning that does
113
+ not say what is about to be bypassed would not have stopped that click.
114
+ - **Timeline.** `sys_approval_action.via_override` has been written since
115
+ framework#4466 and sent on the wire ever since, but **no console surface read
116
+ it** — an override rendered byte-for-byte like an ordinary approval in both the
117
+ record page's approvals panel and the Approval Center. Override rows are now
118
+ marked with an `Admin override` chip and a distinct timeline dot. This is
119
+ framework#4466's own *Expected* ("surfaced in the timeline"), which never
120
+ landed.
121
+
122
+ Nothing here relaxes anything. Who may act or override is unchanged, the request
123
+ the console sends is byte-identical, and no audit record is altered — this only
124
+ renders one that was already being written, and adds friction in front of a
125
+ privileged path.
126
+
127
+ Two details worth knowing, because both are load-bearing:
128
+
129
+ - The warning rides the **param dialog's** title and description rather than a
130
+ chained `confirmText`. These decision actions collect params, so the param
131
+ dialog is already the confirm — nothing is POSTed until its own Confirm — and
132
+ putting a second dialog in front of it produces a first prompt that reads as
133
+ "the action ran" (framework#7278, maintainer ruling 2026-08-10). One condition,
134
+ one wording, one dialog.
135
+ - The notice travels as its own dispatch key, **not** folded into the action's
136
+ `description`. The runtime resolves `description` through
137
+ `_actions.<name>.description`, preferring a bundle hit over the passed literal,
138
+ and `plugin-approvals` ships exactly such an entry for `approval_reject` — so a
139
+ warning routed that way would have been silently replaced by the ordinary
140
+ reject copy in every locale carrying the bundle. A safety notice a translation
141
+ can delete is not a safety notice.
142
+
143
+ Which actions get the treatment is read from each action's **own declared
144
+ `visible` gate** (does it OR in `can_override`?), not from a hard-coded name
145
+ list, so a future decision action still ships as metadata alone — and
146
+ `approval_recall`, gated on `is_submitter`, is never relabelled. Every new string
147
+ goes through the `approvalsInbox.*` i18n path in all ten locale packs.
148
+ - 516663d: RecordAttachmentsPanel no longer offers a Retry for an api-disabled `sys_attachment` read.
149
+
150
+ `OBJECT_API_DISABLED` (404, `enable.apiEnabled: false`) and its sibling
151
+ `OBJECT_API_METHOD_NOT_ALLOWED` (405, the operation is absent from
152
+ `enable.apiMethods`) are pure functions of the object's metadata — no user, no
153
+ session, no request body — so every retry of every persona re-fetches the
154
+ identical refusal. Before this change both landed in `RecordAttachmentsPanel`'s
155
+ `unavailable` state and offered a Retry that was guaranteed to change nothing,
156
+ the same wrong advice `ListView`'s error panel already stops giving for list
157
+ reads.
158
+
159
+ The panel gains a fifth status, `api-unavailable`: no Retry button, and honest
160
+ copy ("The attachments list is not available on this object.", new
161
+ `detail.attachmentsApiUnavailable` key in all ten locale packs) instead of
162
+ "We couldn't load the attachments for this record." The pre-existing `denied`
163
+ (authorization) and `unavailable` (network/5xx/expired-session) states and
164
+ their affordances are unchanged.
165
+
166
+ `ListView.classifyLoadError` — the classifier that already separated this case
167
+ into its own `api-disabled` kind for list views — is lifted out of
168
+ `packages/plugin-list/src/ListView.tsx`'s module scope into
169
+ `@object-ui/react` (`classifyLoadError`, `LoadErrorKind`), so both surfaces
170
+ consume one classification instead of `RecordAttachmentsPanel` re-deriving it.
171
+ `ListView`'s own behavior is unchanged — it now imports the function it
172
+ previously defined locally. The classifier delegates its api-disabled check to
173
+ `isApiAccessDeniedError` (`@object-ui/data-objectstack`), removing a second,
174
+ independently-maintained copy of the same code list.
175
+ - 41ac1b7: RecordAttachmentsPanel distinguishes a DENIED attachment list from an empty one.
176
+
177
+ A `sys_attachment` list read refused for authorization reasons (HTTP 403 /
178
+ `PERMISSION_DENIED` / `FORBIDDEN` / a row-level-security denial) was swallowed
179
+ into the panel's empty state, so a member denied the parent record was told
180
+ "No attachments yet. Upload a file to get started." about a record holding
181
+ 2095+ attachments — and was offered an Upload the server would refuse.
182
+
183
+ The panel now classifies that refusal with the same `isPermissionError`
184
+ predicate the kanban, calendar and form surfaces branch on, renders a distinct
185
+ denied state ("You don't have access to these attachments.", new
186
+ `detail.attachmentsAccessDenied` key in all ten locale packs), and withdraws
187
+ the Upload affordance. The denied state renders the translated sentence and
188
+ nothing sourced from the error — no status code, no server text, no row count.
189
+ The empty state is now reserved for a genuine 200-with-zero-rows; non-authz
190
+ failures keep their pre-existing handling.
191
+ - 1eaf0a1: RecordAttachmentsPanel distinguishes an UNLOADED attachment list from an empty one.
192
+
193
+ A `sys_attachment` list read that failed for any non-authorization reason — a
194
+ network failure (server unreachable, DNS, aborted request), a 5xx, or a 401 /
195
+ `AUTH_REQUIRED` (an expired session is authentication, not authorization, so the
196
+ denied predicate deliberately does not claim it) — was swallowed into the
197
+ panel's empty state. All three rendered "No attachments yet. Upload a file to
198
+ get started.": an affirmative claim about the record's contents, made by a panel
199
+ that never got an answer, over a record that may hold thousands of files.
200
+
201
+ The panel now carries the same four-way status vocabulary its siblings use —
202
+ `loading` / `loaded` / `denied` / `unavailable` — and every state that means
203
+ "the panel does not know" is answered before `rows.length === 0` is allowed to
204
+ mean "the record holds nothing". A failed read renders a distinct unavailable
205
+ state ("We couldn't load the attachments for this record.", new
206
+ `detail.attachmentsLoadFailed` and `detail.retryLoadAttachments` keys in all ten
207
+ locale packs) with a **retry** — unlike the denied state, an outage and a lapsed
208
+ session are both things a second attempt can fix — and withdraws the Upload
209
+ affordance, because offering an upload against a list the panel could not reach
210
+ is the same over-assertion as the empty state it replaces. Like the denied
211
+ state, it renders the translated sentence and nothing sourced from the error: no
212
+ status code, no server message, no host.
213
+
214
+ The empty state is now reserved for a genuine 200-with-zero-rows, and the
215
+ denied state (403 / `PERMISSION_DENIED` / `FORBIDDEN` / a row-level-security
216
+ denial) is unchanged. The "table not provisioned on an older stack" case is
217
+ unaffected: the ObjectStack adapter degrades a bare 404 to `{ data: [], total: 0 }`,
218
+ so it resolves through the success path and still renders the empty state.
219
+
220
+ This restores a house rule that had already landed twice as a bug fix —
221
+ `HomeActionCenter` may only say "You're all caught up" once the inbox has
222
+ answered, and an unloadable app list is UNKNOWN rather than "no default app".
223
+ - 2e82ab2: The config panel footer translates: `ConfigPanelRenderer`'s Save / Discard labels come from the locale pack.
224
+
225
+ `saveLabel` and `discardLabel` carried the English literals `'Save'` and
226
+ `'Discard'` as parameter defaults, and no caller in the repo passes either prop,
227
+ so the sticky footer that appears the moment a config draft is dirty stayed
228
+ English in every locale — inside panels whose every other string had already
229
+ been routed through `t()`. The fix is in the renderer rather than per-caller:
230
+ the footer is the renderer's own chrome, so a caller-side fix would translate
231
+ one panel's footer and leave the next host's English.
232
+
233
+ Both labels now resolve through `createSafeTranslation` — the mechanism this
234
+ package already uses for its built-in copy in `form.tsx`,
235
+ `fullscreen-editor.tsx`, `data-table.tsx` and friends. An explicitly passed
236
+ `saveLabel` / `discardLabel` still wins, unchanged and untranslated.
237
+
238
+ `common.save` is reused rather than twinned: it already ships `Save` in all ten
239
+ packs and is what the console's other save buttons read. `common.discard` is
240
+ new, because the packs carried no shared spelling of the word — the three that
241
+ existed are each scoped to one surface (`form.discard`,
242
+ `console.settingsView.discard`, `console.objectView.discard`) and the last of
243
+ them diverges from the other two in zh/ko/fr. Its ten values are the majority
244
+ spelling, byte-identical to `form.discard` and `console.settingsView.discard`.
245
+
246
+ Both English defaults are byte-identical to the literals they replace, so a
247
+ host that mounts no `I18nProvider` renders exactly what it did before.
248
+ - ef0d150: The dashboard config sidebar translates: `WidgetConfigPanel` and `DashboardConfigPanel` are wired through `t()`.
249
+
250
+ Both panels build a `ConfigPanelSchema` — breadcrumb, section titles, field
251
+ labels, placeholders, help text and option labels — and neither imported a
252
+ translation hook at all, so all 61 of their user-visible strings were English
253
+ literals. Both are exported from the package barrel and mounted by
254
+ `DashboardWithConfig` as the dashboard editing sidebar, so a user on any
255
+ non-English console opened a panel that stayed English inside chrome that had
256
+ translated around it.
257
+
258
+ They now resolve through a new `dashboard.config.*` namespace — 75 keys, added
259
+ to all ten locale packs. The namespace sits beside `dashboard.trend.*` and
260
+ `dashboard.filters.*`, which is where this package's other translated surfaces
261
+ already read from, and the panels reach it through
262
+ `useConfigPanelTranslation`, a `createSafeTranslation` hook whose
263
+ `CONFIG_PANEL_DEFAULT_TRANSLATIONS` map carries the English defaults for hosts
264
+ that mount no `I18nProvider`.
265
+
266
+ The keys are authored fresh against the wording the panels actually ship rather
267
+ than restored from the retired `configPanel.*` block: that vocabulary had no
268
+ reader, was never validated against a shipped label, and covered 16 of the 61
269
+ strings. Where the two name the same word the translations are reused.
270
+
271
+ Every `en` pack value and every built-in default is byte-identical to the
272
+ literal it replaces, so English rendering and provider-less rendering are
273
+ unchanged — asserted row by row, in both directions, against a frozen table of
274
+ the pre-change literals.
275
+ - f34226e: Delete two dead i18n namespaces — `configPanel.*` (16 keys) and `renderer.*` (13 keys) — from all ten locale packs.
276
+
277
+ 290 translated strings (29 keys x 10 packs) with **no reader anywhere in the
278
+ repo**. Measured with `node scripts/check-i18n-dead-keys.mjs`, the reverse sweep
279
+ from objectui#4658: it subtracts the referenced key set (the AST walk that
280
+ `check-i18n-call-site-keys.mjs` already uses, plus plural suffixes, plus
281
+ `returnObjects` branches, plus every dynamic template head) from the pack key
282
+ set, then re-checks each survivor with a fixed-string grep over the whole repo.
283
+ Both namespaces scored a clean sweep — `configPanel.*` 16/16 CONFIRMED and
284
+ `renderer.*` 13/13 CONFIRMED, zero NEEDS-REVIEW, and a fixed-string grep for all
285
+ 29 keys returns nothing at all outside `packages/i18n/src/locales/`.
286
+
287
+ `configPanel.*` is a dashboard-widget config-panel vocabulary (`layout`,
288
+ `columns`, `gap`, `rowHeight`, `refreshInterval`, `appearance`, `theme`,
289
+ `general`/`advanced`, …). The two components that surface exactly that
290
+ vocabulary — `packages/plugin-dashboard/src/WidgetConfigPanel.tsx` and
291
+ `DashboardConfigPanel.tsx` — import no translation hook at all and hardcode the
292
+ same words in English (`title: 'Layout'`, `label: 'Columns'`, `label: 'Gap'`,
293
+ `label: 'Show description'`, `label: 'Theme'`). The two config panels that do
294
+ use i18n read other namespaces: `ViewConfigPanel.tsx` reads
295
+ `console.objectView.*`, `ReportConfigPanel.tsx` reads `common.*` and
296
+ `report.editor.*`. No component reads `configPanel.*`.
297
+
298
+ `renderer.*` is SchemaRenderer placeholder/status vocabulary (`noPageSchema`,
299
+ `noFormSchema`, `noDashboardSchema`, `pageRendering`, `dashboardRendering`,
300
+ `formRenderingMode`, …). It is dead in the stronger sense: the English strings
301
+ have no hardcoded twin either — `No page schema provided`, `No form schema
302
+ provided`, `No dashboard schema provided`, `Page rendering`, `Dashboard
303
+ rendering` and `Form rendering in` each return zero hits repo-wide outside the
304
+ packs, and `packages/react/src/SchemaRenderer.tsx` imports no translation hook.
305
+ The messages themselves are gone from the product, not merely un-translated.
306
+
307
+ No behaviour change is possible: a key with no reader cannot be read. No test
308
+ fixture pinned any of the 29 keys, and neither i18n baseline JSON names one, so
309
+ nothing else had to move.
310
+
311
+ Part of #4730.
312
+ - 564b605: Delete two dead i18n namespaces — `workflow.*` (58 keys) and `publicForm.demo.*` (36 keys) — from all ten locale packs.
313
+
314
+ 940 translated strings (94 keys x 10 packs) with **no reader anywhere in the
315
+ repo**. Measured with `node scripts/check-i18n-dead-keys.mjs`, the reverse sweep
316
+ from objectui#4658: it subtracts the referenced key set (the AST walk that
317
+ `check-i18n-call-site-keys.mjs` already uses, plus plural suffixes, plus
318
+ `returnObjects` branches, plus every dynamic template head) from the pack key
319
+ set, then re-checks each survivor with a fixed-string grep over the whole repo.
320
+ `workflow.*` scored 54/58 CONFIRMED with the other 4 appearing only in two i18n
321
+ test fixtures — mentions, not consumers; `publicForm.demo.*` scored 36/36 with
322
+ zero textual footprint anywhere outside the packs.
323
+
324
+ `workflow.*` is a complete BPMN/workflow-designer vocabulary (`userTask`,
325
+ `serviceTask`, `parallelGateway`, `boundaryEvent`, `importBpmn`/`exportBpmn`,
326
+ `undo`/`redo`, …). Its one plausible consumer,
327
+ `packages/plugin-designer/src/ProcessDesigner.tsx`, hardcodes English
328
+ (`{ label: 'User Task', value: 'user-task' }`) and imports no translation hook
329
+ at all, while six sibling components in the same package do use one — the
330
+ vocabulary was never wired up. `publicForm.demo.*` is demo content (contact and
331
+ support form titles, field labels, industry/issue-type/priority options) for a
332
+ public-form showcase page that does not exist in this repo.
333
+
334
+ No behaviour changes: a key with no reader cannot be read. The `publicForm.*`
335
+ parent namespace and every other namespace are untouched.
336
+ - a1609a6: Console list filters: a `between` range is submitted only when both bounds are filled, and six operator labels stop rendering as raw i18n keys.
337
+
338
+ Two defects in the list-view filter panel (objectstack#8815), both in the Console
339
+ render layer, with no workaround available downstream.
340
+
341
+ **A half-filled range no longer refuses the whole view.** Picking a date column
342
+ and 「介于」 draws two inputs — that part landed in objectui#3958 — but typing
343
+ only one bound produced `["2024-01-01", ""]`, and both write paths read "is this
344
+ row filled in?" with one shape-blind predicate (`null` / `''` / empty array).
345
+ An array of length 2 passed it, so the empty bound went to the server, which
346
+ refuses the query outright (`400 INVALID_FILTER`): the list showed
347
+ 「该视图的查询被拒绝」 and the filters the user had already applied stopped
348
+ applying too. The saved-view fold persisted the same half-range, so the refusal
349
+ came back on every later read of that view, for every user of it.
350
+
351
+ The spec cannot intercept this — `ViewFilterRuleSchema` accepts
352
+ `["2024-01-01", ""]` because it counts the two slots rather than what is in
353
+ them, while refusing a scalar or a one-element array. Authoring validation is
354
+ therefore green on exactly the shape that fails at query time, which makes not
355
+ emitting it the producer's job. `@object-ui/components` now exports
356
+ `isFilterValueComplete(operator, value)` — arity-aware, so a `pair` row needs
357
+ both bounds — and the two consumers that had each kept a copy of the old
358
+ predicate (`plugin-list`'s `convertFilterGroupToAST`, `app-shell`'s
359
+ `foldFilterGroupToSpecRules`) read it instead. A half-filled range is now
360
+ dropped exactly as a half-typed `equals` row already was: no filter, rather than
361
+ a filter the server will reject. Bounds of `0` and `false` stay real bounds.
362
+
363
+ **Six operator labels are translated in all ten locale packs.**
364
+ `startsWith`, `endsWith`, `isNull`, `isNotNull`, `exists` and `notExists` were
365
+ missing from every pack, so i18next resolved them to the raw key and the dropdown
366
+ showed `filterBuilder.operators.isNull` beside translated entries. The
367
+ component's own defaults table could not cover it: that table serves only the
368
+ no-provider path, and the Console mounts a provider. The report named four —
369
+ a `date` column's bucket offers the four nullness operators; a `text` column
370
+ showed all six.
371
+
372
+ Because the label key is built dynamically (`t(\`filterBuilder.operators.${op}\`)`),
373
+ no existing gate could see the gap: the call-site checker classifies a template
374
+ key as `missing-prefix` and only asks whether the prefix resolves, and
375
+ cross-pack parity is satisfied when all ten packs are missing a key together.
376
+ A new parity test pins the packs against `FILTER_BUILDER_OPERATORS` in both
377
+ directions, so an operator added to the dropdown now fails loudly until every
378
+ pack labels it.
379
+ - 37f6844: FilterConditionField can author the spec's `$icontains` — case-insensitive contains is reachable from the filter UI.
380
+
381
+ `@objectstack/spec`'s `FieldOperatorsSchema` gained `$icontains` between
382
+ `17.0.0-rc.2` and `rc.5`, and every driver and evaluation face the platform
383
+ ships now executes it. `FilterConditionField` had no builder operator that could
384
+ author it, so the capability was unreachable from the sharing-rule criteria
385
+ builder and sat in that widget's parity test as an explicit `KNOWN_UNREACHABLE`
386
+ entry.
387
+
388
+ The FilterBuilder gains a `containsCaseInsensitive` operator ("Contains (ignore
389
+ case)", translated in all ten locale packs). `condToMongo` emits
390
+ `{ field: { $icontains: value } }` and `kvToCondition` reads it back, so a saved
391
+ criteria reopens in the visual builder instead of falling into the raw-JSON
392
+ editor. Today's `contains` is unchanged and still emits the case-SENSITIVE
393
+ `$contains`; whether it should have been case-insensitive all along is a product
394
+ question that stays open, and stored filter views keep meaning what they meant.
395
+
396
+ The fold is ASCII-only by contract — `café` does not match `CAFÉ`.
397
+
398
+ The new operator is **opt-in per consumer**: `FilterBuilder` takes an
399
+ `extraOperators` prop, and only `FilterConditionField` passes it. The one
400
+ dropdown feeds three at-rest dialects and only the MongoDB-style criteria this
401
+ widget writes can carry the operator — the spec's `VIEW_FILTER_OPERATORS` (saved
402
+ views) and `VALID_AST_OPERATORS` (the live grid's filter AST) have no
403
+ case-insensitive contains, so offering it there would author a filter those
404
+ paths cannot execute. Every other FilterBuilder is unchanged.
405
+ - 2b50261: `FilterBuilder` gives the set and range operators an input that matches the value shape the spec accepts, and stops minting the shape it refuses.
406
+
407
+ Three independent paths let one filter row end up with `operator: 'in'` and a
408
+ SCALAR `value` — the shape `ViewFilterRuleSchema` refuses at save time since
409
+ objectstack#6227, and the shape the query path answered `400 INVALID_FILTER` on
410
+ before that (objectstack#5869):
411
+
412
+ - Changing the operator dropdown wrote `{ operator }` alone, so the seed `''`
413
+ (or whatever the previous family had produced) survived the switch into
414
+ `in` / `not_in` / `between`. The operator and the shape of its value are one
415
+ edit, so they are now made together: switching families re-shapes the value —
416
+ a typed scalar becomes a one-element list, an empty one becomes `[]`, a range
417
+ keeps its first bound and leaves the second open, and a list collapsing to a
418
+ scalar keeps its first entry.
419
+ - A plain text or number column has no static `options`, so `in` fell through to
420
+ the single-value input and the user could only ever type a scalar into it.
421
+ Those columns now get a token input (type, Enter or comma commits, `×` or
422
+ Backspace removes) that always emits an array; `between` gets its two bounds
423
+ instead of one box. The lookup picker's no-DataSource fallback, which also
424
+ handed back a scalar while `multiple`, emits a list too.
425
+ - The multi-value families were decided from a local `["in", "notIn"]` literal,
426
+ already one spelling adrift: `notIn` is an alias and the canonical member is
427
+ `not_in`, so a stored view read back in canonical form got the single-value
428
+ input for a set operator. The families are now read from `@objectstack/spec`'s
429
+ exported `VIEW_FILTER_LIST_VALUE_OPERATORS` / `VIEW_FILTER_PAIR_VALUE_OPERATORS`
430
+ and folded through `normalizeFilterOperator`, so both spellings of one operator
431
+ get one answer and a family the spec widens is picked up without an edit here.
432
+
433
+ `foldFilterGroupToSpecRules` is unchanged and needed no change: it normalizes the
434
+ operator and carries `value` through verbatim, so the shape that reaches storage
435
+ is the producer's to get right. An untouched `in` row arrives as `[]`, which the
436
+ fold's existing incomplete-row rule already drops.
437
+
438
+ Four locale keys are added to all ten packs for the new inputs
439
+ (`filterBuilder.addValue` / `.removeValue` / `.rangeStart` / `.rangeEnd`).
440
+ - af5e292: Emit explicit file extensions on relative import specifiers, so the published
441
+ entries can be imported by Node's own ESM resolver.
442
+
443
+ `@object-ui/react`'s built entry re-exported through extensionless relative
444
+ specifiers (`export * from './SchemaRenderer'`). Node does not extension-search
445
+ relative specifiers, so `import('@object-ui/react')` under plain Node — an SSR
446
+ host, or any consumer without a bundler — failed with `ERR_MODULE_NOT_FOUND`.
447
+ Bundled consumers were never affected and are unchanged by this.
448
+
449
+ `@object-ui/types`, `@object-ui/core` and `@object-ui/i18n` carried the same
450
+ emission; `@object-ui/react`'s entry stayed unloadable until they were fixed
451
+ too, because evaluation crosses into them. No exported API changed.
452
+ - bea374e: A KPI card's sub-caption now translates from its own convention key
453
+
454
+ objectui#4032 item 4. The metric card renders two authored strings, and they
455
+ are two different authored fields:
456
+
457
+ | authored field | rendered as | bundle key |
458
+ |-----------------------|-------------------------|-------------------------------------------|
459
+ | `widget.description` | the shared card header | `dashboards.<d>.widgets.<id>.description` |
460
+ | `options.description` | the KPI sub-caption | `dashboards.<d>.widgets.<id>.subCaption` |
461
+
462
+ Only the first resolved. The metric dispatch spread `...options` straight
463
+ through, so the sub-caption reached `MetricWidget` as the raw authored English
464
+ and a `zh` dashboard showed a translated header above an untranslated caption.
465
+
466
+ They get two keys, not one — the objectstack#5428 item-4 ruling (2026-08-06):
467
+ "两个作者字段两个 key". That is why PR #4358 landed items 1-3 and deliberately
468
+ stopped here: at the time `@objectstack/spec` accepted no segment for the
469
+ sub-caption and the only key it would take was `description`, the shared key the
470
+ ruling forbids. objectstack#8056 added `subCaption` to the widget translation
471
+ node, and it ships in `@objectstack/spec@17.0.0` — the version this repo pins.
472
+
473
+ The server half already existed: `translateDashboard` overlays `subCaption` onto
474
+ `options.description` on the `/meta` path, so a served document was already
475
+ correct. This is the client half — the same key path, for the app bundles
476
+ objectui loads into `I18nProvider` itself.
477
+
478
+ - `@object-ui/i18n` gains `widgetSubCaption(dashboardName, widgetId, fallback?)`,
479
+ mirroring `widgetDescription` limb for limb rather than re-implementing
480
+ namespace discovery inside the plugin.
481
+ - `DashboardRenderer`'s `tWidgetSubCaption` composes the two channels in the
482
+ order `tWidgetTitle` already fixed — the authored value is collapsed to the
483
+ active language first (an inline per-locale map, the `pickLocalized` seam),
484
+ and the plain string that falls out is offered to the bundle as its fallback —
485
+ so a bundle entry always wins over an inline map, and neither channel is
486
+ replaced by the other. The resolved value is assigned after the `...options`
487
+ spread in both the `object-metric` and static-value branches.
488
+
489
+ The separation is pinned in both directions, because a shared key is exactly
490
+ what a later tidy-up would reach for: the `description` key never reaches
491
+ `options.description`, and `subCaption` never reaches `widget.description`. On a
492
+ `kpi` / `gauge` / `bullet` widget both are on screen at once, so one shared key
493
+ would make a single translation entry overwrite the other field's text.
494
+
495
+ Untranslated dashboards are unchanged: with no bundle entry the resolver hands
496
+ back exactly what the spread would have, and with nothing authored and nothing
497
+ translated it answers `undefined` rather than `''`, so a card that has no
498
+ sub-caption grows no caption row.
499
+ - d109a4d: The `organizations.*` picker locale family (avatar menu / console picker —
500
+ `mine`, `title`, `heading`, `subtitle`, `searchPlaceholder`, `new`, `current`,
501
+ `emptyTitle`, `emptyDescription`, `noMatches`) is now internally consistent in
502
+ all eight non-en/zh packs (`ar`, `de`, `es`, `fr`, `ja`, `ko`, `pt`, `ru`).
503
+
504
+ `en` and `zh` were renamed from "organization" to "workspace" terminology in
505
+ full; the other eight packs only had `create` follow, leaving each pack mixing
506
+ both nouns in one dropdown (e.g. `de.ts` read `create: "Workspace erstellen"`
507
+ directly beside `mine: "Meine Organisationen"`). Since PR #4638 restored the
508
+ avatar menu's "My Workspaces" entry, both lines render two apart in the same
509
+ menu. Each pack now uses the workspace term its own `create` key already
510
+ committed to (`Arbeitsbereich`-style German phrasing → `Workspace`, French
511
+ `espace de travail`, Japanese `ワークスペース`, Korean `워크스페이스`, Spanish/
512
+ Portuguese `espacio de trabajo` / `workspace`, Russian `рабочее пространство`,
513
+ Arabic `مساحة عمل`), with grammatical agreement (gender, definiteness,
514
+ particles) adjusted per key.
515
+
516
+ The sibling `organization.*` (singular) namespace — organization
517
+ **management**, a deliberately distinct surface per the comment separating the
518
+ two blocks in `en.ts` — is untouched in every pack.
519
+ - ad13d63: Removed five locale keys that had no call site in any package, app, example,
520
+ or e2e test: `console.objectView.striped`, `console.objectView.bordered`,
521
+ `console.objectView.virtualScroll`, `appDesigner.stripedRows`, and
522
+ `appDesigner.bordered`. Deleted from all ten packs (`en` plus the nine
523
+ translations) to keep `all-locales-key-parity.test.ts` green.
524
+
525
+ The `console.objectView.*` three describe grid options ObjectStack retired
526
+ upstream (objectstack#7176, 2026-08-10) and objectui stopped forwarding
527
+ (objectui#4649); the `appDesigner.*` pair is textually identical in name only
528
+ and was independently dead — no `AppCreationWizard.tsx` control, no fallback
529
+ entry in `useDesignerTranslation.ts`'s `DESIGNER_DEFAULT_TRANSLATIONS`, and no
530
+ mention anywhere in the repo outside the locale packs themselves.
531
+
532
+ No runtime behavior changes: nothing rendered these labels before this patch.
533
+ - a9e17b4: `auth.forgotPassword.successDescription`'s address hole is now spelled with
534
+ single braces (`{email}`) instead of i18next's double braces (`{{email}}`),
535
+ in all ten locale packs.
536
+
537
+ This is a spelling-only change — rendered output is byte-identical in every
538
+ language, because the hole was never filled by i18next in the first place:
539
+ `ForgotPasswordForm` substitutes the address itself once the user submits
540
+ the form (the label renders before the address exists, so `t()` cannot do
541
+ it). `{{email}}` and a genuinely unfilled i18next hole were indistinguishable
542
+ at the call site, and passing `email` as an interpolation argument — the
543
+ natural "fix" for what looks like a missing argument — would let i18next
544
+ consume the hole and cause the address to be appended a second time
545
+ (objectui#4135).
546
+
547
+ Converging on `{x}` for every hole a component fills downstream of `t()`
548
+ (the convention `resendOtpCountdownText`'s `{seconds}` already used) puts
549
+ this hole outside i18next's `{{…}}` syntax entirely, so the ambiguity is
550
+ gone by construction rather than fenced by an exemption. Accordingly,
551
+ `scripts/check-i18n-call-site-keys.mjs`'s `EXTERNALLY_INTERPOLATED_HOLES`
552
+ registry entry for this key is retired — the gate needs no exemption for a
553
+ hole i18next was never going to touch.
554
+
555
+ `ForgotPasswordForm.tsx`'s replacement marker and its own built-in default
556
+ label move to the same spelling in the same change, as does the inline
557
+ `defaultValue` at `apps/console/src/pages/auth/ForgotPasswordPage.tsx`'s
558
+ call site.
559
+ - 8871c14: Gate Home's metadata-authoring CTAs on the `manage_metadata` capability the
560
+ server reports, with a visible localized reason.
561
+
562
+ "Build an app" and "Start with a template" were gated on `useIsWorkspaceAdmin()`
563
+ — a ROLE check. On a multi-tenant deployment that deliberately withholds
564
+ metadata authoring from tenants, a workspace owner is an admin by role yet holds
565
+ no `manage_metadata`, so the most prominent CTA on their home page led into
566
+ `/studio`, a filled-in new-package dialog, and a capability refusal at submit.
567
+
568
+ Both cover cards are now disabled, with the reason shown on screen and in the
569
+ tooltip, whenever the session lacks the capability. The marketplace shortcut in
570
+ the apps strip is withheld with them (it targets the same route, so leaving it
571
+ live would have made the gate cosmetic), as is the "Build with AI" hero CTA
572
+ (its output is draft metadata that cannot be published without the capability).
573
+ On a workspace with no apps yet, the admin empty state explains the posture
574
+ instead of directing the owner to build their first application.
575
+
576
+ The gate consumes the answer `GET /api/v1/auth/me/permissions` already returns,
577
+ surfaced through the permissions provider — no permission logic is re-derived in
578
+ the client. Unknown capabilities fail OPEN: a backend that reports no
579
+ `systemPermissions` at all is indistinguishable from one reporting an empty set,
580
+ and the server enforces regardless, so nothing is withheld on missing client
581
+ data. New key `home.build.noCapability` in all ten locale packs.
582
+ - 21e4585: A write-warning toast now words each strip reason on its own instead of calling everything that is not `readonly_when` "read-only".
583
+
584
+ `emitWriteWarning` picked its sentence with a two-way conditional on
585
+ `reason === 'readonly_when'`. The other arm was never "readonly" — it was
586
+ "everything that is not `readonly_when`", so any reason the server-side write
587
+ path gained afterwards was announced to the user as a read-only lock, pointing
588
+ them at a permission problem that does not exist. That is the same defect
589
+ objectui#3484 / framework#3794 fixed for `readonly_when` itself, one reason
590
+ later.
591
+
592
+ The reason is now resolved through a table declared
593
+ `Record<DroppedFieldsEvent['reason'], …>`, mirroring the framework side of the
594
+ same seam (`service-automation`'s `DROPPED_REASON_LABEL`) and the shape the
595
+ spec's own `DroppedFieldsEventSchema` comment asks consumers for. The next reason
596
+ added upstream is a `type-check` failure here, unworded, where the conditional
597
+ compiled forever — which is what re-exporting THE spec type rather than a
598
+ hand-widened `string` was for all along.
599
+
600
+ Not a latent fix: the pinned spec (`@objectstack/spec` 17.0.0-rc.6) already
601
+ carries `primary_key` (objectstack#6437), the strip that keeps a payload `id` the
602
+ update dispatch ruled is not an identifier out of the targeted rows' primary key.
603
+ Until now a user who triggered it was told the field was read-only. It gets its
604
+ own wording in all ten locale packs: "The record's identifier cannot be changed
605
+ by a save, so it did not take effect".
606
+
607
+ One more line was added for a reason ahead of this bundle's pin — the adapter
608
+ reads `reason` structurally off the wire without checking it against the enum, so
609
+ a server newer than the bundle can deliver a value no table can have an arm for.
610
+ It names the fields and claims nothing about the cause, rather than throwing
611
+ inside a listener invoked as `void emitWriteWarning(...)` (which would cost the
612
+ user the whole toast, including the reasons that did resolve) or reusing a
613
+ wording that would state a cause we do not know.
614
+ - Updated dependencies [2533ec5]
615
+ - Updated dependencies [bbe8b86]
616
+ - Updated dependencies [8477be5]
617
+ - Updated dependencies [279fb13]
618
+ - Updated dependencies [ad07b65]
619
+ - Updated dependencies [41f498b]
620
+ - Updated dependencies [e1d4251]
621
+ - Updated dependencies [ac600e5]
622
+ - Updated dependencies [c1ef923]
623
+ - Updated dependencies [af5e292]
624
+ - Updated dependencies [167ec42]
625
+ - Updated dependencies [b1119ec]
626
+ - Updated dependencies [9f23d2b]
627
+ - Updated dependencies [af025ee]
628
+ - Updated dependencies [31676be]
629
+ - Updated dependencies [9ce096f]
630
+ - Updated dependencies [e05db88]
631
+ - Updated dependencies [5ffcc14]
632
+ - Updated dependencies [d971e51]
633
+ - Updated dependencies [d2ce342]
634
+ - Updated dependencies [dfc6975]
635
+ - @object-ui/core@17.6.0
636
+
637
+ ## 17.5.0
638
+
639
+ ### Minor Changes
640
+
641
+ - 0e67b53: `/accept-invitation/:invitationId` is one route, one component, one namespace — the console now renders the invitation page that actually shows you the invitation
642
+
643
+ Two components shipped for this single URL. The console routed its own thin page, which offered nothing but an Accept and a Decline button: it never told the user which organization they had been invited to, in what role, or when the link expires, and accepting left them in whatever organization they were already in. App-shell's page — exported as `DefaultAcceptInvitationPage`, routed by nobody — fetches the invitation, shows the organization, the role and the expiry date, and switches the user into that organization on accept. Console now routes that one. The thin page is deleted.
644
+
645
+ Behind them sat two i18n namespaces for one screen: `acceptInvitation.*` (12 keys) for the thin page and `organization.accept.*` (14) for the richer one, both freshly translated into ten languages by different slices of objectui#3546, neither wrong when read on its own. That is 26 keys of duplicated copy with no gate to tell the next author which of the two to edit — the failure mode this repo already has an uncollected precedent for. `acceptInvitation.*` is removed from all ten packs, and its absence is pinned negatively so it cannot drift back: the slice-three test now asserts that no pack defines any of the 12 retired keys (nor an emptied namespace root left by a partial revert), and that neither consuming package asks `t()` for one.
646
+
647
+ One behavior needed repairing before the swap was safe rather than after. `?redirect=` is a basename-stripped path by contract in this console — `LoginPage` re-prefixes it with the mount before navigating — and the thin page built it from the route param, correctly. App-shell's page built it from `window.location.pathname`, which already carries the mount, so a console served under a `<base href="/console/">` would have sent the user back to `/console/console/accept-invitation/…` after signing in. It now reads the router (`useLocation`), like every other producer of that parameter in this repo. Under the default `/` mount the two spellings are identical, which is why only a basename case can see the difference; that case is now a test.
648
+
649
+ Nothing published was removed: `DefaultAcceptInvitationPage` keeps its export and simply becomes the routed implementation. Downstream apps mounting it get the redirect fix and are otherwise untouched.
650
+
651
+ - ae10a01: Console chrome reaches the bundle — the list switcher, the aggregate footer, the dialog a11y fallbacks and the whole Settings namespace screen stop being English on non-English consoles
652
+
653
+ Six strings on the two screens a user looks at most were hardcoded English literals rather than bundle lookups, so they stayed English on every non-English console with nothing an app could author to change them. They are not object, field, view or action labels — no key in `TranslationData` reaches them — while the console's own bundle already ships zh-CN, ja-JP, es-ES, de, fr, pt, ru, ko and ar and translates hundreds of neighbouring strings. Omissions from an otherwise complete bundle, not a missing capability.
654
+
655
+ **Two of the six needed no new keys at all, which is the more interesting half.** The list-view mode switcher named its nine visualizations from a private `VIEW_LABELS` table while `console.objectView.viewType*` — the same nine words — had been resolved through the bundle by the create-view picker for months; the switcher now reads those keys, so the picker's 「画廊」 and the switcher's 「画廊」 cannot drift apart in nine languages. The create/edit dialog's close button is the remainder of a fix that already landed: objectstack#5505 routed the `sr-only` close label through `common.close` for the two Shadcn-synced primitives, but `MobileDialogContent` is a hand-written wrapper outside that regeneration zone with its own close button, and it is exactly what `ModalForm` renders — so the dialog the report measured was the one place still announcing "Close" in English.
656
+
657
+ The aggregate footer is the one the original report singled out: the **number** was already locale-formatted and the **prefix** was a hardcoded `Avg: ` / `Sum: `. All eleven aggregation kinds now take their prefix from `grid.summary.*`, and the label/value join is its own key rather than a `': '` baked into the renderer — the separator is translatable content, so zh sets a fullwidth colon and fr the French space-before-colon. The numbers are untouched. The form dialog's `sr-only` description fallback joins the packs too; it is clipped, not visible, so the only way an app could displace it was to author a `description` and thereby put a visible subtitle on every dialog.
658
+
659
+ **The Settings namespace screen converts as one unit.** `SettingsView` routed zero framing copy through i18n — save/failure toasts, the env-lock and crypto refusals, the load-error card, the empty-route state, the navigation buttons, the unsaved-changes save bar — while its immediate sibling `SettingsHub`, in the same directory, resolved everything through `t('console.settingsHub.*')`. A zh-CN admin read correctly translated field labels sitting inside an English save bar, because `useSettingsLabel` translates a namespace's authored content but reaches none of the chrome around it. All of it now resolves through a `console.settingsView.*` namespace placed beside the hub's, including the crypto-refusal strings that objectui#4579 deliberately left in English rather than leave one translated string among a dozen literals.
660
+
661
+ The save-bar counter was an English plural rule executing in every locale (`change` plus an `s` when the count exceeds one). It is now a real i18next plural family — base key plus `_one` and `_other` in all ten packs — not the `(s)` spelling translated nine ways. The base key is the load-bearing part: i18next asks `Intl.PluralRules` for the one suffix a language needs and, finding no such slot, falls back to English, so without it Russian would read English at counts 2-20 and Arabic at 2-99. Russian and Arabic take the "noun: {count}" form their packs already use for this exact reason, and the counter is verified rendering in-language at 1, 2 and 5.
662
+
663
+ The Beta badge reuses the hub's existing key rather than minting a twin, and the refusal messages interpolate their subject through the bundle instead of concatenating a translated word onto an English prefix.
664
+
665
+ - 66fb4fa: The console's language menu now asks the app which locales it actually ships, instead of always offering the same ten.
666
+
667
+ `LocaleSwitcher` built its items from a module-level `LANGUAGES` constant — exactly the ten codes `@object-ui/i18n` ships packs for — and never consulted the app, even though `GET /api/v1/i18n/locales` has been serving that list all along. It failed in both directions at once. An app shipping a locale outside those ten (`th`, a regional `pt-BR`) had no way to be selected from the console: the bundle could be complete and lint clean, and the menu simply had no entry for it. An app shipping only `en` and `zh` still listed all ten, so picking 日本語 handed the user the console's own chrome in Japanese with everything app-authored in the fallback language — a half-translated UI its author never opted into.
668
+
669
+ The menu is now the **intersection**: the app's own locale list ∩ what the renderer can actually resolve (built-in packs, `config.resources`, and — for an app that wires a dynamic `loadLanguage` loader, which is how the console gets its packs — the locales that loader can fetch). Both failure directions close in the same change: an app-shipped locale becomes offerable, and locales the app does not ship disappear. The endpoint is reached through a new `loadLocales` prop on `I18nProvider`, wired exactly like the existing `loadLanguage`: the app owns the transport, the provider owns what is done with the answer. An app that does not wire it keeps today's menu unchanged.
670
+
671
+ **The restore validation widened in lockstep, because otherwise this fix would have minted the next bug.** A restored language was validated against "the locales this provider can produce" — built-in packs plus `config.resources` — a bound that was correct only for as long as the menu offered exactly the built-in ten. The moment the menu grows to the app's real list, a locale the user can now pick is a locale that bound rejects, so a user-picked app locale would have been purged on the next page load. It now also accepts a locale a wired dynamic loader may be able to fetch, and the bound stays honest rather than absent: only well-formed BCP-47 tags qualify (`constructor`, `__proto__`, `en_US` are still rejected, as is any stored locale in an app with no loader), and the app's own locale list adjudicates the choice for real once it arrives — a locale the app has since dropped is reverted and purged rather than left locking the UI to a language with no translations.
672
+
673
+ Labels come from the built-in native names where they exist (`中文`, `日本語`, … are unchanged) and from `Intl.DisplayNames` for everything else, so an app locale is named in its own language rather than by its code. The endpoint's own `label` is deliberately not used for display: the server sets it to the code echoed back, which would have put `th` in the menu where `ไทย` belongs.
674
+
675
+ While the app's list is in flight the switcher renders nothing, following the sibling menus in the same folder — the ten never flash past on an app that only ships two. When there is no backend, the endpoint fails, or the app answers with nothing this renderer can produce, the built-in ten remain as the offline fallback, so the menu is never empty and never unusable.
676
+
677
+ - 92250d6: One home for the number-display policy — and a percent stops meaning two different things between a list cell and a dashboard measure
678
+
679
+ `formatDisplayNumber`, `shouldGroupDisplayNumber` and `DisplayNumberFormatOptions` move from `@object-ui/i18n` into `@object-ui/core`. `@object-ui/i18n` re-exports all three under the same names, so every existing import path keeps working unchanged and both spellings resolve to the same function object; nothing published was removed.
680
+
681
+ The move is what fixes the bug. `@object-ui/core`'s `formatMeasure` needed exactly this policy and could not import it — `core` is the React-free engine and is a runtime dependency of React-free consumers (the `object-ui` VS Code extension, `@object-ui/data-objectstack`), while `i18n` depends on `i18next`/`react-i18next` and peer-depends on React. So `formatMeasure` carried a parallel `Intl` implementation, recorded at both ends as deliberate duplication, and the two drifted in the one place a hand-built string and `Intl` disagree. A German session read `1.234,5 %` from a list cell and `1.234,5%` from a dashboard measure showing the same number. The function is pure, so the boundary was never a property of the code — only of where the code sat; moving it down removes the obstacle instead of working around it. `core` imports nothing from `i18n`, so the new edge adds no cycle.
682
+
683
+ **Behaviour change — a measure's percent sign now follows the locale.** `formatMeasure` appended a literal `%` in every locale; it now renders the locale's own percent convention, the same one the list-cell `formatPercent` has used since the fix to its own machine-locale defect. Measured to change output in de, fr, es, ru, sv, cs, fi (a no-break space appears before the sign), tr (the sign moves to the FRONT: `%1.234,5`) and ar (its own percent sign plus U+061C). English, Japanese and Chinese are byte-identical — their convention is a bare trailing sign — which is why this was invisible in an English session.
684
+
685
+ **No numeral moves, in any locale, at any magnitude.** The obvious route to the locale's convention is `Intl`'s `style: 'percent'`, but that style expects a fraction, so a value already in percentage points would have to be divided by 100 for `Intl` to multiply it straight back — and that round trip is lossy. Measured, it moves 27,581 of 1,200,013 ordinary-magnitude en-US forms at rounding ties (`0.175` at two decimals becomes `0.17%` instead of `0.18%`), plus `MAX_SAFE_INTEGER` and everything from 1e23 up, where `100,000,000,000,000,000,000,000%` becomes `99,999,999,999,999,990,000,000%`. The percentage points are formatted directly instead, through a new `style: 'percentPoints'` on `DisplayNumberFormatOptions`; that route was measured to produce a byte-identical percent affix to `style: 'percent'` across all 171 locale tags tested while moving none of those 1,200,013 forms. Callers holding a fraction keep using `style: 'percent'`, whose behaviour is unchanged — naming the two cases apart is what stops the next caller from reaching for the lossy one.
686
+
687
+ `@object-ui/i18n`'s entry declaration is byte-identical, but the declaration it points at now lives in `@object-ui/core` and the package gains that dependency, so it takes the same minor bump rather than a patch.
688
+
689
+ - ac853ce: i18n: retire the reader-less `common.search` key from all ten locale packs
690
+
691
+ `common.search` (`Search`, no ellipsis) had exactly one consumer: `LookupField`
692
+ built its dialog placeholder by concatenating the key with three ASCII full
693
+ stops. objectui#4375 / PR #4391 retired that concatenation — the placeholder is
694
+ the reused `table.search` pack value (`Search…`, one U+2026 glyph), which is what
695
+ brought it under objectui#3878's glyph pin. That left `common.search` with zero
696
+ readers repo-wide while it still existed in all ten packs.
697
+
698
+ Re-verified before deleting, repo-wide: no `t()` call site in any package or app,
699
+ no MDX or JSON reference, and the one dynamic template-literal reader of the
700
+ `common` namespace takes a two-member union parameter (`'openChat' |
701
+ 'closeChat'`) that cannot resolve to it. No user-visible string changes — this key never rendered.
702
+
703
+ The dormant copy in `@object-ui/fields`' no-provider fallback table
704
+ (`useFieldTranslation.ts`'s `FIELD_DEFAULTS`) goes with it. That table is a
705
+ module-local `Record<string, string>` read only when no `LocalizationProvider`
706
+ is mounted; it is not exported, so removing an entry no reader asks for changes
707
+ no rendered output and narrows no public type. Hence patch for that package,
708
+ while the pack change is a minor: deleting a key from `en` narrows the exported
709
+ `TranslationKeys` type (`typeof en`), so code indexing `TranslationKeys` at
710
+ `common.search` stops type-checking. Same grading, for the same reason, as
711
+ objectui#4145's `report.editor.*` retirement. No runtime consumer existed to
712
+ break.
713
+
714
+ Retiring a key from `common` was the ruled decision on objectui#4392 rather than
715
+ keeping it as vocabulary: nothing pins a dormant key's meaning, so its next
716
+ reader inherits an unreviewed contract, and a dormant key beside a live
717
+ `table.search` is where a second dialect gets started. The objectui#4328
718
+ dead-surface family has consistently chosen removal for zero-consumer surfaces.
719
+
720
+ The neighbouring `common.select` (minted one commit earlier by objectui#4386 /
721
+ PR #4397) is a different key and is untouched.
722
+
723
+ A negative pin (`packages/i18n/src/__tests__/common-search-retired-4392.test.ts`)
724
+ fails if the key returns to any pack, if any package reads or re-declares it, or
725
+ if a dynamic `common.*` reader grows a `search` member — every existing i18n gate
726
+ runs call site to key, and none of them can see a key with no call site.
727
+
728
+ - fa51109: i18n: retire the orphaned `report.editor.*` namespace — 105 of its 106 keys, in all ten locale packs (~1050 translated strings)
729
+
730
+ The namespace labelled the hand-rolled report editor form. That form no longer
731
+ exists: `ReportConfigPanel`'s body is `ReportDefaultInspector`, a spec-driven
732
+ inspector whose labels come from the report spec's own metadata rather than from
733
+ a pack namespace. Until objectui#4137 the namespace had exactly one live reader,
734
+ and it was the objectui#4118 defect itself — the panel borrowing
735
+ `report.editor.title` (the label of the report's Title _field_) to name itself.
736
+ Moving that slot onto a purpose-built `report.editor.panelTitle` left the other
737
+ 105 keys with no reader anywhere.
738
+
739
+ Re-verified before deleting, repo-wide and per key: no `t()` call site, no
740
+ dynamic `t()` template form, and no JSON or MDX reference reads any of the 105.
741
+ No user-visible string changes — these keys never rendered.
742
+
743
+ `report.editor.panelTitle` survives in all ten packs and is untouched; the
744
+ deletion sweeps around it. `report.editor` therefore remains a live namespace
745
+ holding exactly that one key.
746
+
747
+ This narrows the exported `TranslationKeys` type (`typeof en`), which is why it
748
+ is a minor rather than a patch: code indexing `TranslationKeys` at a retired key
749
+ stops type-checking. No runtime consumer existed to break.
750
+
751
+ A negative pin (`packages/i18n/src/__tests__/report-editor-retired-4145.test.ts`)
752
+ names all 105 retired keys and fails if any returns to any pack, since every
753
+ existing i18n gate runs call site to key and none of them can see a key with no
754
+ call site.
755
+
756
+ - 78fa331: console: seed the UI language from the tenant's server-side locale
757
+
758
+ `GET /auth/me/localization` has always been fetched on every boot, but its
759
+ `locale` only ever fed currency/date formatting — the UI language was decided
760
+ entirely client-side, so a tenant configured `zh-CN` still handed every new
761
+ device an English console until each user switched by hand.
762
+
763
+ The tenant locale now sits in the language precedence chain, between the user's
764
+ own choice and the browser's:
765
+
766
+ 1. the user's explicit choice (`objectui-locale`)
767
+ 2. the tenant's server locale, cached at `objectui-locale-seed`
768
+ 3. the browser language
769
+ 4. `en`
770
+
771
+ The server value is cached in a slot of its own and is never written into the
772
+ explicit-choice slot, so it can never masquerade as a preference the user
773
+ expressed: only a manual switch promotes a language to an explicit choice. A
774
+ cached seed applies synchronously at bootstrap, and the in-app fetch refreshes
775
+ that cache from every successful answer, so a tenant that changes its locale
776
+ reaches choice-less devices on their next boot without an old seed pinning
777
+ them. On a device's true first visit the fetch is raced against a ~500ms
778
+ timeout alongside the console's existing pre-mount round-trips and fails open
779
+ to the browser language; a seed that arrives after the bound is cached for the
780
+ next boot rather than re-languaging a live session. A tenant locale this build
781
+ ships no pack for falls through to the next tier instead of half-rendering.
782
+
783
+ No platform additions: no new endpoint, no client read/write API, and
784
+ `sys_user_preference` is untouched.
785
+
786
+ - 0082db8: The timeline's gantt bucket labels and its row-label default speak the session language
787
+
788
+ objectui#4513 routed every `Intl` call in the timeline renderer through `useDisplayLocale()`, so a Chinese session renders `2026年8月` on the month axis and `2026年8月11日` on item dates. Three sibling strings in the same renderer never went through `Intl` at all and stayed English on that same Chinese axis: the `week` header (`Week 1`), the `quarter` header (`Q3 2026`), and the gantt row-label column default (`Items`). The half-fixed state was the visible one — a Chinese date axis with English bucket labels beside it.
789
+
790
+ They are a translation concern rather than a locale-resolver one, and that distinction is the fix: a locale TAG formats a date, only a TRANSLATION spells a word. All three now resolve through the package's existing channel — `useTimelineTranslation` / `TIMELINE_DEFAULT_TRANSLATIONS`, the `createSafeTranslation` factory `ObjectTimeline` already uses for `timeline.bucket.*` — under three new keys carried by all ten locale packs: `timeline.scale.week`, `timeline.scale.quarter`, `timeline.gantt.rowLabel`.
791
+
792
+ The week number and the quarter/year ride the channel's own `{{hole}}` parameters rather than being concatenated, because the word order belongs to the translation: Chinese puts the year first (`2026年第3季度`), which no `Q${q} ${year}` template can produce at all. Only the row-label DEFAULT moved — an author who writes `rowLabel` still supplies their own string, and the `year` scale stays a bare `String(getFullYear())` with no vocabulary in it to translate.
793
+
794
+ English output is byte-identical to the retired literals: the `en` pack values are the same two templates the code used to interpolate by hand. `generateTimeScaleHeaders` is a pure exported function and cannot host a hook, so the translate fn is threaded in as an optional fifth parameter on the seam #4513 opened for `locale`, defaulting to the package's own defaults table — the same lookup the channel serves with no `I18nProvider` mounted. Existing three- and four-argument call sites are unaffected.
795
+
796
+ One consequence is worth stating because it looks like a bug and is not: dates and vocabulary resolve through different channels on purpose. `useDisplayLocale()` puts the tenant's regional default first (how this organization writes dates), while `t` follows the UI language (what this user reads). A tenant configured `en` whose user reads Chinese chrome therefore sees `Aug 2026` beside `第 1 周` — the same split `timeline.bucket.*` has always had.
797
+
798
+ ### Patch Changes
799
+
800
+ - 932cbcd: An app you are not allowed to open now says so, instead of reporting that it may still be publishing
801
+
802
+ `GET /api/v1/meta/apps` is filtered per session server-side (`filterAppForUser`), so an app withheld by its `requiredPermissions` and an app that does not exist were byte-identical to the console: both simply absent from the list. With one fact and two conditions, `AppContent` rendered its only copy for an absent app — "This app is not available yet — it may still be publishing. Try again in a moment." — over a permanent authorization decision, under a Retry button that could never succeed.
803
+
804
+ That is not a cosmetic complaint. On a downstream acceptance round one role hit this screen while another opened the same app fine, and because the copy names a transient deployment state the finding was filed as a suspected platform defect and carried through two test batches before a clean-baseline investigation found the account was missing a permission-set binding. The gate had been working exactly as designed; the message is what sent everyone to the wrong place.
805
+
806
+ The maintainer ruling (2026-08-12) took the contract half first. objectstack#8013 made the BY-NAME route answer an explicit denial — `403` with the ADR-0112 catalog code `PERMISSION_DENIED` in the declared `{ success: false, error: { code, message } }` envelope — for an app that exists and whose `requiredPermissions` the session lacks, while the LIST route stays filtered exactly as before, with no `authorized: false` flag, so the enumeration surface is not widened past what a direct by-name probe already implies. Absence keeps answering `404 RESOURCE_NOT_FOUND`, and so do the two neighbouring refusals the same ruling deliberately left alone: an unpublished app (ADR-0045 §3 keeps it externally unobservable) and an app gated by an absent optional service (ADR-0057 D10 — nothing was denied to the caller).
807
+
808
+ This is the console half. When a requested app is missing from the list and the existing post-publish readiness re-check still cannot find it, the console asks the by-name route which of the two it is, through a new `ObjectStackAdapter.probeAppAccess(name)`. On the measured code it renders a plain authorization message with a way back to the launcher; on anything else — an absent app, an unreachable server, a host that injected a DataSource without the probe — today's publishing copy renders byte for byte, retry button included.
809
+
810
+ Two properties of that seam are load-bearing rather than incidental. It branches on the ADR-0112 **code**, never the status (objectui#4408): the two answers under test are both errors one status apart, and a status-reading implementation passes the happy path while going blind exactly where the defect lives. And only `denied` moves the copy: this bug exists because the console asserted a state it had not measured, so a probe that fails, times out or cannot be issued must leave the screen alone rather than guess in the other direction.
811
+
812
+ `probeAppAccess` is deliberately separate from `getApp` rather than a flag on it: `getApp` degrades every failure to `null` — the very conflation being undone — and memoises in the adapter's metadata cache, where a verdict about the CALLER would outlive the session it described. New public API on the adapter (`probeAppAccess`, `isAppPermissionDeniedError`, `APP_PERMISSION_DENIED_CODE`, `AppAccessVerdict`), purely additive; nothing existing changed shape. Three new `empty.*` keys ship in all ten locale packs.
813
+
814
+ - 734d186: The console's Applications page is localized — its own chrome only, never the
815
+ server's words (objectui#4307).
816
+
817
+ `AppManagementPage` was raw English end to end: headings, the search field, the
818
+ selection and bulk controls, the six per-row actions with their tooltip/ARIA
819
+ pairs, the status badges, and every toast. It was the last un-i18n'd system page,
820
+ and #4233 / PR #4300 had just given it four live mutations — so the gap became
821
+ user-visible on every non-English console at the moment operators started using
822
+ it. 45 keys land under `appManagement.*` in all ten packs, reached through
823
+ `useObjectTranslation` with the call site's `defaultValue` inline, which is the
824
+ convention the neighbouring system pages already follow.
825
+
826
+ The split that shapes this change is between the strings the PAGE authors and
827
+ the strings the SERVER authors. `PUT`/`DELETE /api/v1/meta/app/:name` is gated on
828
+ `manage_metadata` (ADR-0066 D1), so a refusal like `forbidden: manage_metadata
829
+ required` is the server's diagnosis of one specific request; there is no fixed
830
+ catalogue of those sentences to key against. Each failure toast is therefore a
831
+ keyed template with a `{{reason}}` hole, and what fills the hole is passed
832
+ through byte for byte, untranslated. The one part that IS the page's own — what
833
+ it says when the server sent no message at all — is keyed as
834
+ `appManagement.toast.unknownError`.
835
+
836
+ Two smaller things follow from doing the conversion properly rather than
837
+ mechanically. The per-failure entry of a bulk toast and the separator between
838
+ entries are keys, not literals, because bracket style and list punctuation are
839
+ locale properties (the same rule, and the same past defect, as
840
+ `validation.formInvalidJoiner`). And the row's controls now name an app through
841
+ the resolver the visible heading two lines away already used, with `t` passed:
842
+ an app carrying objectui's keyed label form previously rendered `Select [object
843
+ Object]` into its checkbox's ARIA label.
844
+
845
+ - 3fc2971: A null-keyed group renders as an explicit bucket instead of silently vanishing from a chart (objectui#4466)
846
+
847
+ `buildChartSeries`' single-dimension branch passed rows through verbatim, so a row whose category VALUE is `null` reached recharts with a null category and drew no mark. The visible outcome was not an empty chart but a quietly wrong one: rows `[{user_id: null, event_count: 51}, {user_id: 'Dev Admin', event_count: 2}]` drew exactly ONE bar — the dominant group, 51 of 53 events, dropped while the y-axis scale still accommodated it, so the chart understated its own data and the axis proved the data had been there. With every group null it drew axes, gridlines and an axis title with zero marks and no empty state, which is the shipped first-boot state of the built-in System Overview board's "Events by User" (every seeded `sys_audit_log` row is written with `user_id = NULL`).
848
+
849
+ The mapping lives in the shared series layer, so dashboard widgets and standalone `ObjectChart` get one answer rather than a per-chart patch in the recharts wrapper. It resolves the two-answers disagreement the card names as well: an empty result set keeps the designed empty state, a non-empty result always draws bars — the null bucket included.
850
+
851
+ `@object-ui/core` gains `NULL_CATEGORY_LABEL` and `ChartSeriesOptions`; `buildChartSeries` and `findChartSeriesRow` each take an optional trailing `options`. Both additive — every existing call site compiles and behaves identically, and a result with no null category is still returned by array identity. The two helpers are a pair on purpose: the caller matches a clicked segment against rows that still carry the raw `null`, so `findChartSeriesRow` reads the bucket label back to that row and the newly-visible bar keeps its drill-through instead of resolving to `-1`.
852
+
853
+ The label goes through the i18n channel (`chart.nullCategory`, en `(None)` / zh `(未指定)`, all ten packs), passed down by the renderer: `@object-ui/core` is React-free and cannot read the locale bundle, so it takes the resolved string the same way `dimensionOptionTranslator` takes a resolver. Its English constant is the floor for a provider-less host, not the mechanism.
854
+
855
+ `hasNoCategoryKey` (framework#4033) is untouched and now documented against this: a row that does not carry the category key AT ALL is a different defect — a dimension grouped by but never projected — and keeps its explanatory placeholder. The bucket deliberately never ADDS the key to such a row, which is what keeps that guard's signal alive. Key absent → the placeholder; key present with a null value → the bucket.
856
+
857
+ - f7c6430: The build-history panel tells an operator a 503 means "the commit store could not be reached — retry", instead of `commits HTTP 503`
858
+
859
+ `packages/app-shell/src/preview/commitHistory.ts` flattened every non-OK response to a bare status code (`commits HTTP {status}` for the read, `HTTP {status}` for the revert). Nothing was ever swallowed and no fictional "no history" was ever rendered — those fail-loud properties held, and still hold, which is why objectstack#5980's 503-ification (ADR-0110 D3) needed no follow-up here. What was lost is the meaning the backend already sends, on the one screen where it matters most: this is the rollback surface, read by an operator who is usually mid-incident. A 503 says the read/write did not happen and is worth retrying; a 404 says the store answered "no". They now read differently, and 404, 500 and 503 stay tellable apart.
860
+
861
+ Failures now throw a `CommitStoreError` carrying `status`, the ADR-0112 `code`, and a `retryable` flag, and the panel renders a sentence rather than a number. The revert half gets a deliberately different sentence: a write that could not reach the store may still have landed, and re-issuing it appends a _second_ revert commit to an append-only log, so the copy asks the operator to re-read the timeline before retrying rather than simply saying "try again".
862
+
863
+ Two details of the report this fixes were checked against the producer and came back different, and both are the reason the copy is authored client-side. The semantic code arrives at **`error.code`**, not `details.code` — `HttpDispatcher.errorFromThrown` parks it in `details` and `buildApiError`/`splitSemanticCode` lift it out and drop `details` (objectstack#3842) — so a consumer reading `details.code` would run a check that can only pass vacuously. And the envelope's own `message` for this class is _withheld_: `declaresServerFault` (objectstack#5811) is true for a 5xx carrying a string code, so the prose on the wire is the generic `Internal server error`. Rendering it would have been strictly worse than the bare status code it replaced. Classification therefore keys on the HTTP status first and treats the code as a second signal, which also means a 503 shed by a proxy with an HTML body still produces the retryable reading.
864
+
865
+ Adds `preview.history.loadFailedUnavailable` and `preview.history.revertUnavailable` to all ten locale packs.
866
+
867
+ - 92876f0: Doc comments no longer cite `@objectstack/spec` symbols the pinned spec has retired
868
+
869
+ Eight exported declarations carried a doc comment claiming alignment with a
870
+ `@objectstack/spec` symbol that `17.0.0-rc.6` does not export — four locale
871
+ formatting shapes in `@object-ui/i18n` (`SpecPluralRule`, `SpecDateFormat`,
872
+ `SpecNumberFormat`, `SpecLocaleConfig`) and four activity-feed shapes in
873
+ `@object-ui/types` (`FieldChangeEntry`, `Mention`, `Reaction`,
874
+ `RecordSubscription`). A citation that points at nothing is worse than a stale
875
+ one: the next reader cannot tell whether the protocol retired the symbol,
876
+ renamed it, or never had it.
877
+
878
+ Measuring all eight against the published registry answered that question, and
879
+ the answer was not "these names never existed". Every one was a real export the
880
+ protocol retired on purpose, and every local key set was faithful to the schema
881
+ it named. The feed four left `@objectstack/spec/data` in the `16.0.0` major,
882
+ when the feed surface was replaced by the data API over `sys_comment` /
883
+ `sys_activity`. The i18n four left `@objectstack/spec/ui` in `17.0.0-rc.6`
884
+ itself — they were still present in `rc.5` — retired under ADR-0049
885
+ enforce-or-remove because no authorable shape carried them and nothing ever
886
+ parsed them.
887
+
888
+ Each comment now records that provenance, including the version the symbol left
889
+ and what (if anything) replaced it, so the shapes read as declarations these
890
+ packages own rather than as a view onto a protocol type. Type shapes, runtime
891
+ behaviour and exports are unchanged — the published `.d.ts` files differ only in
892
+ comment text, which is why this is graded `patch`.
893
+
894
+ - 828549a: The gantt's conflict dialog shows the number of affected tasks again, not a literal `{2}`
895
+
896
+ `gantt.conflict.body` was resolved at the render site with a literal string replace on **single** braces — `t('gantt.conflict.body').replace('{count}', String(n))` — while all ten locale packs spell the placeholder the i18next way, `{{count}}`. `"…{{count}}…".replace("{count}", "2")` consumes the inner seven characters and leaves the outer pair behind, so every user on every loaded pack read "自动重新排程 **{2}** 个受影响的任务?". The dialog now interpolates through i18next (`t('gantt.conflict.body', { count })`), the idiom `gantt.delete.body` already used.
897
+
898
+ The two sibling keys three lines away in the same file, `gantt.autoScheduleDlg.body` and `.skipped`, were **not** broken — pack and call site both used single braces, and they rendered correctly. They are converted anyway, because that split is the whole mechanism: two write-confirmation dialogs in one component carried two different interpolation idioms, so `conflict.body` drifting to the i18next spelling in the packs (which is the correct spelling, and matches every other placeholder in the bundle) silently broke the render. Leaving the auto-schedule keys on the literal-replace idiom leaves the same trap armed for the next translator. All ten packs and the plugin's bundled English fallback table now agree on `{{count}}` for all three; only the braces moved, no translation was reworded.
899
+
900
+ `gantt.quickFilter.resultSummary` stays deliberately single-brace — its `ObjectGantt` call site really does resolve `{shown}`/`{total}` with a literal replace, and that convention is pinned by its own parity test. It is now the only key in the gantt namespace on that idiom, and the comments at both spellings say so.
901
+
902
+ Nothing caught this, and each gate was silent for its own reason: the cross-pack parity check compares en against each pack, and all eleven spellings agreed; the en-drift check compares a pack against its own history, and the packs were born matching. Both are **relative** comparisons, and the defect lived in the **absolute** relationship between a pack's spelling and the syntax the call site resolves. The existing render test asserted the dialog body contains `'1'` — which `{1}` satisfies. The new pin asserts the absolute form directly, under a real loaded pack, for every way a placeholder can survive to the screen.
903
+
904
+ - e1ade8f: An illegal gantt dependency link now says why it was refused, instead of doing nothing
905
+
906
+ Dragging a dependency onto a target the gantt refuses — itself, a locked row, a group row, or one that would close a dependency cycle — produced no feedback of any kind: no toast, no dialog, no cursor change, no target outline, not even a console warning. The guard was right and completely invisible, so a user drawing a legitimate-looking dependency got a dead interaction and no way to learn the constraint. The rejection was silent in both places it could have shown: a refused bar never became the drop target, so it got no hover treatment at all, and the release handler only ran its body when a target _had_ been registered, so the drop itself was a no-op.
907
+
908
+ Both halves are now wired, and both read the **same** verdict. `canReceiveLink`'s four-branch boolean became `classifyLinkTarget`, which returns which branch refused (or `null`), with the boolean derived from it. The hover affordance and the drop toast are two consumers of that one classification, so the reason a user is shown cannot drift from the reason the link was actually refused — there is no second classifier to disagree. The branch names are the leaves of the new `gantt.link.rejected.*` keys, so a branch added later without a message surfaces as a missing key rather than as a plausible-but-wrong sentence.
909
+
910
+ During the drag, a refused bar under the pointer gets `cursor: not-allowed` and a destructive outline; on release it raises a toast naming the reason. Four messages, one per branch, in all ten packs. Both the cursor and the outline are driven from inline `style` rather than utility classes, matching the bar's existing read-only cursor three lines away and for the same reason recorded there: `cursor-not-allowed` and the ring alpha utilities are not emitted in the prebuilt components CSS, so a class would look correct in a DOM test and render nothing in a browser.
911
+
912
+ Deliberately unchanged: a host veto through `onBeforeDependencyCreate` stays silent. That rejection carries a reason only the host knows, and the gantt has none to show — surfacing it means exposing a rejection-reason output on the public component, which is a separate contract rather than a rider on this one. The four built-in reasons are the gantt's own policy and are the only ones it can explain.
913
+
914
+ One of the four, `group`, has no end-to-end path today: a `type: 'group'` row renders no bar, so the drag can never target it. The message is kept anyway — without it the branch would render a raw key on screen if it ever did fire — and the test pins the reachability fact, so it goes red the day group rows gain a bar. Filed as objectui#4209.
915
+
916
+ - 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
917
+
918
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
919
+
920
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
921
+
922
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
923
+
924
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
925
+
926
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
927
+
928
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
929
+
930
+ No behavior, no keys added or removed, no placeholder changed.
931
+
932
+ - bb58d1d: i18n: the two search placeholders become pack values, and four values the packs served in English get translated
933
+
934
+ **objectui#4375** — `ListView` and `LookupField` built their search placeholder as
935
+ `t(key) + '...'`, so the ellipsis was a literal concatenated in code: it stayed ASCII
936
+ in all ten locales on screens where objectui#3878 had converged everything else on
937
+ U+2026, and no pack could opt out of it (sharpest in `ar`, where a left-to-right run
938
+ was appended to right-to-left text). Both now read `table.search`, which is already
939
+ the repo's search-input placeholder key — `data-table`, `RecordPickerDialog` and
940
+ `PeoplePicker` render it too — and is translated with the right ellipsis in all ten
941
+ packs. No new keys.
942
+
943
+ **objectui#4376** — `list.loading` served the English `Loading records…` in eight of
944
+ the nine translation packs (`zh` alone had translated it); `designer.undo` and
945
+ `designer.redo` were English in all nine; `appDesigner.snakeCaseHint` in `ko`, `pt`,
946
+ `ru` and `ar`. All translated, reusing each pack's own established vocabulary. A new
947
+ pin (`untranslated-identity-4376.test.ts`) fails on any value byte-identical to `en`
948
+ inside a non-Latin pack unless the key is on an explicit 22-entry allowlist.
949
+
950
+ - 5cc847c: The console shows a standing impersonation banner, with an exit that fails loudly (#4467).
951
+
952
+ While `session.impersonatedBy` is present, `ConsoleShell` renders a banner naming BOTH
953
+ parties — the impersonated user, whose name every write is recorded under, and the
954
+ administrator who started it — plus a stop affordance. It derives from the session rather
955
+ than from client memory of the click, so it survives a full SPA reboot, a new tab and a
956
+ browser restart, and it cannot disagree with who the server thinks is acting. An ordinary
957
+ session renders `null` and its chrome is unchanged.
958
+
959
+ The exit calls `POST /auth/admin/stop-impersonating` over the same data lane and then
960
+ awaits a session refresh. The server restores the administrator from the `admin_session`
961
+ COOKIE, so a deployment that blocks cookies cannot exit this way — the banner says so and
962
+ stays up instead of appearing to succeed, which would leave the operator doing ordinary
963
+ work under someone else's identity.
964
+
965
+ Ten locale packs carry the banner's copy.
966
+
967
+ - fa21254: Kanban: a drop that makes fields required now collects them instead of dead-ending
968
+
969
+ Dragging a card into a column whose value flips a field's `requiredWhen` predicate to TRUE used to PATCH the column value alone. The engine refused the whole update — correctly, that is what the predicate declares — and the board had no way to finish the move: the only path to closing a won deal was to abandon the board and open the record form. HotCRM's opportunity pipeline is the reported case (`win_reason` is required when `stage == "closed_won"`), but the dead end belonged to every board whose target column carries a conditional requirement.
970
+
971
+ The board now evaluates the target column's predicates BEFORE writing anything. If the move would make fields required while they are still empty, it opens a small dialog collecting exactly those fields, then submits the column value and everything collected as ONE PATCH — never two writes, which would leave the record in the refused state if the second one failed. A drop that triggers no predicate is untouched, down to the PATCH body.
972
+
973
+ The verdict comes from `@object-ui/core`'s `resolveFieldRuleState` — the same evaluator the record form, the wizard and the line-item grid already resolve `visibleWhen`/`readonlyWhen`/`requiredWhen` with, delegating to `@objectstack/formula`'s CEL engine. The board's prompt and the server's enforcement therefore reach the identical verdict rather than drifting through a second hand-rolled predicate evaluator. Emptiness is core's `isMissingForRequired`, the presence contract the form and the server share, so a `false` boolean and a `0` count as answers and are not re-asked.
974
+
975
+ Every control in the dialog is `@object-ui/fields`' `FieldEditWidget`, the same widget the record form renders for that field type — a select edits as a select, a date as a date picker — so this adds no second set of field-rendering decisions.
976
+
977
+ Four kinds of field are deliberately NOT collected, and each falls through to the unchanged PATCH where the server's refusal (legible since objectstack#7525) speaks for itself: one that already has a value, one `visibleWhen` hides, one that is readonly, and one whose type has no edit widget at all. A dialog row with no control would be a worse dead end than the one being fixed.
978
+
979
+ Cancelling writes nothing and leaves the card in its original column; a combined PATCH that is still refused for some other reason surfaces the refusal and rolls back exactly as a plain rejected move does, rather than looping the dialog on an arbitrary server error.
980
+
981
+ `@object-ui/i18n` carries two new `kanban.*` strings for the dialog, translated across all ten packs. Its public type surface is unchanged — the `.d.ts` was measured identical before and after — hence the patch bump.
982
+
983
+ - 33c32bf: List sort: the picker stops borrowing the filter whitelist, and a header click is no longer a one-way door out of the view's declared sort
984
+
985
+ `filterableFields` was applied to the single field set both toolbar builders read, so a whitelist authored for _filtering_ silently became the _sort_ whitelist too. A view could declare a two-level default sort — `plan_start_date` then `name` — and get a sort panel that offered neither field and rendered both of its rows blank: the declared sort worked on load and could then be neither reproduced nor modified, and there was no way to express "sortable but not offered as a filter condition" short of widening the filter builder as collateral. The whitelist now narrows the filter builder alone, which is the contract it was written for; the sort picker starts from every field the view can name and applies its own sortability rules.
986
+
987
+ Those rules are about what the sort can honestly reach, so a second one joins the existing relational exclusion: a `formula` field is withheld. It has no materialised column, so ordering by one is refused by the server outright (objectstack `UNMATERIALIZED_SORT_TYPES`) — and it matters here precisely because the base set widened, since a formula field previously reached the picker only if someone had whitelisted it. The exclusion is `formula` alone and deliberately not the spec's `COMPUTED_VALUE_TYPES`: `summary` and `autonumber` are computed too, each gets a real maintained column, and both order correctly. Either rule keeps its existing escape hatch — a field the current sort already uses stays listed, which for a formula field is the only way to remove the offending row.
988
+
989
+ One consequence worth naming: the hint explaining the relational omission used to be gated by the same whitelist. A view whitelisting only `status` showed a near-empty sort picker and no word about why; the withheld relational field now reaches the rule that withholds it, so the explanation appears with it.
990
+
991
+ The second half is the way back. One column-header click replaces the whole sort array, so a view shipping a multi-level default lost it for the rest of the session — the declared `sort` behaved as an initial value only, recoverable just by reloading the page. The sort panel gains a **Reset to view default** control that restores the declared array whole: multi-level, in declared order, not merely cleared. It reads the view's declared sort through the same resolver the initial render already uses, so there is one answer to "what did this view declare". It is disabled while the active sort already matches that default, and absent entirely for a view that declares no sort — there is no default to return to, and clearing the sort under that label would be a second, differently-named way to do what removing the rows already does. The header click's own semantics are unchanged: it still replaces the array, it just no longer does so irreversibly.
992
+
993
+ - 6d641c9: Members & invitations tabs gate their affordances by org role instead of letting the server's 403 be the UI (#4475)
994
+
995
+ A user whose organization role is `member` opened the workspace members page and
996
+ was shown an enabled **Invite member** button plus a per-row **Member actions**
997
+ menu carrying **Remove member** — on every row, the workspace Owner's included.
998
+ Nothing was hidden or disabled; the action only failed after the user had
999
+ committed to it. The Settings tab of the same page already gated correctly; the
1000
+ members and invitations tabs never got the same treatment.
1001
+
1002
+ The affordances are now narrowed to the roles that can actually use them, keyed
1003
+ on the active member's role — the same source the role-change menu on this page
1004
+ already reads. Which roles those are is **measured against the routes that
1005
+ enforce them**, not assumed to be "owner":
1006
+
1007
+ | affordance | route | permission | roles |
1008
+ | ----------------- | --------------------------------- | ----------------------- | ----------------------------- |
1009
+ | Invite member | `/organization/invite-member` | `invitation:["create"]` | owner, admin, delegated_admin |
1010
+ | Remove member | `/organization/remove-member` | `member:["delete"]` | owner, admin |
1011
+ | Cancel invitation | `/organization/cancel-invitation` | `invitation:["cancel"]` | owner, admin |
1012
+
1013
+ Three different gates, because `delegated_admin` holds `invitation:["create"]`
1014
+ without `member:["delete"]` and deliberately without `cancel` — so it keeps the
1015
+ invite button and the copy-link action while losing remove and cancel. A single
1016
+ owner check could not express that.
1017
+
1018
+ An actor left with no row action at all gets no menu rather than a trigger that
1019
+ opens onto nothing, and the members page explains the absence where the Invite
1020
+ button used to sit, in the Settings tab's own voice. An unresolved role is
1021
+ treated as the least privileged, so nothing privileged is offered to a viewer
1022
+ whose membership could not be read.
1023
+
1024
+ Reading the pages is unaffected: the member list and the invitation ledger still
1025
+ render in full. Whether `org_member` should be able to read the invitation
1026
+ ledger at all is a separate, server-side question.
1027
+
1028
+ - 45e1949: Numbers render in the user's locale, and a `Field.number` year is no longer `2,026`
1029
+
1030
+ Every numeric field the console rendered went through an `Intl.NumberFormat` built with the locale hardcoded to `en-US` and `useGrouping` never set. Two defects rode in that one construction: a `zh-CN` or `de-DE` console still grouped and pointed decimals the US way, and a four-digit **year** stored as `Field.number({ scale: 0 })` rendered as `2,026` — in every locale, with no field property able to turn it off. Apps had been converting year columns to `Field.text` to escape it, permanently trading numeric comparison, range filters and dataset dimension types for a display detail.
1031
+
1032
+ The construction had been copied into five places — the number cell renderer, the currency cell renderer, the `CurrencyField` widget, the compact `formatNumber` helper, and the dashboard `MetricWidget` — so fixing any one surface never changed the answer. They now share one formatter, `formatDisplayNumber` in `@object-ui/i18n`, which owns the locale and the grouping policy together, plus one locale resolver, `useDisplayLocale`.
1033
+
1034
+ `useDisplayLocale` composes the two locale channels this repo already had rather than adding a third: the tenant's regional default (`useLocalization().locale`, ADR-0053) when an org has configured one, otherwise the active UI language (`useObjectTranslation().language`) so grouping and decimal marks follow a language switch. That second step is what covers the case the report was measured in — a fresh database, where the tenant localization endpoint has no locale to give.
1035
+
1036
+ Grouping is now suppressed when a field declares `scale: 0` and carries no currency, which is what makes years, fiscal periods and other ordinals render plainly. This is an **interim default** with an accepted cost: a large scale-0 _count_ loses its separators too. It holds only until the spec gains an authorable presentation hint, which is being specified separately, contract-first; when that lands it overrides this heuristic.
1037
+
1038
+ Three surfaces deliberately keep their separators, because a zero-decimal display there does not come from a field declaration: the dashboard `MetricWidget` (its decimals are parsed from a numeral.js format pattern, and its own contract calls the separators load-bearing — "`1,930,000` not `1930000`"), the `element:number` aggregate renderer, and every currency path including amounts whose currency code could not be resolved. An **undeclared** `scale` also keeps grouping — absent means "decimals unknown", not "integer".
1039
+
1040
+ `formatCurrency`, `formatCompactCurrency` and `formatNumber` each take a new optional trailing `locale` argument. Existing calls are unaffected; omitting it now follows the runtime default rather than forcing US conventions.
1041
+
1042
+ - 58bebf6: Organization & invitation console: translate the English holdouts a zh session was left reading (#4474)
1043
+
1044
+ Three families of string, one sweep over `console/organizations/`:
1045
+
1046
+ - **Role names** now come from the single shared `ORG_ROLE_LABELS` map at every
1047
+ site. The role badges on the members and invitations pages were rendering the
1048
+ raw server identifier (`owner`) under a CSS `capitalize` that made it look like
1049
+ a label in English and left it untranslated everywhere else; the accept page
1050
+ did the same in its role row and inside its otherwise-translated sentence. The
1051
+ map's four `organization.roles.*` keys existed in no locale pack, so even the
1052
+ dropdown that did consult it fell through to English — all ten packs now carry
1053
+ them. An unrecognized role renders verbatim rather than blank.
1054
+ - **Server-echoed errors** are mapped by better-auth's stable `code`, never by
1055
+ matching its English text. `createAuthClient` was dropping that code for every
1056
+ `organization.*` call while preserving it for sign-in/sign-up, so the console
1057
+ had nothing to key on; all sixteen organization methods now go through the same
1058
+ `toAuthError` helper. Messages are unchanged — the code simply stops being
1059
+ thrown away. An unmapped code still shows the server's own sentence.
1060
+ - **Icon-only `aria-label`s** (member actions, copy invitation link, cancel
1061
+ invitation, and a fourth on the share-link copy button) are translated — for an
1062
+ icon-only control this is the only name a screen reader gets.
1063
+
1064
+ - 405e808: `pickLocalized` reads own properties only, and takes only string values, on every limb
1065
+
1066
+ The resolver read four of its six limbs — the exact tag, the base language, `default` and `en` — with a bare bracket access. Bare access walks the prototype chain, so a locale that happened to name an `Object.prototype` member resolved to that member and the function stringified it into the label: `pickLocalized({ en: 'Pricing' }, 'constructor')` returned `function Object() { [native code] }`, and the same held for `toString`, `valueOf`, `hasOwnProperty`, `isPrototypeOf`, `propertyIsEnumerable` and `toLocaleString`. Those same four limbs also skipped the `typeof === 'string'` filter the regional and last-resort limbs already applied, so a non-string value short-circuited the chain and rendered as `[object Object]`.
1067
+
1068
+ Both guards now apply uniformly. A guarded limb **misses** rather than aborting the resolution, so an unusable entry falls through to the next limb exactly as an absent one does — `pickLocalized({ en: 'Pricing' }, 'constructor')` is now `'Pricing'` (the `en` limb), and only a map with no usable entry at all resolves to `''`. An empty-string value is still a hit, because `''` is a label the author wrote.
1069
+
1070
+ No real language tag can observe this: no BCP-47 tag is an `Object.prototype` member, and the inline locale map is declared `z.record(<tag>, z.string())`, so every in-contract input resolves byte-identically to before. What it changes is agreement with the backend twin `resolveI18nLabel` (objectstack#6765), which shipped with exactly these two narrowings recorded as deliberate departures from this function because on a server the locale can arrive in an `Accept-Language` header. That recorded rule divergence is now zero; the only remaining difference is how each side spells a miss (`''` here for a text node, `undefined` there for a producer's fallback chain), which is pinned as an identity in the cross-resolver parity table.
1071
+
1072
+ - c0f9a4b: Studio surfaces the runtime authoring gate's advisory findings instead of discarding them client-side
1073
+
1074
+ The framework's runtime authoring gate produces two kinds of verdict on a metadata write. Errors become a 422 and the author sees them. Advisories ride a **200** — the save succeeded, the row persisted, the version bumped — and until objectstack#7435 the server dropped them into a deduped `console.warn` behind a process-level set. That landing put them on the wire as an optional `advisories[]` on the save response, emitted only when non-empty, and objectui was still throwing them away one layer further out: `MetadataClient.save` parsed the body, returned it as an opaque `T`, and every call site awaited it for its side effect and discarded the value.
1075
+
1076
+ The measured case the fix is built on: a `nightly_purge` flow whose only defect is a `delete_record` node with `multi: true` and no filter yields `errors = 0 / advisories = 1`. The save returns 200, the flow goes live, and nothing anywhere tells the author it deletes every row. That matters most for exactly the authors Studio serves — a Studio tenant or an MCP/AI author has no `os lint` and no CLI config for `sys_metadata` overlay rows, so this gate is not the weakest of four doors, it is the only one.
1077
+
1078
+ `MetadataClient` now carries an `onSaveAdvisory` sink, invoked after a save whose response carried a non-empty `advisories[]`, and the console wires it in `useMetadataClient` — the one hook every app-shell write path takes its client from, so a single wiring covers `ResourceEditPage`, `StudioDesignSurface`, `EmbeddedItemEditor`, `DatasourceResourcePage`, `ObjectHooksPanel` and any future call site rather than a toast copied into twenty of them. The finding shape is re-exported from `@objectstack/spec` (`RuntimeAuthoringIssue`) rather than restated, so it cannot fork from the 422 `issues[]` it deliberately shares a declaration with.
1079
+
1080
+ The affordance is the warning tier and says "Saved" first. A successful save that reads as a failure is the specific defect this surface must not ship, so the toast acknowledges the write, lists `rule` + `message` + `hint` per finding with `where` as secondary context, and renders that text **verbatim** — `message` and `hint` are server prose composed by the gate's rules, not i18n keys. Only the frame around them is translated (`console.saveAdvisoryTitle`, ten packs). The sink is best-effort in both directions: a malformed finding is dropped rather than printed as blanks, and a throwing renderer cannot turn a save the server already committed into an error.
1081
+
1082
+ **What this does not surface yet, and why.** Studio's designer saves as a **draft** on every edit, and drafts are never gated — the framework returns at its D1 early-return (`if (args.state !== 'active') return null`) before running a single rule, so a draft save produces no findings at all rather than producing some that get withheld. The publish step that promotes a draft to active _does_ run the gate, but the publish route returns no `advisories` field until objectstack#7294 lands. So a draft-then-publish flow renders nothing today, at both of its doors, for two different reasons; the active-mode save door renders findings now. That gap is pinned as a test rather than left for a reader to rediscover.
1083
+
1084
+ - d46f9b8: i18n: `createSafeTranslation`'s provider-less fallback now honours a call site's inline `defaultValue`
1085
+
1086
+ `fallbackT` looked its key up in the hook's hand-written `defaults` map and, on a miss, rendered the
1087
+ **raw key** to the user — then ran every option, `defaultValue` included, through the interpolation
1088
+ loop as if it were a `{{defaultValue}}` variable. So `t('perm.facet.none', { defaultValue: 'None' })`
1089
+ showed `perm.facet.none` on a host with no `I18nProvider`, which is a supported scenario (standalone
1090
+ embedding and tests are the whole reason this factory exists).
1091
+
1092
+ The lookup order is now `defaults[key]` -> a string `defaultValue` -> the key, matching i18next,
1093
+ which serves the provider path: the defaults map is the pack value's stand-in here, so it keeps the
1094
+ pack's winning position. `defaultValue` is also excluded from the interpolation loop as a reserved
1095
+ name — it selects the string, it does not fill holes in one. Non-string `defaultValue` is ignored.
1096
+ The provider path is untouched.
1097
+
1098
+ Measured over all 26 `createSafeTranslation` hooks in the repo: 27 keys reach a hook whose defaults
1099
+ map lacks them, 21 of those carrying an inline `defaultValue` that used to be dropped (16 keys in
1100
+ `plugin-detail` alone). Those 21 now render their English instead of a raw key on provider-less
1101
+ hosts; the other 6 pass no inline default and still need a map or pack entry.
1102
+
1103
+ - 2fea4d2: `detail.showEmptyRelated` renders Russian and Arabic again — the "+N empty" button no longer falls through to English at the counts it takes most often
1104
+
1105
+ This was the repo's only pre-existing i18next plural family, and all ten packs defined exactly two slots: `_one` and `_other`. i18next asks `Intl.PluralRules` for the one suffix a language needs for that number, and when the pack has no such slot it walks `fallbackLng` to `en`. Russian has four plural categories and Arabic six, so `ru` at counts 2-4 (`few`) and 5-20, 25-30, … (`many`), and `ar` at 0, 2, 3-10 and 11-99, resolved nothing locally and rendered the English string. The call site is the collapsed-empties button in the record detail's reference rail, whose count is the number of empty related lists — 2 to 4 are the most common values it ever takes, so a Russian user essentially always read English.
1106
+
1107
+ The fix is a base key (no suffix) beside the two existing slots, in all ten packs. The base key is always in i18next's lookup chain, so every category a pack did not enumerate resolves to it, in that pack's own language — and, unlike adding `_few`/`_many` to `ru` alone, it keeps the ten packs' key sets identical, which full key parity requires. Same shape objectui#3546 slice six established for `perm.facet.*`. Where the base key is genuinely reachable it carries a count-invariant phrasing: `ru` uses the «Существительное: {{count}}» form the pack already writes 22 times, `ar` the «{{count}} مفرد(جمع)» marker it uses throughout. For `en`/`de`/`zh`/`ja`/`ko` the base key cannot be reached at all (their categories are covered by the two existing slots) and repeats `_other` for parity; `fr`/`es`/`pt` reach it only from a million up, where the plural form is already correct. No English copy moves.
1108
+
1109
+ The provider-less path needed the same row for a different reason: `createSafeTranslation`'s fallback resolves `defaults[key]` literally and never appends a plural suffix, so the two suffixed rows in plugin-detail's defaults table were unreachable through it and that path answered with the raw key. It now carries the base key too.
1110
+
1111
+ Parity across packs turned out to be necessary and not sufficient — ten identical key sets were green throughout, because the defect is one level below key names: the slot the language needs is not in the set. So the invariant "a plural family must carry a base key" is now asserted over all ten packs in `all-locales-key-parity.test.ts`, where it is pack-intrinsic and fails at PR time without needing a call site to exist. It went red on all ten packs before this change and names the family that is missing its base.
1112
+
1113
+ - 7f1cb33: List sort: the relational hint stops recommending a formula field, the one type the server refuses to sort by
1114
+
1115
+ The Sort panel withholds columns that link to another record and explains why, and the last sentence of that explanation named the remedy: _add a formula field holding it_. A formula field is exactly what the platform will not order by. The server keeps `UNMATERIALIZED_SORT_TYPES = new Set(['formula'])` and, since objectstack#6994, a sort naming one is a hard `400 INVALID_SORT` — before that it degraded silently, returning every row with `asc` and `desc` byte-identical. So an author who read the hint, followed it, and built a formula field arrived at a refusal; and since #4243 withheld formula fields from this very picker, at a field the panel does not offer either. Two doors, opposite advice, for one problem.
1116
+
1117
+ The remedy sentence now names a **stored, denormalised field — written when the source changes** — and rules the formula field out in as many words: it is virtual, no column is stored for it, and the server refuses to sort by one. That is deliberately the server's own vocabulary rather than a third phrasing of the same fact: objectstack#6924 and objectstack#6994 settled on one wording across the refusal doors so an author refused twice is not sent two different ways, and this is the UI door of that same set. The first half of the hint — why relation columns are withheld at all — is unchanged.
1118
+
1119
+ All ten locale packs move together, as `check:i18n-drift` requires of any `en` edit. The same sentence also lives in `plugin-list`'s provider-less fallback table, which is what renders when the component is used outside an `I18nProvider`; it is updated to match `en` byte for byte, because a pack-only reword would have left the retired advice on exactly the surface this fixes.
1120
+
1121
+ - 2e3b0c0: fix(list): an `OBJECT_API_DISABLED` list request renders an honest cannot-work state instead of the empty state
1122
+
1123
+ A list pointed at an object whose `enable` block withholds the API rendered its ordinary
1124
+ empty state, so _"this page cannot work, and never could"_ reached the user as _"you have no
1125
+ records"_ (objectui#4408). The reported instance — `Setup › Advanced › Signing Keys`, whose
1126
+ `sys_jwks` declares `enable.apiEnabled: false` — could not load for any persona and said so
1127
+ to nobody. That is also why the upstream defect objectstack#7544 survived review for its
1128
+ whole life: a merely unpopulated page invites nobody to click through.
1129
+
1130
+ The masking had two halves, in two packages, and neither package could see the other:
1131
+
1132
+ - **`@object-ui/data-objectstack`** (minor — see the grading note below) — `find()` degraded
1133
+ **every** 404 into `{ data: [], total: 0 }` and memoised the resource, so the denial arrived
1134
+ at the surface as a successful empty result, indistinguishable from a genuinely empty
1135
+ object. The two `enable`-block denials are now let through instead: `OBJECT_API_DISABLED`
1136
+ (404) and `OBJECT_API_METHOD_NOT_ALLOWED` (405). The memo skips them too — absorbing one
1137
+ would have pinned the object to "empty" for the rest of the session.
1138
+ - **`@object-ui/plugin-list`** — the load-error panel gained an `api-disabled` kind. The 405
1139
+ half was never swallowed, so it already reached this panel, but classified as `network`:
1140
+ _"check your connection and try again"_ for a condition no retry can change. It now says
1141
+ the object is not exposed through the API, that this is a setting on the object rather than
1142
+ a permission, and it offers **no Retry** button, because every retry re-fetches the
1143
+ identical refusal.
1144
+
1145
+ Both denials are pure functions of the object's metadata — no user, no permission, no
1146
+ context — so neither is transient or per-user, which is exactly the case where a silent empty
1147
+ state is most misleading. Discrimination is on the ADR-0112 `code`, never the status: a
1148
+ missing collection, a missing record and a disabled object are all 404.
1149
+
1150
+ **A genuinely empty object still renders the ordinary empty state**, and a backend without an
1151
+ optional collection still degrades to empty — pinned in both directions, at the adapter, at
1152
+ the view, and once end-to-end over a real adapter and a real `ListView`.
1153
+
1154
+ Also closes a code-propagation gap on the same path: `find()`'s raw `$expand`/`$search`
1155
+ branch bypasses `@objectstack/client` and hand-rolled its own error, stamping only `status`.
1156
+ It now carries the ADR-0112 envelope (`code` + `httpStatus`), so a denial arriving on the
1157
+ branch a list takes whenever it expands a lookup or runs a search is no longer anonymous.
1158
+
1159
+ New strings: `list.loadErrorApiDisabledTitle` / `list.loadErrorApiDisabledMessage`, in the
1160
+ `en` pack and mirrored in the list defaults map.
1161
+
1162
+ ## Grading note — why `@object-ui/data-objectstack` is **minor** and not patch
1163
+
1164
+ Two independent reasons, either of which is sufficient under this repo's precedent
1165
+ (objectui#4403 / #4177, and #4485's grading of `@object-ui/core`'s `toDomProps` lift):
1166
+
1167
+ 1. **The emitted `.d.ts` grows two NEW exports.** `isApiAccessDeniedError(error: unknown):
1168
+ boolean` and `API_ACCESS_DENIED_CODES` (the readonly tuple
1169
+ `['OBJECT_API_DISABLED', 'OBJECT_API_METHOD_NOT_ALLOWED']`) are added to the package's
1170
+ public surface. Additive surface growth is minor.
1171
+ 2. **Observable behaviour on a published API moves.** `ObjectStackDataSource.find()` now
1172
+ **REJECTS** for the two `enable`-block denial codes where it previously **RESOLVED** with
1173
+ `{ data: [], total: 0 }`. No signature changed and nothing was removed, but a caller that
1174
+ relied on those two codes arriving as a successful empty result now receives a rejected
1175
+ promise carrying `code` + `httpStatus`, and must handle it.
1176
+
1177
+ Deliberately unchanged, and still resolving to an empty result exactly as before: a bare 404
1178
+ with no code, `OBJECT_NOT_FOUND` (still memoised) and `RECORD_NOT_FOUND`. The behaviour move
1179
+ is scoped to the two denial codes named above and to nothing else.
1180
+
1181
+ Not major: this follows AGENTS.md's version-alignment rule — objectui's major tracks
1182
+ `@objectstack`'s, so this repo's own breaking semantics are declared as minor with the change
1183
+ described in the body, which is what this note is.
1184
+
1185
+ - 31ab1ac: fix(print): `window.print()` produces a usable page, and the Print buttons say what they do
1186
+
1187
+ The list, report and dashboard Print controls were bare `window.print()` calls with no
1188
+ print stylesheet, so the browser printed the whole console — sidebar, top bar, chat rail,
1189
+ toasts — with the data table clipped to a single viewport. With no label to the contrary
1190
+ they were being accepted against "export to PDF" requirements, which they have never been.
1191
+
1192
+ - `@object-ui/app-shell/styles.css` gains a shared `@media print` block: it hides the shell
1193
+ chrome, prints the active content area full-width, releases the viewport-height flex chain
1194
+ so long tables paginate instead of clipping, repeats table headers on every sheet, and
1195
+ neutralises dark mode (which otherwise prints white-on-white). One sheet serves list,
1196
+ report and dashboard.
1197
+ - The list and report Print buttons carry a tooltip and accessible name stating that they
1198
+ open the browser's own print dialog and are not a PDF export (new `common.printDialogHint`,
1199
+ translated in all ten locale packs).
1200
+ - The dashboard's `export_dashboard_pdf` action no longer toasts "Preparing PDF export…" —
1201
+ it names the print dialog it actually opens (`dashboardActions.pdfPreparing` is replaced by
1202
+ `dashboardActions.printDialogOpening`).
1203
+
1204
+ No control was removed and no headless detection was added. A real print/PDF primitive
1205
+ remains out of scope (`objectstack-ai/objectstack#1301`, closed NOT_PLANNED).
1206
+
1207
+ - 06915b0: fix(i18n): every date branch threads the active locale, so a `zh` session no longer renders half its dates in English
1208
+
1209
+ Date rendering had two locale channels and only one followed the user's
1210
+ language, so the same row could read `逾期 6 天` in one column and `In 3 days`
1211
+ in the next, with a datetime column showing `8/11/2026 12:00 am`
1212
+ (objectui#4468).
1213
+
1214
+ The overdue phrase resolves through the translate fn (the active UI language),
1215
+ while every `Intl` branch took its tag from the raw tenant locale
1216
+ (`useLocalization().locale`) — which is `undefined` on any workspace that never
1217
+ configured one, and `undefined` makes `Intl` use the _machine's_ locale.
1218
+ `DateTimeCellRenderer` passed no tag at all.
1219
+
1220
+ Every date-formatting site in `@object-ui/fields` now resolves through the one
1221
+ existing channel, `useDisplayLocale()` (tenant regional default → active UI
1222
+ language → `en`): `DateCellRenderer` (relative past, relative future, near-today
1223
+ and the beyond-±7-days absolute fallback), `DateTimeCellRenderer`, the read-only
1224
+ `DateField` / `DateTimeField` / `FormulaField` faces, and the sub-grid's
1225
+ temporal cells. English output is unchanged, and the already-localized overdue
1226
+ wording is untouched.
1227
+
1228
+ No public signature changed. `@object-ui/i18n` carries a documentation
1229
+ correction only: `useDisplayLocale`'s docstring claimed `DateCellRenderer`
1230
+ already formatted from this channel, which was the very thing that was not true.
1231
+
1232
+ - ff84b05: Stop the report config panel being titled "Title", and the view-settings colour section "Color"
1233
+
1234
+ Two call sites asked for a key whose value was written for a different slot, so the rendered copy was wrong (objectui#4118, surfaced by objectui#3810's census).
1235
+
1236
+ `ReportConfigPanel` used `report.editor.title` for both its heading and the accessible name of its `role="complementary"` landmark. That key is the label of the report's Title _field_ — `report.editor.titlePlaceholder` ('e.g. Pipeline by Quarter') sits directly under it in the pack. So the panel was headed "Title", and a screen reader announced a complementary region named "Title", which says nothing about what the region is. A new `report.editor.panelTitle` ('Edit report' — what the call site's own dead fallback said before objectui#3810 aligned it to the pack) now names the panel, in all ten locale packs.
1237
+
1238
+ `ViewSettingsPopover`'s colour section used `list.color`. On the wide toolbar `ListView` already uses both keys correctly for the two slots of this one feature: the compact `Paintbrush` button is `list.color` ('Color') and the panel it opens is headed `list.rowColor` ('Row Color'). This popover is that same panel on the collapsed/`compactToolbar` surface, so it now takes `list.rowColor` — an existing key, no pack change.
1239
+
1240
+ No `en` value of an existing key changed; `scripts/check-i18n-en-drift.mjs` reports 0 en values changed, 1 key added.
1241
+
1242
+ - Updated dependencies [ee66e2e]
1243
+ - Updated dependencies [ee26e65]
1244
+ - Updated dependencies [5900ac5]
1245
+ - Updated dependencies [3fc2971]
1246
+ - Updated dependencies [aca27fa]
1247
+ - Updated dependencies [dde7283]
1248
+ - Updated dependencies [f279deb]
1249
+ - Updated dependencies [eb7f586]
1250
+ - Updated dependencies [e901131]
1251
+ - Updated dependencies [d9d3463]
1252
+ - Updated dependencies [613b167]
1253
+ - Updated dependencies [abb0f81]
1254
+ - Updated dependencies [38ab505]
1255
+ - Updated dependencies [7e4f0e5]
1256
+ - Updated dependencies [92250d6]
1257
+ - Updated dependencies [c1d939f]
1258
+ - Updated dependencies [49ae9f4]
1259
+ - Updated dependencies [2459a3e]
1260
+ - Updated dependencies [d6aa172]
1261
+ - Updated dependencies [fe52a04]
1262
+ - Updated dependencies [bb68488]
1263
+ - Updated dependencies [9461dd3]
1264
+ - @object-ui/core@17.5.0
1265
+
1266
+ ## 17.4.0
1267
+
1268
+ ### Patch Changes
1269
+
1270
+ - 7864f03: Backfill the auth family's 54 missing locale keys — `auth` 26 + `oauth` 16 + `acceptInvitation` 12 (objectui#3546, slice three)
1271
+
1272
+ `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) measured 54 keys that a
1273
+ `t()` call site asks for and that **no locale pack defined** — 54 distinct keys at
1274
+ 54 call sites across the console's six auth pages. All 54 carried an inline
1275
+ `t(key, { defaultValue: 'English' })`, which is exactly the objectui#3517 class:
1276
+ English rendered correctly, and **all ten languages were stuck on it** for
1277
+ months. Nothing here rendered a raw key — slice one (PR #3583) held those sites.
1278
+
1279
+ What that meant on the page: a `zh` user reaching `/login` and switching to the
1280
+ phone/SMS branch got "Email or phone number", "Get code", "Resend in {seconds}s"
1281
+ and "Sign in with password instead" in English; the whole `/oauth/consent` screen
1282
+ — including the four scope sentences describing what a third-party client is
1283
+ about to be granted — was English-only; so was the `/accept-invitation` page and
1284
+ the device-authorization dead end.
1285
+
1286
+ - **`packages/i18n/src/locales/en.ts`** gains the 54 keys. `oauth.consent.*` and
1287
+ `acceptInvitation.*` are new top-level namespaces; the other 26 extend
1288
+ `auth.login`, `auth.forgotPassword`, `auth.device` and `auth.verifyEmail`.
1289
+ Every one of the 52 keys whose call site carries a **string** `defaultValue`
1290
+ gets that exact string, byte for byte (52/52, script-compared), so the pack
1291
+ path and the inline-default path cannot diverge. The two remaining keys —
1292
+ `oauth.consent.title` / `oauth.consent.request` — have **template**
1293
+ defaultValues, where byte identity is structurally impossible (JS `${…}` vs
1294
+ i18next `{{…}}`); both take the interpolation contract the call site actually
1295
+ declares in its options.
1296
+ - **The nine other packs** get real translations, each evidenced against a
1297
+ neighbour key in the same pack (fr's space before `?`/`:`, de's en dash, ru's
1298
+ ё, ar's verb-first placement so an RTL sentence does not open on a Latin
1299
+ client name, zh's full-width punctuation). The one string all ten packs share
1300
+ is `phonePlaceholder` — the E.164 example number, treated like the
1301
+ `name@example.com` the packs already keep untranslated.
1302
+ - **`scripts/i18n-call-site-key-baseline.json`** loses exactly those 54 entries
1303
+ (163 → 109). The file is a ratchet: an unfixed key missing from it fails the
1304
+ build, and a fixed key still listed fails it too.
1305
+ - **No component changed.** An AST sweep of all 122 call sites in these three
1306
+ namespaces found zero dead `t(key) || 'English'` fallbacks (the construct
1307
+ slice one had to delete from `ObjectView.tsx`, where i18next's key-as-value
1308
+ return made `||` unreachable).
1309
+
1310
+ Two holes here are **not** i18next's and must survive translation intact:
1311
+ `resendOtpCountdownText` carries `{seconds}` in single braces because
1312
+ `packages/auth/src/LoginForm.tsx` and `ForgotPasswordForm.tsx` substitute it with
1313
+ a literal `.replace()`, and `oauth.consent.request`'s `{{suffix}}` arrives
1314
+ pre-formatted from the page. Both are pinned in
1315
+ `packages/i18n/src/__tests__/auth-namespace-3546.test.tsx`, in both directions.
1316
+
1317
+ - f5f8744: Backfill the `console` namespace's 41 missing locale keys plus the `console.ai.group.` template family (objectui#3546, slice four)
1318
+
1319
+ `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) measured **41 distinct
1320
+ keys at 47 call sites** under `console.*` that a `t()` call site asks for and
1321
+ that **no locale pack defined** — five of those keys have more than one site
1322
+ (`console.ai.dock.maximize` has three), which is why the denominator is measured
1323
+ and never counted by hand. All 47 carried an inline
1324
+ `t(key, { defaultValue: 'English' })`, so this is the objectui#3517 class:
1325
+ English rendered correctly, and **all ten languages were stuck on it**. Nothing
1326
+ here rendered a raw key — slice one (PR #3583) held those sites.
1327
+
1328
+ What that meant on the page: a `zh` user opening `/ai` got "Waiting for
1329
+ server…", "Still working…" and "Connection lost — reconnecting…" in the
1330
+ connection banner, all ten "Designing your app…" progress hints in English, and
1331
+ "Built" / "Not yet built" / "Published" / "Publish failed" on the plan card; the
1332
+ ChatDock's whole chrome (title, resize handle, collapse, "Open full page") was
1333
+ English including two `aria-label`s; the conversation sidebar's date headers read
1334
+ "Today" / "Yesterday" / "Previous 7 days"; a mistyped URL produced an English
1335
+ "Page not found"; and the `?` shortcuts dialog's AI group was English inside an
1336
+ otherwise translated table. Two of these strings ("Not yet built", "TODAY") are
1337
+ named in objectui#2458's mixed-language list.
1338
+
1339
+ - **`packages/i18n/src/locales/en.ts`** gains 46 keys: the 41 measured ones plus
1340
+ the five members of the `console.ai.group.` family. `console.notFound` is a new
1341
+ sub-namespace; the rest extend `console.shortcuts` and `console.ai` (with new
1342
+ `console.ai.dock`, `console.ai.designingPlanHint` and `console.ai.group`
1343
+ objects). Every one of the 41 measured keys takes its call site's inline
1344
+ `defaultValue` **byte for byte** (46/47 sites, script-compared), so the pack
1345
+ path and the inline-default path cannot diverge.
1346
+
1347
+ - The one site that cannot match is `ChatDock.tsx:563`, where a single key
1348
+ (`console.ai.dock.open`) carries two different English strings: the
1349
+ `aria-label` says `Open assistant` and the `title` says
1350
+ `Open assistant (⌘⇧I)`. A key can hold one value, so `en` takes the
1351
+ `aria-label` spelling — an accessible name must not carry a glyph run that
1352
+ screen readers announce as symbols, and `⌘` is a mac-only glyph that a
1353
+ _language_ pack cannot vary per platform. The tooltip therefore stops
1354
+ advertising the shortcut; recorded on objectui#3810 (whose class this
1355
+ divergence joins) rather than papered over.
1356
+
1357
+ - **`console.ai.group.` leaves the ratchet's `missingPrefixes` (4 → 3).** It is a
1358
+ template key — `` t(`console.ai.group.${group.key}`) `` in
1359
+ `ConversationsSidebar.tsx:277` — whose static head matched no `en` key, so every
1360
+ expansion missed. Its value surface is the **closed** `ConversationGroupKey`
1361
+ union, so the repair is an enumeration of all five members, not a wildcard; a
1362
+ test reads the component's own union and label map and fails if a sixth bucket
1363
+ is ever added without a key.
1364
+
1365
+ - **The nine other packs** get real translations, each evidenced against its own
1366
+ `console` neighbours: `zh` full-width punctuation and the pack's single-em-dash
1367
+ status-line style, `ja`/`ko` the pack's `AI ` spacing, `de` formal _Sie_ and
1368
+ its `Wird …` progressive, `fr` straight apostrophes, `es` _usted_ (as the
1369
+ `console.ai` neighbourhood already uses), `pt` the pack's `off-line` spelling,
1370
+ `ru` ё orthography, `ar` verb-first phrasing that never opens an RTL sentence
1371
+ with a Latin token. Where `en` repeats a string the packs already translate
1372
+ (`Go back`, `Publish failed`, `Try again`, `Back to home`, `Assistant`,
1373
+ `Today`, `Yesterday`), the existing neighbour's wording is reused rather than
1374
+ re-invented.
1375
+
1376
+ - **`scripts/i18n-call-site-key-baseline.json`** shrinks by exactly 42 entries
1377
+ (41 keys + 1 prefix family): 109 → 68 keys, 4 → 3 prefixes.
1378
+
1379
+ No component changed: an AST sweep of all 308 `console.*` call sites in the repo
1380
+ found zero dead `t(key) || 'English'` fallbacks among this slice's keys.
1381
+
1382
+ - 69becd2: `de` approvals inbox no longer shows two quote typographies on one screen
1383
+
1384
+ Three `approvalsInbox` values quoted the record title with ASCII straight quotes
1385
+ on **both** sides while their own sibling `approvalsInbox.approveOneTitle` used
1386
+ the correct German pair (U+201E low-9 opener, U+201C closer):
1387
+
1388
+ | key | before | after |
1389
+ | ------------------------------- | ----------------------- | ----------------------- |
1390
+ | `approvalsInbox.rejectOneTitle` | `"{{title}}" ablehnen?` | `„{{title}}“ ablehnen?` |
1391
+ | `approvalsInbox.inlineApproved` | `"{{title}}" genehmigt` | `„{{title}}“ genehmigt` |
1392
+ | `approvalsInbox.inlineRejected` | `"{{title}}" abgelehnt` | `„{{title}}“ abgelehnt` |
1393
+
1394
+ A German approver therefore met both typographies in a single screen and inside a
1395
+ single operation pair — German quotes on the approve confirmation, typewriter
1396
+ quotes on the reject confirmation and on both inline toasts. `„…“` is the pack's
1397
+ own majority (50 paired spans) and DUDEN R11, so the three values were the
1398
+ outlier. Keys and the `{{title}}` placeholder are unchanged; this is a
1399
+ value-domain typography fix, and `en` (ASCII on both sides by design) is
1400
+ untouched.
1401
+
1402
+ This is a **different defect shape** from objectui#3876, which paired a German
1403
+ opener with an ASCII closer. Because these three were ASCII on both sides they
1404
+ were self-consistent, so the pairing invariant objectui#3876 left behind could
1405
+ not see them — it scans forward from each `„`, and there was no `„` in them to
1406
+ scan from. The pack's straight-quote census being empty now lets that pin become
1407
+ strictly stronger: `de-quote-pairing-3876.test.ts` asserted an explicit
1408
+ three-key allowlist and now asserts that **no `de` value holds a U+0022 at all**,
1409
+ which covers both defect shapes and needs no per-key maintenance as new values
1410
+ land. The pack census moves from `„` 47 / `“` 47 / `”` 0 / `"` 6 to
1411
+ `„` 50 / `“` 50 / `”` 0 / `"` 0.
1412
+
1413
+ None of the three i18n gates could have caught this: `all-locales-key-parity`
1414
+ compares key sets and placeholder shapes, `check-i18n-call-site-keys.mjs` only
1415
+ asks whether a key resolves, and `check-i18n-en-drift.mjs` fires on `en` value
1416
+ changes — these values were wrong from the day they landed, so no drift event
1417
+ ever existed. All three are value-blind by design, which is why the invariant
1418
+ lives in a test.
1419
+
1420
+ - 5e52495: German pack: the 20 values that closed the German opening quote with an ASCII straight quote now close it with `“`
1421
+
1422
+ `packages/i18n/src/locales/de.ts` opened a quoted span with the German low-9
1423
+ quote `„` (U+201E) and closed it with the ASCII typewriter quote `"` (U+0022) in
1424
+ 20 keys, so a German user read `Registerkarte „Alle Datensätze" anzeigen` — a
1425
+ mismatched pair. `search.resultsCount` showed it most plainly: the value ended
1426
+ `„{{query}}""`, the mismatched closer immediately followed by the TS string
1427
+ terminator.
1428
+
1429
+ This was never a pack convention. Measured on `main@2937bcf7d` before the fix,
1430
+ the same file already spelled 23 spans the correct German way, `„…“` — the two
1431
+ styles sat side by side, in sibling keys of the same namespace. Every mismatched
1432
+ closer is now `“` (U+201C), which is what German orthography (DUDEN R11) and the
1433
+ majority of the file already used. Affected surfaces: the four empty states
1434
+ (object / page / dashboard / report not found), the search results header, the
1435
+ lookup "create named" action, four `console.objectView` strings, the home
1436
+ getting-started hint, the six `navigationSync` toasts, the local marketplace
1437
+ install toast, and the preview not-ready title.
1438
+
1439
+ No other language pack and no `en` value changed — the counts are unchanged for
1440
+ every other locale, and the mismatch was measured only in `de`.
1441
+
1442
+ ### Counted at landing, in two units
1443
+
1444
+ The card reported "20 values" and the triage re-scan reported "22 mismatched
1445
+ pairs"; both are correct about different units, and the difference is now
1446
+ recorded rather than left to the next reader. 20 keys carried a mismatch;
1447
+ 22 mismatch occurrences lived in them, because `navigationSync.renamedPage` and
1448
+ `navigationSync.renamedDashboard` each quote two names in one sentence
1449
+ ("Seite „alt“ in „neu“ umbenannt") and so contribute two each. Full-file counts
1450
+ before → after: `„` 45 → 45, `“` 25 → 47, `”` 2 → 2, `"` 28 → 6.
1451
+
1452
+ ### The durable half, and why it is not the count equality the card proposed
1453
+
1454
+ Three i18n gates run over these packs and none can see a wrong quote:
1455
+ `all-locales-key-parity` compares key sets and placeholder shapes,
1456
+ `check-i18n-call-site-keys.mjs` only asks whether a key resolves, and
1457
+ `check-i18n-en-drift.mjs` fires on `en` **value changes** — these values were
1458
+ wrong from the day they landed, so no drift event ever existed. Without a
1459
+ value-domain assertion the next backfill copies the mismatch from a neighbour
1460
+ again, which is exactly how these 20 accumulated.
1461
+
1462
+ The card proposed asserting `count(„) === count(“)`. That is **false on the
1463
+ correctly fixed file** (45 vs 47) and would have sent the next reader hunting a
1464
+ bug that is not there: two values in this pack, `grid.import.savedMappingHint`
1465
+ and `grid.import.savedMappingPreviewNote`, are still untranslated English prose
1466
+ and quote in the English style `“…”`, so their two `“` are legitimate _openers_.
1467
+ The invariant that is pinned instead is the pairing itself — for every `„`, the
1468
+ first quote character that follows must be `“` — backed by the arithmetic
1469
+ identity `count(“) === count(„) + count(”)`, which stays true if those two values
1470
+ are later translated (47 === 47 + 0) and breaks the moment a mismatch returns.
1471
+ Both carry a presence guard so a broken import cannot make them pass by scanning
1472
+ nothing.
1473
+
1474
+ - b750823: `preview.draftBar` speaks one second person in `es` — the draft-preview banner no
1475
+ longer switches from tú to usted when a Spanish user publishes (#3844)
1476
+
1477
+ `DraftPreviewBar` renders two mutually exclusive sentences in the same strip of
1478
+ the same banner: `message` while there are unpublished changes, `messageClean`
1479
+ once there are none. In `es` the two disagreed on register — `message` was tú
1480
+ (`estás viendo`, `publiques`) while `messageClean` (`ve`) and `sampleDataBody`
1481
+ (`Está`, `su`, `Publíquela`) were usted. So a Spanish user who pressed Publish
1482
+ watched the banner change person: same component, same position, same session.
1483
+
1484
+ This is a third defect class in the value-domain blind spot behind #3582 and
1485
+ #3625, and no gate in the repo can see it. Both `es` values are correct
1486
+ translations of their `en` sentences — nothing is missing, nothing is stale, and
1487
+ nothing holds English. The inconsistency is _internal to one pack, on one UI
1488
+ surface_: `scripts/check-i18n-call-site-keys.mjs` only asks whether a key exists
1489
+ in `en`, `all-locales-key-parity` compares key sets and placeholder shapes and
1490
+ never reads values, and `scripts/check-i18n-en-drift.mjs` only fires when an `en`
1491
+ value moves — these two `en` values never moved.
1492
+
1493
+ `message` is the value that changes, because usted is what the pack already says
1494
+ everywhere around it: the `es` pack censuses 102 usted markers to 30 tú (tú being
1495
+ the marked exception, concentrated in the auth, report-editor and organizations
1496
+ neighbourhoods that #3546 slice two deliberately ruled informal); the other three
1497
+ strings of this same object were already usted; and #3546 slice five gave the 19
1498
+ new `preview.unpublishedBar.*` / `preview.history.*` keys usted on the strength
1499
+ of `home.pendingDrafts.published` ("¡Publicado! Sus cambios están activos."). Two
1500
+ smaller divergences inside the same sentence are closed with it, so the banner's
1501
+ two halves stop disagreeing about wording as well as person:
1502
+
1503
+ - **`—` instead of `:`** in `messageClean`, matching `en`, where both sentences
1504
+ open `Draft preview — `. That is the whole of `messageClean`'s diff.
1505
+ - **`activo` instead of `en producción`** for "live". The pack spells this concept
1506
+ `activo` in four neighbouring places including `publishCta` in this very object
1507
+ ("Publicar para verlo activo"), and it reserves _producción_ for the actual
1508
+ production environment (`environment.entitlement.planLockedBody`), so the
1509
+ outlier was ambiguous as well as inconsistent.
1510
+ - **`Vista previa del borrador`** as the shared opening, `del` being the form
1511
+ `messageClean` already used.
1512
+
1513
+ No `en` value changes (the en-drift gate reports 0), no key is added or removed
1514
+ (so `all-locales-key-parity` is untouched by construction), and the nine other
1515
+ packs are not touched. The diff is two values in one file.
1516
+
1517
+ Re-voicing the whole `preview` namespace to tú was considered and rejected — a
1518
+ much larger change that would collide with the adjacent `marketplace.*` (9:0
1519
+ usted) and `console.ai.*`. `preview.empty.notReadyDescription` therefore stays tú
1520
+ here, since #3844's ruling is this banner only; it is filed as #3875 rather than
1521
+ waved through, because `PreviewDraftEmptyState` renders _underneath_
1522
+ `DraftPreviewBar` and so those two show usted and tú on screen at the same time.
1523
+ After this change the `preview` namespace is 23 usted to that 1 tú.
1524
+
1525
+ A gate that checks "one register per namespace" is deliberately **not** here:
1526
+ recognising usted vs tú needs real morphology, and token matching demonstrably
1527
+ cannot — in this pack the token `revisa` is usted in `console.ai.empty.build`
1528
+ (es.ts:1365, "…y usted revisa y publica.") and tú in
1529
+ `auth.forgotPassword.successTitle` (es.ts:1918, "Revisa tu correo electrónico"),
1530
+ the same eight letters in opposite registers. The neighbourhood boundary such a
1531
+ gate would police is human judgement anyway — #3546 slice two's "same rule,
1532
+ different answer". A new `draftBar-es-register-3844.test.ts` pins the four `es`
1533
+ values byte for byte instead, plus the `en` literals, so a future reword of
1534
+ either `en` sentence fails in the same PR that reworded it.
1535
+
1536
+ - ac2139c: `grid.import` saved-mapping copy is now translated in ko / de / fr / es / pt / ru / ar instead of served as English
1537
+
1538
+ Five keys — `grid.import.savedMapping`, `chooseSavedMapping`, `manualMapping`,
1539
+ `savedMappingHint` and `savedMappingPreviewNote` — were **byte-identical to `en`**
1540
+ in seven of the nine non-`en` packs, 35 values in all. Only zh and ja had been
1541
+ translated, and two of the five are multi-clause explanatory sentences rather
1542
+ than placeholders or proper nouns, so this was not a deliberately untranslated
1543
+ term: a German, French, Spanish, Portuguese, Russian, Korean or Arabic user
1544
+ picking a saved mapping in the import wizard read English.
1545
+
1546
+ None of the three i18n gates could see it. `all-locales-key-parity` compares key
1547
+ sets and placeholder shapes, so English passed perfectly; the call-site gate only
1548
+ asks whether a key resolves; `check-i18n-en-drift.mjs` fires on an `en` **value
1549
+ change** and these keys arrived in the packs already English, so no drift event
1550
+ ever existed. objectui#3920 also proposed a general "no non-`en` value may be
1551
+ byte-equal to `en`" gate; that is deliberately not part of this change, because
1552
+ values like `auth.*.emailPlaceholder` (`name@example.com`),
1553
+ `fields.image.counter` (`{{current}} / {{total}}`) and
1554
+ `grid.import.templateFileName` are byte-equal across packs correctly, and
1555
+ objectui#3880 records the other face of the family. The five keys are pinned by
1556
+ name instead.
1557
+
1558
+ Each translation reuses vocabulary its own pack already ships rather than
1559
+ inventing a second word for the same concept: `stepMapping` for "mapping"
1560
+ (Zuordnung / Correspondance / Asignación / Mapeamento / Сопоставление / تعيين /
1561
+ 매핑), `legacyFallbackNotice` for "type coercion" (Typkonvertierung / conversion
1562
+ de type / conversión de tipos / conversão de tipos / приведение типов / تحويل
1563
+ الأنواع / 형 변환), `view.readonlyTooltip` for "read-only", and `validateHint`
1564
+ for "on the server". `es` follows the usted ruling objectui#3844 measured for
1565
+ this pack; `pt` is the pack's own pt-BR (mapeamento / salvo / no servidor);
1566
+ `ru` keeps its ё; `fr` keeps the straight apostrophe and the space before colon
1567
+ and semicolon; `ar` punctuates with U+060C and U+061B; `ko` uses the 은(는) /
1568
+ 이(가) spelling its `fields.file.exceedsMaxSize` already uses for a placeholder
1569
+ whose particle cannot be resolved at authoring time.
1570
+
1571
+ The quote around `{{name}}` follows each pack's measured majority for wrapping a
1572
+ placeholder — de `„…“` (38 spans against 3 ASCII), fr `« … »` with an ASCII
1573
+ space (25 against 18), ru `«…»` (23 against 18), and ASCII `"…"` for ko (38:0),
1574
+ pt (39:0), es (22:19) and ar (27:15). The card cited
1575
+ `residue-namespaces-3546.test.tsx` as pinning "the rest ASCII", but that line
1576
+ pins the `empty.*` family only and its own comment scopes the rule to "the
1577
+ sibling value in the SAME pack"; `grid.import` has no other quoted span, so the
1578
+ census one ring out decides.
1579
+
1580
+ Germanising the two `de` sentences also closes the 45/47 gap
1581
+ `de-quote-pairing-3876.test.ts` documented: that pack's census moves from
1582
+ `„` 45 / `“` 47 / `”` 2 to `„` 47 / `“` 47 / `”` 0, exactly as that file's header
1583
+ predicted, and its `count(“) === count(„) + count(”)` identity still holds. The
1584
+ three literal counts in it were updated and the two keys are now pinned by name
1585
+ there, so `rdqKeys === []` cannot become an assertion that passes because nothing
1586
+ is produced.
1587
+
1588
+ `en`, `zh` and `ja` are untouched, and `grid.import.transform` — the sixth key in
1589
+ the same block, English in the same seven packs, and the third column header of
1590
+ the very table this hint sits above — is deliberately left alone, filed as
1591
+ objectui#3938 and pinned as still-English so the number cannot drift while that
1592
+ issue waits its turn.
1593
+
1594
+ - b14ab3a: `grid.import.transform` is now translated in ko / de / fr / es / pt / ru / ar instead of served as English
1595
+
1596
+ The third column header of the import wizard's saved-mapping summary was
1597
+ **byte-identical to `en`** — the literal string `Transform` — in seven of the
1598
+ nine non-`en` packs. zh (`转换`) and ja (`変換`) had translated it, which is what
1599
+ rules out the "deliberately untranslated term" reading; this is the sixth key of
1600
+ the same six-line block objectui#3920 / PR #3936 fixed the other five of, and it
1601
+ was left out of that card only to keep its 5-keys / 35-values census verifiable.
1602
+
1603
+ | pack | before | after | the pack's own anchor |
1604
+ | ---- | ----------- | ---------------- | ---------------------------------------------------------------------------------------- |
1605
+ | ko | `Transform` | `변환` | `savedMappingHint` "이름 변경 + 변환 + 형 변환" |
1606
+ | de | `Transform` | `Transformation` | `savedMappingHint` "Umbenennung + Transformationen + Typkonvertierung" |
1607
+ | fr | `Transform` | `Transformation` | `savedMappingHint` "le renommage + les transformations + la conversion de type" |
1608
+ | es | `Transform` | `Transformación` | `savedMappingHint` "el cambio de nombre + las transformaciones + la conversión de tipos" |
1609
+ | pt | `Transform` | `Transformação` | `savedMappingHint` "renomeação + transformações + conversão de tipos" |
1610
+ | ru | `Transform` | `Преобразование` | `savedMappingHint` "переименование + преобразования + приведение типов" |
1611
+ | ar | `Transform` | `التحويل` | `savedMappingHint` "إعادة التسمية + التحويلات + تحويل الأنواع" |
1612
+
1613
+ Each value is the **singular of the word the pack already uses** for `en`'s
1614
+ plural "transforms" in `grid.import.savedMappingHint` — the sentence
1615
+ `SavedMappingSummary` renders directly above this header. That is the anchor
1616
+ rather than `legacyFallbackNotice`'s term, because `en`'s own sentence lists
1617
+ "transforms" and "type coercion" as two different server-side operations and
1618
+ `legacyFallbackNotice` is about the second: reusing de "Typkonvertierung", ko
1619
+ "형 변환" or ar "تحويل الأنواع" for this header would name the wrong operation.
1620
+
1621
+ Why it was worth a card of its own rather than waiting for a general gate: this
1622
+ value is not a hidden string. It is the third `TableHead` of
1623
+ `SavedMappingSummary`, sitting in one row with `csvColumn` and `mapsTo`, which
1624
+ were always translated — so a German user read `Spalte` / `Zugeordnet zu` /
1625
+ `Transform`, two languages in a single header row, directly under the German
1626
+ hint PR #3936 had just landed. Before that PR the whole panel was English and
1627
+ therefore at least self-consistent.
1628
+
1629
+ The three i18n gates are value-blind here exactly as they were for
1630
+ objectui#3920: `all-locales-key-parity` compares key sets and placeholder shapes
1631
+ (the key was present with no placeholder, so English passed perfectly), the
1632
+ call-site gate only asks whether a key resolves, and `check-i18n-en-drift.mjs`
1633
+ fires on an `en` **value change** — this value entered the seven packs already
1634
+ English, so no drift event ever existed. `en`, zh and ja are untouched, and the
1635
+ pin PR #3936 left in `gridImportSavedMapping-i18n-3920.test.ts` asserting these
1636
+ seven were "still English" has been replaced by the translated pin it asked for.
1637
+
1638
+ - 8c60819: The inbox popover now spells out what the bell badge is made of
1639
+
1640
+ The bell badge is `unread notification topics + pending approvals`, clamped to
1641
+ "9+" above nine. As one number it is unexplainable: objectstack#7213 measured
1642
+ Home's "pending approvals" card saying 8 while the bell said "9+", and read that
1643
+ as the two counts disagreeing — they never did, the bell was simply carrying a
1644
+ second addend the user could not see.
1645
+
1646
+ The popover already tabs the two streams and puts a count pill on each tab, so
1647
+ the split was partly visible — but those pills clamp at "9+" too. A loaded
1648
+ console therefore showed three "9+"s that reconcile to nothing, which is why
1649
+ sectioning alone did not close this.
1650
+
1651
+ A breakdown line under the popover header now states the exact, unclamped
1652
+ addends beside the exact total — `15 total · 12 notifications + 3 pending
1653
+ approvals`. The approvals half is the same `pendingApprovalsCount` the Home card
1654
+ and the Approvals Inbox tab read, so the number a user reconciles against is
1655
+ literally the one they see elsewhere.
1656
+
1657
+ The badge formula, the counting APIs and the "9+" clamp on the badge itself are
1658
+ unchanged — this is a display fix. Three new keys
1659
+ (`notifications.badgeTotal` / `badgeNotifications` / `badgeApprovals`) land in
1660
+ all ten locale packs. They interpolate named placeholders (`{{total}}`,
1661
+ `{{unread}}`, `{{approvals}}`) rather than i18next's `{{count}}`, which would
1662
+ additionally drive plural-key resolution these packs carry no forms for.
1663
+
1664
+ - e64a52e: 回填 `perm` + `home` 两命名空间 14 个缺失语言 key,十个语言包补齐(#3546 切片六)
1665
+
1666
+ `scripts/check-i18n-call-site-keys.mjs` 实测:14 个不重复 key / 14 个调用点(1:1),分布在权限集
1667
+ 记录的授权面摘要(`PermissionFacetLink`,ADR-0056 P1)与"一键发布全部草稿"的其余几条 toast
1668
+ (`usePublishAllDrafts`:ADR-0038 L3 探针健康、示例数据健康、ADR-0066 ⑨ 能力引用巡检)。
1669
+
1670
+ 这 14 处都带内联 `defaultValue`,所以英文一直正常渲染,**十种语言都翻不了** —— 修的是这一半。
1671
+ 英文字串一字未改:9 处静态 `defaultValue` 与 en 逐字节相同;另外 4 个计数标签(对象 / 字段规则 /
1672
+ RLS 策略 / 标签页规则)的 `defaultValue` 是带英文单复数分支的模板串,改用 i18next 复数族
1673
+ (`key` + `key_one`)后逐个计数渲染结果与模板串一致。
1674
+
1675
+ 复数族刻意带**基础 key** 而不是只写 `_one`/`_other`:i18next 只按语言的 CLDR 类别取一个后缀,
1676
+ 取不到就沿 fallback 链落到 `en`。ru(few 2-4、many 5-20)与 ar(two、few 3-10、many 11-99)
1677
+ 恰好在用户最先遇到的计数上会因此显示英文;基础 key 让这些类别落回**本语言**。
1678
+
1679
+ - 844d17f: Backfill the `marketplace` and `preview` namespaces' 37 missing locale keys plus the `marketplace.disclosure.runtime.` template-key family (objectui#3546, slice five)
1680
+
1681
+ `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) measured 37 keys that a
1682
+ `t()` call site asks for and that **no locale pack defined** — 37 distinct keys at
1683
+ 37 call sites across five console components — plus one `missing-prefix` family
1684
+ whose static head matched no `en` key at all, so every expansion missed. All 37
1685
+ carried an inline `t(key, { defaultValue: 'English' })`, which is exactly the
1686
+ objectui#3517 class: English rendered correctly, and **all ten languages were
1687
+ stuck on it** for months. Nothing here rendered a raw key — slice one (PR #3583)
1688
+ held those sites.
1689
+
1690
+ What that meant on the page for a `zh` (or `ja`, `de`, `ar`, …) user: the
1691
+ marketplace's "Your organization" strip, its Install / Installing… / Installed
1692
+ buttons and the version-update affordances were English; the whole ADR-0025 PD4
1693
+ **pre-install permission disclosure** was English — "This package contains code",
1694
+ the trust-tier badge, "Reviewed & approved" / "Not yet reviewed" / "Signed", the
1695
+ four permission group labels (platform services, lifecycle hooks, network,
1696
+ filesystem) and the consent checkbox the user ticks to accept them; the ADR-0045
1697
+ unpublished-app banner and its publish toasts were English; and the entire
1698
+ ADR-0067 build-history sheet — title, description, the per-commit labels, the
1699
+ Revert button and both of its result toasts — was English.
1700
+
1701
+ `marketplace.disclosure.runtime.` is repaired as an **enumeration, not a
1702
+ wildcard**: its value surface is the closed trust-tier enum
1703
+ (`PluginRuntimeSchema` = `z.enum(['node', 'sandbox', 'worker'])`, ADR-0025 §3.6),
1704
+ so all three members are backfilled and the family leaves the ratchet's
1705
+ `missingPrefixes` (3 → 2). A test reads the component's own fallback map and
1706
+ fails if a fourth tier is ever added without a key — the job the prefix entry
1707
+ used to do.
1708
+
1709
+ Each `en` value is byte-identical to the inline `defaultValue` it replaces (36 of
1710
+ 36 literal sites; the 37th's `defaultValue` is a template literal whose
1711
+ `${pkg.display_name}` becomes the `{{name}}` hole its call site already passes),
1712
+ so no English string a user sees today changes. The nine translations follow each
1713
+ pack's own neighbourhood — including two namespaces that legitimately take
1714
+ **different** second persons in `zh` (`marketplace` 你, `preview` 您) — and reuse
1715
+ an existing neighbour's translation wherever the `en` string already existed
1716
+ verbatim, so one English string never renders as two different sentences in the
1717
+ same language.
1718
+
1719
+ No component changed: an AST sweep of the whole `marketplace.*`/`preview.*`
1720
+ call-site surface found the slice's own dead-`||`-fallback count to be zero.
1721
+
1722
+ - 4dcd52a: `console.objectView.systemViewReadonly` and `console.objectView.expandToPage`
1723
+ are translated in the eight packs that stored English for them, so a Japanese,
1724
+ Korean, German, French, Spanish, Portuguese, Russian or Arabic session reads
1725
+ the system-view hint and the expand affordance in its own language (#3582).
1726
+
1727
+ This is a different defect class from #3546's ledger, and no gate in the repo
1728
+ could see it. There the key was _missing_ from a pack and `fallbackLng: 'en'`
1729
+ rendered English; here the key was **present in all ten** and eight of them
1730
+ stored English as the value. `all-locales-key-parity` compares key _sets_ and
1731
+ placeholder _shape_ — identical before and after this change, and neither key
1732
+ interpolates anything. `scripts/check-i18n-call-site-keys.mjs` and its baseline
1733
+ ratchet ask whether a `t()` key exists in `en`; it did, so these two were never
1734
+ in the 258.
1735
+
1736
+ `systemViewReadonly` carried the sharper half: the eight packs did not hold
1737
+ `en`'s sentence, they held one `en` had already abandoned. `en` says the view
1738
+ is read-only; the eight said `System view defined in code - duplicate to
1739
+ customize.` — pointing eight locales at a duplicate-to-customize path the
1740
+ product no longer presents. They are translated against `en`'s **current**
1741
+ read-only meaning, not against the stale English they replaced.
1742
+
1743
+ Each value is built from terminology the same pack already uses rather than
1744
+ invented: `view.readonlyTooltip` supplies "system view" and
1745
+ `console.objectView.cannotEditMetaView` (landed in #3583) supplies "defined in
1746
+ code", so the new hint agrees with the copy beside it in every pack. For
1747
+ `expandToPage`, `detail.openAsFullPage` is the identical English sentence one
1748
+ namespace over and was already translated everywhere — `en` and `zh` hold their
1749
+ two byte-identical to each other, so the eight now do too, and one locale
1750
+ cannot end up with two different words for one action.
1751
+
1752
+ `en` and `zh` are unchanged, byte for byte. No key is added or removed —
1753
+ the diff is 16 values in 8 files. A new
1754
+ `objectView-value-language-3582.test.ts` pins the `en` literal (so the next
1755
+ rewording of `en` fails loudly instead of silently orphaning nine
1756
+ translations), asserts that no pack but `en` serves either English spelling,
1757
+ and requires the zh/ja/ko/ru/ar values to contain characters of their own
1758
+ script. The repo-wide "no ASCII English sentence in a non-Latin pack" gate that
1759
+ #3582 also sketched is deliberately **not** here; it is a separate, lands-green
1760
+ change.
1761
+
1762
+ - 42ae5c6: The organization-management console is translatable. The 90 keys under
1763
+ `organization.*` — the org layout and its tabs, the members list, the whole
1764
+ invitation flow, organization settings including the leave and delete
1765
+ confirmations, the accept-invitation page, and the workspace switcher — are now
1766
+ defined in all ten locale packs, so a non-English session reads the org admin
1767
+ surface in its own language instead of English (part of #3546).
1768
+
1769
+ `scripts/check-i18n-call-site-keys.mjs` measured 258 keys that a `t()` call site
1770
+ asks for and no pack defines. `organization.*` was the largest namespace in that
1771
+ tally at 90 keys across 93 call sites in seven components. Every one of them
1772
+ carried an inline `t(key, { defaultValue: 'English' })`, which is why nothing
1773
+ looked broken: English rendered correctly at each site and all ten languages
1774
+ were pinned to it. That is the #3517 class, not the raw-key class slice one
1775
+ (#3583) held — no organization site rendered an identifier, and none had a dead
1776
+ `||` fallback to remove, which was measured before deciding not to touch the
1777
+ components.
1778
+
1779
+ Adding a `defaultValue` is deliberately not the fix; it is the mechanism that
1780
+ kept these invisible for months. The existing defaults stay where they are, and
1781
+ each `en` value is byte-identical to the default at its call site so the two
1782
+ paths cannot render different text.
1783
+
1784
+ `organization` is a new top-level namespace, sitting next to — and distinct
1785
+ from — `organizations`: the singular one is the management surface, the plural
1786
+ one the org picker. The ratchet in `scripts/i18n-call-site-key-baseline.json`
1787
+ shrinks by exactly these 90 entries, from 253 to 163. The
1788
+ `organization.invitations.status.*` template-key family is untouched and still
1789
+ baselined: enumerating an invitation status set is a different repair from
1790
+ backfilling literal keys.
1791
+
1792
+ - 6d762da: The five locale keys behind #3546's eight no-fallback `t()` call sites are now defined in all ten packs, so the built-in-view toasts, the activity-timeline source link, the wizard's required-field toast and the Gantt refresh button's accessible name are translated instead of falling back to English — or, on two surfaces, to the key itself (part of #3546).
1793
+
1794
+ `scripts/check-i18n-call-site-keys.mjs` measured 258 keys that a `t()` call site asks for and no pack defines. These five were the subset with no working inline default: `console.objectView.cannotEditMetaView`, `console.objectView.cannotDeleteMetaView`, `detail.viewSource`, `gantt.toolbar.refresh` and `wizard.missingRequired`. Adding a `defaultValue` is deliberately not the fix — that mechanism is what kept all 258 invisible for months.
1795
+
1796
+ **Two of the eight sites really did render the raw key**, and both go through a binding with nothing in front of i18next. `ObjectView.tsx` calls `useObjectTranslation()` directly, so five toasts read `console.objectView.cannotEditMetaView` / `cannotDeleteMetaView` on screen; the `|| 'Built-in views cannot be renamed.'` guards next to them were dead on every path, because i18next answers a miss with the key itself and a non-empty string never falls through `||`. Those four unreachable English strings are removed rather than repaired: one key served four call sites (rename / pin / set-as-default / configure), so the pack copy covers any change to a built-in view instead of naming one operation. `RecordActivityTimeline.tsx` fails the same way for a subtler reason — `useDetailTranslation` is `createSafeTranslation(..., 'detail.back')`, and because `detail.back` does resolve, the probe hands back i18next's `t` for every key and bypasses the defaults map wholesale, so `detail.viewSource` reached the user verbatim.
1797
+
1798
+ **The other two sites were not rendering a raw key**, contrary to the issue's description, and are fixed here as the milder "English in all ten languages" class. `wizard.missingRequired` is its own hook's probe key, so the probe failed and `createSafeTranslation` correctly served its English default. `gantt.toolbar.refresh` goes through `useGanttTranslation`, which deliberately does not use `createSafeTranslation` and falls back per key — so the refresh button's `aria-label` was "Refresh", in English, never the key. Screen-reader users heard an English word rather than an identifier; a `zh` session now hears 刷新.
1799
+
1800
+ Regression cover is provider-mounted on purpose: with no `I18nProvider` the defaults maps answer every one of these keys and the assertions pass while the console is broken, which is precisely the false-green the issue documents. For the two sites whose English output was already correct, `en` cannot discriminate before from after — the `zh` assertions are the ones that pin the fix.
1801
+
1802
+ - f9faa7d: Backfill the last 17 missing locale keys and both remaining template-key families, emptying the call-site key ratchet (objectui#3546, slice seven — final)
1803
+
1804
+ `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) opened this backlog with
1805
+ **258 keys and 4 template-key families** that a `t()` call site asks for and that
1806
+ **no locale pack defined**. Seven slices later the last of it is paid: this change
1807
+ takes the ratchet from 17 keys to **zero** and from 2 prefix families to **zero**,
1808
+ and the gate now reports every one of the **2320** literal call-site keys
1809
+ resolving against `en`.
1810
+
1811
+ The residue was the long tail — nine namespaces across `app-shell`,
1812
+ `plugin-detail`, `plugin-dashboard`, `plugin-kanban` and `plugin-gantt`, none of
1813
+ them big enough to have been its own slice. 17 distinct keys at **23** call sites
1814
+ (five keys are used at more than one site) plus **3** call sites behind the two
1815
+ families.
1816
+
1817
+ What that meant on the page for a `zh` (or `ja`, `de`, `ar`, …) user: the "App not
1818
+ available" empty state a user lands on when an app is still publishing, including
1819
+ its whole explanation and its Retry button; the interface page's "source is not
1820
+ available" message; the system navigation's **Administration** group header,
1821
+ **Datasources** and **Documentation** entries; the "creating new organizations is
1822
+ disabled on this instance" guard in the workspace dialog; the invitation list's
1823
+ five status labels (All / Pending / Accepted / Rejected / Canceled) on both the
1824
+ filter tabs and every invitation badge; the Gantt dependency-drag hint that names
1825
+ which endpoint the drop will link (`start` / `end`); the record detail's Add,
1826
+ "Record deleted", "No history yet" and the concurrent-update dialog's "this
1827
+ record"; the kanban empty board's column count; the dashboard widget's screen
1828
+ reader "Loading…"; and the page editor's "Edit in studio" tooltip and accessible
1829
+ name. All of it rendered English, in every one of the ten languages.
1830
+
1831
+ Nothing here rendered a raw key — slice one (PR #3583) held those sites, and the
1832
+ three keys the issue body named as unprotected (`detail.viewSource`,
1833
+ `wizard.missingRequired`, `gantt.toolbar.refresh`) have resolved in `en` since.
1834
+
1835
+ Both families are repaired as **enumerations, not wildcards**, and the assertion
1836
+ that used to live in the ratchet's `missingPrefixes` moves into a test that fails
1837
+ if either union grows a member without a key:
1838
+
1839
+ - `gantt.linkEnd.` — the closed union `'start' | 'end'`, declared by GanttView's
1840
+ own `linkDrag` state.
1841
+ - `organization.invitations.status.` — `StatusFilter`
1842
+ (`all | pending | accepted | rejected | canceled`), declared by InvitationsPage.
1843
+
1844
+ Every `en` value is byte-identical to the English the call site rendered before,
1845
+ so no string a user sees today changes: 16 keys match an inline
1846
+ `t(key, { defaultValue: … })`; `dashboard.loading` matches `useSafeTranslate`'s
1847
+ positional fallback `tt(key, 'Loading…')`; `gantt.linkEnd.*` match
1848
+ `useGanttTranslation`'s per-key fallback map; and the five status labels match the
1849
+ CSS-capitalised wire value each badge and tab showed. The nine translations follow
1850
+ each pack's own neighbourhood and reuse an existing neighbour's row wherever the
1851
+ `en` string already existed verbatim **and** that row is grammatical here — the
1852
+ four invitation adjectives are deliberately not reused from the approvals family,
1853
+ because those agree with each pack's word for "request" (`ru` masculine `Отклонён`)
1854
+ while an invitation needs its own agreement (`ru` neuter `Отклонено`).
1855
+
1856
+ `scripts/i18n-call-site-key-baseline.json` is kept rather than deleted: empty is
1857
+ its terminal, load-bearing state — against an empty baseline any NEW unresolved
1858
+ call-site key is unexpected and fails the build.
1859
+
1860
+ No component changed.
1861
+
1862
+ - 33526fd: `view.readonlyTooltip` — the tooltip on a view tab's read-only lock — is
1863
+ retranslated in the eight packs (ja/ko/de/fr/es/pt/ru/ar) that still described
1864
+ the retired "duplicate to customize" workflow, so a Japanese, Korean, German,
1865
+ French, Spanish, Portuguese, Russian or Arabic session is told the view is
1866
+ defined in code and read-only, which is what `en` says and what the product
1867
+ does (#3625).
1868
+
1869
+ This is the same stale sentence #3582 fixed one namespace over, but it hid
1870
+ behind a much better disguise. In #3582 the eight packs stored the **English**
1871
+ string, so two cheap criteria could see it: "value equals `en`" and "a
1872
+ non-Latin pack holds pure ASCII". Neither can see this key. Its eight values
1873
+ were **idiomatic translations** — real Japanese, real Cyrillic, real Arabic —
1874
+ of a sentence `en` itself had already abandoned. Nothing about their form was
1875
+ wrong; only their meaning was. Key sets were complete, so
1876
+ `all-locales-key-parity` was green; the key exists in `en`, so the call-site
1877
+ guard and its ratchet were green; the values are distinct and in their own
1878
+ scripts, so every heuristic #3582 sketched would have been green too. Eight
1879
+ locales spent those releases pointing users at a path the product no longer
1880
+ offers, with every gate reporting success.
1881
+
1882
+ Each value is translated against `en`'s **current** meaning and built from
1883
+ words the same pack already uses — "read-only" from its own `view.readOnly` /
1884
+ `view.readonlyAriaLabel`, "defined in code" from
1885
+ `console.objectView.systemViewReadonly` / `cannotEditMetaView` — so the tooltip
1886
+ agrees with the copy beside it instead of introducing a ninth way to say
1887
+ read-only. Nothing is rewritten from the stale text.
1888
+
1889
+ `en` and `zh` are unchanged, byte for byte, and no key is added or removed —
1890
+ the diff is eight values in eight files. A new
1891
+ `viewReadonlyTooltip-semantics-3625.test.ts` tests **meaning** rather than
1892
+ form, in both directions: no pack may name the duplicate/copy workflow in its
1893
+ own language, and every pack must positively carry all three pieces of the
1894
+ sentence ("system view", "defined in code", "read-only") so the negative check
1895
+ cannot pass on a gutted string. It also pins the `en` literal, so the next
1896
+ rewording of `en` fails in the PR that does the rewording rather than orphaning
1897
+ nine translations for another release — which is the invariant this family of
1898
+ defects has actually been missing.
1899
+
1900
+ - 32413ec: Resolve `_views` translation keys by the bare view name only — the prefixed full name is no longer a second candidate
1901
+
1902
+ `useObjectLabel().viewLabel` / `viewDescription` / `viewEmptyState` build their key by stripping the object prefix off the runtime view id (`crm_opportunity.pipeline_kanban` → `objects.crm_opportunity._views.pipeline_kanban.<tail>`). Until now, if that bare key missed, the resolver **also** tried the prefixed full name — `objects.crm_opportunity._views.crm_opportunity.pipeline_kanban.<tail>` — so a bundle authored against the prefixed spelling resolved too.
1903
+
1904
+ **Behavior change:** it no longer does. A `_views` entry keyed by the prefixed full name is not read at all; the label falls back to the metadata default, exactly as it would if no translation had been written. Bundles keyed by the bare view name — the only spelling the extractor emits and `os lint` accepts — are unaffected.
1905
+
1906
+ This closes an asymmetry, not a feature. The server-side resolver reads the one bare key (objectstack#5165), so a prefixed-key bundle produced a **translated label in the Console and English everywhere else**: the REST boundary, mobile, plain HTTP and SDUI consumers do not run this second resolution pass. The half-success was harder to notice than a clean miss, and it fossilized a second de-facto spelling of a key the platform has now converged on: per the objectstack#5164 ruling (2026-08-06, option A), the canonical `_views` key is the runtime view identity's bare name, with the i18n extractor deriving it from the view composer (objectstack#6124) and `packages/lint` enforcing that single spelling (objectstack#6038). This is the third and last leg of that convergence.
1907
+
1908
+ The object-name axis is untouched: a bundle written against the short object name (`objects.opportunity._views.…`) still resolves when the runtime presents the namespaced name (`crm__opportunity`).
1909
+
1910
+ **If a label stopped translating after this upgrade,** its `_views` key is written with the object prefix. Drop the prefix — `_views.crm_opportunity.pipeline_kanban.label` becomes `_views.pipeline_kanban.label`. `os lint` names these for you: a prefixed key is reported as `translation-target-unknown`, because no view of the object declares it.
1911
+
3
1912
  ## 17.3.0
4
1913
 
5
1914
  ### Minor Changes
@@ -249,6 +2158,7 @@ first`. The form renderer passes a new `dependsOnLabels` widget prop (the
249
2158
  ten packs missing it identically kept parity fully green.
250
2159
 
251
2160
  ## What changed
2161
+
252
2162
  - `createTargetOrg` is backfilled into `en` as `Creates in {{org}}`, which makes
253
2163
  the parity gate demand it from the other nine; each is translated to its pack's
254
2164
  existing `form`-section tone rather than copied or machine-filled.
@@ -711,7 +2621,7 @@ plan` sentence users reported (cloud#959). Both spec builders now take a transla
711
2621
 
712
2622
  ### Patch Changes
713
2623
 
714
- - 752e18f: fix(console,app-shell): readable reassign hand-off + "System" label for svc:* audit actors — objectstack#4365 / objectstack#4366
2624
+ - 752e18f: fix(console,app-shell): readable reassign hand-off + "System" label for svc:\* audit actors — objectstack#4365 / objectstack#4366
715
2625
 
716
2626
  - **Approvals inbox** (`ApprovalsInboxPage`): a reassign timeline entry now
717
2627
  renders "from A to B" from the structured
@@ -1423,6 +3333,7 @@ os_tianshun_ehr_position — use a unique value or the record id` — with the f
1423
3333
  is byte-identical, it just comes from the pack now.
1424
3334
 
1425
3335
  Adds two guards, both mutation-verified:
3336
+
1426
3337
  - `en` ↔ `zh` full key parity, asserted in both directions. The other eight
1427
3338
  packs are still ~357 keys behind and are tracked separately (objectui#2872
1428
3339
  part a), so they are deliberately not asserted yet.
@@ -2357,6 +4268,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2357
4268
  wired by `RecentItemsProvider` + `useTrackRouteAsRecent` +
2358
4269
  `RecordDetailView`). Multi-device by construction: open a record on
2359
4270
  laptop, see it in `⌘K → Recently viewed` on phone.
4271
+
2360
4272
  - Group renders only when input is empty (no competition with search).
2361
4273
  - Limited to the 5 most recent record-type entries.
2362
4274
  - New i18n key `console.commandPalette.recentRecords` (en + zh seeded;
@@ -2366,6 +4278,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2366
4278
 
2367
4279
  When `useRecordSearch` is mid-flight (debounced fetch across objects
2368
4280
  hasn't returned yet), the palette now surfaces a subtle visual:
4281
+
2369
4282
  - A small pulsing primary-coloured dot next to the **Records** group
2370
4283
  heading, so the user sees that more results may still appear.
2371
4284
  - A `Searching…` placeholder inside the empty state when the user has
@@ -2379,6 +4292,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2379
4292
  - Stage chevron (`record:path`): bump completed-stage contrast (emerald-800 text on emerald-500/15, was 700 on /10) and future-stage text from `foreground/70` to `foreground/85` for legibility.
2380
4293
  - i18n: add `notifications.emptyUnread`, `notifications.filterUnread`, `notifications.filterAll` (en + zh) so the InboxPopover Unread/All sub-filter renders in the active locale.
2381
4294
  - 5425608: CRM UX polish pass — calmer enterprise look across detail + kanban.
4295
+
2382
4296
  - **plugin-kanban**: column headers now use a 2px muted accent stripe with
2383
4297
  neutral foreground titles + a quiet grey count pill instead of full
2384
4298
  rainbow gradient + colored title + colored count. Pipeline boards
@@ -2410,6 +4324,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2410
4324
  `defaultCurrency` is set on the field/column, formatting is unchanged.
2411
4325
 
2412
4326
  Fixed call sites:
4327
+
2413
4328
  - `@object-ui/fields`: `formatCurrency`, `formatCompactCurrency`, and
2414
4329
  `CurrencyCellRenderer` no longer default-param `'USD'`.
2415
4330
  - `@object-ui/i18n`: `formatCurrency()` falls back to `formatNumber`
@@ -2433,6 +4348,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2433
4348
  ### Minor Changes
2434
4349
 
2435
4350
  - 49b1760: Polish the ConcurrentUpdateDialog and add i18n.
4351
+
2436
4352
  - Internationalise all dialog strings (title, body, button labels, "your edit" / "current value" headings, audit-trail line) through `useDetailTranslation`. Locale strings added to `@object-ui/i18n` for English and Chinese.
2437
4353
  - Replace the plain dialog header with an amber warning badge + `AlertTriangle` icon to communicate that this is a conflict, not a routine confirmation.
2438
4354
  - Visually differentiate the two value blocks: amber tint for the user's pending edit, sky tint for the server's current value. Both wrap long values cleanly.
@@ -2498,6 +4414,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2498
4414
  default detail view. They were missing cross-cutting affordances and
2499
4415
  shipped with English-only tab labels and heavy bordered section cards
2500
4416
  even when the host locale was Chinese. Track 1 closes the visible gap:
4417
+
2501
4418
  - **app-shell `RecordDetailView`**: the `assignedPage` branch now wears
2502
4419
  the same chrome as the default branch — lifecycle managed-by badge
2503
4420
  and presence avatars in the top-right, `MetadataPanel` debug panel,
@@ -2553,6 +4470,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2553
4470
  The matrix/summary "Group by" (rows) and "Columns axis" (cols) sections now
2554
4471
  share the same searchable popup picker as the columns section, with a
2555
4472
  commit-on-select single-pick mode wired through `FieldPickerDialog`.
4473
+
2556
4474
  - Per-row field buttons display the human-readable field label and open a
2557
4475
  dialog scoped to swap that single field (already-used fields filtered out)
2558
4476
  - "Add grouping" trigger uses the same dialog
@@ -2562,6 +4480,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2562
4480
  - Bigger row spacing (h-7 / text-xs) — the old `text-[10px]` was unreadable
2563
4481
 
2564
4482
  `FieldPickerDialog` gains:
4483
+
2565
4484
  - `commitOnSelect`: hides the Confirm/Cancel footer; clicking a row commits
2566
4485
  - closes immediately (intended for `singleSelect` flows)
2567
4486
  - `trigger`: custom trigger element override (used by the per-row field button)
@@ -2590,6 +4509,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2590
4509
  in the spec.
2591
4510
 
2592
4511
  New exports from `@object-ui/plugin-report`:
4512
+
2593
4513
  - `JoinedBlocksEditor` — standalone component for embedding the
2594
4514
  block editor anywhere.
2595
4515
  - `validateJoinedBlocks` — pure helper returning translated
@@ -2621,6 +4541,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2621
4541
  The report configuration panel is now safe to open on any spec-shape `Report` and only exposes fields that are actually persisted by `@objectstack/spec`.
2622
4542
 
2623
4543
  `@object-ui/plugin-report`:
4544
+
2624
4545
  - Add a bidirectional `SpecFilterAdapter` so `ReportConfigPanel` can edit
2625
4546
  spec `FilterCondition` filters (`{field: value}`, `{field: {$op: value}}`,
2626
4547
  top-level `$and`/`$or`). Complex / nested filters fall back to a
@@ -2642,12 +4563,14 @@ actionName, spec)`: overlays translated `title` / `description` /
2642
4563
  - 18 new unit tests cover the filter adapter round-trip.
2643
4564
 
2644
4565
  `@object-ui/components`:
4566
+
2645
4567
  - `FilterBuilder` now guards against malformed external `value` props.
2646
4568
  Previously a spec-shape filter (`{is_active: true}`) would crash the
2647
4569
  component on first render; the builder now falls back to an empty
2648
4570
  AND group whenever `value` is not a valid `FilterGroup`.
2649
4571
 
2650
4572
  `@object-ui/i18n`:
4573
+
2651
4574
  - Add `report.editor.*` strings to `en` and `zh`.
2652
4575
 
2653
4576
  - 8442c05: Improve report editor panel usability based on real-user browser testing:
@@ -2727,6 +4650,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2727
4650
  no visible effect — the user/session were nulled but the UI stayed authenticated.
2728
4651
 
2729
4652
  Changes:
4653
+
2730
4654
  - **`@object-ui/auth`** — added `isAuthEnabled: boolean` to `AuthContextValue`
2731
4655
  (`true` only when real auth is in use, `false` for guest/preview modes).
2732
4656
  - **`@object-ui/app-shell`** — `AppHeader` and `AppSidebar` now hide the "Log out"
@@ -2744,6 +4668,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2744
4668
  - fd15918: Comprehensive i18n refactor + CI test fix.
2745
4669
 
2746
4670
  **i18n (`@object-ui/i18n`)**
4671
+
2747
4672
  - Added ~130 new keys under 12 new top-level namespaces: `layout`, `search`,
2748
4673
  `empty`, `renderer`, `actionDialog`, `rowAction`, `navigationSync`,
2749
4674
  `objectActions`, `objectViewActions`, `dashboardActions`, `recordDetail`,
@@ -2755,6 +4680,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2755
4680
 
2756
4681
  **App shell (`@object-ui/app-shell`)** — replaced hardcoded English in 14
2757
4682
  files with `useObjectTranslation`:
4683
+
2758
4684
  - Layout: `AppSidebar`, `ActivityFeed` (locale-aware relative time),
2759
4685
  `MetadataInspector`.
2760
4686
  - Views: `SearchResultsPage`, `ActionParamDialog`, `RecordFormPage`,
@@ -2766,12 +4692,14 @@ actionName, spec)`: overlays translated `title` / `description` /
2766
4692
  `useObjectActions` (delete confirm + success / failure toasts).
2767
4693
 
2768
4694
  **Plugin grid (`@object-ui/plugin-grid`)**
4695
+
2769
4696
  - `ObjectGrid` record-detail panel now translates Empty / Yes / No / System
2770
4697
  via the existing `useGridTranslation` safe-fallback wrapper.
2771
4698
  - `RowActionMenu` adopts a local safe-fallback i18n wrapper for
2772
4699
  `Open menu` / `Edit` / `Delete`, preserving standalone-usage guarantees.
2773
4700
 
2774
4701
  **CLI test fix (`@object-ui/cli`)**
4702
+
2775
4703
  - `cli-bin.test.ts` auto-builds the package on first run when `dist/cli.js`
2776
4704
  is missing, instead of throwing. This unbreaks `pnpm test:coverage` in CI
2777
4705
  (root vitest run does not honor turbo's `^build` deps) and removes the