@object-ui/i18n 17.7.0 → 18.0.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.
@@ -11,6 +11,7 @@
11
11
  * | What | Auto-generated key | Fallback |
12
12
  * |--------------------|-------------------------------------------------|-----------------------|
13
13
  * | Object label | {ns}.objects.{objectName}.label | objectDef.label |
14
+ * | Object plural | {ns}.objects.{objectName}.pluralLabel | objectDef.pluralLabel, then the object label |
14
15
  * | Object description | {ns}.objects.{objectName}.description | objectDef.description |
15
16
  * | Field label | {ns}.fields.{objectName}.{fieldName} | field.label |
16
17
  *
@@ -39,6 +40,32 @@ export declare function useObjectLabel(): {
39
40
  name: string;
40
41
  label: string;
41
42
  }) => string;
43
+ /**
44
+ * Resolve the object's PLURAL label — what a list of its records is called:
45
+ * an object list page's title and the breadcrumb segment that links to that
46
+ * list (objectui#11696). Record-scoped surfaces (the record page, a record
47
+ * drawer, "New …") keep {@link objectLabel}.
48
+ *
49
+ * Order: the translated plural (`{ns}.objects.{objectName}.pluralLabel`),
50
+ * else the declared `objectDef.pluralLabel`, else the singular as
51
+ * `objectLabel` resolves it — `pluralLabel` is optional in the spec, and an
52
+ * object that declares none is called by its label everywhere.
53
+ *
54
+ * The first two rungs are the client half of `translateObject` in
55
+ * `@objectstack/spec` (`system/i18n-resolver`), which serves `pluralLabel`
56
+ * as the catalog entry, else the authored value. There is deliberately NO
57
+ * rung that prefers a translated SINGULAR over a declared plural the bundle
58
+ * leaves untranslated: the server has none either, so every consumer of
59
+ * `/meta` shows the authored plural in that case, and this client showing
60
+ * something else would be a second dialect (see `viewSuffixes`). The gap is
61
+ * the bundle's — `os i18n extract` writes `pluralLabel` wherever the object
62
+ * declares one — and is fixed there.
63
+ */
64
+ objectPluralLabel: (objectDef: {
65
+ name: string;
66
+ label: string;
67
+ pluralLabel?: string;
68
+ }) => string;
42
69
  /**
43
70
  * Resolve translated object description, falling back to objectDef.description.
44
71
  */
