@object-ui/i18n 17.2.0 → 17.5.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 (41) hide show
  1. package/CHANGELOG.md +1817 -1
  2. package/README.md +26 -13
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.js +14 -1
  5. package/dist/locales/ar.d.ts +550 -107
  6. package/dist/locales/ar.js +646 -156
  7. package/dist/locales/de.d.ts +550 -107
  8. package/dist/locales/de.js +661 -178
  9. package/dist/locales/en.d.ts +586 -143
  10. package/dist/locales/en.js +851 -143
  11. package/dist/locales/es.d.ts +550 -107
  12. package/dist/locales/es.js +647 -160
  13. package/dist/locales/fr.d.ts +550 -107
  14. package/dist/locales/fr.js +640 -155
  15. package/dist/locales/index.d.ts +5587 -1157
  16. package/dist/locales/ja.d.ts +550 -107
  17. package/dist/locales/ja.js +632 -149
  18. package/dist/locales/ko.d.ts +550 -107
  19. package/dist/locales/ko.js +638 -156
  20. package/dist/locales/pt.d.ts +550 -107
  21. package/dist/locales/pt.js +639 -157
  22. package/dist/locales/ru.d.ts +550 -107
  23. package/dist/locales/ru.js +650 -156
  24. package/dist/locales/zh.d.ts +588 -145
  25. package/dist/locales/zh.js +629 -145
  26. package/dist/pickLocalized.d.ts +21 -0
  27. package/dist/pickLocalized.js +40 -7
  28. package/dist/provider.d.ts +125 -1
  29. package/dist/provider.js +380 -4
  30. package/dist/useDisplayLocale.d.ts +49 -0
  31. package/dist/useDisplayLocale.js +55 -0
  32. package/dist/useObjectLabel.d.ts +8 -2
  33. package/dist/useObjectLabel.js +28 -8
  34. package/dist/useSafeTranslation.js +67 -3
  35. package/dist/utils/index.d.ts +1 -0
  36. package/dist/utils/index.js +1 -0
  37. package/dist/utils/number-display.d.ts +32 -0
  38. package/dist/utils/number-display.js +32 -0
  39. package/dist/utils/spec-formatters.d.ts +39 -13
  40. package/dist/utils/spec-formatters.js +3 -3
  41. package/package.json +5 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1803 @@
1
1
  # @object-ui/i18n
2
2
 
