diff --git a/.changeset/widget-translation-subcaption.md b/.changeset/widget-translation-subcaption.md new file mode 100644 index 0000000000..e7fcbe8e69 --- /dev/null +++ b/.changeset/widget-translation-subcaption.md @@ -0,0 +1,13 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): the dashboard widget translation node gains an optional third member, `subCaption`, keyed `dashboards..widgets..subCaption` (#7862) + +The metric widget draws two authored strings under its header: `widget.description` (the copy under the card header) and `options.description` (the sub-caption under the number). The #5428 ruling (2026-08-06, item 4) gives each its own translation key — sharing `widget.description`'s key is forbidden (「两个作者字段两个 key」) — but the strict `{ title?, description? }` widget node refused every spelling of a second key, and its own error hint pointed `subtitle` at `description`, i.e. at the shared key the ruling forbids. + +- `dashboards..widgets..subCaption` is now accepted and translates the metric sub-caption (`options.description`); `description` keeps translating `widget.description` and never reaches the options bag. +- `translateDashboard` resolves the new key, overlaying `options.description` and carrying every other `options` key through untouched, on the same REST `/meta` path that already resolves widget `title`/`description`. +- The `subtitle` hint now points at `subCaption`. Unknown keys on the node are still refused. + +The node stays strict; existing bundles are unaffected (the accept-set only grows). objectui's client-side renderer half consumes the same key path as a follow-up (objectui#4032 / objectui#4358). diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index 9382413d49..d177ea7d2c 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -52,7 +52,7 @@ "status": "live", "verifiedAt": "2026-08-01", "evidence": "packages/spec/src/system/i18n-resolver.ts:538, :554", - "note": "translateDashboard: label/description plus per-widget title/description by widget id; header action labels." + "note": "translateDashboard: label/description plus per-widget title/description/subCaption by widget id; header action labels. `subCaption` (#7862, #5428 item 4) overlays the metric widget's `options.description` — a different authored field from `widget.description`, each on its own key — live through the same translateDashboard REST path; objectui's client-side renderer half is the downstream follow-up." }, "pages": { "status": "live", diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index aa42b249b8..5acd0ee4ef 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -730,6 +730,7 @@ import { translateApp, translateDashboard, resolveViewLabel as _resolveViewLabel, + type DashboardLike, } from './i18n-resolver'; describe('locale fallback resolution (BCP-47)', () => { @@ -871,6 +872,73 @@ describe('translateDashboard', () => { expect(out.widgets[1].title).toBe('Other'); }); + // #7862 — `subCaption` overlays the metric widget's `options.description`, + // a DIFFERENT authored field from `widget.description` (#5428 item 4: two + // authored fields, two keys). + describe('widget subCaption (#7862)', () => { + const subBundle: TranslationBundle = { + 'zh-CN': { + dashboards: { + system_overview: { + widgets: { + widget_total_users: { + title: '用户总数', + description: '系统中注册的用户总数', + subCaption: '较上月', + }, + }, + }, + }, + }, + }; + const metricDashboard = { + name: 'system_overview', + widgets: [ + { + id: 'widget_total_users', + type: 'metric', + title: 'Total Users', + description: 'Total registered users', + options: { description: 'vs last month', sortBy: 'created' }, + }, + { + id: 'widget_other', + type: 'metric', + title: 'Other', + description: 'Other card copy', + options: { description: 'untouched extra' }, + }, + ], + }; + + it('overlays `options.description` and carries the other options keys through', () => { + const out = translateDashboard(metricDashboard, subBundle, { locale: 'zh-CN' }); + expect(out.widgets[0].options).toEqual({ description: '较上月', sortBy: 'created' }); + }); + + it('keeps the two authored fields on their own keys — `description` never reaches `options.description`, `subCaption` never reaches `widget.description`', () => { + const out = translateDashboard(metricDashboard, subBundle, { locale: 'zh-CN' }); + expect(out.widgets[0].description).toBe('系统中注册的用户总数'); + expect(out.widgets[0].options?.description).toBe('较上月'); + }); + + it('leaves `options` of a widget without a subCaption entry untouched (same reference semantics as title)', () => { + const out = translateDashboard(metricDashboard, subBundle, { locale: 'zh-CN' }); + expect(out.widgets[1].options).toEqual({ description: 'untouched extra' }); + }); + + it('does not mutate the input document', () => { + translateDashboard(metricDashboard, subBundle, { locale: 'zh-CN' }); + expect(metricDashboard.widgets[0].options.description).toBe('vs last month'); + }); + + it('creates the options bag when the bundle carries a subCaption and the widget has none — mirroring how a bundle-only `title` renders', () => { + const bare: DashboardLike = { name: 'system_overview', widgets: [{ id: 'widget_total_users' }] }; + const out = translateDashboard(bare, subBundle, { locale: 'zh-CN' }); + expect(out.widgets?.[0]?.options).toEqual({ description: '较上月' }); + }); + }); + it('works through translateMetadataDocument with dashboard type', () => { const out = translateMetadataDocument('dashboard', dashboard, bundle, { locale: 'zh-CN' }); expect(out.label).toBe('系统概览'); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index d7a7eb7aa5..7e3badd4d1 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -687,6 +687,13 @@ export interface WidgetLike { id?: string; title?: string; description?: string; + /** + * The renderer-extras bag (`DashboardWidgetOptionsSchema`). `translateDashboard` + * writes exactly one key into it — `description`, the metric widget's + * sub-caption slot — when the bundle carries a + * `dashboards..widgets..subCaption` entry (#5428 item 4, #7862). + */ + options?: Record; [key: string]: any; } @@ -717,7 +724,7 @@ function lookupWidgetAttr( bundle: TranslationBundle | undefined, dashboardName: string, widgetId: string, - attr: 'title' | 'description', + attr: 'title' | 'description' | 'subCaption', opts?: ResolveOptions, ): string | undefined { if (!bundle) return undefined; @@ -732,8 +739,15 @@ function lookupWidgetAttr( /** * Apply the active locale to a dashboard metadata document — translates the * dashboard's `label` / `description` and each widget's `title` / - * `description` against `dashboards..widgets..*`. The input document - * is not mutated. + * `description` / `subCaption` against `dashboards..widgets..*`. + * The input document is not mutated. + * + * `subCaption` overlays the widget's `options.description` — the metric + * widget's sub-caption, a DIFFERENT authored field from `widget.description` + * (#5428 item 4: two authored fields, two keys; #7862). The `description` + * key never reaches `options.description`, and `subCaption` never reaches + * `widget.description`; the other `options` keys are carried through + * untouched. */ export function translateDashboard( doc: T, @@ -755,6 +769,8 @@ export function translateDashboard( if (title) next.title = title; const desc = lookupWidgetAttr(bundle, name, w.id, 'description', opts); if (desc) next.description = desc; + const subCaption = lookupWidgetAttr(bundle, name, w.id, 'subCaption', opts); + if (subCaption) next.options = { ...w.options, description: subCaption }; return next; }) : doc.widgets; diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 8e63371fa7..d2e0999ed4 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -894,6 +894,59 @@ describe('translation unknown-key strictness (#4001)', () => { }); }); + // ────────────────────────────────────────────────────────────────────────── + // #7862 — `dashboards..widgets..subCaption`, the metric widget's + // sub-caption (`options.description`). #5428 item 4: two authored fields, + // two keys — `description` translates `widget.description`, `subCaption` + // translates `widget.options.description`; sharing one key is forbidden. + // ────────────────────────────────────────────────────────────────────────── + describe('dashboard widget sub-caption (#7862)', () => { + const parse = (widgets: unknown) => + TranslationDataSchema.safeParse({ dashboards: { sales: { widgets } } }); + + it('accepts `subCaption` on the widget node, alongside title/description', () => { + const result = parse({ + rev: { title: '营收', description: '本季度确认的营收', subCaption: '较上季度' }, + }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it('keeps `title`/`description` byte-identical through the parse', () => { + const body = { rev: { title: 'Revenue', description: 'Recognized revenue this quarter' } }; + const result = parse(body); + expect(result.success).toBe(true); + expect((result as { success: true; data: any }).data.dashboards.sales.widgets) + .toEqual(body); + }); + + it('stays `.strict()` — an invented key is still refused, with substance', () => { + const result = parse({ rev: { footnote: 'x' } }); + expect(result.success).toBe(false); + const message = result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message ?? ''; + expect(message).toContain('this widget translation'); + expect(message).toContain('`footnote`'); + }); + + it('sends `subtitle` to `subCaption`, not to `description`', () => { + // On a metric widget the string an author calls the "subtitle" is the + // sub-caption under the number (`options.description`). Pointing it at + // `description` would steer authors to precisely the shared key the + // #5428 ruling forbids (「两个作者字段两个 key」). + const result = parse({ rev: { subtitle: '较上季度' } }); + expect(result.success).toBe(false); + const message = result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message ?? ''; + expect(message).toContain('`subtitle` → `subCaption`'); + expect(message).not.toContain('`subtitle` → `description`'); + }); + + it('suggests `subCaption` for a near-miss spelling', () => { + const result = parse({ rev: { subCaptoin: '较上季度' } }); + expect(result.success).toBe(false); + expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain('→ `subCaption`'); + }); + }); + // ────────────────────────────────────────────────────────────────────────── // #7646 — `flows..screens.`, the screen-flow wizard's copy // ────────────────────────────────────────────────────────────────────────── @@ -1087,7 +1140,7 @@ describe('translation unknown-key strictness (#4001)', () => { apps: { crm: { label: 'CRM', navigation: { sales: { label: 'Sales' } } } }, messages: { 'common.save': 'Save' }, globalActions: { export_csv: { label: 'Export', params: { format: { label: 'Format' } } } }, - dashboards: { sales: { label: 'Sales', widgets: { rev: { title: 'Revenue' } } } }, + dashboards: { sales: { label: 'Sales', widgets: { rev: { title: 'Revenue', subCaption: 'vs last quarter' } } } }, pages: { home: { label: 'Home', title: 'Welcome' } }, flows: { lead_conversion: { label: 'Convert Lead', screens: { details: { title: 'Details', fields: { name: { label: 'Name', placeholder: 'Enter a name' } } } } } }, settings: { mail: { title: 'Mail', keys: { host: { label: 'Host' } } } }, diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index f6487b2f60..86dbf31a33 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -467,6 +467,7 @@ const translationDataShape = () => ({ * dashboards..actions..label * dashboards..widgets..title * dashboards..widgets..description + * dashboards..widgets..subCaption */ dashboards: z.record(z.string(), strictObject({ surface: 'this dashboard translation', @@ -488,10 +489,29 @@ const translationDataShape = () => ({ // A widget's headline is `title`; a dashboard's is `label`. Same document, // one level apart, opposite spellings — so `label` on a widget is the // likeliest mistake on this surface and the least likely to be noticed. - aliases: { label: 'title', name: 'title', heading: 'title', subtitle: 'description' }, + // + // `subtitle` points at `subCaption`, not `description`: on a metric + // widget the string an author calls the "subtitle" is the sub-caption + // under the number (`widget.options.description`), a DIFFERENT authored + // field from `widget.description` (the copy under the card header). The + // #5428 ruling (2026-08-06, item 4) gives each authored field its own + // key — 「两个作者字段两个 key」 — so steering `subtitle` authors at + // `description` would steer them at exactly the shared key the ruling + // forbids (#7862). + aliases: { label: 'title', name: 'title', heading: 'title', subtitle: 'subCaption' }, }, { title: z.string().optional().describe('Translated widget title'), description: z.string().optional().describe('Translated widget description'), + /** + * Overlays the metric widget's sub-caption — the authored + * `widget.options.description`, NOT `widget.description`. Two authored + * fields, two keys (#5428 item 4, #7862): `description` above translates + * `widget.description`; this key translates `options.description`. + * Resolved by `translateDashboard` (i18n-resolver.ts); objectui's + * client-side renderer half consumes the same + * `dashboards..widgets..subCaption` path. + */ + subCaption: z.string().optional().describe("Translated metric sub-caption (overlays the widget's `options.description`, a different authored field from `description`)"), })).optional().describe('Widget translations keyed by widget id'), })).optional().describe('Dashboard translations keyed by dashboard name'),