@@ -147,31 +174,13 @@ export declare function useObjectLabel(): {
147
174
  * Resolve translated widget description within a dashboard.
148
175
  * Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.description`.
149
176
  * Returns undefined when neither metadata nor translation provides one.
150
- */
151
- widgetDescription: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
152
- /**
153
- * Resolve a translated metric-widget SUB-CAPTION within a dashboard.
154
- * Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.subCaption`.
155
- * Returns undefined when neither metadata nor translation provides one.
156
- *
157
- * Deliberately its OWN key, not a second reader of `widgets.{id}.description`.
158
- * The KPI card renders two authored strings from two different fields — the
159
- * shared card header's `widget.description`, and the sub-caption under the
160
- * value, which is authored as `widget.options.description` — and the
161
- * objectstack#5428 item-4 ruling (2026-08-06) settled that they get two
162
- * keys, not one: 「两个作者字段两个 key」. Collapsing them would make one
163
- * translation entry silently retarget the other field on any widget type
164
- * that renders both at once (`kpi`, `gauge`, `bullet` — every metric-family
165
- * type except the self-contained `metric`).
166
177
  *
167
- * `subCaption` is the member objectstack#8056 added to the widget
168
- * translation node for exactly this, shipped in `@objectstack/spec@17.0.0`.
169
- * The server-side resolver reads the SAME key and overlays it onto
170
- * `options.description` (`translateDashboard`), so a document served
171
- * through `/meta` and a document translated here land on the same string —
172
- * this is the client half of one convention, not a second dialect.
178
+ * This is a widget's ONE translated description. The sibling sub-caption
179
+ * key, `widgets.{widgetId}.subCaption`, is retired with its resolver
180
+ * (objectui#11389, ruling C): `@objectstack/spec` 17.7.0 refuses it by
181
+ * name in a translation bundle, and nothing here reads it.
173
182
  */
174
- widgetSubCaption: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
183
+ widgetDescription: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
175
184
  /**
176
185
  * Resolve translated page label, falling back to pageDef.label.
177
186
  * Convention: `{ns}.pages.{pageName}.label`.
@@ -218,32 +227,33 @@ export declare function useObjectLabel(): {
218
227
  sectionLabel: (objectName: string, sectionName: string, fallback: string) => string;
219
228
  /**
220
229
  * Resolve translated action label.
221
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.label`,
222
- * falling back to `{ns}.globalActions.{actionName}.label` — both when
223
- * objectName is omitted AND when the object-scoped key misses (so a
224
- * globalAction surfaced on a record-detail action bar still resolves;
225
- * objectui#3372).
230
+ * `objectName` is the action's KEY object (its declared `objectName`, else
231
+ * the host that embeds it). Convention:
232
+ * `{ns}.objects.{objectName}._actions.{actionName}.label`; with no key
233
+ * object, `{ns}.globalActions.{actionName}.label`. Never both: a bound
234
+ * action does not read `globalActions` (objectui#11439, see
235
+ * `actionSuffixes`). Every action resolver below follows the same rule.
226
236
  */
227
237
  actionLabel: (objectName: string | undefined, actionName: string, fallback: string) => string;
228
238
  /**
229
239
  * Resolve translated action confirmation prompt.
230
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.confirmText`,
231
- * falling back to `{ns}.globalActions.{actionName}.confirmText`.
240
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.confirmText`;
241
+ * with no key object, `{ns}.globalActions.{actionName}.confirmText`.
232
242
  * Returns undefined when no translation and no fallback exist.
233
243
  */
234
244
  actionConfirm: (objectName: string | undefined, actionName: string, fallback?: string) => string | undefined;
235
245
  /**
236
246
  * Resolve translated action success message.
237
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.successMessage`,
238
- * falling back to `{ns}.globalActions.{actionName}.successMessage`.
247
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.successMessage`;
248
+ * with no key object, `{ns}.globalActions.{actionName}.successMessage`.
239
249
  */
240
250
  actionSuccess: (objectName: string | undefined, actionName: string, fallback?: string) => string | undefined;
241
251
  /**
242
252
  * Resolve the translated success copy for ONE handler outcome — an entry of
243
253
  * the action's `outcomeMessages` (`@objectstack/spec` 17.6.0, objectui#11344).
244
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.outcomeMessages.{outcome}`,
245
- * falling back to `{ns}.globalActions.{actionName}.outcomeMessages.{outcome}`
246
- * — the address the spec's translation bundle declares for the key.
254
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.outcomeMessages.{outcome}`;
255
+ * with no key object, `{ns}.globalActions.{actionName}.outcomeMessages.{outcome}`
256
+ * — the addresses the spec's translation bundle declares for the key.
247
257
  * Returns undefined when no translation and no fallback exist.
248
258
  *
249
259
  * Like `actionSuccess`, the answer is a TEMPLATE: a `${result.*}` token in a
@@ -255,18 +265,17 @@ export declare function useObjectLabel(): {
255
265
  /**
256
266
  * Resolve translated action description (the explanatory line shown in the
257
267
  * action's param dialog / sheet / drawer header).
258
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.description`.
259
- * Falls back to `{ns}.globalActions.{actionName}.description`, then the
260
- * metadata's literal string; undefined when nothing resolves.
268
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.description`;
269
+ * with no key object, `{ns}.globalActions.{actionName}.description`. Falls
270
+ * back to the metadata's literal string; undefined when nothing resolves.
261
271
  */
262
272
  actionDescription: (objectName: string | undefined, actionName: string | undefined, fallback?: string) => string | undefined;
263
273
  /**
264
274
  * Resolve a translated copy of an action's post-success RESULT DIALOG
265
275
  * (`title` / `description` / `acknowledge` + per-field labels).
266
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.resultDialog.*`,
267
- * falling back to `{ns}.globalActions.{actionName}.resultDialog.*` (when
268
- * objectName is omitted OR the object-scoped key misses), then to the
269
- * metadata's literal strings.
276
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.resultDialog.*`;
277
+ * with no key object, `{ns}.globalActions.{actionName}.resultDialog.*`.
278
+ * Falls back to the metadata's literal strings.
270
279
  *
271
280
  * The `fields` translation node is keyed by the LITERAL result-field path
272
281
  * from the action metadata (may contain dots, e.g. `"user.email"`), so it
@@ -285,14 +294,16 @@ export declare function useObjectLabel(): {
285
294
  }>(objectName: string | undefined, actionName: string | undefined, spec: T | undefined) => T | undefined;
286
295
  /**
287
296
  * Resolve translated action-PARAMETER text (label / placeholder / helpText).
288
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.{attr}`.
297
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.{attr}`;
298
+ * with no key object, `{ns}.globalActions.{actionName}.params.{paramName}.{attr}`.
289
299
  * Falls back to the provided value (the metadata's literal string) when no
290
300
  * translation exists, so untranslated params keep rendering as authored.
291
301
  */
292
302
  actionParamText: (objectName: string | undefined, actionName: string | undefined, paramName: string, attr: "label" | "placeholder" | "helpText", fallback?: string) => string | undefined;
293
303
  /**
294
304
  * Resolve a translated action-parameter SELECT OPTION label.
295
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.options.{optionValue}`.
305
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.options.{optionValue}`;
306
+ * with no key object, the same path under `{ns}.globalActions.{actionName}`.
296
307
  * Falls back to the provided (English metadata) label when untranslated.
297
308
  */
298
309
  actionParamOptionLabel: (objectName: string | undefined, actionName: string | undefined, paramName: string, optionValue: string, fallback: string) => string;
@@ -310,6 +321,32 @@ export declare function useSafeFieldLabel(): {
310
321
  name: string;
311
322
  label: string;
312
323
  }) => string;
324
+ /**
325
+ * Resolve the object's PLURAL label — what a list of its records is called:
326
+ * an object list page's title and the breadcrumb segment that links to that
327
+ * list (objectui#11696). Record-scoped surfaces (the record page, a record
328
+ * drawer, "New …") keep {@link objectLabel}.
329
+ *
330
+ * Order: the translated plural (`{ns}.objects.{objectName}.pluralLabel`),
331
+ * else the declared `objectDef.pluralLabel`, else the singular as
332
+ * `objectLabel` resolves it — `pluralLabel` is optional in the spec, and an
333
+ * object that declares none is called by its label everywhere.
334
+ *
335
+ * The first two rungs are the client half of `translateObject` in
336
+ * `@objectstack/spec` (`system/i18n-resolver`), which serves `pluralLabel`
337
+ * as the catalog entry, else the authored value. There is deliberately NO
338
+ * rung that prefers a translated SINGULAR over a declared plural the bundle
339
+ * leaves untranslated: the server has none either, so every consumer of
340
+ * `/meta` shows the authored plural in that case, and this client showing
341
+ * something else would be a second dialect (see `viewSuffixes`). The gap is
342
+ * the bundle's — `os i18n extract` writes `pluralLabel` wherever the object
343
+ * declares one — and is fixed there.
344
+ */
345
+ objectPluralLabel: (objectDef: {
346
+ name: string;
347
+ label: string;
348
+ pluralLabel?: string;
349
+ }) => string;
313
350
  /**
314
351
  * Resolve translated object description, falling back to objectDef.description.
315
352
  */
@@ -418,31 +455,13 @@ export declare function useSafeFieldLabel(): {
418
455
  * Resolve translated widget description within a dashboard.
419
456
  * Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.description`.
420
457
  * Returns undefined when neither metadata nor translation provides one.
421
- */
422
- widgetDescription: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
423
- /**
424
- * Resolve a translated metric-widget SUB-CAPTION within a dashboard.
425
- * Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.subCaption`.
426
- * Returns undefined when neither metadata nor translation provides one.
427
- *
428
- * Deliberately its OWN key, not a second reader of `widgets.{id}.description`.
429
- * The KPI card renders two authored strings from two different fields — the
430
- * shared card header's `widget.description`, and the sub-caption under the
431
- * value, which is authored as `widget.options.description` — and the
432
- * objectstack#5428 item-4 ruling (2026-08-06) settled that they get two
433
- * keys, not one: 「两个作者字段两个 key」. Collapsing them would make one
434
- * translation entry silently retarget the other field on any widget type
435
- * that renders both at once (`kpi`, `gauge`, `bullet` — every metric-family
436
- * type except the self-contained `metric`).
437
458
  *
438
- * `subCaption` is the member objectstack#8056 added to the widget
439
- * translation node for exactly this, shipped in `@objectstack/spec@17.0.0`.
440
- * The server-side resolver reads the SAME key and overlays it onto
441
- * `options.description` (`translateDashboard`), so a document served
442
- * through `/meta` and a document translated here land on the same string —
443
- * this is the client half of one convention, not a second dialect.
459
+ * This is a widget's ONE translated description. The sibling sub-caption
460
+ * key, `widgets.{widgetId}.subCaption`, is retired with its resolver
461
+ * (objectui#11389, ruling C): `@objectstack/spec` 17.7.0 refuses it by
462
+ * name in a translation bundle, and nothing here reads it.
444
463
  */
445
- widgetSubCaption: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
464
+ widgetDescription: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
446
465
  /**
447
466
  * Resolve translated page label, falling back to pageDef.label.
448
467
  * Convention: `{ns}.pages.{pageName}.label`.
@@ -489,32 +508,33 @@ export declare function useSafeFieldLabel(): {
489
508
  sectionLabel: (objectName: string, sectionName: string, fallback: string) => string;
490
509
  /**
491
510
  * Resolve translated action label.
492
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.label`,
493
- * falling back to `{ns}.globalActions.{actionName}.label` — both when
494
- * objectName is omitted AND when the object-scoped key misses (so a
495
- * globalAction surfaced on a record-detail action bar still resolves;
496
- * objectui#3372).
511
+ * `objectName` is the action's KEY object (its declared `objectName`, else
512
+ * the host that embeds it). Convention:
513
+ * `{ns}.objects.{objectName}._actions.{actionName}.label`; with no key
514
+ * object, `{ns}.globalActions.{actionName}.label`. Never both: a bound
515
+ * action does not read `globalActions` (objectui#11439, see
516
+ * `actionSuffixes`). Every action resolver below follows the same rule.
497
517
  */
498
518
  actionLabel: (objectName: string | undefined, actionName: string, fallback: string) => string;
499
519
  /**
500
520
  * Resolve translated action confirmation prompt.
501
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.confirmText`,
502
- * falling back to `{ns}.globalActions.{actionName}.confirmText`.
521
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.confirmText`;
522
+ * with no key object, `{ns}.globalActions.{actionName}.confirmText`.
503
523
  * Returns undefined when no translation and no fallback exist.
504
524
  */
505
525
  actionConfirm: (objectName: string | undefined, actionName: string, fallback?: string) => string | undefined;
506
526
  /**
507
527
  * Resolve translated action success message.
508
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.successMessage`,
509
- * falling back to `{ns}.globalActions.{actionName}.successMessage`.
528
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.successMessage`;
529
+ * with no key object, `{ns}.globalActions.{actionName}.successMessage`.
510
530
  */
511
531
  actionSuccess: (objectName: string | undefined, actionName: string, fallback?: string) => string | undefined;
512
532
  /**
513
533
  * Resolve the translated success copy for ONE handler outcome — an entry of
514
534
  * the action's `outcomeMessages` (`@objectstack/spec` 17.6.0, objectui#11344).
515
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.outcomeMessages.{outcome}`,
516
- * falling back to `{ns}.globalActions.{actionName}.outcomeMessages.{outcome}`
517
- * — the address the spec's translation bundle declares for the key.
535
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.outcomeMessages.{outcome}`;
536
+ * with no key object, `{ns}.globalActions.{actionName}.outcomeMessages.{outcome}`
537
+ * — the addresses the spec's translation bundle declares for the key.
518
538
  * Returns undefined when no translation and no fallback exist.
519
539
  *
520
540
  * Like `actionSuccess`, the answer is a TEMPLATE: a `${result.*}` token in a
@@ -526,18 +546,17 @@ export declare function useSafeFieldLabel(): {
526
546
  /**
527
547
  * Resolve translated action description (the explanatory line shown in the
528
548
  * action's param dialog / sheet / drawer header).
529
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.description`.
530
- * Falls back to `{ns}.globalActions.{actionName}.description`, then the
531
- * metadata's literal string; undefined when nothing resolves.
549
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.description`;
550
+ * with no key object, `{ns}.globalActions.{actionName}.description`. Falls
551
+ * back to the metadata's literal string; undefined when nothing resolves.
532
552
  */
533
553
  actionDescription: (objectName: string | undefined, actionName: string | undefined, fallback?: string) => string | undefined;
534
554
  /**
535
555
  * Resolve a translated copy of an action's post-success RESULT DIALOG
536
556
  * (`title` / `description` / `acknowledge` + per-field labels).
537
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.resultDialog.*`,
538
- * falling back to `{ns}.globalActions.{actionName}.resultDialog.*` (when
539
- * objectName is omitted OR the object-scoped key misses), then to the
540
- * metadata's literal strings.
557
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.resultDialog.*`;
558
+ * with no key object, `{ns}.globalActions.{actionName}.resultDialog.*`.
559
+ * Falls back to the metadata's literal strings.
541
560
  *
542
561
  * The `fields` translation node is keyed by the LITERAL result-field path
543
562
  * from the action metadata (may contain dots, e.g. `"user.email"`), so it
@@ -556,14 +575,16 @@ export declare function useSafeFieldLabel(): {
556
575
  }>(objectName: string | undefined, actionName: string | undefined, spec: T | undefined) => T | undefined;
557
576
  /**
558
577
  * Resolve translated action-PARAMETER text (label / placeholder / helpText).
559
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.{attr}`.
578
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.{attr}`;
579
+ * with no key object, `{ns}.globalActions.{actionName}.params.{paramName}.{attr}`.
560
580
  * Falls back to the provided value (the metadata's literal string) when no
561
581
  * translation exists, so untranslated params keep rendering as authored.
562
582
  */
563
583
  actionParamText: (objectName: string | undefined, actionName: string | undefined, paramName: string, attr: "label" | "placeholder" | "helpText", fallback?: string) => string | undefined;
564
584
  /**
565
585
  * Resolve a translated action-parameter SELECT OPTION label.
566
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.options.{optionValue}`.
586
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.options.{optionValue}`;
587
+ * with no key object, the same path under `{ns}.globalActions.{actionName}`.
567
588
  * Falls back to the provided (English metadata) label when untranslated.
568
589
  */
569
590
  actionParamOptionLabel: (objectName: string | undefined, actionName: string | undefined, paramName: string, optionValue: string, fallback: string) => string;
@@ -11,6 +11,7 @@
11
11
  * | What | Auto-generated key | Fallback |
12
12
  * |--------------------|-------------------------------------------------|-----------------------|
13
13
  * | Object label | {ns}.objects.{objectName}.label | objectDef.label |
14
+ * | Object plural | {ns}.objects.{objectName}.pluralLabel | objectDef.pluralLabel, then the object label |
14
15
  * | Object description | {ns}.objects.{objectName}.description | objectDef.description |
15
16
  * | Field label | {ns}.fields.{objectName}.{fieldName} | field.label |
16
17
  *
@@ -108,9 +109,10 @@ export function useObjectLabel() {
108
109
  * include crm.objects.* or crm.apps.*.
109
110
  *
110
111
  * `globalActions` is included so an app bundle whose only translated scope
111
- * is global actions (no object/field entries) is still discovered — its
112
- * `globalActions.<action>.*` overlays would otherwise be unreachable
113
- * (objectui#3372).
112
+ * is global actions (no object/field entries) is still discovered — the
113
+ * `globalActions.<action>.*` copy of its object-less actions would otherwise
114
+ * be unreachable (objectui#3372; object-less only since objectui#11439, see
115
+ * `actionSuffixes`).
114
116
  */
115
117
  const getAppNamespaces = () => {
116
118
  if (!hasUsableI18nInstance(i18n))
@@ -168,28 +170,35 @@ export function useObjectLabel() {
168
170
  };
169
171
  /**
170
172
  * Build suffix candidates for an action-scoped key, mirroring the canonical
171
- * `@objectstack/spec` resolver (`lookupActionField` in `system/i18n-resolver`).
173
+ * `@objectstack/spec` resolver (`actionTranslationNode` behind
174
+ * `lookupActionField`, in `system/i18n-resolver`): ONE node per action, chosen
175
+ * by its key object.
172
176
  *
173
- * When the action is object-scoped, the object key
174
- * (`objects.<obj>._actions.<action>.<tail>`) wins, but the global key
175
- * (`globalActions.<action>.<tail>`) is appended as a fallback so a
176
- * **globalAction surfaced on an object's action bar** still picks up its
177
- * `globalActions.<action>.*` overlay when no object-scoped translation
178
- * exists (objectui#3372). Without this fallback, a globalAction rendered on
179
- * a record-detail action bar — where the caller passes `objectDef.name` for
180
- * every action — misses `objects.<obj>._actions.<action>.label` and leaks the
181
- * English metadata literal.
177
+ * `objectName` is the action's KEY object — the object the action belongs
178
+ * to: its declared `objectName`, else the host object that embeds it, which
179
+ * is what the spec's `translateObject` stamps on an embedded action. Callers
180
+ * pass that object (`useActionTextLocalizer` derives it from the action).
182
181
  *
183
- * Object-less actions (`objectName` omitted) resolve straight to the global
184
- * namespace, as before. Object precedence is preserved: the global key is
185
- * only consulted after every object-scoped candidate misses.
182
+ * - A key object reads `objects.<obj>._actions.<action>.<tail>` and nothing
183
+ * else.
184
+ * - No key object reads `globalActions.<action>.<tail>` and nothing else.
185
+ *
186
+ * There is deliberately NO object-then-global chain. objectui#3372 added one
187
+ * so `globalActions` copy reached actions drawn on an object's bar; the
188
+ * spec's `TranslationDataSchema.globalActions` is for actions "not bound to a
189
+ * specific object via `objectName`", `@objectstack/spec` 17.7.0 never reads it
190
+ * for a bound action, and objectui#11439 (triage's amended ruling) retired the
191
+ * chain so this client and the server resolve one bundle entry the same way.
192
+ * A bound action's copy filed under `globalActions` now misses on every
193
+ * surface alike and the authored text shows — the misfiling is what
194
+ * `os validate` already refuses, and it is fixed in the bundle, not here.
195
+ *
196
+ * The two object-name candidates (`objects.<ns__obj>` then `objects.<obj>`)
197
+ * are a separate axis and stay — see `stripNamespace`.
186
198
  */
187
- const actionSuffixes = (objectName, actionName, tail) => {
188
- const globalSuffix = `globalActions.${actionName}.${tail}`;
189
- return objectName
190
- ? [...objectSuffixes(objectName, `_actions.${actionName}.${tail}`), globalSuffix]
191
- : [globalSuffix];
192
- };
199
+ const actionSuffixes = (objectName, actionName, tail) => objectName
200
+ ? objectSuffixes(objectName, `_actions.${actionName}.${tail}`)
201
+ : [`globalActions.${actionName}.${tail}`];
193
202
  const fieldSuffixes = (objectName, fieldName) => {
194
203
  const base = stripNamespace(objectName);
195
204
  return base !== objectName
@@ -268,11 +277,37 @@ export function useObjectLabel() {
268
277
  const bareViewName = matchedPrefix ? viewName.slice(matchedPrefix.length) : viewName;
269
278
  return objectSuffixes(objectName, `_views.${bareViewName}.${tail}`);
270
279
  };
280
+ /** The singular object label; `objectPluralLabel` falls back to it. */
281
+ const objectLabel = (objectDef) => resolve(objectSuffixes(objectDef.name, 'label'), objectDef.label);
271
282
  return {
272
283
  /**
273
284
  * Resolve translated object label, falling back to objectDef.label.
274
285
  */
275
- objectLabel: (objectDef) => resolve(objectSuffixes(objectDef.name, 'label'), objectDef.label),
286
+ objectLabel,
287
+ /**
288
+ * Resolve the object's PLURAL label — what a list of its records is called:
289
+ * an object list page's title and the breadcrumb segment that links to that
290
+ * list (objectui#11696). Record-scoped surfaces (the record page, a record
291
+ * drawer, "New …") keep {@link objectLabel}.
292
+ *
293
+ * Order: the translated plural (`{ns}.objects.{objectName}.pluralLabel`),
294
+ * else the declared `objectDef.pluralLabel`, else the singular as
295
+ * `objectLabel` resolves it — `pluralLabel` is optional in the spec, and an
296
+ * object that declares none is called by its label everywhere.
297
+ *
298
+ * The first two rungs are the client half of `translateObject` in
299
+ * `@objectstack/spec` (`system/i18n-resolver`), which serves `pluralLabel`
300
+ * as the catalog entry, else the authored value. There is deliberately NO
301
+ * rung that prefers a translated SINGULAR over a declared plural the bundle
302
+ * leaves untranslated: the server has none either, so every consumer of
303
+ * `/meta` shows the authored plural in that case, and this client showing
304
+ * something else would be a second dialect (see `viewSuffixes`). The gap is
305
+ * the bundle's — `os i18n extract` writes `pluralLabel` wherever the object
306
+ * declares one — and is fixed there.
307
+ */
308
+ objectPluralLabel: (objectDef) => resolve(objectSuffixes(objectDef.name, 'pluralLabel'), '')
309
+ || objectDef.pluralLabel
310
+ || objectLabel(objectDef),
276
311
  /**
277
312
  * Resolve translated object description, falling back to objectDef.description.
278
313
  */
@@ -373,39 +408,17 @@ export function useObjectLabel() {
373
408
  * Resolve translated widget description within a dashboard.
374
409
  * Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.description`.
375
410
  * Returns undefined when neither metadata nor translation provides one.
411
+ *
412
+ * This is a widget's ONE translated description. The sibling sub-caption
413
+ * key, `widgets.{widgetId}.subCaption`, is retired with its resolver
414
+ * (objectui#11389, ruling C): `@objectstack/spec` 17.7.0 refuses it by
415
+ * name in a translation bundle, and nothing here reads it.
376
416
  */
377
417
  widgetDescription: (dashboardName, widgetId, fallback) => {
378
418
  const fb = fallback ?? '';
379
419
  const resolved = resolve(dashboardSuffixes(dashboardName, `widgets.${widgetId}.description`), fb);
380
420
  return resolved || undefined;
381
421
  },
382
- /**
383
- * Resolve a translated metric-widget SUB-CAPTION within a dashboard.
384
- * Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.subCaption`.
385
- * Returns undefined when neither metadata nor translation provides one.
386
- *
387
- * Deliberately its OWN key, not a second reader of `widgets.{id}.description`.
388
- * The KPI card renders two authored strings from two different fields — the
389
- * shared card header's `widget.description`, and the sub-caption under the
390
- * value, which is authored as `widget.options.description` — and the
391
- * objectstack#5428 item-4 ruling (2026-08-06) settled that they get two
392
- * keys, not one: 「两个作者字段两个 key」. Collapsing them would make one
393
- * translation entry silently retarget the other field on any widget type
394
- * that renders both at once (`kpi`, `gauge`, `bullet` — every metric-family
395
- * type except the self-contained `metric`).
396
- *
397
- * `subCaption` is the member objectstack#8056 added to the widget
398
- * translation node for exactly this, shipped in `@objectstack/spec@17.0.0`.
399
- * The server-side resolver reads the SAME key and overlays it onto
400
- * `options.description` (`translateDashboard`), so a document served
401
- * through `/meta` and a document translated here land on the same string —
402
- * this is the client half of one convention, not a second dialect.
403
- */
404
- widgetSubCaption: (dashboardName, widgetId, fallback) => {
405
- const fb = fallback ?? '';
406
- const resolved = resolve(dashboardSuffixes(dashboardName, `widgets.${widgetId}.subCaption`), fb);
407
- return resolved || undefined;
408
- },
409
422
  /**
410
423
  * Resolve translated page label, falling back to pageDef.label.
411
424
  * Convention: `{ns}.pages.{pageName}.label`.
@@ -469,17 +482,18 @@ export function useObjectLabel() {
469
482
  sectionLabel: (objectName, sectionName, fallback) => resolve(objectSuffixes(objectName, `_sections.${sectionName}.label`), fallback),
470
483
  /**
471
484
  * Resolve translated action label.
472
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.label`,
473
- * falling back to `{ns}.globalActions.{actionName}.label` — both when
474
- * objectName is omitted AND when the object-scoped key misses (so a
475
- * globalAction surfaced on a record-detail action bar still resolves;
476
- * objectui#3372).
485
+ * `objectName` is the action's KEY object (its declared `objectName`, else
486
+ * the host that embeds it). Convention:
487
+ * `{ns}.objects.{objectName}._actions.{actionName}.label`; with no key
488
+ * object, `{ns}.globalActions.{actionName}.label`. Never both: a bound
489
+ * action does not read `globalActions` (objectui#11439, see
490
+ * `actionSuffixes`). Every action resolver below follows the same rule.
477
491
  */
478
492
  actionLabel: (objectName, actionName, fallback) => resolve(actionSuffixes(objectName, actionName, 'label'), fallback),
479
493
  /**
480
494
  * Resolve translated action confirmation prompt.
481
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.confirmText`,
482
- * falling back to `{ns}.globalActions.{actionName}.confirmText`.
495
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.confirmText`;
496
+ * with no key object, `{ns}.globalActions.{actionName}.confirmText`.
483
497
  * Returns undefined when no translation and no fallback exist.
484
498
  */
485
499
  actionConfirm: (objectName, actionName, fallback) => {
@@ -489,8 +503,8 @@ export function useObjectLabel() {
489
503
  },
490
504
  /**
491
505
  * Resolve translated action success message.
492
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.successMessage`,
493
- * falling back to `{ns}.globalActions.{actionName}.successMessage`.
506
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.successMessage`;
507
+ * with no key object, `{ns}.globalActions.{actionName}.successMessage`.
494
508
  */
495
509
  actionSuccess: (objectName, actionName, fallback) => {
496
510
  const fb = fallback ?? '';
@@ -500,9 +514,9 @@ export function useObjectLabel() {
500
514
  /**
501
515
  * Resolve the translated success copy for ONE handler outcome — an entry of
502
516
  * the action's `outcomeMessages` (`@objectstack/spec` 17.6.0, objectui#11344).
503
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.outcomeMessages.{outcome}`,
504
- * falling back to `{ns}.globalActions.{actionName}.outcomeMessages.{outcome}`
505
- * — the address the spec's translation bundle declares for the key.
517
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.outcomeMessages.{outcome}`;
518
+ * with no key object, `{ns}.globalActions.{actionName}.outcomeMessages.{outcome}`
519
+ * — the addresses the spec's translation bundle declares for the key.
506
520
  * Returns undefined when no translation and no fallback exist.
507
521
  *
508
522
  * Like `actionSuccess`, the answer is a TEMPLATE: a `${result.*}` token in a
@@ -518,9 +532,9 @@ export function useObjectLabel() {
518
532
  /**
519
533
  * Resolve translated action description (the explanatory line shown in the
520
534
  * action's param dialog / sheet / drawer header).
521
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.description`.
522
- * Falls back to `{ns}.globalActions.{actionName}.description`, then the
523
- * metadata's literal string; undefined when nothing resolves.
535
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.description`;
536
+ * with no key object, `{ns}.globalActions.{actionName}.description`. Falls
537
+ * back to the metadata's literal string; undefined when nothing resolves.
524
538
  */
525
539
  actionDescription: (objectName, actionName, fallback) => {
526
540
  const fb = fallback ?? '';
@@ -532,10 +546,9 @@ export function useObjectLabel() {
532
546
  /**
533
547
  * Resolve a translated copy of an action's post-success RESULT DIALOG
534
548
  * (`title` / `description` / `acknowledge` + per-field labels).
535
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.resultDialog.*`,
536
- * falling back to `{ns}.globalActions.{actionName}.resultDialog.*` (when
537
- * objectName is omitted OR the object-scoped key misses), then to the
538
- * metadata's literal strings.
549
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.resultDialog.*`;
550
+ * with no key object, `{ns}.globalActions.{actionName}.resultDialog.*`.
551
+ * Falls back to the metadata's literal strings.
539
552
  *
540
553
  * The `fields` translation node is keyed by the LITERAL result-field path
541
554
  * from the action metadata (may contain dots, e.g. `"user.email"`), so it
@@ -588,7 +601,8 @@ export function useObjectLabel() {
588
601
  },
589
602
  /**
590
603
  * Resolve translated action-PARAMETER text (label / placeholder / helpText).
591
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.{attr}`.
604
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.{attr}`;
605
+ * with no key object, `{ns}.globalActions.{actionName}.params.{paramName}.{attr}`.
592
606
  * Falls back to the provided value (the metadata's literal string) when no
593
607
  * translation exists, so untranslated params keep rendering as authored.
594
608
  */
@@ -601,7 +615,8 @@ export function useObjectLabel() {
601
615
  },
602
616
  /**
603
617
  * Resolve a translated action-parameter SELECT OPTION label.
604
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.options.{optionValue}`.
618
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.options.{optionValue}`;
619
+ * with no key object, the same path under `{ns}.globalActions.{actionName}`.
605
620
  * Falls back to the provided (English metadata) label when untranslated.
606
621
  */
607
622
  actionParamOptionLabel: (objectName, actionName, paramName, optionValue, fallback) => {