3
+ ## 17.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0e67b53: `/accept-invitation/:invitationId` is one route, one component, one namespace — the console now renders the invitation page that actually shows you the invitation
8
+
9
+ 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.
10
+
11
+ 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.
12
+
13
+ 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.
14
+
15
+ 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.
16
+
17
+ - 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
18
+
19
+ 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.
20
+
21
+ **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.
22
+
23
+ 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.
24
+
25
+ **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.
26
+
27
+ 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.
28
+
29
+ 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.
30
+
31
+ - 66fb4fa: The console's language menu now asks the app which locales it actually ships, instead of always offering the same ten.
32
+
33
+ `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.
34
+
35
+ 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.
36
+
37
+ **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.
38
+
39
+ 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.
40
+
41
+ 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.
42
+
43
+ - 92250d6: One home for the number-display policy — and a percent stops meaning two different things between a list cell and a dashboard measure
44
+
45
+ `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.
46
+
47
+ 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.
48
+
49
+ **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.
50
+
51
+ **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.
52
+
53
+ `@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.
54
+
55
+ - ac853ce: i18n: retire the reader-less `common.search` key from all ten locale packs
56
+
57
+ `common.search` (`Search`, no ellipsis) had exactly one consumer: `LookupField`
58
+ built its dialog placeholder by concatenating the key with three ASCII full
59
+ stops. objectui#4375 / PR #4391 retired that concatenation — the placeholder is
60
+ the reused `table.search` pack value (`Search…`, one U+2026 glyph), which is what
61
+ brought it under objectui#3878's glyph pin. That left `common.search` with zero
62
+ readers repo-wide while it still existed in all ten packs.
63
+
64
+ Re-verified before deleting, repo-wide: no `t()` call site in any package or app,
65
+ no MDX or JSON reference, and the one dynamic template-literal reader of the
66
+ `common` namespace takes a two-member union parameter (`'openChat' |
67
+ 'closeChat'`) that cannot resolve to it. No user-visible string changes — this key never rendered.
68
+
69
+ The dormant copy in `@object-ui/fields`' no-provider fallback table
70
+ (`useFieldTranslation.ts`'s `FIELD_DEFAULTS`) goes with it. That table is a
71
+ module-local `Record<string, string>` read only when no `LocalizationProvider`
72
+ is mounted; it is not exported, so removing an entry no reader asks for changes
73
+ no rendered output and narrows no public type. Hence patch for that package,
74
+ while the pack change is a minor: deleting a key from `en` narrows the exported
75
+ `TranslationKeys` type (`typeof en`), so code indexing `TranslationKeys` at
76
+ `common.search` stops type-checking. Same grading, for the same reason, as
77
+ objectui#4145's `report.editor.*` retirement. No runtime consumer existed to
78
+ break.
79
+
80
+ Retiring a key from `common` was the ruled decision on objectui#4392 rather than
81
+ keeping it as vocabulary: nothing pins a dormant key's meaning, so its next
82
+ reader inherits an unreviewed contract, and a dormant key beside a live
83
+ `table.search` is where a second dialect gets started. The objectui#4328
84
+ dead-surface family has consistently chosen removal for zero-consumer surfaces.
85
+
86
+ The neighbouring `common.select` (minted one commit earlier by objectui#4386 /
87
+ PR #4397) is a different key and is untouched.
88
+
89
+ A negative pin (`packages/i18n/src/__tests__/common-search-retired-4392.test.ts`)
90
+ fails if the key returns to any pack, if any package reads or re-declares it, or
91
+ if a dynamic `common.*` reader grows a `search` member — every existing i18n gate
92
+ runs call site to key, and none of them can see a key with no call site.
93
+
94
+ - fa51109: i18n: retire the orphaned `report.editor.*` namespace — 105 of its 106 keys, in all ten locale packs (~1050 translated strings)
95
+
96
+ The namespace labelled the hand-rolled report editor form. That form no longer
97
+ exists: `ReportConfigPanel`'s body is `ReportDefaultInspector`, a spec-driven
98
+ inspector whose labels come from the report spec's own metadata rather than from
99
+ a pack namespace. Until objectui#4137 the namespace had exactly one live reader,
100
+ and it was the objectui#4118 defect itself — the panel borrowing
101
+ `report.editor.title` (the label of the report's Title _field_) to name itself.
102
+ Moving that slot onto a purpose-built `report.editor.panelTitle` left the other
103
+ 105 keys with no reader anywhere.
104
+
105
+ Re-verified before deleting, repo-wide and per key: no `t()` call site, no
106
+ dynamic `t()` template form, and no JSON or MDX reference reads any of the 105.
107
+ No user-visible string changes — these keys never rendered.
108
+
109
+ `report.editor.panelTitle` survives in all ten packs and is untouched; the
110
+ deletion sweeps around it. `report.editor` therefore remains a live namespace
111
+ holding exactly that one key.
112
+
113
+ This narrows the exported `TranslationKeys` type (`typeof en`), which is why it
114
+ is a minor rather than a patch: code indexing `TranslationKeys` at a retired key
115
+ stops type-checking. No runtime consumer existed to break.
116
+
117
+ A negative pin (`packages/i18n/src/__tests__/report-editor-retired-4145.test.ts`)
118
+ names all 105 retired keys and fails if any returns to any pack, since every
119
+ existing i18n gate runs call site to key and none of them can see a key with no
120
+ call site.
121
+
122
+ - 78fa331: console: seed the UI language from the tenant's server-side locale
123
+
124
+ `GET /auth/me/localization` has always been fetched on every boot, but its
125
+ `locale` only ever fed currency/date formatting — the UI language was decided
126
+ entirely client-side, so a tenant configured `zh-CN` still handed every new
127
+ device an English console until each user switched by hand.
128
+
129
+ The tenant locale now sits in the language precedence chain, between the user's
130
+ own choice and the browser's:
131
+
132
+ 1. the user's explicit choice (`objectui-locale`)
133
+ 2. the tenant's server locale, cached at `objectui-locale-seed`
134
+ 3. the browser language
135
+ 4. `en`
136
+
137
+ The server value is cached in a slot of its own and is never written into the
138
+ explicit-choice slot, so it can never masquerade as a preference the user
139
+ expressed: only a manual switch promotes a language to an explicit choice. A
140
+ cached seed applies synchronously at bootstrap, and the in-app fetch refreshes
141
+ that cache from every successful answer, so a tenant that changes its locale
142
+ reaches choice-less devices on their next boot without an old seed pinning
143
+ them. On a device's true first visit the fetch is raced against a ~500ms
144
+ timeout alongside the console's existing pre-mount round-trips and fails open
145
+ to the browser language; a seed that arrives after the bound is cached for the
146
+ next boot rather than re-languaging a live session. A tenant locale this build
147
+ ships no pack for falls through to the next tier instead of half-rendering.
148
+
149
+ No platform additions: no new endpoint, no client read/write API, and
150
+ `sys_user_preference` is untouched.
151
+
152
+ - 0082db8: The timeline's gantt bucket labels and its row-label default speak the session language
153
+
154
+ 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.
155
+
156
+ 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`.
157
+
158
+ 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.
159
+
160
+ 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.
161
+
162
+ 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.
163
+
164
+ ### Patch Changes
165
+
166
+ - 932cbcd: An app you are not allowed to open now says so, instead of reporting that it may still be publishing
167
+
168
+ `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.
169
+
170
+ 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.
171
+
172
+ 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).
173
+
174
+ 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.
175
+
176
+ 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.
177
+
178
+ `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.
179
+
180
+ - 734d186: The console's Applications page is localized — its own chrome only, never the
181
+ server's words (objectui#4307).
182
+
183
+ `AppManagementPage` was raw English end to end: headings, the search field, the
184
+ selection and bulk controls, the six per-row actions with their tooltip/ARIA
185
+ pairs, the status badges, and every toast. It was the last un-i18n'd system page,
186
+ and #4233 / PR #4300 had just given it four live mutations — so the gap became
187
+ user-visible on every non-English console at the moment operators started using
188
+ it. 45 keys land under `appManagement.*` in all ten packs, reached through
189
+ `useObjectTranslation` with the call site's `defaultValue` inline, which is the
190
+ convention the neighbouring system pages already follow.
191
+
192
+ The split that shapes this change is between the strings the PAGE authors and
193
+ the strings the SERVER authors. `PUT`/`DELETE /api/v1/meta/app/:name` is gated on
194
+ `manage_metadata` (ADR-0066 D1), so a refusal like `forbidden: manage_metadata
195
+ required` is the server's diagnosis of one specific request; there is no fixed
196
+ catalogue of those sentences to key against. Each failure toast is therefore a
197
+ keyed template with a `{{reason}}` hole, and what fills the hole is passed
198
+ through byte for byte, untranslated. The one part that IS the page's own — what
199
+ it says when the server sent no message at all — is keyed as
200
+ `appManagement.toast.unknownError`.
201
+
202
+ Two smaller things follow from doing the conversion properly rather than
203
+ mechanically. The per-failure entry of a bulk toast and the separator between
204
+ entries are keys, not literals, because bracket style and list punctuation are
205
+ locale properties (the same rule, and the same past defect, as
206
+ `validation.formInvalidJoiner`). And the row's controls now name an app through
207
+ the resolver the visible heading two lines away already used, with `t` passed:
208
+ an app carrying objectui's keyed label form previously rendered `Select [object
209
+ Object]` into its checkbox's ARIA label.
210
+
211
+ - 3fc2971: A null-keyed group renders as an explicit bucket instead of silently vanishing from a chart (objectui#4466)
212
+
213
+ `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`).
214
+
215
+ 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.
216
+
217
+ `@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`.
218
+
219
+ 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.
220
+
221
+ `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.
222
+
223
+ - f7c6430: The build-history panel tells an operator a 503 means "the commit store could not be reached — retry", instead of `commits HTTP 503`
224
+
225
+ `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.
226
+
227
+ 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".
228
+
229
+ 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.
230
+
231
+ Adds `preview.history.loadFailedUnavailable` and `preview.history.revertUnavailable` to all ten locale packs.
232
+
233
+ - 92876f0: Doc comments no longer cite `@objectstack/spec` symbols the pinned spec has retired
234
+
235
+ Eight exported declarations carried a doc comment claiming alignment with a
236
+ `@objectstack/spec` symbol that `17.0.0-rc.6` does not export — four locale
237
+ formatting shapes in `@object-ui/i18n` (`SpecPluralRule`, `SpecDateFormat`,
238
+ `SpecNumberFormat`, `SpecLocaleConfig`) and four activity-feed shapes in
239
+ `@object-ui/types` (`FieldChangeEntry`, `Mention`, `Reaction`,
240
+ `RecordSubscription`). A citation that points at nothing is worse than a stale
241
+ one: the next reader cannot tell whether the protocol retired the symbol,
242
+ renamed it, or never had it.
243
+
244
+ Measuring all eight against the published registry answered that question, and
245
+ the answer was not "these names never existed". Every one was a real export the
246
+ protocol retired on purpose, and every local key set was faithful to the schema
247
+ it named. The feed four left `@objectstack/spec/data` in the `16.0.0` major,
248
+ when the feed surface was replaced by the data API over `sys_comment` /
249
+ `sys_activity`. The i18n four left `@objectstack/spec/ui` in `17.0.0-rc.6`
250
+ itself — they were still present in `rc.5` — retired under ADR-0049
251
+ enforce-or-remove because no authorable shape carried them and nothing ever
252
+ parsed them.
253
+
254
+ Each comment now records that provenance, including the version the symbol left
255
+ and what (if anything) replaced it, so the shapes read as declarations these
256
+ packages own rather than as a view onto a protocol type. Type shapes, runtime
257
+ behaviour and exports are unchanged — the published `.d.ts` files differ only in
258
+ comment text, which is why this is graded `patch`.
259
+
260
+ - 828549a: The gantt's conflict dialog shows the number of affected tasks again, not a literal `{2}`
261
+
262
+ `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.
263
+
264
+ 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.
265
+
266
+ `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.
267
+
268
+ 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.
269
+
270
+ - e1ade8f: An illegal gantt dependency link now says why it was refused, instead of doing nothing
271
+
272
+ 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.
273
+
274
+ 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.
275
+
276
+ 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.
277
+
278
+ 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.
279
+
280
+ 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.
281
+
282
+ - 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
283
+
284
+ 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.
285
+
286
+ **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.
287
+
288
+ 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.
289
+
290
+ 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.
291
+
292
+ **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.
293
+
294
+ **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.
295
+
296
+ No behavior, no keys added or removed, no placeholder changed.
297
+
298
+ - bb58d1d: i18n: the two search placeholders become pack values, and four values the packs served in English get translated
299
+
300
+ **objectui#4375** — `ListView` and `LookupField` built their search placeholder as
301
+ `t(key) + '...'`, so the ellipsis was a literal concatenated in code: it stayed ASCII
302
+ in all ten locales on screens where objectui#3878 had converged everything else on
303
+ U+2026, and no pack could opt out of it (sharpest in `ar`, where a left-to-right run
304
+ was appended to right-to-left text). Both now read `table.search`, which is already
305
+ the repo's search-input placeholder key — `data-table`, `RecordPickerDialog` and
306
+ `PeoplePicker` render it too — and is translated with the right ellipsis in all ten
307
+ packs. No new keys.
308
+
309
+ **objectui#4376** — `list.loading` served the English `Loading records…` in eight of
310
+ the nine translation packs (`zh` alone had translated it); `designer.undo` and
311
+ `designer.redo` were English in all nine; `appDesigner.snakeCaseHint` in `ko`, `pt`,
312
+ `ru` and `ar`. All translated, reusing each pack's own established vocabulary. A new
313
+ pin (`untranslated-identity-4376.test.ts`) fails on any value byte-identical to `en`
314
+ inside a non-Latin pack unless the key is on an explicit 22-entry allowlist.
315
+
316
+ - 5cc847c: The console shows a standing impersonation banner, with an exit that fails loudly (#4467).
317
+
318
+ While `session.impersonatedBy` is present, `ConsoleShell` renders a banner naming BOTH
319
+ parties — the impersonated user, whose name every write is recorded under, and the
320
+ administrator who started it — plus a stop affordance. It derives from the session rather
321
+ than from client memory of the click, so it survives a full SPA reboot, a new tab and a
322
+ browser restart, and it cannot disagree with who the server thinks is acting. An ordinary
323
+ session renders `null` and its chrome is unchanged.
324
+
325
+ The exit calls `POST /auth/admin/stop-impersonating` over the same data lane and then
326
+ awaits a session refresh. The server restores the administrator from the `admin_session`
327
+ COOKIE, so a deployment that blocks cookies cannot exit this way — the banner says so and
328
+ stays up instead of appearing to succeed, which would leave the operator doing ordinary
329
+ work under someone else's identity.
330
+
331
+ Ten locale packs carry the banner's copy.
332
+
333
+ - fa21254: Kanban: a drop that makes fields required now collects them instead of dead-ending
334
+
335
+ 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.
336
+
337
+ 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.
338
+
339
+ 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.
340
+
341
+ 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.
342
+
343
+ 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.
344
+
345
+ 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.
346
+
347
+ `@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.
348
+
349
+ - 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
350
+
351
+ `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.
352
+
353
+ 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.
354
+
355
+ 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.
356
+
357
+ 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.
358
+
359
+ - 6d641c9: Members & invitations tabs gate their affordances by org role instead of letting the server's 403 be the UI (#4475)
360
+
361
+ A user whose organization role is `member` opened the workspace members page and
362
+ was shown an enabled **Invite member** button plus a per-row **Member actions**
363
+ menu carrying **Remove member** — on every row, the workspace Owner's included.
364
+ Nothing was hidden or disabled; the action only failed after the user had
365
+ committed to it. The Settings tab of the same page already gated correctly; the
366
+ members and invitations tabs never got the same treatment.
367
+
368
+ The affordances are now narrowed to the roles that can actually use them, keyed
369
+ on the active member's role — the same source the role-change menu on this page
370
+ already reads. Which roles those are is **measured against the routes that
371
+ enforce them**, not assumed to be "owner":
372
+
373
+ | affordance | route | permission | roles |
374
+ | ----------------- | --------------------------------- | ----------------------- | ----------------------------- |
375
+ | Invite member | `/organization/invite-member` | `invitation:["create"]` | owner, admin, delegated_admin |
376
+ | Remove member | `/organization/remove-member` | `member:["delete"]` | owner, admin |
377
+ | Cancel invitation | `/organization/cancel-invitation` | `invitation:["cancel"]` | owner, admin |
378
+
379
+ Three different gates, because `delegated_admin` holds `invitation:["create"]`
380
+ without `member:["delete"]` and deliberately without `cancel` — so it keeps the
381
+ invite button and the copy-link action while losing remove and cancel. A single
382
+ owner check could not express that.
383
+
384
+ An actor left with no row action at all gets no menu rather than a trigger that
385
+ opens onto nothing, and the members page explains the absence where the Invite
386
+ button used to sit, in the Settings tab's own voice. An unresolved role is
387
+ treated as the least privileged, so nothing privileged is offered to a viewer
388
+ whose membership could not be read.
389
+
390
+ Reading the pages is unaffected: the member list and the invitation ledger still
391
+ render in full. Whether `org_member` should be able to read the invitation
392
+ ledger at all is a separate, server-side question.
393
+
394
+ - 45e1949: Numbers render in the user's locale, and a `Field.number` year is no longer `2,026`
395
+
396
+ 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.
397
+
398
+ 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`.
399
+
400
+ `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.
401
+
402
+ 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.
403
+
404
+ 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".
405
+
406
+ `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.
407
+
408
+ - 58bebf6: Organization & invitation console: translate the English holdouts a zh session was left reading (#4474)
409
+
410
+ Three families of string, one sweep over `console/organizations/`:
411
+
412
+ - **Role names** now come from the single shared `ORG_ROLE_LABELS` map at every
413
+ site. The role badges on the members and invitations pages were rendering the
414
+ raw server identifier (`owner`) under a CSS `capitalize` that made it look like
415
+ a label in English and left it untranslated everywhere else; the accept page
416
+ did the same in its role row and inside its otherwise-translated sentence. The
417
+ map's four `organization.roles.*` keys existed in no locale pack, so even the
418
+ dropdown that did consult it fell through to English — all ten packs now carry
419
+ them. An unrecognized role renders verbatim rather than blank.
420
+ - **Server-echoed errors** are mapped by better-auth's stable `code`, never by
421
+ matching its English text. `createAuthClient` was dropping that code for every
422
+ `organization.*` call while preserving it for sign-in/sign-up, so the console
423
+ had nothing to key on; all sixteen organization methods now go through the same
424
+ `toAuthError` helper. Messages are unchanged — the code simply stops being
425
+ thrown away. An unmapped code still shows the server's own sentence.
426
+ - **Icon-only `aria-label`s** (member actions, copy invitation link, cancel
427
+ invitation, and a fourth on the share-link copy button) are translated — for an
428
+ icon-only control this is the only name a screen reader gets.
429
+
430
+ - 405e808: `pickLocalized` reads own properties only, and takes only string values, on every limb
431
+
432
+ 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]`.
433
+
434
+ 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.
435
+
436
+ 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.
437
+
438
+ - c0f9a4b: Studio surfaces the runtime authoring gate's advisory findings instead of discarding them client-side
439
+
440
+ 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.
441
+
442
+ 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.
443
+
444
+ `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.
445
+
446
+ 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.
447
+
448
+ **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.
449
+
450
+ - d46f9b8: i18n: `createSafeTranslation`'s provider-less fallback now honours a call site's inline `defaultValue`
451
+
452
+ `fallbackT` looked its key up in the hook's hand-written `defaults` map and, on a miss, rendered the
453
+ **raw key** to the user — then ran every option, `defaultValue` included, through the interpolation
454
+ loop as if it were a `{{defaultValue}}` variable. So `t('perm.facet.none', { defaultValue: 'None' })`
455
+ showed `perm.facet.none` on a host with no `I18nProvider`, which is a supported scenario (standalone
456
+ embedding and tests are the whole reason this factory exists).
457
+
458
+ The lookup order is now `defaults[key]` -> a string `defaultValue` -> the key, matching i18next,
459
+ which serves the provider path: the defaults map is the pack value's stand-in here, so it keeps the
460
+ pack's winning position. `defaultValue` is also excluded from the interpolation loop as a reserved
461
+ name — it selects the string, it does not fill holes in one. Non-string `defaultValue` is ignored.
462
+ The provider path is untouched.
463
+
464
+ Measured over all 26 `createSafeTranslation` hooks in the repo: 27 keys reach a hook whose defaults
465
+ map lacks them, 21 of those carrying an inline `defaultValue` that used to be dropped (16 keys in
466
+ `plugin-detail` alone). Those 21 now render their English instead of a raw key on provider-less
467
+ hosts; the other 6 pass no inline default and still need a map or pack entry.
468
+
469
+ - 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
470
+
471
+ 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.
472
+
473
+ 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.
474
+
475
+ 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.
476
+
477
+ 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.
478
+
479
+ - 7f1cb33: List sort: the relational hint stops recommending a formula field, the one type the server refuses to sort by
480
+
481
+ 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.
482
+
483
+ 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.
484
+
485
+ 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.
486
+
487
+ - 2e3b0c0: fix(list): an `OBJECT_API_DISABLED` list request renders an honest cannot-work state instead of the empty state
488
+
489
+ A list pointed at an object whose `enable` block withholds the API rendered its ordinary
490
+ empty state, so _"this page cannot work, and never could"_ reached the user as _"you have no
491
+ records"_ (objectui#4408). The reported instance — `Setup › Advanced › Signing Keys`, whose
492
+ `sys_jwks` declares `enable.apiEnabled: false` — could not load for any persona and said so
493
+ to nobody. That is also why the upstream defect objectstack#7544 survived review for its
494
+ whole life: a merely unpopulated page invites nobody to click through.
495
+
496
+ The masking had two halves, in two packages, and neither package could see the other:
497
+
498
+ - **`@object-ui/data-objectstack`** (minor — see the grading note below) — `find()` degraded
499
+ **every** 404 into `{ data: [], total: 0 }` and memoised the resource, so the denial arrived
500
+ at the surface as a successful empty result, indistinguishable from a genuinely empty
501
+ object. The two `enable`-block denials are now let through instead: `OBJECT_API_DISABLED`
502
+ (404) and `OBJECT_API_METHOD_NOT_ALLOWED` (405). The memo skips them too — absorbing one
503
+ would have pinned the object to "empty" for the rest of the session.
504
+ - **`@object-ui/plugin-list`** — the load-error panel gained an `api-disabled` kind. The 405
505
+ half was never swallowed, so it already reached this panel, but classified as `network`:
506
+ _"check your connection and try again"_ for a condition no retry can change. It now says
507
+ the object is not exposed through the API, that this is a setting on the object rather than
508
+ a permission, and it offers **no Retry** button, because every retry re-fetches the
509
+ identical refusal.
510
+
511
+ Both denials are pure functions of the object's metadata — no user, no permission, no
512
+ context — so neither is transient or per-user, which is exactly the case where a silent empty
513
+ state is most misleading. Discrimination is on the ADR-0112 `code`, never the status: a
514
+ missing collection, a missing record and a disabled object are all 404.
515
+
516
+ **A genuinely empty object still renders the ordinary empty state**, and a backend without an
517
+ optional collection still degrades to empty — pinned in both directions, at the adapter, at
518
+ the view, and once end-to-end over a real adapter and a real `ListView`.
519
+
520
+ Also closes a code-propagation gap on the same path: `find()`'s raw `$expand`/`$search`
521
+ branch bypasses `@objectstack/client` and hand-rolled its own error, stamping only `status`.
522
+ It now carries the ADR-0112 envelope (`code` + `httpStatus`), so a denial arriving on the
523
+ branch a list takes whenever it expands a lookup or runs a search is no longer anonymous.
524
+
525
+ New strings: `list.loadErrorApiDisabledTitle` / `list.loadErrorApiDisabledMessage`, in the
526
+ `en` pack and mirrored in the list defaults map.
527
+
528
+ ## Grading note — why `@object-ui/data-objectstack` is **minor** and not patch
529
+
530
+ Two independent reasons, either of which is sufficient under this repo's precedent
531
+ (objectui#4403 / #4177, and #4485's grading of `@object-ui/core`'s `toDomProps` lift):
532
+
533
+ 1. **The emitted `.d.ts` grows two NEW exports.** `isApiAccessDeniedError(error: unknown):
534
+ boolean` and `API_ACCESS_DENIED_CODES` (the readonly tuple
535
+ `['OBJECT_API_DISABLED', 'OBJECT_API_METHOD_NOT_ALLOWED']`) are added to the package's
536
+ public surface. Additive surface growth is minor.
537
+ 2. **Observable behaviour on a published API moves.** `ObjectStackDataSource.find()` now
538
+ **REJECTS** for the two `enable`-block denial codes where it previously **RESOLVED** with
539
+ `{ data: [], total: 0 }`. No signature changed and nothing was removed, but a caller that
540
+ relied on those two codes arriving as a successful empty result now receives a rejected
541
+ promise carrying `code` + `httpStatus`, and must handle it.
542
+
543
+ Deliberately unchanged, and still resolving to an empty result exactly as before: a bare 404
544
+ with no code, `OBJECT_NOT_FOUND` (still memoised) and `RECORD_NOT_FOUND`. The behaviour move
545
+ is scoped to the two denial codes named above and to nothing else.
546
+
547
+ Not major: this follows AGENTS.md's version-alignment rule — objectui's major tracks
548
+ `@objectstack`'s, so this repo's own breaking semantics are declared as minor with the change
549
+ described in the body, which is what this note is.
550
+
551
+ - 31ab1ac: fix(print): `window.print()` produces a usable page, and the Print buttons say what they do
552
+
553
+ The list, report and dashboard Print controls were bare `window.print()` calls with no
554
+ print stylesheet, so the browser printed the whole console — sidebar, top bar, chat rail,
555
+ toasts — with the data table clipped to a single viewport. With no label to the contrary
556
+ they were being accepted against "export to PDF" requirements, which they have never been.
557
+
558
+ - `@object-ui/app-shell/styles.css` gains a shared `@media print` block: it hides the shell
559
+ chrome, prints the active content area full-width, releases the viewport-height flex chain
560
+ so long tables paginate instead of clipping, repeats table headers on every sheet, and
561
+ neutralises dark mode (which otherwise prints white-on-white). One sheet serves list,
562
+ report and dashboard.
563
+ - The list and report Print buttons carry a tooltip and accessible name stating that they
564
+ open the browser's own print dialog and are not a PDF export (new `common.printDialogHint`,
565
+ translated in all ten locale packs).
566
+ - The dashboard's `export_dashboard_pdf` action no longer toasts "Preparing PDF export…" —
567
+ it names the print dialog it actually opens (`dashboardActions.pdfPreparing` is replaced by
568
+ `dashboardActions.printDialogOpening`).
569
+
570
+ No control was removed and no headless detection was added. A real print/PDF primitive
571
+ remains out of scope (`objectstack-ai/objectstack#1301`, closed NOT_PLANNED).
572
+
573
+ - 06915b0: fix(i18n): every date branch threads the active locale, so a `zh` session no longer renders half its dates in English
574
+
575
+ Date rendering had two locale channels and only one followed the user's
576
+ language, so the same row could read `逾期 6 天` in one column and `In 3 days`
577
+ in the next, with a datetime column showing `8/11/2026 12:00 am`
578
+ (objectui#4468).
579
+
580
+ The overdue phrase resolves through the translate fn (the active UI language),
581
+ while every `Intl` branch took its tag from the raw tenant locale
582
+ (`useLocalization().locale`) — which is `undefined` on any workspace that never
583
+ configured one, and `undefined` makes `Intl` use the _machine's_ locale.
584
+ `DateTimeCellRenderer` passed no tag at all.
585
+
586
+ Every date-formatting site in `@object-ui/fields` now resolves through the one
587
+ existing channel, `useDisplayLocale()` (tenant regional default → active UI
588
+ language → `en`): `DateCellRenderer` (relative past, relative future, near-today
589
+ and the beyond-±7-days absolute fallback), `DateTimeCellRenderer`, the read-only
590
+ `DateField` / `DateTimeField` / `FormulaField` faces, and the sub-grid's
591
+ temporal cells. English output is unchanged, and the already-localized overdue
592
+ wording is untouched.
593
+
594
+ No public signature changed. `@object-ui/i18n` carries a documentation
595
+ correction only: `useDisplayLocale`'s docstring claimed `DateCellRenderer`
596
+ already formatted from this channel, which was the very thing that was not true.
597
+
598
+ - ff84b05: Stop the report config panel being titled "Title", and the view-settings colour section "Color"
599
+
600
+ 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).
601
+
602
+ `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.
603
+
604
+ `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.
605
+
606
+ No `en` value of an existing key changed; `scripts/check-i18n-en-drift.mjs` reports 0 en values changed, 1 key added.
607
+
608
+ - Updated dependencies [ee66e2e]
609
+ - Updated dependencies [ee26e65]
610
+ - Updated dependencies [5900ac5]
611
+ - Updated dependencies [3fc2971]
612
+ - Updated dependencies [aca27fa]
613
+ - Updated dependencies [dde7283]
614
+ - Updated dependencies [f279deb]
615
+ - Updated dependencies [eb7f586]
616
+ - Updated dependencies [e901131]
617
+ - Updated dependencies [d9d3463]
618
+ - Updated dependencies [613b167]
619
+ - Updated dependencies [abb0f81]
620
+ - Updated dependencies [38ab505]
621
+ - Updated dependencies [7e4f0e5]
622
+ - Updated dependencies [92250d6]
623
+ - Updated dependencies [c1d939f]
624
+ - Updated dependencies [49ae9f4]
625
+ - Updated dependencies [2459a3e]
626
+ - Updated dependencies [d6aa172]
627
+ - Updated dependencies [fe52a04]
628
+ - Updated dependencies [bb68488]
629
+ - Updated dependencies [9461dd3]
630
+ - @object-ui/core@17.5.0
631
+
632
+ ## 17.4.0
633
+
634
+ ### Patch Changes
635
+
636
+ - 7864f03: Backfill the auth family's 54 missing locale keys — `auth` 26 + `oauth` 16 + `acceptInvitation` 12 (objectui#3546, slice three)
637
+
638
+ `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) measured 54 keys that a
639
+ `t()` call site asks for and that **no locale pack defined** — 54 distinct keys at
640
+ 54 call sites across the console's six auth pages. All 54 carried an inline
641
+ `t(key, { defaultValue: 'English' })`, which is exactly the objectui#3517 class:
642
+ English rendered correctly, and **all ten languages were stuck on it** for
643
+ months. Nothing here rendered a raw key — slice one (PR #3583) held those sites.
644
+
645
+ What that meant on the page: a `zh` user reaching `/login` and switching to the
646
+ phone/SMS branch got "Email or phone number", "Get code", "Resend in {seconds}s"
647
+ and "Sign in with password instead" in English; the whole `/oauth/consent` screen
648
+ — including the four scope sentences describing what a third-party client is
649
+ about to be granted — was English-only; so was the `/accept-invitation` page and
650
+ the device-authorization dead end.
651
+
652
+ - **`packages/i18n/src/locales/en.ts`** gains the 54 keys. `oauth.consent.*` and
653
+ `acceptInvitation.*` are new top-level namespaces; the other 26 extend
654
+ `auth.login`, `auth.forgotPassword`, `auth.device` and `auth.verifyEmail`.
655
+ Every one of the 52 keys whose call site carries a **string** `defaultValue`
656
+ gets that exact string, byte for byte (52/52, script-compared), so the pack
657
+ path and the inline-default path cannot diverge. The two remaining keys —
658
+ `oauth.consent.title` / `oauth.consent.request` — have **template**
659
+ defaultValues, where byte identity is structurally impossible (JS `${…}` vs
660
+ i18next `{{…}}`); both take the interpolation contract the call site actually
661
+ declares in its options.
662
+ - **The nine other packs** get real translations, each evidenced against a
663
+ neighbour key in the same pack (fr's space before `?`/`:`, de's en dash, ru's
664
+ ё, ar's verb-first placement so an RTL sentence does not open on a Latin
665
+ client name, zh's full-width punctuation). The one string all ten packs share
666
+ is `phonePlaceholder` — the E.164 example number, treated like the
667
+ `name@example.com` the packs already keep untranslated.
668
+ - **`scripts/i18n-call-site-key-baseline.json`** loses exactly those 54 entries
669
+ (163 → 109). The file is a ratchet: an unfixed key missing from it fails the
670
+ build, and a fixed key still listed fails it too.
671
+ - **No component changed.** An AST sweep of all 122 call sites in these three
672
+ namespaces found zero dead `t(key) || 'English'` fallbacks (the construct
673
+ slice one had to delete from `ObjectView.tsx`, where i18next's key-as-value
674
+ return made `||` unreachable).
675
+
676
+ Two holes here are **not** i18next's and must survive translation intact:
677
+ `resendOtpCountdownText` carries `{seconds}` in single braces because
678
+ `packages/auth/src/LoginForm.tsx` and `ForgotPasswordForm.tsx` substitute it with
679
+ a literal `.replace()`, and `oauth.consent.request`'s `{{suffix}}` arrives
680
+ pre-formatted from the page. Both are pinned in
681
+ `packages/i18n/src/__tests__/auth-namespace-3546.test.tsx`, in both directions.
682
+
683
+ - f5f8744: Backfill the `console` namespace's 41 missing locale keys plus the `console.ai.group.` template family (objectui#3546, slice four)
684
+
685
+ `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) measured **41 distinct
686
+ keys at 47 call sites** under `console.*` that a `t()` call site asks for and
687
+ that **no locale pack defined** — five of those keys have more than one site
688
+ (`console.ai.dock.maximize` has three), which is why the denominator is measured
689
+ and never counted by hand. All 47 carried an inline
690
+ `t(key, { defaultValue: 'English' })`, so this is the objectui#3517 class:
691
+ English rendered correctly, and **all ten languages were stuck on it**. Nothing
692
+ here rendered a raw key — slice one (PR #3583) held those sites.
693
+
694
+ What that meant on the page: a `zh` user opening `/ai` got "Waiting for
695
+ server…", "Still working…" and "Connection lost — reconnecting…" in the
696
+ connection banner, all ten "Designing your app…" progress hints in English, and
697
+ "Built" / "Not yet built" / "Published" / "Publish failed" on the plan card; the
698
+ ChatDock's whole chrome (title, resize handle, collapse, "Open full page") was
699
+ English including two `aria-label`s; the conversation sidebar's date headers read
700
+ "Today" / "Yesterday" / "Previous 7 days"; a mistyped URL produced an English
701
+ "Page not found"; and the `?` shortcuts dialog's AI group was English inside an
702
+ otherwise translated table. Two of these strings ("Not yet built", "TODAY") are
703
+ named in objectui#2458's mixed-language list.
704
+
705
+ - **`packages/i18n/src/locales/en.ts`** gains 46 keys: the 41 measured ones plus
706
+ the five members of the `console.ai.group.` family. `console.notFound` is a new
707
+ sub-namespace; the rest extend `console.shortcuts` and `console.ai` (with new
708
+ `console.ai.dock`, `console.ai.designingPlanHint` and `console.ai.group`
709
+ objects). Every one of the 41 measured keys takes its call site's inline
710
+ `defaultValue` **byte for byte** (46/47 sites, script-compared), so the pack
711
+ path and the inline-default path cannot diverge.
712
+
713
+ - The one site that cannot match is `ChatDock.tsx:563`, where a single key
714
+ (`console.ai.dock.open`) carries two different English strings: the
715
+ `aria-label` says `Open assistant` and the `title` says
716
+ `Open assistant (⌘⇧I)`. A key can hold one value, so `en` takes the
717
+ `aria-label` spelling — an accessible name must not carry a glyph run that
718
+ screen readers announce as symbols, and `⌘` is a mac-only glyph that a
719
+ _language_ pack cannot vary per platform. The tooltip therefore stops
720
+ advertising the shortcut; recorded on objectui#3810 (whose class this
721
+ divergence joins) rather than papered over.
722
+
723
+ - **`console.ai.group.` leaves the ratchet's `missingPrefixes` (4 → 3).** It is a
724
+ template key — `` t(`console.ai.group.${group.key}`) `` in
725
+ `ConversationsSidebar.tsx:277` — whose static head matched no `en` key, so every
726
+ expansion missed. Its value surface is the **closed** `ConversationGroupKey`
727
+ union, so the repair is an enumeration of all five members, not a wildcard; a
728
+ test reads the component's own union and label map and fails if a sixth bucket
729
+ is ever added without a key.
730
+
731
+ - **The nine other packs** get real translations, each evidenced against its own
732
+ `console` neighbours: `zh` full-width punctuation and the pack's single-em-dash
733
+ status-line style, `ja`/`ko` the pack's `AI ` spacing, `de` formal _Sie_ and
734
+ its `Wird …` progressive, `fr` straight apostrophes, `es` _usted_ (as the
735
+ `console.ai` neighbourhood already uses), `pt` the pack's `off-line` spelling,
736
+ `ru` ё orthography, `ar` verb-first phrasing that never opens an RTL sentence
737
+ with a Latin token. Where `en` repeats a string the packs already translate
738
+ (`Go back`, `Publish failed`, `Try again`, `Back to home`, `Assistant`,
739
+ `Today`, `Yesterday`), the existing neighbour's wording is reused rather than
740
+ re-invented.
741
+
742
+ - **`scripts/i18n-call-site-key-baseline.json`** shrinks by exactly 42 entries
743
+ (41 keys + 1 prefix family): 109 → 68 keys, 4 → 3 prefixes.
744
+
745
+ No component changed: an AST sweep of all 308 `console.*` call sites in the repo
746
+ found zero dead `t(key) || 'English'` fallbacks among this slice's keys.
747
+
748
+ - 69becd2: `de` approvals inbox no longer shows two quote typographies on one screen
749
+
750
+ Three `approvalsInbox` values quoted the record title with ASCII straight quotes
751
+ on **both** sides while their own sibling `approvalsInbox.approveOneTitle` used
752
+ the correct German pair (U+201E low-9 opener, U+201C closer):
753
+
754
+ | key | before | after |
755
+ | ------------------------------- | ----------------------- | ----------------------- |
756
+ | `approvalsInbox.rejectOneTitle` | `"{{title}}" ablehnen?` | `„{{title}}“ ablehnen?` |
757
+ | `approvalsInbox.inlineApproved` | `"{{title}}" genehmigt` | `„{{title}}“ genehmigt` |
758
+ | `approvalsInbox.inlineRejected` | `"{{title}}" abgelehnt` | `„{{title}}“ abgelehnt` |
759
+
760
+ A German approver therefore met both typographies in a single screen and inside a
761
+ single operation pair — German quotes on the approve confirmation, typewriter
762
+ quotes on the reject confirmation and on both inline toasts. `„…“` is the pack's
763
+ own majority (50 paired spans) and DUDEN R11, so the three values were the
764
+ outlier. Keys and the `{{title}}` placeholder are unchanged; this is a
765
+ value-domain typography fix, and `en` (ASCII on both sides by design) is
766
+ untouched.
767
+
768
+ This is a **different defect shape** from objectui#3876, which paired a German
769
+ opener with an ASCII closer. Because these three were ASCII on both sides they
770
+ were self-consistent, so the pairing invariant objectui#3876 left behind could
771
+ not see them — it scans forward from each `„`, and there was no `„` in them to
772
+ scan from. The pack's straight-quote census being empty now lets that pin become
773
+ strictly stronger: `de-quote-pairing-3876.test.ts` asserted an explicit
774
+ three-key allowlist and now asserts that **no `de` value holds a U+0022 at all**,
775
+ which covers both defect shapes and needs no per-key maintenance as new values
776
+ land. The pack census moves from `„` 47 / `“` 47 / `”` 0 / `"` 6 to
777
+ `„` 50 / `“` 50 / `”` 0 / `"` 0.
778
+
779
+ None of the three i18n gates could have caught this: `all-locales-key-parity`
780
+ compares key sets and placeholder shapes, `check-i18n-call-site-keys.mjs` only
781
+ asks whether a key resolves, and `check-i18n-en-drift.mjs` fires on `en` value
782
+ changes — these values were wrong from the day they landed, so no drift event
783
+ ever existed. All three are value-blind by design, which is why the invariant
784
+ lives in a test.
785
+
786
+ - 5e52495: German pack: the 20 values that closed the German opening quote with an ASCII straight quote now close it with `“`
787
+
788
+ `packages/i18n/src/locales/de.ts` opened a quoted span with the German low-9
789
+ quote `„` (U+201E) and closed it with the ASCII typewriter quote `"` (U+0022) in
790
+ 20 keys, so a German user read `Registerkarte „Alle Datensätze" anzeigen` — a
791
+ mismatched pair. `search.resultsCount` showed it most plainly: the value ended
792
+ `„{{query}}""`, the mismatched closer immediately followed by the TS string
793
+ terminator.
794
+
795
+ This was never a pack convention. Measured on `main@2937bcf7d` before the fix,
796
+ the same file already spelled 23 spans the correct German way, `„…“` — the two
797
+ styles sat side by side, in sibling keys of the same namespace. Every mismatched
798
+ closer is now `“` (U+201C), which is what German orthography (DUDEN R11) and the
799
+ majority of the file already used. Affected surfaces: the four empty states
800
+ (object / page / dashboard / report not found), the search results header, the
801
+ lookup "create named" action, four `console.objectView` strings, the home
802
+ getting-started hint, the six `navigationSync` toasts, the local marketplace
803
+ install toast, and the preview not-ready title.
804
+
805
+ No other language pack and no `en` value changed — the counts are unchanged for
806
+ every other locale, and the mismatch was measured only in `de`.
807
+
808
+ ### Counted at landing, in two units
809
+
810
+ The card reported "20 values" and the triage re-scan reported "22 mismatched
811
+ pairs"; both are correct about different units, and the difference is now
812
+ recorded rather than left to the next reader. 20 keys carried a mismatch;
813
+ 22 mismatch occurrences lived in them, because `navigationSync.renamedPage` and
814
+ `navigationSync.renamedDashboard` each quote two names in one sentence
815
+ ("Seite „alt“ in „neu“ umbenannt") and so contribute two each. Full-file counts
816
+ before → after: `„` 45 → 45, `“` 25 → 47, `”` 2 → 2, `"` 28 → 6.
817
+
818
+ ### The durable half, and why it is not the count equality the card proposed
819
+
820
+ Three i18n gates run over these packs and none can see a wrong quote:
821
+ `all-locales-key-parity` compares key sets and placeholder shapes,
822
+ `check-i18n-call-site-keys.mjs` only asks whether a key resolves, and
823
+ `check-i18n-en-drift.mjs` fires on `en` **value changes** — these values were
824
+ wrong from the day they landed, so no drift event ever existed. Without a
825
+ value-domain assertion the next backfill copies the mismatch from a neighbour
826
+ again, which is exactly how these 20 accumulated.
827
+
828
+ The card proposed asserting `count(„) === count(“)`. That is **false on the
829
+ correctly fixed file** (45 vs 47) and would have sent the next reader hunting a
830
+ bug that is not there: two values in this pack, `grid.import.savedMappingHint`
831
+ and `grid.import.savedMappingPreviewNote`, are still untranslated English prose
832
+ and quote in the English style `“…”`, so their two `“` are legitimate _openers_.
833
+ The invariant that is pinned instead is the pairing itself — for every `„`, the
834
+ first quote character that follows must be `“` — backed by the arithmetic
835
+ identity `count(“) === count(„) + count(”)`, which stays true if those two values
836
+ are later translated (47 === 47 + 0) and breaks the moment a mismatch returns.
837
+ Both carry a presence guard so a broken import cannot make them pass by scanning
838
+ nothing.
839
+
840
+ - b750823: `preview.draftBar` speaks one second person in `es` — the draft-preview banner no
841
+ longer switches from tú to usted when a Spanish user publishes (#3844)
842
+
843
+ `DraftPreviewBar` renders two mutually exclusive sentences in the same strip of
844
+ the same banner: `message` while there are unpublished changes, `messageClean`
845
+ once there are none. In `es` the two disagreed on register — `message` was tú
846
+ (`estás viendo`, `publiques`) while `messageClean` (`ve`) and `sampleDataBody`
847
+ (`Está`, `su`, `Publíquela`) were usted. So a Spanish user who pressed Publish
848
+ watched the banner change person: same component, same position, same session.
849
+
850
+ This is a third defect class in the value-domain blind spot behind #3582 and
851
+ #3625, and no gate in the repo can see it. Both `es` values are correct
852
+ translations of their `en` sentences — nothing is missing, nothing is stale, and
853
+ nothing holds English. The inconsistency is _internal to one pack, on one UI
854
+ surface_: `scripts/check-i18n-call-site-keys.mjs` only asks whether a key exists
855
+ in `en`, `all-locales-key-parity` compares key sets and placeholder shapes and
856
+ never reads values, and `scripts/check-i18n-en-drift.mjs` only fires when an `en`
857
+ value moves — these two `en` values never moved.
858
+
859
+ `message` is the value that changes, because usted is what the pack already says
860
+ everywhere around it: the `es` pack censuses 102 usted markers to 30 tú (tú being
861
+ the marked exception, concentrated in the auth, report-editor and organizations
862
+ neighbourhoods that #3546 slice two deliberately ruled informal); the other three
863
+ strings of this same object were already usted; and #3546 slice five gave the 19
864
+ new `preview.unpublishedBar.*` / `preview.history.*` keys usted on the strength
865
+ of `home.pendingDrafts.published` ("¡Publicado! Sus cambios están activos."). Two
866
+ smaller divergences inside the same sentence are closed with it, so the banner's
867
+ two halves stop disagreeing about wording as well as person:
868
+
869
+ - **`—` instead of `:`** in `messageClean`, matching `en`, where both sentences
870
+ open `Draft preview — `. That is the whole of `messageClean`'s diff.
871
+ - **`activo` instead of `en producción`** for "live". The pack spells this concept
872
+ `activo` in four neighbouring places including `publishCta` in this very object
873
+ ("Publicar para verlo activo"), and it reserves _producción_ for the actual
874
+ production environment (`environment.entitlement.planLockedBody`), so the
875
+ outlier was ambiguous as well as inconsistent.
876
+ - **`Vista previa del borrador`** as the shared opening, `del` being the form
877
+ `messageClean` already used.
878
+
879
+ No `en` value changes (the en-drift gate reports 0), no key is added or removed
880
+ (so `all-locales-key-parity` is untouched by construction), and the nine other
881
+ packs are not touched. The diff is two values in one file.
882
+
883
+ Re-voicing the whole `preview` namespace to tú was considered and rejected — a
884
+ much larger change that would collide with the adjacent `marketplace.*` (9:0
885
+ usted) and `console.ai.*`. `preview.empty.notReadyDescription` therefore stays tú
886
+ here, since #3844's ruling is this banner only; it is filed as #3875 rather than
887
+ waved through, because `PreviewDraftEmptyState` renders _underneath_
888
+ `DraftPreviewBar` and so those two show usted and tú on screen at the same time.
889
+ After this change the `preview` namespace is 23 usted to that 1 tú.
890
+
891
+ A gate that checks "one register per namespace" is deliberately **not** here:
892
+ recognising usted vs tú needs real morphology, and token matching demonstrably
893
+ cannot — in this pack the token `revisa` is usted in `console.ai.empty.build`
894
+ (es.ts:1365, "…y usted revisa y publica.") and tú in
895
+ `auth.forgotPassword.successTitle` (es.ts:1918, "Revisa tu correo electrónico"),
896
+ the same eight letters in opposite registers. The neighbourhood boundary such a
897
+ gate would police is human judgement anyway — #3546 slice two's "same rule,
898
+ different answer". A new `draftBar-es-register-3844.test.ts` pins the four `es`
899
+ values byte for byte instead, plus the `en` literals, so a future reword of
900
+ either `en` sentence fails in the same PR that reworded it.
901
+
902
+ - ac2139c: `grid.import` saved-mapping copy is now translated in ko / de / fr / es / pt / ru / ar instead of served as English
903
+
904
+ Five keys — `grid.import.savedMapping`, `chooseSavedMapping`, `manualMapping`,
905
+ `savedMappingHint` and `savedMappingPreviewNote` — were **byte-identical to `en`**
906
+ in seven of the nine non-`en` packs, 35 values in all. Only zh and ja had been
907
+ translated, and two of the five are multi-clause explanatory sentences rather
908
+ than placeholders or proper nouns, so this was not a deliberately untranslated
909
+ term: a German, French, Spanish, Portuguese, Russian, Korean or Arabic user
910
+ picking a saved mapping in the import wizard read English.
911
+
912
+ None of the three i18n gates could see it. `all-locales-key-parity` compares key
913
+ sets and placeholder shapes, so English passed perfectly; the call-site gate only
914
+ asks whether a key resolves; `check-i18n-en-drift.mjs` fires on an `en` **value
915
+ change** and these keys arrived in the packs already English, so no drift event
916
+ ever existed. objectui#3920 also proposed a general "no non-`en` value may be
917
+ byte-equal to `en`" gate; that is deliberately not part of this change, because
918
+ values like `auth.*.emailPlaceholder` (`name@example.com`),
919
+ `fields.image.counter` (`{{current}} / {{total}}`) and
920
+ `grid.import.templateFileName` are byte-equal across packs correctly, and
921
+ objectui#3880 records the other face of the family. The five keys are pinned by
922
+ name instead.
923
+
924
+ Each translation reuses vocabulary its own pack already ships rather than
925
+ inventing a second word for the same concept: `stepMapping` for "mapping"
926
+ (Zuordnung / Correspondance / Asignación / Mapeamento / Сопоставление / تعيين /
927
+ 매핑), `legacyFallbackNotice` for "type coercion" (Typkonvertierung / conversion
928
+ de type / conversión de tipos / conversão de tipos / приведение типов / تحويل
929
+ الأنواع / 형 변환), `view.readonlyTooltip` for "read-only", and `validateHint`
930
+ for "on the server". `es` follows the usted ruling objectui#3844 measured for
931
+ this pack; `pt` is the pack's own pt-BR (mapeamento / salvo / no servidor);
932
+ `ru` keeps its ё; `fr` keeps the straight apostrophe and the space before colon
933
+ and semicolon; `ar` punctuates with U+060C and U+061B; `ko` uses the 은(는) /
934
+ 이(가) spelling its `fields.file.exceedsMaxSize` already uses for a placeholder
935
+ whose particle cannot be resolved at authoring time.
936
+
937
+ The quote around `{{name}}` follows each pack's measured majority for wrapping a
938
+ placeholder — de `„…“` (38 spans against 3 ASCII), fr `« … »` with an ASCII
939
+ space (25 against 18), ru `«…»` (23 against 18), and ASCII `"…"` for ko (38:0),
940
+ pt (39:0), es (22:19) and ar (27:15). The card cited
941
+ `residue-namespaces-3546.test.tsx` as pinning "the rest ASCII", but that line
942
+ pins the `empty.*` family only and its own comment scopes the rule to "the
943
+ sibling value in the SAME pack"; `grid.import` has no other quoted span, so the
944
+ census one ring out decides.
945
+
946
+ Germanising the two `de` sentences also closes the 45/47 gap
947
+ `de-quote-pairing-3876.test.ts` documented: that pack's census moves from
948
+ `„` 45 / `“` 47 / `”` 2 to `„` 47 / `“` 47 / `”` 0, exactly as that file's header
949
+ predicted, and its `count(“) === count(„) + count(”)` identity still holds. The
950
+ three literal counts in it were updated and the two keys are now pinned by name
951
+ there, so `rdqKeys === []` cannot become an assertion that passes because nothing
952
+ is produced.
953
+
954
+ `en`, `zh` and `ja` are untouched, and `grid.import.transform` — the sixth key in
955
+ the same block, English in the same seven packs, and the third column header of
956
+ the very table this hint sits above — is deliberately left alone, filed as
957
+ objectui#3938 and pinned as still-English so the number cannot drift while that
958
+ issue waits its turn.
959
+
960
+ - b14ab3a: `grid.import.transform` is now translated in ko / de / fr / es / pt / ru / ar instead of served as English
961
+
962
+ The third column header of the import wizard's saved-mapping summary was
963
+ **byte-identical to `en`** — the literal string `Transform` — in seven of the
964
+ nine non-`en` packs. zh (`转换`) and ja (`変換`) had translated it, which is what
965
+ rules out the "deliberately untranslated term" reading; this is the sixth key of
966
+ the same six-line block objectui#3920 / PR #3936 fixed the other five of, and it
967
+ was left out of that card only to keep its 5-keys / 35-values census verifiable.
968
+
969
+ | pack | before | after | the pack's own anchor |
970
+ | ---- | ----------- | ---------------- | ---------------------------------------------------------------------------------------- |
971
+ | ko | `Transform` | `변환` | `savedMappingHint` "이름 변경 + 변환 + 형 변환" |
972
+ | de | `Transform` | `Transformation` | `savedMappingHint` "Umbenennung + Transformationen + Typkonvertierung" |
973
+ | fr | `Transform` | `Transformation` | `savedMappingHint` "le renommage + les transformations + la conversion de type" |
974
+ | es | `Transform` | `Transformación` | `savedMappingHint` "el cambio de nombre + las transformaciones + la conversión de tipos" |
975
+ | pt | `Transform` | `Transformação` | `savedMappingHint` "renomeação + transformações + conversão de tipos" |
976
+ | ru | `Transform` | `Преобразование` | `savedMappingHint` "переименование + преобразования + приведение типов" |
977
+ | ar | `Transform` | `التحويل` | `savedMappingHint` "إعادة التسمية + التحويلات + تحويل الأنواع" |
978
+
979
+ Each value is the **singular of the word the pack already uses** for `en`'s
980
+ plural "transforms" in `grid.import.savedMappingHint` — the sentence
981
+ `SavedMappingSummary` renders directly above this header. That is the anchor
982
+ rather than `legacyFallbackNotice`'s term, because `en`'s own sentence lists
983
+ "transforms" and "type coercion" as two different server-side operations and
984
+ `legacyFallbackNotice` is about the second: reusing de "Typkonvertierung", ko
985
+ "형 변환" or ar "تحويل الأنواع" for this header would name the wrong operation.
986
+
987
+ Why it was worth a card of its own rather than waiting for a general gate: this
988
+ value is not a hidden string. It is the third `TableHead` of
989
+ `SavedMappingSummary`, sitting in one row with `csvColumn` and `mapsTo`, which
990
+ were always translated — so a German user read `Spalte` / `Zugeordnet zu` /
991
+ `Transform`, two languages in a single header row, directly under the German
992
+ hint PR #3936 had just landed. Before that PR the whole panel was English and
993
+ therefore at least self-consistent.
994
+
995
+ The three i18n gates are value-blind here exactly as they were for
996
+ objectui#3920: `all-locales-key-parity` compares key sets and placeholder shapes
997
+ (the key was present with no placeholder, so English passed perfectly), the
998
+ call-site gate only asks whether a key resolves, and `check-i18n-en-drift.mjs`
999
+ fires on an `en` **value change** — this value entered the seven packs already
1000
+ English, so no drift event ever existed. `en`, zh and ja are untouched, and the
1001
+ pin PR #3936 left in `gridImportSavedMapping-i18n-3920.test.ts` asserting these
1002
+ seven were "still English" has been replaced by the translated pin it asked for.
1003
+
1004
+ - 8c60819: The inbox popover now spells out what the bell badge is made of
1005
+
1006
+ The bell badge is `unread notification topics + pending approvals`, clamped to
1007
+ "9+" above nine. As one number it is unexplainable: objectstack#7213 measured
1008
+ Home's "pending approvals" card saying 8 while the bell said "9+", and read that
1009
+ as the two counts disagreeing — they never did, the bell was simply carrying a
1010
+ second addend the user could not see.
1011
+
1012
+ The popover already tabs the two streams and puts a count pill on each tab, so
1013
+ the split was partly visible — but those pills clamp at "9+" too. A loaded
1014
+ console therefore showed three "9+"s that reconcile to nothing, which is why
1015
+ sectioning alone did not close this.
1016
+
1017
+ A breakdown line under the popover header now states the exact, unclamped
1018
+ addends beside the exact total — `15 total · 12 notifications + 3 pending
1019
+ approvals`. The approvals half is the same `pendingApprovalsCount` the Home card
1020
+ and the Approvals Inbox tab read, so the number a user reconciles against is
1021
+ literally the one they see elsewhere.
1022
+
1023
+ The badge formula, the counting APIs and the "9+" clamp on the badge itself are
1024
+ unchanged — this is a display fix. Three new keys
1025
+ (`notifications.badgeTotal` / `badgeNotifications` / `badgeApprovals`) land in
1026
+ all ten locale packs. They interpolate named placeholders (`{{total}}`,
1027
+ `{{unread}}`, `{{approvals}}`) rather than i18next's `{{count}}`, which would
1028
+ additionally drive plural-key resolution these packs carry no forms for.
1029
+
1030
+ - e64a52e: 回填 `perm` + `home` 两命名空间 14 个缺失语言 key,十个语言包补齐(#3546 切片六)
1031
+
1032
+ `scripts/check-i18n-call-site-keys.mjs` 实测:14 个不重复 key / 14 个调用点(1:1),分布在权限集
1033
+ 记录的授权面摘要(`PermissionFacetLink`,ADR-0056 P1)与"一键发布全部草稿"的其余几条 toast
1034
+ (`usePublishAllDrafts`:ADR-0038 L3 探针健康、示例数据健康、ADR-0066 ⑨ 能力引用巡检)。
1035
+
1036
+ 这 14 处都带内联 `defaultValue`,所以英文一直正常渲染,**十种语言都翻不了** —— 修的是这一半。
1037
+ 英文字串一字未改:9 处静态 `defaultValue` 与 en 逐字节相同;另外 4 个计数标签(对象 / 字段规则 /
1038
+ RLS 策略 / 标签页规则)的 `defaultValue` 是带英文单复数分支的模板串,改用 i18next 复数族
1039
+ (`key` + `key_one`)后逐个计数渲染结果与模板串一致。
1040
+
1041
+ 复数族刻意带**基础 key** 而不是只写 `_one`/`_other`:i18next 只按语言的 CLDR 类别取一个后缀,
1042
+ 取不到就沿 fallback 链落到 `en`。ru(few 2-4、many 5-20)与 ar(two、few 3-10、many 11-99)
1043
+ 恰好在用户最先遇到的计数上会因此显示英文;基础 key 让这些类别落回**本语言**。
1044
+
1045
+ - 844d17f: Backfill the `marketplace` and `preview` namespaces' 37 missing locale keys plus the `marketplace.disclosure.runtime.` template-key family (objectui#3546, slice five)
1046
+
1047
+ `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) measured 37 keys that a
1048
+ `t()` call site asks for and that **no locale pack defined** — 37 distinct keys at
1049
+ 37 call sites across five console components — plus one `missing-prefix` family
1050
+ whose static head matched no `en` key at all, so every expansion missed. All 37
1051
+ carried an inline `t(key, { defaultValue: 'English' })`, which is exactly the
1052
+ objectui#3517 class: English rendered correctly, and **all ten languages were
1053
+ stuck on it** for months. Nothing here rendered a raw key — slice one (PR #3583)
1054
+ held those sites.
1055
+
1056
+ What that meant on the page for a `zh` (or `ja`, `de`, `ar`, …) user: the
1057
+ marketplace's "Your organization" strip, its Install / Installing… / Installed
1058
+ buttons and the version-update affordances were English; the whole ADR-0025 PD4
1059
+ **pre-install permission disclosure** was English — "This package contains code",
1060
+ the trust-tier badge, "Reviewed & approved" / "Not yet reviewed" / "Signed", the
1061
+ four permission group labels (platform services, lifecycle hooks, network,
1062
+ filesystem) and the consent checkbox the user ticks to accept them; the ADR-0045
1063
+ unpublished-app banner and its publish toasts were English; and the entire
1064
+ ADR-0067 build-history sheet — title, description, the per-commit labels, the
1065
+ Revert button and both of its result toasts — was English.
1066
+
1067
+ `marketplace.disclosure.runtime.` is repaired as an **enumeration, not a
1068
+ wildcard**: its value surface is the closed trust-tier enum
1069
+ (`PluginRuntimeSchema` = `z.enum(['node', 'sandbox', 'worker'])`, ADR-0025 §3.6),
1070
+ so all three members are backfilled and the family leaves the ratchet's
1071
+ `missingPrefixes` (3 → 2). A test reads the component's own fallback map and
1072
+ fails if a fourth tier is ever added without a key — the job the prefix entry
1073
+ used to do.
1074
+
1075
+ Each `en` value is byte-identical to the inline `defaultValue` it replaces (36 of
1076
+ 36 literal sites; the 37th's `defaultValue` is a template literal whose
1077
+ `${pkg.display_name}` becomes the `{{name}}` hole its call site already passes),
1078
+ so no English string a user sees today changes. The nine translations follow each
1079
+ pack's own neighbourhood — including two namespaces that legitimately take
1080
+ **different** second persons in `zh` (`marketplace` 你, `preview` 您) — and reuse
1081
+ an existing neighbour's translation wherever the `en` string already existed
1082
+ verbatim, so one English string never renders as two different sentences in the
1083
+ same language.
1084
+
1085
+ No component changed: an AST sweep of the whole `marketplace.*`/`preview.*`
1086
+ call-site surface found the slice's own dead-`||`-fallback count to be zero.
1087
+
1088
+ - 4dcd52a: `console.objectView.systemViewReadonly` and `console.objectView.expandToPage`
1089
+ are translated in the eight packs that stored English for them, so a Japanese,
1090
+ Korean, German, French, Spanish, Portuguese, Russian or Arabic session reads
1091
+ the system-view hint and the expand affordance in its own language (#3582).
1092
+
1093
+ This is a different defect class from #3546's ledger, and no gate in the repo
1094
+ could see it. There the key was _missing_ from a pack and `fallbackLng: 'en'`
1095
+ rendered English; here the key was **present in all ten** and eight of them
1096
+ stored English as the value. `all-locales-key-parity` compares key _sets_ and
1097
+ placeholder _shape_ — identical before and after this change, and neither key
1098
+ interpolates anything. `scripts/check-i18n-call-site-keys.mjs` and its baseline
1099
+ ratchet ask whether a `t()` key exists in `en`; it did, so these two were never
1100
+ in the 258.
1101
+
1102
+ `systemViewReadonly` carried the sharper half: the eight packs did not hold
1103
+ `en`'s sentence, they held one `en` had already abandoned. `en` says the view
1104
+ is read-only; the eight said `System view defined in code - duplicate to
1105
+ customize.` — pointing eight locales at a duplicate-to-customize path the
1106
+ product no longer presents. They are translated against `en`'s **current**
1107
+ read-only meaning, not against the stale English they replaced.
1108
+
1109
+ Each value is built from terminology the same pack already uses rather than
1110
+ invented: `view.readonlyTooltip` supplies "system view" and
1111
+ `console.objectView.cannotEditMetaView` (landed in #3583) supplies "defined in
1112
+ code", so the new hint agrees with the copy beside it in every pack. For
1113
+ `expandToPage`, `detail.openAsFullPage` is the identical English sentence one
1114
+ namespace over and was already translated everywhere — `en` and `zh` hold their
1115
+ two byte-identical to each other, so the eight now do too, and one locale
1116
+ cannot end up with two different words for one action.
1117
+
1118
+ `en` and `zh` are unchanged, byte for byte. No key is added or removed —
1119
+ the diff is 16 values in 8 files. A new
1120
+ `objectView-value-language-3582.test.ts` pins the `en` literal (so the next
1121
+ rewording of `en` fails loudly instead of silently orphaning nine
1122
+ translations), asserts that no pack but `en` serves either English spelling,
1123
+ and requires the zh/ja/ko/ru/ar values to contain characters of their own
1124
+ script. The repo-wide "no ASCII English sentence in a non-Latin pack" gate that
1125
+ #3582 also sketched is deliberately **not** here; it is a separate, lands-green
1126
+ change.
1127
+
1128
+ - 42ae5c6: The organization-management console is translatable. The 90 keys under
1129
+ `organization.*` — the org layout and its tabs, the members list, the whole
1130
+ invitation flow, organization settings including the leave and delete
1131
+ confirmations, the accept-invitation page, and the workspace switcher — are now
1132
+ defined in all ten locale packs, so a non-English session reads the org admin
1133
+ surface in its own language instead of English (part of #3546).
1134
+
1135
+ `scripts/check-i18n-call-site-keys.mjs` measured 258 keys that a `t()` call site
1136
+ asks for and no pack defines. `organization.*` was the largest namespace in that
1137
+ tally at 90 keys across 93 call sites in seven components. Every one of them
1138
+ carried an inline `t(key, { defaultValue: 'English' })`, which is why nothing
1139
+ looked broken: English rendered correctly at each site and all ten languages
1140
+ were pinned to it. That is the #3517 class, not the raw-key class slice one
1141
+ (#3583) held — no organization site rendered an identifier, and none had a dead
1142
+ `||` fallback to remove, which was measured before deciding not to touch the
1143
+ components.
1144
+
1145
+ Adding a `defaultValue` is deliberately not the fix; it is the mechanism that
1146
+ kept these invisible for months. The existing defaults stay where they are, and
1147
+ each `en` value is byte-identical to the default at its call site so the two
1148
+ paths cannot render different text.
1149
+
1150
+ `organization` is a new top-level namespace, sitting next to — and distinct
1151
+ from — `organizations`: the singular one is the management surface, the plural
1152
+ one the org picker. The ratchet in `scripts/i18n-call-site-key-baseline.json`
1153
+ shrinks by exactly these 90 entries, from 253 to 163. The
1154
+ `organization.invitations.status.*` template-key family is untouched and still
1155
+ baselined: enumerating an invitation status set is a different repair from
1156
+ backfilling literal keys.
1157
+
1158
+ - 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).
1159
+
1160
+ `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.
1161
+
1162
+ **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.
1163
+
1164
+ **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 刷新.
1165
+
1166
+ 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.
1167
+
1168
+ - 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)
1169
+
1170
+ `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) opened this backlog with
1171
+ **258 keys and 4 template-key families** that a `t()` call site asks for and that
1172
+ **no locale pack defined**. Seven slices later the last of it is paid: this change
1173
+ takes the ratchet from 17 keys to **zero** and from 2 prefix families to **zero**,
1174
+ and the gate now reports every one of the **2320** literal call-site keys
1175
+ resolving against `en`.
1176
+
1177
+ The residue was the long tail — nine namespaces across `app-shell`,
1178
+ `plugin-detail`, `plugin-dashboard`, `plugin-kanban` and `plugin-gantt`, none of
1179
+ them big enough to have been its own slice. 17 distinct keys at **23** call sites
1180
+ (five keys are used at more than one site) plus **3** call sites behind the two
1181
+ families.
1182
+
1183
+ What that meant on the page for a `zh` (or `ja`, `de`, `ar`, …) user: the "App not
1184
+ available" empty state a user lands on when an app is still publishing, including
1185
+ its whole explanation and its Retry button; the interface page's "source is not
1186
+ available" message; the system navigation's **Administration** group header,
1187
+ **Datasources** and **Documentation** entries; the "creating new organizations is
1188
+ disabled on this instance" guard in the workspace dialog; the invitation list's
1189
+ five status labels (All / Pending / Accepted / Rejected / Canceled) on both the
1190
+ filter tabs and every invitation badge; the Gantt dependency-drag hint that names
1191
+ which endpoint the drop will link (`start` / `end`); the record detail's Add,
1192
+ "Record deleted", "No history yet" and the concurrent-update dialog's "this
1193
+ record"; the kanban empty board's column count; the dashboard widget's screen
1194
+ reader "Loading…"; and the page editor's "Edit in studio" tooltip and accessible
1195
+ name. All of it rendered English, in every one of the ten languages.
1196
+
1197
+ Nothing here rendered a raw key — slice one (PR #3583) held those sites, and the
1198
+ three keys the issue body named as unprotected (`detail.viewSource`,
1199
+ `wizard.missingRequired`, `gantt.toolbar.refresh`) have resolved in `en` since.
1200
+
1201
+ Both families are repaired as **enumerations, not wildcards**, and the assertion
1202
+ that used to live in the ratchet's `missingPrefixes` moves into a test that fails
1203
+ if either union grows a member without a key:
1204
+
1205
+ - `gantt.linkEnd.` — the closed union `'start' | 'end'`, declared by GanttView's
1206
+ own `linkDrag` state.
1207
+ - `organization.invitations.status.` — `StatusFilter`
1208
+ (`all | pending | accepted | rejected | canceled`), declared by InvitationsPage.
1209
+
1210
+ Every `en` value is byte-identical to the English the call site rendered before,
1211
+ so no string a user sees today changes: 16 keys match an inline
1212
+ `t(key, { defaultValue: … })`; `dashboard.loading` matches `useSafeTranslate`'s
1213
+ positional fallback `tt(key, 'Loading…')`; `gantt.linkEnd.*` match
1214
+ `useGanttTranslation`'s per-key fallback map; and the five status labels match the
1215
+ CSS-capitalised wire value each badge and tab showed. The nine translations follow
1216
+ each pack's own neighbourhood and reuse an existing neighbour's row wherever the
1217
+ `en` string already existed verbatim **and** that row is grammatical here — the
1218
+ four invitation adjectives are deliberately not reused from the approvals family,
1219
+ because those agree with each pack's word for "request" (`ru` masculine `Отклонён`)
1220
+ while an invitation needs its own agreement (`ru` neuter `Отклонено`).
1221
+
1222
+ `scripts/i18n-call-site-key-baseline.json` is kept rather than deleted: empty is
1223
+ its terminal, load-bearing state — against an empty baseline any NEW unresolved
1224
+ call-site key is unexpected and fails the build.
1225
+
1226
+ No component changed.
1227
+
1228
+ - 33526fd: `view.readonlyTooltip` — the tooltip on a view tab's read-only lock — is
1229
+ retranslated in the eight packs (ja/ko/de/fr/es/pt/ru/ar) that still described
1230
+ the retired "duplicate to customize" workflow, so a Japanese, Korean, German,
1231
+ French, Spanish, Portuguese, Russian or Arabic session is told the view is
1232
+ defined in code and read-only, which is what `en` says and what the product
1233
+ does (#3625).
1234
+
1235
+ This is the same stale sentence #3582 fixed one namespace over, but it hid
1236
+ behind a much better disguise. In #3582 the eight packs stored the **English**
1237
+ string, so two cheap criteria could see it: "value equals `en`" and "a
1238
+ non-Latin pack holds pure ASCII". Neither can see this key. Its eight values
1239
+ were **idiomatic translations** — real Japanese, real Cyrillic, real Arabic —
1240
+ of a sentence `en` itself had already abandoned. Nothing about their form was
1241
+ wrong; only their meaning was. Key sets were complete, so
1242
+ `all-locales-key-parity` was green; the key exists in `en`, so the call-site
1243
+ guard and its ratchet were green; the values are distinct and in their own
1244
+ scripts, so every heuristic #3582 sketched would have been green too. Eight
1245
+ locales spent those releases pointing users at a path the product no longer
1246
+ offers, with every gate reporting success.
1247
+
1248
+ Each value is translated against `en`'s **current** meaning and built from
1249
+ words the same pack already uses — "read-only" from its own `view.readOnly` /
1250
+ `view.readonlyAriaLabel`, "defined in code" from
1251
+ `console.objectView.systemViewReadonly` / `cannotEditMetaView` — so the tooltip
1252
+ agrees with the copy beside it instead of introducing a ninth way to say
1253
+ read-only. Nothing is rewritten from the stale text.
1254
+
1255
+ `en` and `zh` are unchanged, byte for byte, and no key is added or removed —
1256
+ the diff is eight values in eight files. A new
1257
+ `viewReadonlyTooltip-semantics-3625.test.ts` tests **meaning** rather than
1258
+ form, in both directions: no pack may name the duplicate/copy workflow in its
1259
+ own language, and every pack must positively carry all three pieces of the
1260
+ sentence ("system view", "defined in code", "read-only") so the negative check
1261
+ cannot pass on a gutted string. It also pins the `en` literal, so the next
1262
+ rewording of `en` fails in the PR that does the rewording rather than orphaning
1263
+ nine translations for another release — which is the invariant this family of
1264
+ defects has actually been missing.
1265
+
1266
+ - 32413ec: Resolve `_views` translation keys by the bare view name only — the prefixed full name is no longer a second candidate
1267
+
1268
+ `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.
1269
+
1270
+ **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.
1271
+
1272
+ 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.
1273
+
1274
+ 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`).
1275
+
1276
+ **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.
1277
+
1278
+ ## 17.3.0
1279
+
1280
+ ### Minor Changes
1281
+
1282
+ - 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).
1283
+
1284
+ 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.
1285
+
1286
+ 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.
1287
+
1288
+ - 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.
1289
+
1290
+ ### Patch Changes
1291
+
1292
+ - b71fc92: Localize the last untranslated console-chrome accessible names (objectstack#5430)
1293
+
1294
+ Four icon-only controls still carried hardcoded English accessible names, so
1295
+ under a non-English session they were the only English left in the record
1296
+ chrome — and because the controls have no visible label, that literal _is_ the
1297
+ control to a screen reader and to the hover tooltip.
1298
+
1299
+ - `page:header`'s `role="toolbar"` — now `detail.pageHeaderActions` (its `⋯`
1300
+ overflow trigger eight lines below was fixed in #5407; the toolbar was missed)
1301
+ - `ReactionPicker`'s `role="listbox"` popup — now `detail.emojiPicker`
1302
+ - `ReactionPicker`'s per-reaction chip, which built its name by concatenation
1303
+ with English pluralization baked in (`reaction${count !== 1 ? 's' : ''}`) —
1304
+ now `detail.reactionCount` / `detail.reactionCountOne`
1305
+ - `NavigationOverlay`'s drawer close and split-panel close — now `common.close`
1306
+ (the key the rest of the console already uses) and `common.closePanel`
1307
+
1308
+ The pluralized label follows this repo's **two-key** convention
1309
+ (`detail.relatedRecords`/`relatedRecordOne`, `lookup.recordCount`/`recordCountOne`)
1310
+ rather than an i18next `_one`/`_other` pair: zh/ja/ko have no separate singular
1311
+ form, so those packs would legitimately omit the `_one` half and
1312
+ `all-locales-key-parity` would read that as a lost key.
1313
+
1314
+ All five new keys are added to all ten locale packs.
1315
+
1316
+ - 65516ba: Name `CommentThread`'s three emoji-only buttons, and follow the session language past the 7-day mark (objectui#3441)
1317
+
1318
+ Two leftovers from objectstack#5506 / objectui#3424, in the same component. It
1319
+ is an exported, published component with no in-repo consumer, so both only ever
1320
+ bite an external host.
1321
+
1322
+ **One — three controls with no authored accessible name.** Each comment's two
1323
+ quick-reaction buttons (`'👍'` and `'❤️'`) and the reply banner's dismiss button
1324
+ (`'✕'`) carried no `aria-label` and no `title`. The `+` reaction picker right
1325
+ beside them has had one since #3424 (`collaboration.addThumbsUp`), which is what
1326
+ makes these three an omission rather than a design choice.
1327
+
1328
+ `aria-label`, not the `title` the `+` uses: a `button`'s accessible name is
1329
+ computed from its CONTENT (accname §2F) before the `title` tooltip is ever
1330
+ consulted (§2I), so on a button whose only child is a glyph a `title` decorates
1331
+ the mouse and leaves the name alone. What a screen reader read out was the
1332
+ codepoint — "thumbs up", "red heart", in English whatever the session language,
1333
+ and for U+2715 MULTIPLICATION X very often nothing at all.
1334
+
1335
+ Three new keys in all ten packs: `collaboration.reactThumbsUp`,
1336
+ `collaboration.reactHeart`, `collaboration.cancelReply`.
1337
+
1338
+ `reactThumbsUp` is deliberately NOT a reuse of `addThumbsUp`, even though both
1339
+ dispatch the same `onReaction(id, '👍')` today. `addThumbsUp` names the reaction
1340
+ bar's picker entry point, whose copy follows the picker if it ever picks; and on
1341
+ any comment that already has reactions the two controls are on screen together,
1342
+ so one shared key would put two visibly different buttons under one name.
1343
+ `cancelReply` rather than the generic `common.cancel` for the same reason — an
1344
+ accessible name has to say what is being cancelled (only the reply target is
1345
+ dropped; anything typed into the composer survives).
1346
+
1347
+ **Two — the >= 7 day timestamp ignored the session language.** `formatTimestamp`
1348
+ ended in a bare `date.toLocaleDateString()`, i.e. the RUNTIME's locale, so a
1349
+ `zh` session read "6 天前" for a six-day-old comment and `8/1/2026` for an
1350
+ eight-day-old one.
1351
+
1352
+ The fix passes the session `language`, but not straight through — that is the
1353
+ trap #3424 flagged and declined to walk into. `toLocaleDateString(tag)`
1354
+ canonicalizes its argument and throws `RangeError` on anything not well-formed
1355
+ per BCP 47, and the session language reaches the component verbatim: a host that
1356
+ configures `defaultLanguage: 'en_US'` (the POSIX spelling — well-formed-looking,
1357
+ and rejected) hands `Intl` a tag it refuses. That `RangeError` would land in
1358
+ `formatTimestamp`'s outer `catch`, whose fallback is `return iso`, replacing the
1359
+ date with a raw `2026-08-01T09:30:00.000Z` — worse than the un-localized date it
1360
+ set out to fix.
1361
+
1362
+ So the absolute-date branch gets its own local `try`/`catch` that falls back to
1363
+ the no-argument call. A malformed tag degrades to exactly the previous
1364
+ behaviour (the runtime's own locale); the worst case of following the session
1365
+ language is the status quo, never a regression. A well-formed but unknown tag
1366
+ such as `xx-YY` does not throw at all — `Intl` resolves it to the default — so
1367
+ only genuinely malformed tags reach the guard. No date library, and no month or
1368
+ weekday copy in the locale packs: `Intl` already owns the per-locale ordering
1369
+ and separators.
1370
+
1371
+ Tests assert the computed accessible name via `getByRole('button', { name })`
1372
+ rather than the presence of an attribute, which is the distinction the fix turns
1373
+ on, and pin that no button is left answering to a bare emoji. The malformed-tag
1374
+ case is recorded honestly as green on both sides of this change — `origin/main`
1375
+ never passed a tag anywhere, so it could not trip over a bad one; its
1376
+ counterfactual is the naive fix, and dropping the inner `catch` is what turns it
1377
+ red with the raw ISO string in the DOM.
1378
+
1379
+ - 94c5b7c: Localize `@object-ui/collaboration` — `CommentThread` no longer hardcodes English (objectstack#5506)
1380
+
1381
+ `@object-ui/collaboration` depended only on `@object-ui/types` and carried every
1382
+ user-visible string as an English literal, so a `zh` console rendered a Chinese
1383
+ shell around an English comment thread: "3 comments", "Reply", "Resolve",
1384
+ "just now", "Add a comment... (use @ to mention)".
1385
+
1386
+ The package now takes `@object-ui/i18n` as a dependency and exposes one
1387
+ translation seam, `useCollaborationTranslation` /
1388
+ `COLLAB_DEFAULT_TRANSLATIONS`, built on `createSafeTranslation` — the same
1389
+ factory `data-table`, `form` and `filter-builder` use. Under an `I18nProvider`
1390
+ it resolves the session locale; with no provider it resolves the English
1391
+ defaults map, which is what keeps `CommentThread` usable standalone. There is
1392
+ deliberately no `formatter`/label prop escape hatch: a host that wants
1393
+ different copy overrides the locale keys, so one thread can never end up half
1394
+ translated by the bundle and half by props.
1395
+
1396
+ The issue listed 13 sites. A site-by-site sweep of the file found **20** — the
1397
+ seven the original sweep missed are `{n}h ago`, `{n}d ago`, `(edited)`, the
1398
+ thread's own comment count, the `Oldest`/`Newest` sort options,
1399
+ `Replying to {name}...`, and the composer's `Send` button. All 20 are keyed
1400
+ here; leaving any behind would have shipped a thread that is 90% translated.
1401
+
1402
+ Two of them carry a second defect on top of being untranslated: the plural
1403
+ **rule** was compiled into the component, not just the words.
1404
+
1405
+ - the header read `` `${n} comment${n !== 1 ? 's' : ''}` ``;
1406
+ - the reaction chip tooltip read `` n === 1 ? '1 reaction' : `${n} reactions` ``.
1407
+
1408
+ Both produced correct _English_ — this is not the "1 items" bug objectui#3423
1409
+ fixed on the tab badge — but the choice between the two forms was English
1410
+ grammar hardwired into the render path. No locale could apply its own: ru needs
1411
+ three forms and ja needs none, and neither could ever be expressed no matter
1412
+ what the packs said.
1413
+
1414
+ Both now use the repo's **two-key** plural convention
1415
+ (`collaboration.commentCount`/`commentCountOne`,
1416
+ `collaboration.reactionCount`/`reactionCountOne`) rather than an i18next
1417
+ `_one`/`_other` pair: zh/ja/ko have no separate singular form, so those packs
1418
+ would legitimately omit the `_one` half and `all-locales-key-parity` reads a
1419
+ legitimately-absent half as a lost key. Counts are interpolated as strings, so
1420
+ i18next skips its own plural resolution and the two-key scheme stays in charge.
1421
+
1422
+ The reaction tooltip gets a **dedicated** key pair rather than reusing
1423
+ `detail.reactionCount`: that one interpolates `{{emoji}}`, and at this call
1424
+ site the emoji is the chip's visible label with nothing to hand the
1425
+ placeholder — reuse would have left a literal `{{emoji}}` in the accessible
1426
+ name under every locale.
1427
+
1428
+ Relative timestamps stayed word-level: the existing minute/hour/day buckets are
1429
+ untouched and no date library was introduced. The `>= 7d` branch still uses the
1430
+ runtime's own `toLocaleDateString()` — that is not a hardcoded English literal,
1431
+ and pinning it to the session language has its own failure mode (an
1432
+ unrecognised tag throws into the surrounding `catch`, which would render a raw
1433
+ ISO string), so it is tracked separately.
1434
+
1435
+ `Save` / `Cancel` / `Edit` / `Delete` read from the shared `common` namespace
1436
+ instead of being re-spelled under `collaboration` — they are the generic action
1437
+ words, already translated in all ten packs, and a second spelling would only be
1438
+ a second thing to keep in sync. The 21 genuinely new keys are added to all ten
1439
+ locale packs with real translations.
1440
+
1441
+ - ca0fa8f: Localize `PresenceAvatars` — the avatar stack's accessible name and tooltips follow the session language (objectui#3440)
1442
+
1443
+ objectui#3424 wired `@object-ui/collaboration` up to `@object-ui/i18n` but only
1444
+ converted `CommentThread`. `PresenceAvatars` in the same package kept three
1445
+ English literals, and it is not a dormant export — the console renders it in
1446
+ two places: `app-shell/src/layout/AppHeader.tsx` (tenant presence beside the
1447
+ lifecycle badge) and `app-shell/src/views/RecordDetailView.tsx` (who else is on
1448
+ this record). A `zh` session got them in English.
1449
+
1450
+ The three sites:
1451
+
1452
+ - the group's `aria-label`, `` `${n} user${n !== 1 ? 's' : ''} present` ``;
1453
+ - the overflow badge's tooltip, `` `${n} more user${n !== 1 ? 's' : ''}` ``;
1454
+ - each avatar's tooltip, `` `${name} (${status})` ``.
1455
+
1456
+ The first one is the whole control as far as a screen reader is concerned: the
1457
+ stack renders images and initials and nothing else, so there was no other
1458
+ accessible name to fall back on.
1459
+
1460
+ As with the comment count in #3424, the first two carried a second defect on
1461
+ top of being untranslated — the plural **rule** was compiled into the component.
1462
+ Both produced correct _English_ (each has a real singular branch, so this is
1463
+ not the "1 items" defect objectui#3423 fixed on the tab badge), but
1464
+ `n !== 1 ? 's' : ''` is English grammar in a render path and no locale could
1465
+ apply its own. Both now use the repo's **two-key** plural convention
1466
+ (`collaboration.presentUserCount`/`presentUserCountOne`,
1467
+ `collaboration.moreUserCount`/`moreUserCountOne`) rather than an i18next
1468
+ `_one`/`_other` pair, with the count interpolated as a string so i18next skips
1469
+ its own plural resolution. German is what witnesses the move: "1 anwesender
1470
+ Benutzer" vs "2 anwesende Benutzer" inflects the adjective, which the deleted
1471
+ ternary could not have produced for any pack.
1472
+
1473
+ The avatar tooltip becomes a single `collaboration.userStatusTitle` key
1474
+ (`{{name}} ({{status}})`) so the parentheses and their spacing belong to the
1475
+ translation — the CJK packs drop the space English puts before `(`, matching
1476
+ their existing `edited: '(已编辑)'`.
1477
+
1478
+ Its `status` is a **display-layer** translation
1479
+ (`collaboration.statusActive` / `statusIdle` / `statusAway`): the
1480
+ `PresenceUser['status']` enum value stays raw data everywhere it is stored,
1481
+ compared or passed around — including the `statusColors` lookup — and is
1482
+ translated only at this render exit. A status outside the declared union
1483
+ renders as itself, the raw string: presence users arrive from a host-supplied
1484
+ `PresenceSource` transport, so an unmapped value is reachable at runtime
1485
+ whatever the type says, and the fallback invents nothing rather than leaving an
1486
+ empty bracket pair.
1487
+
1488
+ Eight new keys, added to all ten locale packs with real translations.
1489
+
1490
+ - 3889ffb: Console chrome i18n gaps (objectstack#5407).
1491
+
1492
+ - A dependency-gated lookup now names its controlling field by its **label**
1493
+ instead of its raw API name. The sentence was localized but the interpolated
1494
+ name was not, so every locale — English included — read `Select crm_account
1495
+ first`. The form renderer passes a new `dependsOnLabels` widget prop (the
1496
+ lookup-side counterpart of `emptyHint`, which it already resolves to labels
1497
+ for the fixed-option widgets); a name the host does not cover still falls
1498
+ back to itself.
1499
+ - The page-header overflow trigger's `More actions` accessible name now reads
1500
+ `detail.moreActions`, the same key `action:menu`'s own overflow trigger uses,
1501
+ so the two cannot diverge per locale.
1502
+ - The activity-feed reaction button's `Add reaction` accessible name is now a
1503
+ bundle key (`detail.addReaction`, added to all ten packs).
1504
+ - The "check the highlighted fields" toast joins field names with a per-locale
1505
+ separator (`validation.formInvalidJoiner`) instead of a hardcoded `、`
1506
+ (U+3001) — right for zh/ja by accident, wrong in English and every Latin
1507
+ locale. Latin packs use `, `, CJK `、`, Arabic `، `.
1508
+ - The Spanish `validation.required` / `validation.unique` templates gained
1509
+ their own masculine head noun (`El campo {{field}} es obligatorio`) so the
1510
+ adjective agrees for feminine field labels too — `Cuenta es obligatorio` was
1511
+ ungrammatical.
1512
+
1513
+ - 7e2406a: The group-tenancy write-target badge is now translated in all ten locales (objectui#3517)
1514
+
1515
+ `form.createTargetOrg` — the ADR-0105 badge `RecordFormPage` shows in create mode
1516
+ to name the organization a new record will land in — was defined in **no** locale
1517
+ pack, not even `en`. i18next therefore genuinely missed the key and rendered the
1518
+ call site's inline `defaultValue`, so the badge read English `Creates in <org>` in
1519
+ all ten languages: a Chinese console creating a record on an org-walled object
1520
+ showed `Creates in 某某组织`.
1521
+
1522
+ `all-locales-key-parity.test.ts` could not see this. It asserts that every pack
1523
+ defines every **`en`** key, so a key `en` itself lacks is outside the comparison —
1524
+ ten packs missing it identically kept parity fully green.
1525
+
1526
+ ## What changed
1527
+
1528
+ - `createTargetOrg` is backfilled into `en` as `Creates in {{org}}`, which makes
1529
+ the parity gate demand it from the other nine; each is translated to its pack's
1530
+ existing `form`-section tone rather than copied or machine-filled.
1531
+ - The inline `defaultValue` in `RecordFormPage.tsx` is deleted, finishing what
1532
+ objectui#3469 started — that key was the file's last remaining exception, and
1533
+ every `t()` on the page now passes bare. Declared = enforced: the packs are the
1534
+ single source of this copy, and a missing key must surface (raw key + dev
1535
+ missing-key warning) instead of being papered over at the call site.
1536
+ - The two exception-pinning tests objectui#3516 left behind invert. `form.createTargetOrg`
1537
+ joins the `BARE_KEYS` list (pinned present in all ten packs), and the render
1538
+ assertion now checks the badge against the **pack** copy in both `en` and `zh` —
1539
+ the deleted English default could not satisfy the Chinese assertion, so the
1540
+ badge fails loudly if the packs ever stop driving it.
1541
+
1542
+ - 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.
1543
+
1544
+ **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.
1545
+
1546
+ 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.
1547
+
1548
+ - 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.
1549
+
1550
+ 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.
1551
+
1552
+ - 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).
1553
+
1554
+ 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.
1555
+
1556
+ 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.
1557
+
1558
+ - 28b2e65: Localize the create / edit / view form title `ObjectView` builds itself
1559
+ (objectui#3462)
1560
+
1561
+ The same family as #3426 / PR #3457 and #3459 / PR #3464, one call site further
1562
+ in. `ObjectView.getFormTitle()` string-built its three verbs in TypeScript:
1563
+
1564
+ case 'create': return `Create ${objectLabel}`;
1565
+ case 'edit': return `Edit ${objectLabel}`;
1566
+ case 'view': return `View ${objectLabel}`;
1567
+
1568
+ so a Chinese session whose object is labelled 联系人 read a drawer headed
1569
+ **"View 联系人"** — an English verb glued onto a localized label. All three
1570
+ consumers are visible chrome: `renderDrawerForm`'s `DrawerTitle`,
1571
+ `renderModalForm`'s `DialogTitle`, and the `title` prop handed to
1572
+ `NavigationOverlay` in the `popover` branch (a host-supplied `title` displaces
1573
+ the overlay's own `resolvedTitle` default, so it is what the user sees).
1574
+
1575
+ The bar to reach it is lower than #3459's split panel: `ObjectViewSchema.layout`
1576
+ already defaults to `'drawer'`, and `navigation` is a declared authorable input
1577
+ on the registered `object-view` block whose `mode` union carries `drawer`,
1578
+ `modal` and `popover`. A row click under any of them sets `formMode: 'view'` and
1579
+ opens the container. `app-shell`'s wrapper pinning `layout: 'page'` is one host
1580
+ overriding a registered block, not proof the branch is dead.
1581
+
1582
+ ## What changed
1583
+
1584
+ The three verb branches resolve `form.createTitle` / `form.editTitle` /
1585
+ `form.viewTitle`.
1586
+
1587
+ **No new key family was minted.** `form.createTitle` (`'Create {{object}}'`) and
1588
+ `form.editTitle` (`'Edit {{object}}'`) already ship in all ten packs and are
1589
+ already how `app-shell` heads the PAGE-mode record form
1590
+ (`RecordFormPage.tsx`, `AppContent.tsx`). The drawer / modal / popover titles are
1591
+ the same heading on a different surface, so they resolve the same keys — a
1592
+ parallel per-plugin family would have guaranteed the two spellings drift, which
1593
+ is what the sibling issues were about. Only the third verb had no sibling:
1594
+ `form.viewTitle` is added to all ten packs, following each pack's existing
1595
+ arrangement for its create/edit twins rather than a translated-verb-plus-label
1596
+ concatenation (de puts the verb last, ja/zh use particles and no space).
1597
+
1598
+ `VIEW_DEFAULT_TRANSLATIONS` in `ObjectView.tsx` gains the three English entries,
1599
+ which is what `createSafeTranslation` falls back to with no `I18nProvider`
1600
+ mounted.
1601
+
1602
+ Two branches stay literal on purpose and are pinned by tests: `schema.form.title`
1603
+ (the author wrote a title, so the author's title wins, in every locale) and the
1604
+ `default` branch (bare object label, no verb to translate).
1605
+
1606
+ ## Visible English change
1607
+
1608
+ None. Every branch is byte-identical in English — `Create Contacts`,
1609
+ `Edit Contacts`, `View Contacts` — with and without a provider, so e2e specs and
1610
+ host tests that address this chrome by its English name keep addressing it. The
1611
+ provider-less path has its own test file, kept separate because
1612
+ `initReactI18next` registers its instance as a module global that outlives
1613
+ `cleanup()`.
1614
+
1615
+ The toolbar's create BUTTON keeps resolving `console.objectView.new`
1616
+ ("New" / 新建) and was deliberately not reused for the heading: a button verb and
1617
+ a title are different contexts, and folding them together is how the next drift
1618
+ of this shape would start.
1619
+
1620
+ - 825bbe3: The option widgets' "this list cannot be filled" message now has one source, and
1621
+ it is translated (objectui#3231).
1622
+
1623
+ FROM: `SelectField`, `MultiSelectField`, `RadioField` and `CheckboxesField` each
1624
+ carried their own copy of the empty/gated state, each destructured the declared
1625
+ `emptyHint` prop into `_emptyHint` and dropped it, and each rendered a hardcoded
1626
+ English literal (`'No options available'`, `` `Select ${…} first` ``) even in a
1627
+ Chinese or Japanese session. TO: one shared `OptionsEmptyState` — the host's
1628
+ `emptyHint` when it supplied one, otherwise a translated fallback
1629
+ (`fields.options.empty` / `fields.options.selectFirst`, added to all ten locale
1630
+ packs).
1631
+
1632
+ `emptyHint` was declared, produced by the form renderer and transported, then
1633
+ lost three times over — so no registered widget could ever render it. All three
1634
+ breaks are fixed, because closing only the last one delivers nothing:
1635
+
1636
+ - `isOptionField` compared the raw resolved type against `'select'` /`'radio'` /
1637
+ `'multiselect'` / `'checkboxes'`. Object-derived forms emit
1638
+ `mapFieldTypeToFormType`'s prefixed ids (`field:select`), which matched none of
1639
+ them, so for every option field coming from an object schema — the normal case
1640
+ in the console — the whole cascade block was skipped and no hint was computed
1641
+ at all. It now normalizes the `field:` prefix, the same normalization
1642
+ `stripRegisteredFieldProps` already applied a few lines below.
1643
+ - `stripRegisteredFieldProps` then removed the `emptyHint` key from what was
1644
+ left. It is now forwarded to the four cascade option types, alongside
1645
+ `dependentValues`. This stays an allow-list rather than a blanket
1646
+ pass-through: every other registered widget spreads its leftover props onto a
1647
+ DOM node, where an unknown `emptyHint` attribute is a React warning.
1648
+ - the widgets themselves discarded it. Keeping it out of the `...props` spread
1649
+ was correct; not using it afterwards was not.
1650
+
1651
+ User-visible effect: a dependency-gated option list now prompts with the
1652
+ controlling field's **label** ("Select Country first") instead of its raw
1653
+ metadata name, in the session's language; an unconfigured list says so in the
1654
+ session's language too. The gate sentence is one i18n key shared by the renderer
1655
+ and the widget fallback, so the two sides cannot word it differently.
1656
+
1657
+ Untouched: the built-in (unregistered) `select` branch of the form renderer,
1658
+ which already consumed `emptyHint`. That is a separate live path.
1659
+
1660
+ - 6195841: Localize the record-detail overlay heading that `ListView` and `ObjectGrid`
1661
+ build themselves (objectui#3426)
1662
+
1663
+ #3423 gave `NavigationOverlay`'s `resolvedTitle` an i18n default
1664
+ (`detail.recordDetail`), but two hosts never let that default run: they
1665
+ string-built an English heading in TypeScript and passed it as the `title`
1666
+ prop, so a zh/ja/de session got a fully localized drawer with one English
1667
+ heading on it.
1668
+
1669
+ - `packages/plugin-list/src/ListView.tsx` — `` `${schema.label} Detail` ``
1670
+ - `packages/plugin-grid/src/ObjectGrid.tsx` — the same template, plus a bare
1671
+ `'Record Detail'` literal for the no-label case
1672
+
1673
+ Both are user-reachable, not dead defaults. `list-view` / `object-grid` are
1674
+ public page blocks and `navigation` is an authorable key on their schema, so a
1675
+ page that authors `navigation: { mode: 'drawer' }` opens exactly this overlay
1676
+ on row click. (`app-shell`'s `ObjectView` does suppress it — it passes its own
1677
+ `onRowClick`, which takes priority inside `useNavigationOverlay`, and renders
1678
+ its own overlay — but that is one host overriding a public block, not proof the
1679
+ branch is unreachable.)
1680
+
1681
+ ## What changed
1682
+
1683
+ Both call sites now key their heading instead of concatenating it:
1684
+
1685
+ - a new `detail.recordDetailWithLabel` (`'{{label}} Detail'`) carries the
1686
+ object label through interpolation, so a pack whose qualifier trails the noun
1687
+ (`de`) or that needs a possessive particle (`ja`/`zh`) can write its own
1688
+ arrangement rather than inherit English word order;
1689
+ - the no-label branch reuses `detail.recordDetail` — the very key the overlay
1690
+ itself defaults to — so one heading on one control cannot drift into two
1691
+ translations.
1692
+
1693
+ The new key is added to all ten locale packs and to each plugin's English
1694
+ defaults map (`LIST_DEFAULT_TRANSLATIONS` / `GRID_DEFAULT_TRANSLATIONS`), which
1695
+ is what `createSafeTranslation` falls back to with no `I18nProvider` mounted.
1696
+
1697
+ English output is byte-identical in every branch (`Contacts Detail` /
1698
+ `Contacts Detail` / `Record Detail`), with and without a provider — pinned by a
1699
+ provider-less test file per plugin, kept separate because `initReactI18next`
1700
+ registers its instance as a module global that outlives `cleanup()`.
1701
+
1702
+ - 5dd0127: Localize the record-overlay and tab-badge chrome that #5430's sweep left behind (objectstack#5506)
1703
+
1704
+ Four more console-chrome strings were still hardcoded English literals. Unlike
1705
+ #5430's set they are not all accessible names — one is visible copy, and one was
1706
+ a component **default** that only the console happened to override.
1707
+
1708
+ - `page:tabs`' count badge built its `aria-label` by template literal,
1709
+ `` `${formatTabCount(count)} items` ``. The badge renders digits only, so that
1710
+ label _is_ the badge to a screen reader — and the English plural was baked in
1711
+ with no singular branch at all, so a related list with one row announced
1712
+ "1 items". Now `common.itemCount` / `common.itemCountOne`.
1713
+ - `NavigationOverlay`'s drag-resize handle (`role="separator"`, no visible label)
1714
+ — now `common.resizeDrawer`.
1715
+ - `NavigationOverlay`'s `expandLabel` **default**. Hosts may override it and the
1716
+ console does, but the default is what every other host ships — and it feeds
1717
+ both `aria-label` and `title` of an icon-only button. Now
1718
+ `detail.openAsFullPage`, still overridable by the prop.
1719
+ - `NavigationOverlay`'s `resolvedTitle` fallback, `'Record Detail'` — **visible**
1720
+ overlay heading, not just an a11y name. Now `detail.recordDetail`.
1721
+ - The sr-only `SheetDescription`/`DialogDescription` prose
1722
+ `Record detail overlay for {title}.`, which existed in three copies
1723
+ (drawer / modal / popover) — now one `detail.recordDetailOverlay` key with a
1724
+ `{{title}}` placeholder.
1725
+
1726
+ The count badge follows this repo's **two-key** plural convention
1727
+ (`detail.reactionCount`/`reactionCountOne`, `detail.relatedRecords`/`relatedRecordOne`)
1728
+ rather than an i18next `_one`/`_other` pair: zh/ja/ko have no separate singular
1729
+ form, so those packs would legitimately omit the `_one` half and
1730
+ `all-locales-key-parity` would read that as a lost key. The formatted count
1731
+ (`1.2k`, not `1200`) is interpolated so the accessible name and the visible
1732
+ digits never disagree — and because i18next skips its own plural resolution when
1733
+ `count` is a string, the two-key scheme stays in charge of the choice.
1734
+
1735
+ Both touched components moved from `useSafeTranslate` to `createSafeTranslation`,
1736
+ which carries an options bag (two of the new keys interpolate) and an English
1737
+ defaults map. That map is what keeps the provider-less path English, which
1738
+ consumers outside this package depend on — `plugin-view`'s `ObjectView.test.tsx`
1739
+ and `e2e/live/inline-edit-polish-2572.spec.ts` address this chrome by English
1740
+ accessible name with no `I18nProvider` mounted.
1741
+
1742
+ All six new keys are added to all ten locale packs.
1743
+
1744
+ - a415684: The console server-action wrapper's `opensInNewTab` choreography no longer
1745
+ ships hard-coded bilingual Chinese/English copy (objectui#3321, AGENTS.md
1746
+ Commandment #-1): the pre-opened SSO spinner tab (title + body) and the
1747
+ popup-blocked toast (title, description, action label) are now localized
1748
+ through new `console.serverAction.*` keys in `@object-ui/i18n`, added at full
1749
+ parity across all ten locale packs.
1750
+
1751
+ `createConsoleServerActionHandler` gains an optional i18next-style `t` option
1752
+ (`t(key, englishDefault)`) — the wrapper is a plain function, so the translate
1753
+ function is injected from the two hook-context call sites
1754
+ (`useConsoleActionRuntime`, `RecordDetailView`) via `useObjectTranslation`.
1755
+ When omitted (tests / standalone), every string falls back to its English
1756
+ default; no non-English copy remains in code. Locale strings are HTML-escaped
1757
+ before being written into the spinner document.
1758
+
1759
+ - a6ec93d: `createSafeTranslation`'s no-provider fallback interpolation now replaces **all**
1760
+ occurrences of each placeholder, matching i18next semantics on the provider path.
1761
+
1762
+ The fallback used `value.replace('{{k}}', String(v))`, and `String.prototype.replace`
1763
+ with a string needle substitutes only the _first_ match. A default string repeating a
1764
+ placeholder — `'Selected {{count}} of {{count}} items'`, natural in many locales and
1765
+ sometimes required by RTL / agglutinative word order — therefore leaked literal braces
1766
+ to users on hosts with no `I18nProvider` mounted (standalone / embedded renderers),
1767
+ while the same string interpolated correctly once a provider was present. A silent
1768
+ semantic fork, in exactly the environments we observe least.
1769
+
1770
+ The replacement is `value.split(needle).join(String(v))` rather than `replaceAll`:
1771
+ both `replace` and `replaceAll` interpret `$&`, `` $` ``, `$'` and `$$` in the
1772
+ _replacement_ string, which i18next does not. Values here are runtime data (record
1773
+ labels, search terms), so that second divergence was reachable today — a label
1774
+ containing `$&` was mangled on the fallback path. split/join is literal on both sides
1775
+ and needs no regex escaping of the placeholder name.
1776
+
1777
+ Key resolution (`defaults[key] || key`), the `String(v)` coercion, and the
1778
+ leave-it-literal behaviour for a placeholder with no matching option are unchanged.
1779
+ Fixes objectui#3418.
1780
+
1781
+ - c7ed4c3: `TagsField` no longer ships a hardcoded Chinese input placeholder
1782
+ (objectui#3342, AGENTS.md Commandment #-1). The placeholder now resolves
1783
+ through the pinned chain: the author-declared `field.placeholder` wins
1784
+ (previously ignored by this widget); otherwise the widget's own copy arrives
1785
+ via `useFieldTranslation()` under the new `fields.tags.placeholder` key, added
1786
+ at full parity across all locale packs (Chinese lives in the zh pack, not in
1787
+ code); with no `I18nProvider` mounted the English default from FIELD_DEFAULTS
1788
+ renders — never a raw key.
1789
+ - 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).
1790
+
1791
+ 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.
1792
+
1793
+ Behaviour is unchanged: `aria-live="polite"` and the per-keystroke recompute are deliberately untouched here and tracked separately (#3408).
1794
+
1795
+ - 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).
1796
+
1797
+ 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.
1798
+
1799
+ 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.
1800
+
3
1801
  ## 17.2.0
4
1802
 
5
1803
  ### Minor Changes
@@ -189,7 +1987,7 @@ plan` sentence users reported (cloud#959). Both spec builders now take a transla
189
1987
 
190
1988
  ### Patch Changes
191
1989
 
192
- - 752e18f: fix(console,app-shell): readable reassign hand-off + "System" label for svc:* audit actors — objectstack#4365 / objectstack#4366
1990
+ - 752e18f: fix(console,app-shell): readable reassign hand-off + "System" label for svc:\* audit actors — objectstack#4365 / objectstack#4366
193
1991
 
194
1992
  - **Approvals inbox** (`ApprovalsInboxPage`): a reassign timeline entry now
195
1993
  renders "from A to B" from the structured
@@ -901,6 +2699,7 @@ os_tianshun_ehr_position — use a unique value or the record id` — with the f
901
2699
  is byte-identical, it just comes from the pack now.
902
2700
 
903
2701
  Adds two guards, both mutation-verified:
2702
+
904
2703
  - `en` ↔ `zh` full key parity, asserted in both directions. The other eight
905
2704
  packs are still ~357 keys behind and are tracked separately (objectui#2872
906
2705
  part a), so they are deliberately not asserted yet.
@@ -1835,6 +3634,7 @@ actionName, spec)`: overlays translated `title` / `description` /
1835
3634
  wired by `RecentItemsProvider` + `useTrackRouteAsRecent` +
1836
3635
  `RecordDetailView`). Multi-device by construction: open a record on
1837
3636
  laptop, see it in `⌘K → Recently viewed` on phone.
3637
+
1838
3638
  - Group renders only when input is empty (no competition with search).
1839
3639
  - Limited to the 5 most recent record-type entries.
1840
3640
  - New i18n key `console.commandPalette.recentRecords` (en + zh seeded;
@@ -1844,6 +3644,7 @@ actionName, spec)`: overlays translated `title` / `description` /
1844
3644
 
1845
3645
  When `useRecordSearch` is mid-flight (debounced fetch across objects
1846
3646
  hasn't returned yet), the palette now surfaces a subtle visual:
3647
+
1847
3648
  - A small pulsing primary-coloured dot next to the **Records** group
1848
3649
  heading, so the user sees that more results may still appear.
1849
3650
  - A `Searching…` placeholder inside the empty state when the user has
@@ -1857,6 +3658,7 @@ actionName, spec)`: overlays translated `title` / `description` /
1857
3658
  - 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.
1858
3659
  - i18n: add `notifications.emptyUnread`, `notifications.filterUnread`, `notifications.filterAll` (en + zh) so the InboxPopover Unread/All sub-filter renders in the active locale.
1859
3660
  - 5425608: CRM UX polish pass — calmer enterprise look across detail + kanban.
3661
+
1860
3662
  - **plugin-kanban**: column headers now use a 2px muted accent stripe with
1861
3663
  neutral foreground titles + a quiet grey count pill instead of full
1862
3664
  rainbow gradient + colored title + colored count. Pipeline boards
@@ -1888,6 +3690,7 @@ actionName, spec)`: overlays translated `title` / `description` /
1888
3690
  `defaultCurrency` is set on the field/column, formatting is unchanged.
1889
3691
 
1890
3692
  Fixed call sites:
3693
+
1891
3694
  - `@object-ui/fields`: `formatCurrency`, `formatCompactCurrency`, and
1892
3695
  `CurrencyCellRenderer` no longer default-param `'USD'`.
1893
3696
  - `@object-ui/i18n`: `formatCurrency()` falls back to `formatNumber`
@@ -1911,6 +3714,7 @@ actionName, spec)`: overlays translated `title` / `description` /
1911
3714
  ### Minor Changes
1912
3715
 
1913
3716
  - 49b1760: Polish the ConcurrentUpdateDialog and add i18n.
3717
+
1914
3718
  - 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.
1915
3719
  - Replace the plain dialog header with an amber warning badge + `AlertTriangle` icon to communicate that this is a conflict, not a routine confirmation.
1916
3720
  - 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.
@@ -1976,6 +3780,7 @@ actionName, spec)`: overlays translated `title` / `description` /
1976
3780
  default detail view. They were missing cross-cutting affordances and
1977
3781
  shipped with English-only tab labels and heavy bordered section cards
1978
3782
  even when the host locale was Chinese. Track 1 closes the visible gap:
3783
+
1979
3784
  - **app-shell `RecordDetailView`**: the `assignedPage` branch now wears
1980
3785
  the same chrome as the default branch — lifecycle managed-by badge
1981
3786
  and presence avatars in the top-right, `MetadataPanel` debug panel,
@@ -2031,6 +3836,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2031
3836
  The matrix/summary "Group by" (rows) and "Columns axis" (cols) sections now
2032
3837
  share the same searchable popup picker as the columns section, with a
2033
3838
  commit-on-select single-pick mode wired through `FieldPickerDialog`.
3839
+
2034
3840
  - Per-row field buttons display the human-readable field label and open a
2035
3841
  dialog scoped to swap that single field (already-used fields filtered out)
2036
3842
  - "Add grouping" trigger uses the same dialog
@@ -2040,6 +3846,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2040
3846
  - Bigger row spacing (h-7 / text-xs) — the old `text-[10px]` was unreadable
2041
3847
 
2042
3848
  `FieldPickerDialog` gains:
3849
+
2043
3850
  - `commitOnSelect`: hides the Confirm/Cancel footer; clicking a row commits
2044
3851
  - closes immediately (intended for `singleSelect` flows)
2045
3852
  - `trigger`: custom trigger element override (used by the per-row field button)
@@ -2068,6 +3875,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2068
3875
  in the spec.
2069
3876
 
2070
3877
  New exports from `@object-ui/plugin-report`:
3878
+
2071
3879
  - `JoinedBlocksEditor` — standalone component for embedding the
2072
3880
  block editor anywhere.
2073
3881
  - `validateJoinedBlocks` — pure helper returning translated
@@ -2099,6 +3907,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2099
3907
  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`.
2100
3908
 
2101
3909
  `@object-ui/plugin-report`:
3910
+
2102
3911
  - Add a bidirectional `SpecFilterAdapter` so `ReportConfigPanel` can edit
2103
3912
  spec `FilterCondition` filters (`{field: value}`, `{field: {$op: value}}`,
2104
3913
  top-level `$and`/`$or`). Complex / nested filters fall back to a
@@ -2120,12 +3929,14 @@ actionName, spec)`: overlays translated `title` / `description` /
2120
3929
  - 18 new unit tests cover the filter adapter round-trip.
2121
3930
 
2122
3931
  `@object-ui/components`:
3932
+
2123
3933
  - `FilterBuilder` now guards against malformed external `value` props.
2124
3934
  Previously a spec-shape filter (`{is_active: true}`) would crash the
2125
3935
  component on first render; the builder now falls back to an empty
2126
3936
  AND group whenever `value` is not a valid `FilterGroup`.
2127
3937
 
2128
3938
  `@object-ui/i18n`:
3939
+
2129
3940
  - Add `report.editor.*` strings to `en` and `zh`.
2130
3941
 
2131
3942
  - 8442c05: Improve report editor panel usability based on real-user browser testing:
@@ -2205,6 +4016,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2205
4016
  no visible effect — the user/session were nulled but the UI stayed authenticated.
2206
4017
 
2207
4018
  Changes:
4019
+
2208
4020
  - **`@object-ui/auth`** — added `isAuthEnabled: boolean` to `AuthContextValue`
2209
4021
  (`true` only when real auth is in use, `false` for guest/preview modes).
2210
4022
  - **`@object-ui/app-shell`** — `AppHeader` and `AppSidebar` now hide the "Log out"
@@ -2222,6 +4034,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2222
4034
  - fd15918: Comprehensive i18n refactor + CI test fix.
2223
4035
 
2224
4036
  **i18n (`@object-ui/i18n`)**
4037
+
2225
4038
  - Added ~130 new keys under 12 new top-level namespaces: `layout`, `search`,
2226
4039
  `empty`, `renderer`, `actionDialog`, `rowAction`, `navigationSync`,
2227
4040
  `objectActions`, `objectViewActions`, `dashboardActions`, `recordDetail`,
@@ -2233,6 +4046,7 @@ actionName, spec)`: overlays translated `title` / `description` /
2233
4046
 
2234
4047
  **App shell (`@object-ui/app-shell`)** — replaced hardcoded English in 14
2235
4048
  files with `useObjectTranslation`:
4049
+
2236
4050
  - Layout: `AppSidebar`, `ActivityFeed` (locale-aware relative time),
2237
4051
  `MetadataInspector`.
2238
4052
  - Views: `SearchResultsPage`, `ActionParamDialog`, `RecordFormPage`,
@@ -2244,12 +4058,14 @@ actionName, spec)`: overlays translated `title` / `description` /
2244
4058
  `useObjectActions` (delete confirm + success / failure toasts).
2245
4059
 
2246
4060
  **Plugin grid (`@object-ui/plugin-grid`)**
4061
+
2247
4062
  - `ObjectGrid` record-detail panel now translates Empty / Yes / No / System
2248
4063
  via the existing `useGridTranslation` safe-fallback wrapper.
2249
4064
  - `RowActionMenu` adopts a local safe-fallback i18n wrapper for
2250
4065
  `Open menu` / `Edit` / `Delete`, preserving standalone-usage guarantees.
2251
4066
 
2252
4067
  **CLI test fix (`@object-ui/cli`)**
4068
+
2253
4069
  - `cli-bin.test.ts` auto-builds the package on first run when `dist/cli.js`
2254
4070
  is missing, instead of throwing. This unbreaks `pnpm test:coverage` in CI
2255
4071
  (root vitest run does not honor turbo's `^build` deps) and removes the