@object-ui/i18n 17.1.0 → 17.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,591 @@
1
1
  # @object-ui/i18n
2
2
 
3
+ ## 17.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0554e88: The console language choice now survives a reload. `I18nProvider` writes every language change to `localStorage` (`objectui-locale`, exported as `LOCALE_STORAGE_KEY`) and boots the next session in that language, so switching to 中文/日本語/… is no longer reverted to `en` by the next F5 or new tab (objectstack#5406).
8
+
9
+ Bootstrap precedence is **stored choice → browser language → `defaultLanguage`**: an explicit choice outranks browser detection, and a stored value the app no longer offers is ignored and purged rather than locking the UI to a locale with no translations. The restore lands in the instance's bootstrap language, so `<html lang>` — and therefore the `Accept-Language` header on the first wave of API calls — is already correct on the first render.
10
+
11
+ New public surface on `@object-ui/i18n`: `persistLanguage` (default `true`; set `false` for fixed-language previews/demos), `LOCALE_STORAGE_KEY`, and `readStoredLanguage()` for apps that bootstrap their own i18next instance.
12
+
13
+ - 5af2852: The record detail page now shows a read-gated approval panel (#3461). A record in approval used to expose NOTHING about the running approval to anyone but the current pending approver — `useRecordApprovals` was consumed solely to inject the header Approve/Reject buttons, while the pending-approver list, decision progress, and the `sys_approval_action` timeline existed only in the Approval Center's drawer, a `setup`-app surface that business roles can't navigate to (and whose backing object is tenant-wide, so granting read there is over-broad). The submitter couldn't tell whom to nudge; the record's own audit history was no help either, since the engine mirrors business fields as `runAs:'system'` and decisions never enter record history. The new surface is an **Approvals tab** on the record page — a peer of Details/Related (same promotion Attachments got in objectstack#4358), emitted by `buildDefaultTabs` only when the record actually has requests, with a request-count badge and the label localizing through the tab strip's KNOWN_LABEL_DICT (审批). The tab wraps the new `record:approvals` node (`RecordApprovalsPanel`), visible to EVERY viewer who can read the record: current flow/step with the enriched flow-steps strip, server-computed decision progress (quorum tally, per-group 会签 ticks), the waiting-on chips with server-resolved names and group labels (never raw ids), one chronological action timeline merged across all of the record's requests (a multi-level flow opens one request per node), decision comments and attachments, and an inline remind button for the submitter (`viewer.is_submitter`, with an id-match fallback for older backends) that POSTs the existing `/approvals/requests/:id/remind`. The host threads its live `useRecordApprovals` read through the node so the tab and the header decision buttons never disagree; on authored pages the `record:approvals` renderer self-fetches, and an authored page that omits the node gets a bottom-of-page fallback append so the approval story is never lost to a custom layout. Copy reuses the Approval Center's `approvalsInbox.*` keys so the two surfaces can't drift; `useRecordApprovals` now exposes the full `requests` array plus `listApprovalActions` / `remindApprovalRequest`, and its `ApprovalRequestLite` carries the display enrichment (`process_label`, `step_label`, `flow_steps`, `viewer`, `round`) the single-read endpoint already sent.
14
+
15
+ ### Patch Changes
16
+
17
+ - b71fc92: Localize the last untranslated console-chrome accessible names (objectstack#5430)
18
+
19
+ Four icon-only controls still carried hardcoded English accessible names, so
20
+ under a non-English session they were the only English left in the record
21
+ chrome — and because the controls have no visible label, that literal _is_ the
22
+ control to a screen reader and to the hover tooltip.
23
+
24
+ - `page:header`'s `role="toolbar"` — now `detail.pageHeaderActions` (its `⋯`
25
+ overflow trigger eight lines below was fixed in #5407; the toolbar was missed)
26
+ - `ReactionPicker`'s `role="listbox"` popup — now `detail.emojiPicker`
27
+ - `ReactionPicker`'s per-reaction chip, which built its name by concatenation
28
+ with English pluralization baked in (`reaction${count !== 1 ? 's' : ''}`) —
29
+ now `detail.reactionCount` / `detail.reactionCountOne`
30
+ - `NavigationOverlay`'s drawer close and split-panel close — now `common.close`
31
+ (the key the rest of the console already uses) and `common.closePanel`
32
+
33
+ The pluralized label follows this repo's **two-key** convention
34
+ (`detail.relatedRecords`/`relatedRecordOne`, `lookup.recordCount`/`recordCountOne`)
35
+ rather than an i18next `_one`/`_other` pair: zh/ja/ko have no separate singular
36
+ form, so those packs would legitimately omit the `_one` half and
37
+ `all-locales-key-parity` would read that as a lost key.
38
+
39
+ All five new keys are added to all ten locale packs.
40
+
41
+ - 65516ba: Name `CommentThread`'s three emoji-only buttons, and follow the session language past the 7-day mark (objectui#3441)
42
+
43
+ Two leftovers from objectstack#5506 / objectui#3424, in the same component. It
44
+ is an exported, published component with no in-repo consumer, so both only ever
45
+ bite an external host.
46
+
47
+ **One — three controls with no authored accessible name.** Each comment's two
48
+ quick-reaction buttons (`'👍'` and `'❤️'`) and the reply banner's dismiss button
49
+ (`'✕'`) carried no `aria-label` and no `title`. The `+` reaction picker right
50
+ beside them has had one since #3424 (`collaboration.addThumbsUp`), which is what
51
+ makes these three an omission rather than a design choice.
52
+
53
+ `aria-label`, not the `title` the `+` uses: a `button`'s accessible name is
54
+ computed from its CONTENT (accname §2F) before the `title` tooltip is ever
55
+ consulted (§2I), so on a button whose only child is a glyph a `title` decorates
56
+ the mouse and leaves the name alone. What a screen reader read out was the
57
+ codepoint — "thumbs up", "red heart", in English whatever the session language,
58
+ and for U+2715 MULTIPLICATION X very often nothing at all.
59
+
60
+ Three new keys in all ten packs: `collaboration.reactThumbsUp`,
61
+ `collaboration.reactHeart`, `collaboration.cancelReply`.
62
+
63
+ `reactThumbsUp` is deliberately NOT a reuse of `addThumbsUp`, even though both
64
+ dispatch the same `onReaction(id, '👍')` today. `addThumbsUp` names the reaction
65
+ bar's picker entry point, whose copy follows the picker if it ever picks; and on
66
+ any comment that already has reactions the two controls are on screen together,
67
+ so one shared key would put two visibly different buttons under one name.
68
+ `cancelReply` rather than the generic `common.cancel` for the same reason — an
69
+ accessible name has to say what is being cancelled (only the reply target is
70
+ dropped; anything typed into the composer survives).
71
+
72
+ **Two — the >= 7 day timestamp ignored the session language.** `formatTimestamp`
73
+ ended in a bare `date.toLocaleDateString()`, i.e. the RUNTIME's locale, so a
74
+ `zh` session read "6 天前" for a six-day-old comment and `8/1/2026` for an
75
+ eight-day-old one.
76
+
77
+ The fix passes the session `language`, but not straight through — that is the
78
+ trap #3424 flagged and declined to walk into. `toLocaleDateString(tag)`
79
+ canonicalizes its argument and throws `RangeError` on anything not well-formed
80
+ per BCP 47, and the session language reaches the component verbatim: a host that
81
+ configures `defaultLanguage: 'en_US'` (the POSIX spelling — well-formed-looking,
82
+ and rejected) hands `Intl` a tag it refuses. That `RangeError` would land in
83
+ `formatTimestamp`'s outer `catch`, whose fallback is `return iso`, replacing the
84
+ date with a raw `2026-08-01T09:30:00.000Z` — worse than the un-localized date it
85
+ set out to fix.
86
+
87
+ So the absolute-date branch gets its own local `try`/`catch` that falls back to
88
+ the no-argument call. A malformed tag degrades to exactly the previous
89
+ behaviour (the runtime's own locale); the worst case of following the session
90
+ language is the status quo, never a regression. A well-formed but unknown tag
91
+ such as `xx-YY` does not throw at all — `Intl` resolves it to the default — so
92
+ only genuinely malformed tags reach the guard. No date library, and no month or
93
+ weekday copy in the locale packs: `Intl` already owns the per-locale ordering
94
+ and separators.
95
+
96
+ Tests assert the computed accessible name via `getByRole('button', { name })`
97
+ rather than the presence of an attribute, which is the distinction the fix turns
98
+ on, and pin that no button is left answering to a bare emoji. The malformed-tag
99
+ case is recorded honestly as green on both sides of this change — `origin/main`
100
+ never passed a tag anywhere, so it could not trip over a bad one; its
101
+ counterfactual is the naive fix, and dropping the inner `catch` is what turns it
102
+ red with the raw ISO string in the DOM.
103
+
104
+ - 94c5b7c: Localize `@object-ui/collaboration` — `CommentThread` no longer hardcodes English (objectstack#5506)
105
+
106
+ `@object-ui/collaboration` depended only on `@object-ui/types` and carried every
107
+ user-visible string as an English literal, so a `zh` console rendered a Chinese
108
+ shell around an English comment thread: "3 comments", "Reply", "Resolve",
109
+ "just now", "Add a comment... (use @ to mention)".
110
+
111
+ The package now takes `@object-ui/i18n` as a dependency and exposes one
112
+ translation seam, `useCollaborationTranslation` /
113
+ `COLLAB_DEFAULT_TRANSLATIONS`, built on `createSafeTranslation` — the same
114
+ factory `data-table`, `form` and `filter-builder` use. Under an `I18nProvider`
115
+ it resolves the session locale; with no provider it resolves the English
116
+ defaults map, which is what keeps `CommentThread` usable standalone. There is
117
+ deliberately no `formatter`/label prop escape hatch: a host that wants
118
+ different copy overrides the locale keys, so one thread can never end up half
119
+ translated by the bundle and half by props.
120
+
121
+ The issue listed 13 sites. A site-by-site sweep of the file found **20** — the
122
+ seven the original sweep missed are `{n}h ago`, `{n}d ago`, `(edited)`, the
123
+ thread's own comment count, the `Oldest`/`Newest` sort options,
124
+ `Replying to {name}...`, and the composer's `Send` button. All 20 are keyed
125
+ here; leaving any behind would have shipped a thread that is 90% translated.
126
+
127
+ Two of them carry a second defect on top of being untranslated: the plural
128
+ **rule** was compiled into the component, not just the words.
129
+
130
+ - the header read `` `${n} comment${n !== 1 ? 's' : ''}` ``;
131
+ - the reaction chip tooltip read `` n === 1 ? '1 reaction' : `${n} reactions` ``.
132
+
133
+ Both produced correct _English_ — this is not the "1 items" bug objectui#3423
134
+ fixed on the tab badge — but the choice between the two forms was English
135
+ grammar hardwired into the render path. No locale could apply its own: ru needs
136
+ three forms and ja needs none, and neither could ever be expressed no matter
137
+ what the packs said.
138
+
139
+ Both now use the repo's **two-key** plural convention
140
+ (`collaboration.commentCount`/`commentCountOne`,
141
+ `collaboration.reactionCount`/`reactionCountOne`) rather than an i18next
142
+ `_one`/`_other` pair: zh/ja/ko have no separate singular form, so those packs
143
+ would legitimately omit the `_one` half and `all-locales-key-parity` reads a
144
+ legitimately-absent half as a lost key. Counts are interpolated as strings, so
145
+ i18next skips its own plural resolution and the two-key scheme stays in charge.
146
+
147
+ The reaction tooltip gets a **dedicated** key pair rather than reusing
148
+ `detail.reactionCount`: that one interpolates `{{emoji}}`, and at this call
149
+ site the emoji is the chip's visible label with nothing to hand the
150
+ placeholder — reuse would have left a literal `{{emoji}}` in the accessible
151
+ name under every locale.
152
+
153
+ Relative timestamps stayed word-level: the existing minute/hour/day buckets are
154
+ untouched and no date library was introduced. The `>= 7d` branch still uses the
155
+ runtime's own `toLocaleDateString()` — that is not a hardcoded English literal,
156
+ and pinning it to the session language has its own failure mode (an
157
+ unrecognised tag throws into the surrounding `catch`, which would render a raw
158
+ ISO string), so it is tracked separately.
159
+
160
+ `Save` / `Cancel` / `Edit` / `Delete` read from the shared `common` namespace
161
+ instead of being re-spelled under `collaboration` — they are the generic action
162
+ words, already translated in all ten packs, and a second spelling would only be
163
+ a second thing to keep in sync. The 21 genuinely new keys are added to all ten
164
+ locale packs with real translations.
165
+
166
+ - ca0fa8f: Localize `PresenceAvatars` — the avatar stack's accessible name and tooltips follow the session language (objectui#3440)
167
+
168
+ objectui#3424 wired `@object-ui/collaboration` up to `@object-ui/i18n` but only
169
+ converted `CommentThread`. `PresenceAvatars` in the same package kept three
170
+ English literals, and it is not a dormant export — the console renders it in
171
+ two places: `app-shell/src/layout/AppHeader.tsx` (tenant presence beside the
172
+ lifecycle badge) and `app-shell/src/views/RecordDetailView.tsx` (who else is on
173
+ this record). A `zh` session got them in English.
174
+
175
+ The three sites:
176
+
177
+ - the group's `aria-label`, `` `${n} user${n !== 1 ? 's' : ''} present` ``;
178
+ - the overflow badge's tooltip, `` `${n} more user${n !== 1 ? 's' : ''}` ``;
179
+ - each avatar's tooltip, `` `${name} (${status})` ``.
180
+
181
+ The first one is the whole control as far as a screen reader is concerned: the
182
+ stack renders images and initials and nothing else, so there was no other
183
+ accessible name to fall back on.
184
+
185
+ As with the comment count in #3424, the first two carried a second defect on
186
+ top of being untranslated — the plural **rule** was compiled into the component.
187
+ Both produced correct _English_ (each has a real singular branch, so this is
188
+ not the "1 items" defect objectui#3423 fixed on the tab badge), but
189
+ `n !== 1 ? 's' : ''` is English grammar in a render path and no locale could
190
+ apply its own. Both now use the repo's **two-key** plural convention
191
+ (`collaboration.presentUserCount`/`presentUserCountOne`,
192
+ `collaboration.moreUserCount`/`moreUserCountOne`) rather than an i18next
193
+ `_one`/`_other` pair, with the count interpolated as a string so i18next skips
194
+ its own plural resolution. German is what witnesses the move: "1 anwesender
195
+ Benutzer" vs "2 anwesende Benutzer" inflects the adjective, which the deleted
196
+ ternary could not have produced for any pack.
197
+
198
+ The avatar tooltip becomes a single `collaboration.userStatusTitle` key
199
+ (`{{name}} ({{status}})`) so the parentheses and their spacing belong to the
200
+ translation — the CJK packs drop the space English puts before `(`, matching
201
+ their existing `edited: '(已编辑)'`.
202
+
203
+ Its `status` is a **display-layer** translation
204
+ (`collaboration.statusActive` / `statusIdle` / `statusAway`): the
205
+ `PresenceUser['status']` enum value stays raw data everywhere it is stored,
206
+ compared or passed around — including the `statusColors` lookup — and is
207
+ translated only at this render exit. A status outside the declared union
208
+ renders as itself, the raw string: presence users arrive from a host-supplied
209
+ `PresenceSource` transport, so an unmapped value is reachable at runtime
210
+ whatever the type says, and the fallback invents nothing rather than leaving an
211
+ empty bracket pair.
212
+
213
+ Eight new keys, added to all ten locale packs with real translations.
214
+
215
+ - 3889ffb: Console chrome i18n gaps (objectstack#5407).
216
+
217
+ - A dependency-gated lookup now names its controlling field by its **label**
218
+ instead of its raw API name. The sentence was localized but the interpolated
219
+ name was not, so every locale — English included — read `Select crm_account
220
+ first`. The form renderer passes a new `dependsOnLabels` widget prop (the
221
+ lookup-side counterpart of `emptyHint`, which it already resolves to labels
222
+ for the fixed-option widgets); a name the host does not cover still falls
223
+ back to itself.
224
+ - The page-header overflow trigger's `More actions` accessible name now reads
225
+ `detail.moreActions`, the same key `action:menu`'s own overflow trigger uses,
226
+ so the two cannot diverge per locale.
227
+ - The activity-feed reaction button's `Add reaction` accessible name is now a
228
+ bundle key (`detail.addReaction`, added to all ten packs).
229
+ - The "check the highlighted fields" toast joins field names with a per-locale
230
+ separator (`validation.formInvalidJoiner`) instead of a hardcoded `、`
231
+ (U+3001) — right for zh/ja by accident, wrong in English and every Latin
232
+ locale. Latin packs use `, `, CJK `、`, Arabic `، `.
233
+ - The Spanish `validation.required` / `validation.unique` templates gained
234
+ their own masculine head noun (`El campo {{field}} es obligatorio`) so the
235
+ adjective agrees for feminine field labels too — `Cuenta es obligatorio` was
236
+ ungrammatical.
237
+
238
+ - 7e2406a: The group-tenancy write-target badge is now translated in all ten locales (objectui#3517)
239
+
240
+ `form.createTargetOrg` — the ADR-0105 badge `RecordFormPage` shows in create mode
241
+ to name the organization a new record will land in — was defined in **no** locale
242
+ pack, not even `en`. i18next therefore genuinely missed the key and rendered the
243
+ call site's inline `defaultValue`, so the badge read English `Creates in <org>` in
244
+ all ten languages: a Chinese console creating a record on an org-walled object
245
+ showed `Creates in 某某组织`.
246
+
247
+ `all-locales-key-parity.test.ts` could not see this. It asserts that every pack
248
+ defines every **`en`** key, so a key `en` itself lacks is outside the comparison —
249
+ ten packs missing it identically kept parity fully green.
250
+
251
+ ## What changed
252
+ - `createTargetOrg` is backfilled into `en` as `Creates in {{org}}`, which makes
253
+ the parity gate demand it from the other nine; each is translated to its pack's
254
+ existing `form`-section tone rather than copied or machine-filled.
255
+ - The inline `defaultValue` in `RecordFormPage.tsx` is deleted, finishing what
256
+ objectui#3469 started — that key was the file's last remaining exception, and
257
+ every `t()` on the page now passes bare. Declared = enforced: the packs are the
258
+ single source of this copy, and a missing key must surface (raw key + dev
259
+ missing-key warning) instead of being papered over at the call site.
260
+ - The two exception-pinning tests objectui#3516 left behind invert. `form.createTargetOrg`
261
+ joins the `BARE_KEYS` list (pinned present in all ten packs), and the render
262
+ assertion now checks the badge against the **pack** copy in both `en` and `zh` —
263
+ the deleted English default could not satisfy the Chinese assertion, so the
264
+ badge fails loudly if the packs ever stop driving it.
265
+
266
+ - 4eeb932: The form renderer's last user-visible English literals now go through i18n (#3272). The fullscreen long-text editor (`mobile_fullscreen`) was an entire untranslated dialog — title, screen-reader description, `Cancel` / `Done` footer buttons, and the expand trigger's accessible name — rendering English inside an otherwise translated zh/ja/ar form; it now reads the new `form.fullscreen.*` keys, shipped in all ten locale packs.
267
+
268
+ **Behaviour change worth reading if you author forms:** `submitLabel` and `cancelLabel` no longer default to the literals `'Submit'` and `'Cancel'` in the renderer. They default to _unset_, and the action bar falls back at render time to `common.submit` / `common.cancel`, so a form that declares no button copy now follows the session language instead of being silently frozen to English. A label you DO declare still wins verbatim in every locale — including an English one under a zh session, and including an explicit empty string (the fallback uses `??`, so `submitLabel: ''` renders a blank button rather than being overwritten). The only forms whose rendered text changes are those that never declared the labels and are viewed in a non-English session — which is the bug. `FormSchema.submitLabel` / `cancelLabel` stay optional strings; no spec or type change.
269
+
270
+ Also removed the built-in `select` branch's second `|| 'Select an option'` fallback. The single call site already supplies `t('common.selectOption')`, so the literal was reachable only through an authored `placeholder: ''` — where it replaced the author's deliberate blank with an untranslated English word.
271
+
272
+ - 5c856ec: `ObjectGantt`'s quick-filter bar is now localized instead of pinned to Chinese. The four `QuickFilterBar` labels (`all`, `clear`, `empty`, `resultSummary`) were hardcoded as Chinese string literals at the `ObjectGantt` call site, so the bar read 全部 / 清除筛选 / 无可选项 / 显示 N / M 项任务 under an `en`, `ja`, `es` or `ar` session while the rest of the gantt toolbar localized correctly — a conspicuous mismatch, and a violation of the English-only-codebase rule. `QuickFilterBar` itself was never at fault: it is presentational and already falls back to English, so the host was the only thing pinning the copy.
273
+
274
+ The four strings moved into a new `gantt.quickFilter` namespace, added to all ten built-in locale packs, and the call site now resolves them through the gantt package's existing `useGanttTranslation` — the same per-key hook every other gantt string already uses, so a host dictionary that lags on these keys still renders the bundled English default rather than a raw key. `gantt.quickFilter.resultSummary` deliberately keeps SINGLE-brace placeholders (`{shown}` / `{total}`): the call site substitutes them with a literal `.replace`, not i18next interpolation, matching `gantt.autoScheduleDlg.body` and the placeholder convention `all-locales-key-parity` already recognises. Anyone retranslating these packs must keep that spelling — a respell to `{{shown}}` would render the raw placeholder to the user.
275
+
276
+ - 68b6a28: The list toolbar's "Filter" now saves. Saving a filter from the runtime toolbar PUT the FilterBuilder's whole group object (`{ id, logic, conditions }`) into the view's `filter`, where `@objectstack/spec`'s `ListViewSchema.filter` declares `ViewFilterRule[]` — so every save came back `422 invalid_metadata` and the filter was silently never persisted (objectstack#5159).
277
+
278
+ The producer now folds the builder's group to the spec's flat `{ field, operator, value }` rule list before persisting, sharing one transform with the Studio view inspector (which had the only copy). Operators normalize through the spec's own `normalizeFilterOperator`, so the four builder operators the Studio's local table had drifted behind — `startsWith`, `endsWith`, `isNull`, `isNotNull` — now persist correctly too. The builder's per-row `id` is no longer written: it is a React list key that the read path regenerates, so stored view bodies keep the declared vocabulary only.
279
+
280
+ A filter whose shape cannot be represented losslessly as a flat rule list — `OR` across several conditions, or nested condition groups — is now refused with a translated message instead of being quietly saved as `AND`, which would have returned a different set of records than the one on screen. Such a filter still applies to the current list; it just does not become part of the saved view.
281
+
282
+ - 28b2e65: Localize the create / edit / view form title `ObjectView` builds itself
283
+ (objectui#3462)
284
+
285
+ The same family as #3426 / PR #3457 and #3459 / PR #3464, one call site further
286
+ in. `ObjectView.getFormTitle()` string-built its three verbs in TypeScript:
287
+
288
+ case 'create': return `Create ${objectLabel}`;
289
+ case 'edit': return `Edit ${objectLabel}`;
290
+ case 'view': return `View ${objectLabel}`;
291
+
292
+ so a Chinese session whose object is labelled 联系人 read a drawer headed
293
+ **"View 联系人"** — an English verb glued onto a localized label. All three
294
+ consumers are visible chrome: `renderDrawerForm`'s `DrawerTitle`,
295
+ `renderModalForm`'s `DialogTitle`, and the `title` prop handed to
296
+ `NavigationOverlay` in the `popover` branch (a host-supplied `title` displaces
297
+ the overlay's own `resolvedTitle` default, so it is what the user sees).
298
+
299
+ The bar to reach it is lower than #3459's split panel: `ObjectViewSchema.layout`
300
+ already defaults to `'drawer'`, and `navigation` is a declared authorable input
301
+ on the registered `object-view` block whose `mode` union carries `drawer`,
302
+ `modal` and `popover`. A row click under any of them sets `formMode: 'view'` and
303
+ opens the container. `app-shell`'s wrapper pinning `layout: 'page'` is one host
304
+ overriding a registered block, not proof the branch is dead.
305
+
306
+ ## What changed
307
+
308
+ The three verb branches resolve `form.createTitle` / `form.editTitle` /
309
+ `form.viewTitle`.
310
+
311
+ **No new key family was minted.** `form.createTitle` (`'Create {{object}}'`) and
312
+ `form.editTitle` (`'Edit {{object}}'`) already ship in all ten packs and are
313
+ already how `app-shell` heads the PAGE-mode record form
314
+ (`RecordFormPage.tsx`, `AppContent.tsx`). The drawer / modal / popover titles are
315
+ the same heading on a different surface, so they resolve the same keys — a
316
+ parallel per-plugin family would have guaranteed the two spellings drift, which
317
+ is what the sibling issues were about. Only the third verb had no sibling:
318
+ `form.viewTitle` is added to all ten packs, following each pack's existing
319
+ arrangement for its create/edit twins rather than a translated-verb-plus-label
320
+ concatenation (de puts the verb last, ja/zh use particles and no space).
321
+
322
+ `VIEW_DEFAULT_TRANSLATIONS` in `ObjectView.tsx` gains the three English entries,
323
+ which is what `createSafeTranslation` falls back to with no `I18nProvider`
324
+ mounted.
325
+
326
+ Two branches stay literal on purpose and are pinned by tests: `schema.form.title`
327
+ (the author wrote a title, so the author's title wins, in every locale) and the
328
+ `default` branch (bare object label, no verb to translate).
329
+
330
+ ## Visible English change
331
+
332
+ None. Every branch is byte-identical in English — `Create Contacts`,
333
+ `Edit Contacts`, `View Contacts` — with and without a provider, so e2e specs and
334
+ host tests that address this chrome by its English name keep addressing it. The
335
+ provider-less path has its own test file, kept separate because
336
+ `initReactI18next` registers its instance as a module global that outlives
337
+ `cleanup()`.
338
+
339
+ The toolbar's create BUTTON keeps resolving `console.objectView.new`
340
+ ("New" / 新建) and was deliberately not reused for the heading: a button verb and
341
+ a title are different contexts, and folding them together is how the next drift
342
+ of this shape would start.
343
+
344
+ - 825bbe3: The option widgets' "this list cannot be filled" message now has one source, and
345
+ it is translated (objectui#3231).
346
+
347
+ FROM: `SelectField`, `MultiSelectField`, `RadioField` and `CheckboxesField` each
348
+ carried their own copy of the empty/gated state, each destructured the declared
349
+ `emptyHint` prop into `_emptyHint` and dropped it, and each rendered a hardcoded
350
+ English literal (`'No options available'`, `` `Select ${…} first` ``) even in a
351
+ Chinese or Japanese session. TO: one shared `OptionsEmptyState` — the host's
352
+ `emptyHint` when it supplied one, otherwise a translated fallback
353
+ (`fields.options.empty` / `fields.options.selectFirst`, added to all ten locale
354
+ packs).
355
+
356
+ `emptyHint` was declared, produced by the form renderer and transported, then
357
+ lost three times over — so no registered widget could ever render it. All three
358
+ breaks are fixed, because closing only the last one delivers nothing:
359
+
360
+ - `isOptionField` compared the raw resolved type against `'select'` /`'radio'` /
361
+ `'multiselect'` / `'checkboxes'`. Object-derived forms emit
362
+ `mapFieldTypeToFormType`'s prefixed ids (`field:select`), which matched none of
363
+ them, so for every option field coming from an object schema — the normal case
364
+ in the console — the whole cascade block was skipped and no hint was computed
365
+ at all. It now normalizes the `field:` prefix, the same normalization
366
+ `stripRegisteredFieldProps` already applied a few lines below.
367
+ - `stripRegisteredFieldProps` then removed the `emptyHint` key from what was
368
+ left. It is now forwarded to the four cascade option types, alongside
369
+ `dependentValues`. This stays an allow-list rather than a blanket
370
+ pass-through: every other registered widget spreads its leftover props onto a
371
+ DOM node, where an unknown `emptyHint` attribute is a React warning.
372
+ - the widgets themselves discarded it. Keeping it out of the `...props` spread
373
+ was correct; not using it afterwards was not.
374
+
375
+ User-visible effect: a dependency-gated option list now prompts with the
376
+ controlling field's **label** ("Select Country first") instead of its raw
377
+ metadata name, in the session's language; an unconfigured list says so in the
378
+ session's language too. The gate sentence is one i18n key shared by the renderer
379
+ and the widget fallback, so the two sides cannot word it differently.
380
+
381
+ Untouched: the built-in (unregistered) `select` branch of the form renderer,
382
+ which already consumed `emptyHint`. That is a separate live path.
383
+
384
+ - 6195841: Localize the record-detail overlay heading that `ListView` and `ObjectGrid`
385
+ build themselves (objectui#3426)
386
+
387
+ #3423 gave `NavigationOverlay`'s `resolvedTitle` an i18n default
388
+ (`detail.recordDetail`), but two hosts never let that default run: they
389
+ string-built an English heading in TypeScript and passed it as the `title`
390
+ prop, so a zh/ja/de session got a fully localized drawer with one English
391
+ heading on it.
392
+
393
+ - `packages/plugin-list/src/ListView.tsx` — `` `${schema.label} Detail` ``
394
+ - `packages/plugin-grid/src/ObjectGrid.tsx` — the same template, plus a bare
395
+ `'Record Detail'` literal for the no-label case
396
+
397
+ Both are user-reachable, not dead defaults. `list-view` / `object-grid` are
398
+ public page blocks and `navigation` is an authorable key on their schema, so a
399
+ page that authors `navigation: { mode: 'drawer' }` opens exactly this overlay
400
+ on row click. (`app-shell`'s `ObjectView` does suppress it — it passes its own
401
+ `onRowClick`, which takes priority inside `useNavigationOverlay`, and renders
402
+ its own overlay — but that is one host overriding a public block, not proof the
403
+ branch is unreachable.)
404
+
405
+ ## What changed
406
+
407
+ Both call sites now key their heading instead of concatenating it:
408
+
409
+ - a new `detail.recordDetailWithLabel` (`'{{label}} Detail'`) carries the
410
+ object label through interpolation, so a pack whose qualifier trails the noun
411
+ (`de`) or that needs a possessive particle (`ja`/`zh`) can write its own
412
+ arrangement rather than inherit English word order;
413
+ - the no-label branch reuses `detail.recordDetail` — the very key the overlay
414
+ itself defaults to — so one heading on one control cannot drift into two
415
+ translations.
416
+
417
+ The new key is added to all ten locale packs and to each plugin's English
418
+ defaults map (`LIST_DEFAULT_TRANSLATIONS` / `GRID_DEFAULT_TRANSLATIONS`), which
419
+ is what `createSafeTranslation` falls back to with no `I18nProvider` mounted.
420
+
421
+ English output is byte-identical in every branch (`Contacts Detail` /
422
+ `Contacts Detail` / `Record Detail`), with and without a provider — pinned by a
423
+ provider-less test file per plugin, kept separate because `initReactI18next`
424
+ registers its instance as a module global that outlives `cleanup()`.
425
+
426
+ - 5dd0127: Localize the record-overlay and tab-badge chrome that #5430's sweep left behind (objectstack#5506)
427
+
428
+ Four more console-chrome strings were still hardcoded English literals. Unlike
429
+ #5430's set they are not all accessible names — one is visible copy, and one was
430
+ a component **default** that only the console happened to override.
431
+
432
+ - `page:tabs`' count badge built its `aria-label` by template literal,
433
+ `` `${formatTabCount(count)} items` ``. The badge renders digits only, so that
434
+ label _is_ the badge to a screen reader — and the English plural was baked in
435
+ with no singular branch at all, so a related list with one row announced
436
+ "1 items". Now `common.itemCount` / `common.itemCountOne`.
437
+ - `NavigationOverlay`'s drag-resize handle (`role="separator"`, no visible label)
438
+ — now `common.resizeDrawer`.
439
+ - `NavigationOverlay`'s `expandLabel` **default**. Hosts may override it and the
440
+ console does, but the default is what every other host ships — and it feeds
441
+ both `aria-label` and `title` of an icon-only button. Now
442
+ `detail.openAsFullPage`, still overridable by the prop.
443
+ - `NavigationOverlay`'s `resolvedTitle` fallback, `'Record Detail'` — **visible**
444
+ overlay heading, not just an a11y name. Now `detail.recordDetail`.
445
+ - The sr-only `SheetDescription`/`DialogDescription` prose
446
+ `Record detail overlay for {title}.`, which existed in three copies
447
+ (drawer / modal / popover) — now one `detail.recordDetailOverlay` key with a
448
+ `{{title}}` placeholder.
449
+
450
+ The count badge follows this repo's **two-key** plural convention
451
+ (`detail.reactionCount`/`reactionCountOne`, `detail.relatedRecords`/`relatedRecordOne`)
452
+ rather than an i18next `_one`/`_other` pair: zh/ja/ko have no separate singular
453
+ form, so those packs would legitimately omit the `_one` half and
454
+ `all-locales-key-parity` would read that as a lost key. The formatted count
455
+ (`1.2k`, not `1200`) is interpolated so the accessible name and the visible
456
+ digits never disagree — and because i18next skips its own plural resolution when
457
+ `count` is a string, the two-key scheme stays in charge of the choice.
458
+
459
+ Both touched components moved from `useSafeTranslate` to `createSafeTranslation`,
460
+ which carries an options bag (two of the new keys interpolate) and an English
461
+ defaults map. That map is what keeps the provider-less path English, which
462
+ consumers outside this package depend on — `plugin-view`'s `ObjectView.test.tsx`
463
+ and `e2e/live/inline-edit-polish-2572.spec.ts` address this chrome by English
464
+ accessible name with no `I18nProvider` mounted.
465
+
466
+ All six new keys are added to all ten locale packs.
467
+
468
+ - a415684: The console server-action wrapper's `opensInNewTab` choreography no longer
469
+ ships hard-coded bilingual Chinese/English copy (objectui#3321, AGENTS.md
470
+ Commandment #-1): the pre-opened SSO spinner tab (title + body) and the
471
+ popup-blocked toast (title, description, action label) are now localized
472
+ through new `console.serverAction.*` keys in `@object-ui/i18n`, added at full
473
+ parity across all ten locale packs.
474
+
475
+ `createConsoleServerActionHandler` gains an optional i18next-style `t` option
476
+ (`t(key, englishDefault)`) — the wrapper is a plain function, so the translate
477
+ function is injected from the two hook-context call sites
478
+ (`useConsoleActionRuntime`, `RecordDetailView`) via `useObjectTranslation`.
479
+ When omitted (tests / standalone), every string falls back to its English
480
+ default; no non-English copy remains in code. Locale strings are HTML-escaped
481
+ before being written into the spinner document.
482
+
483
+ - a6ec93d: `createSafeTranslation`'s no-provider fallback interpolation now replaces **all**
484
+ occurrences of each placeholder, matching i18next semantics on the provider path.
485
+
486
+ The fallback used `value.replace('{{k}}', String(v))`, and `String.prototype.replace`
487
+ with a string needle substitutes only the _first_ match. A default string repeating a
488
+ placeholder — `'Selected {{count}} of {{count}} items'`, natural in many locales and
489
+ sometimes required by RTL / agglutinative word order — therefore leaked literal braces
490
+ to users on hosts with no `I18nProvider` mounted (standalone / embedded renderers),
491
+ while the same string interpolated correctly once a provider was present. A silent
492
+ semantic fork, in exactly the environments we observe least.
493
+
494
+ The replacement is `value.split(needle).join(String(v))` rather than `replaceAll`:
495
+ both `replace` and `replaceAll` interpret `$&`, `` $` ``, `$'` and `$$` in the
496
+ _replacement_ string, which i18next does not. Values here are runtime data (record
497
+ labels, search terms), so that second divergence was reachable today — a label
498
+ containing `$&` was mangled on the fallback path. split/join is literal on both sides
499
+ and needs no regex escaping of the placeholder name.
500
+
501
+ Key resolution (`defaults[key] || key`), the `String(v)` coercion, and the
502
+ leave-it-literal behaviour for a placeholder with no matching option are unchanged.
503
+ Fixes objectui#3418.
504
+
505
+ - c7ed4c3: `TagsField` no longer ships a hardcoded Chinese input placeholder
506
+ (objectui#3342, AGENTS.md Commandment #-1). The placeholder now resolves
507
+ through the pinned chain: the author-declared `field.placeholder` wins
508
+ (previously ignored by this widget); otherwise the widget's own copy arrives
509
+ via `useFieldTranslation()` under the new `fields.tags.placeholder` key, added
510
+ at full parity across all locale packs (Chinese lives in the zh pack, not in
511
+ code); with no `I18nProvider` mounted the English default from FIELD_DEFAULTS
512
+ renders — never a raw key.
513
+ - 2409e1d: `TextAreaField`'s character counter now announces itself in the session locale. The counter block — rendered only when the field declares `maxLength` — carried the accessible name `Character count: {n} of {max}` as an English literal, and the element is an `aria-live="polite"` region, so a zh/ja/ar session had that English sentence read out on every keystroke while sighted users saw only the language-independent `{n}/{max}` digits. Nothing was wrong on screen, which is why it survived: only screen reader users could perceive it (#3406).
514
+
515
+ Unlike #3404, no key existed to consume — none of the ten locale packs had any character-count string. `fields.textarea.characterCount` is new in all ten, interpolating `{{count}}` and `{{max}}` as one sentence rather than parts assembled in code, because `ja` and `ko` put the cap before the count ("of {{max}} characters, {{count}}"), an order no concatenation can produce. The English pack value and the `FIELD_DEFAULTS` fallback are byte-identical to the literal they replace, so an `en` session and a provider-less embed both render exactly what they did before.
516
+
517
+ Behaviour is unchanged: `aria-live="polite"` and the per-keystroke recompute are deliberately untouched here and tracked separately (#3408).
518
+
519
+ - 789fe3e: `TextAreaField`'s character counter no longer re-announces itself on every keystroke. Measured on `main` in a zh session with `maxLength: 500`, typing a 52-character sentence one character at a time produced 52 distinct screen-reader announcements totalling 979 spoken characters — roughly 19x the text being written, each one cutting off the reader's echo of the letter just typed. The counter element was simultaneously the visible `{n}/{max}` digits, the carrier of the translated sentence and the `aria-live` region itself, so "re-render" and "announce" were the same event; the field also had no `aria-describedby`, so focusing it said nothing about the cap at all (#3408).
520
+
521
+ It is now the three-node shape the GOV.UK Design System character-count component uses: the visible digits are `aria-hidden` and purely decorative; the counter sentence (`fields.textarea.characterCount`, unchanged in all ten packs) has moved onto the textarea's `aria-describedby`, so focus reads "Character count: 12 of 500" once and then stays quiet; and a separate visually-hidden `aria-live="polite"` region carries a new near-limit warning, `fields.textarea.charactersRemaining` (new in all ten packs), which stays silent until the value is inside the last 10% or last 20 characters of the cap — whichever the typist reaches first — and updates only after typing pauses for a second. The same 52-keystroke probe now produces zero announcements; a run that types all the way onto a 500-character cap produces five. Any `aria-describedby` the host already supplied (the form renderer's description and error-message ids) is appended to, never replaced.
522
+
523
+ No metadata change: the counter still renders exactly when the field declares `maxLength` (or the legacy `max_length`), and a widget rendered with no `I18nProvider` still shows the same English sentences.
524
+
525
+ ## 17.2.0
526
+
527
+ ### Minor Changes
528
+
529
+ - a889e31: A record's approval band now shows the quorum / per-group tally the server already computes.
530
+
531
+ The showcase's `showcase_committee_quorum` node declares `behavior: 'quorum'` with
532
+ `minApprovals: 2` over three approvers, and even ships a pre-rendered
533
+ `"Committee Sign-off (2 of 3)"` label; `showcase_expense_signoff` declares
534
+ `per_group` (会签) with named manager / finance groups. On the business record
535
+ the approval band rendered none of it — the lock badge, the recall button and
536
+ the approve/reject actions were all correct, but a two-of-three committee step
537
+ looked exactly like a one-approver step. An approver could not see whether their
538
+ own click finalized the node or was one of three, which is the single fact a
539
+ quorum node exists to express (objectstack#4478).
540
+
541
+ Nothing was wrong on the wire, and nothing here papers over the server. The
542
+ framework computes `decision_progress` — `{ behavior, got, need, groups? }`,
543
+ derived from the node's own `node_config_json` snapshot, so the count a client
544
+ shows is the count the engine will enforce. **It attaches that block in
545
+ `getRequest` only**: `listRequests` deliberately skips it, because the
546
+ `sys_approval_action` tally it costs is per row and a list read may return
547
+ hundreds. The record header's `useRecordApprovals` reads
548
+ `GET /approvals/requests?object=…&recordId=…` — the list route — so the
549
+ enrichment was never in the payload it had. The hook now follows up with one
550
+ single read for the ONE pending row and folds the result onto it; a failed or
551
+ mismatched follow-up leaves the row exactly as the list sent it, so a display-only
552
+ enrichment can never take the approval panel down and no tally is ever invented.
553
+
554
+ `InlineEditProvider` carries the block through as `approvalProgress`, and the
555
+ DetailView approval band renders it beside the existing badge: a labelled
556
+ `role="progressbar"` with one tick per required approval for `quorum` /
557
+ `unanimous`, and for `per_group` a chip per group marking which have signed
558
+ (`finance 1/1` ✓, `manager 0/1`). Group names come from the flow author's own
559
+ config, so they need no locale strings; the three new label keys are added to all
560
+ ten packs. `first_response` nodes carry no `decision_progress` and are unchanged —
561
+ one decision is the whole step there, and a "1 of 1" bar would be noise.
562
+
563
+ Scored `minor` rather than `patch`: this is new observable rendering plus a new
564
+ public `approvalProgress` prop / `ApprovalProgress` type on `@object-ui/react`,
565
+ not a behavior correction inside an existing surface.
566
+
567
+ ### Patch Changes
568
+
569
+ - 4b470b9: Localize the environment entitlement dialog and read cloud's nested error envelope.
570
+
571
+ The free-plan "Development environments are a paid feature" prompt was built from
572
+ English string literals in `entitlements.ts` — including the lowercase `your free
573
+ plan` sentence users reported (cloud#959). Both spec builders now take a translator
574
+ and resolve `environment.entitlement.*`; all ten locale packs carry the strings.
575
+ `entitlements.ts` stays dependency-free: `t` is passed in, not imported, and
576
+ defaults to the English copy with local `{{token}}` interpolation.
577
+
578
+ The dialog now renders the Console's own copy rather than the server's prose — a
579
+ control plane upgrades independently and only localizes these messages from
580
+ cloud#959 on, so preferring the server string left the reactive path English
581
+ against every older deployment.
582
+
583
+ Also fixes the reactive dialog not firing at all: cloud#948 moved coded errors into
584
+ a nested envelope (`{ success, error: { code, … } }`), and
585
+ `entitlementDialogFromError` read `code` off the top level — returning `null` for
586
+ every entitlement 403, so the upgrade dialog degraded to a generic red error toast.
587
+ Both shapes are read now.
588
+
3
589
  ## 17.1.0
4
590
 
5
591
  ### Minor Changes
package/README.md CHANGED
@@ -61,6 +61,32 @@ Wraps your application with i18n context:
61
61
  </I18nProvider>
62
62
  ```
63
63
 
64
+ #### Language persistence
65
+
66
+ The active language is a **user preference**, so it survives a reload: every
67
+ language change is written to `localStorage` under `objectui-locale`
68
+ (`LOCALE_STORAGE_KEY`), and the provider boots the next session in that
69
+ language.
70
+
71
+ Precedence at bootstrap: **stored choice → browser language → `defaultLanguage`**.
72
+ An explicit choice outranks browser detection — otherwise a user who picked 中文
73
+ on a `ja` browser would be handed `ja` back on every reload. A stored value the
74
+ app no longer offers (not a built-in pack, not in `config.resources`) is ignored
75
+ *and purged*, so a stale entry can never lock the UI to a locale with no
76
+ translations.
77
+
78
+ ```tsx
79
+ // Fixed-language surfaces (previews, demos, screenshot harnesses) opt out —
80
+ // they neither restore nor write the preference.
81
+ <I18nProvider config={{ defaultLanguage: 'en' }} persistLanguage={false}>
82
+ <Preview />
83
+ </I18nProvider>
84
+ ```
85
+
86
+ Bringing your own `instance`? Then its bootstrap language is yours to choose —
87
+ read the preference with `readStoredLanguage()` and pass it to `createI18n`.
88
+ Switching through such an instance is still persisted.
89
+
64
90
  ### useObjectTranslation
65
91
 
66
92
  Hook for translations and language management:
package/dist/index.d.ts CHANGED
@@ -31,7 +31,7 @@
31
31
  * @packageDocumentation
32
32
  */
33
33
  export { createI18n, getDirection, getAvailableLanguages, type I18nConfig, type TranslationKeys } from './i18n';
34
- export { I18nProvider, useObjectTranslation, useI18nContext, type I18nProviderProps } from './provider';
34
+ export { I18nProvider, useObjectTranslation, useI18nContext, LOCALE_STORAGE_KEY, readStoredLanguage, type I18nProviderProps, } from './provider';
35
35
  export { createSafeTranslation, useSafeTranslate } from './useSafeTranslation';
36
36
  export { useObjectLabel, useSafeFieldLabel } from './useObjectLabel';
37
37
  export { builtInLocales, isRTL, RTL_LANGUAGES } from './locales/index';