Skip to content

Commit dd11563

Browse files
feat(spec): settings info_banner translation slot — banners.ID.{title,text} and its two resolvers (#23886)
Fixes #23855 Clause-②: yes ## What changed The platform translation contract gains a slot for a settings `info_banner` row, and the `resolveSettings*` family gains its two readers. - `packages/spec/src/system/translation.zod.ts` — `platformSettingsShape` declares `settings.NAMESPACE.banners.BANNER_ID.{title,text}`, keyed by the `info_banner` specifier's `id`, beside `groups` / `keys` / `actions`. It is built with the same `strictObject({ surface, history, aliases })` helper as its siblings. The convention list in the docblock names the two new paths. - Leaf aliases: `label`, `name`, `heading` → `title`; `bannerText`, `body`, `description`, `message` → `text`. - Namespace aliases: `banner`, `notices`, `alerts` → `banners`. A wrong key now gets a named remedy, not a bare `unrecognized_keys`. - `packages/spec/src/system/i18n-resolver.ts` — `resolveSettingsBannerTitle` and `resolveSettingsBannerText`. They walk the requested locale and then the declared fallback chain, and finally return the manifest literal (`label` / `bannerText`). - `packages/spec/src/system/settings-manifest.zod.ts` — `SpecifierSchema.id` TSDoc names `info_banner` among the specifiers whose text is looked up by `id`. TSDoc only: no `superRefine`, and the specifier's accept set is unchanged. - Generated: `api-surface/system.json` and `export-origins/system.json` (+2 exports each), `content/docs/references/system/translation.mdx`, strictness-ledger counts (`system/` 373 → 374). - `.changeset/23855-settings-banner-translation-slot.md` — `minor`, `Clause-②: yes (widening)`. ## Spelling: why `title` / `text` The specifier carries `label` and `bannerText`. The nearest house precedent is `groups`: a `group` specifier's `label` translates as `groups.ID.title`. So the banner's heading reuses that leaf name, `title`. For the body there is no sibling that holds the same field. `description` on a group is the specifier's `description`, a different field, so reusing it would invite confusion. `text` is the card's proposal and names the `bannerText` field it translates. The specifier's own spellings and the group's `description` are aliases, so the likely borrowings are refused with the right leaf. ## Who reads it Measured at `origin/main` `7a6ba03109`: `git grep "resolveSettings[A-Z]"` outside `i18n-resolver.ts` finds only the generated baselines and docblocks. The family has no caller in objectstack outside its own exports. The objectui console resolves by convention in its own hook. That hook was not measured here, because no objectui checkout exists in this container. The resolvers are the family's executable statement of the convention. Rendering the slot in the console is a follow-up card in objectui, filed at landing. This PR makes no claim that the console renders it yet. ## Verification (head `1f081ab7c1` unless noted) - New pins: `packages/spec/src/system/settings-banner-translation.test.ts`, 8 tests. - The slot parses on the platform face. - `bannerText` / `label` / `description` on a banner and the singular `banner` group are each refused at the right path, naming `text` / `title` / `banners`. - Each resolver resolves a hit, walks the fallback chain, and returns the manifest literal on a miss. - Ablations (fix committed first, mutation through `scripts/ablation-replace.mjs`, restore proven by blob == HEAD and an empty `git diff HEAD`): - Rename the `banners` key out of the shape: 4 schema pins go red (accept, plus the three leaf-alias refusals). The singular-`banner` pin stays green, because its alias lives one level up. - Point `resolveSettingsBannerTitle` at `groups.ID.description`: 2 resolver pins go red (hit, fallback chain). - The same mutation on `resolveSettingsBannerText`: the same 2 go red. - Control, the four shipped bundles: a one-off probe parsed `settingsBuiltinTranslations` (`en`, `zh-CN`, `ja-JP`, `es-ES`) with `PlatformTranslationDataSchema`. All four parse. - `@objectstack/spec`: `typecheck` exit 0. The full `vitest run` at `63894195e5` (pre-merge) passed 748 files, 21985 tests. Rerun on the merged head: see the report. - `pnpm --filter @objectstack/spec check:generated` after a real build: all 14 artifacts up to date, before and after merging `origin/main`. - Derived gates (`dispatch-gates --commands`): 109 of 109 exit 0, reconciled with `--ran` (a derived zero, all exit codes recorded). Nine first exited 3 (prerequisite: unbuilt workspace or a shallow clone). After a workspace build and an unshallow fetch, all nine exit 0. ## Acceptance notes - `.objectui-sha` at this base pins `daeb77fc84`. That is the card's pin, not the `1f23d5b070` the dispatch named. Noted, not acted on. - `beta_notice` (`feature-flags.manifest.ts`) already carries an `id`, so it is addressable as soon as #23839 adds its three locale copies. #23839 is not addressed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01MbQTsgWtBx7fxYsCN5UKgC)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0744f94 commit dd11563

9 files changed

Lines changed: 162 additions & 4 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
Clause-②: yes (widening)
6+
7+
The platform translation bundle (`PlatformTranslationDataSchema`) gains a slot for a settings
8+
`info_banner` row: `settings.<namespace>.banners.<banner_id>.title` and `.text`, keyed by the
9+
specifier's `id`, beside `groups`, `keys` and `actions`. `title` translates the specifier's `label`
10+
(the heading spelling `groups` already uses) and `text` translates its `bannerText` (markdown).
11+
Before this, a translated banner was refused with `unrecognized_keys`, so a banner reached every
12+
locale in its manifest English. The slot is strict: a borrowed `label`, `bannerText` or
13+
`description` leaf, or a singular `banner` group, is refused with the spelling it meant.
14+
15+
Two resolvers read it, `resolveSettingsBannerTitle` and `resolveSettingsBannerText`, from the
16+
`resolveSettings*` family: they walk the requested locale and the declared fallback chain, then
17+
return the manifest literal. The `info_banner` specifier's own accept set is unchanged. Give the
18+
banner an `id` to make it addressable; a banner without one keeps rendering its literals.

‎content/docs/references/system/translation.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ Translation data for a single object
287287
| **groups** | `Record<string, { title?: string; description?: string }>` | optional | Group translations keyed by group key |
288288
| **keys** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Per-setting field translations keyed by setting key |
289289
| **actions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string }>` | optional | Action button translations keyed by action id |
290+
| **banners** | `Record<string, { title?: string; text?: string }>` | optional | Info banner translations keyed by the info_banner specifier id |
290291

291292

292293
---

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
1919

2020
| Dir | Sites |
2121
|---|---|
22-
| `system/` | 373 |
22+
| `system/` | 374 |

‎packages/spec/api-surface/system.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,8 @@
855855
"resolveSettingsActionConfirm (function)",
856856
"resolveSettingsActionLabel (function)",
857857
"resolveSettingsActionSuccess (function)",
858+
"resolveSettingsBannerText (function)",
859+
"resolveSettingsBannerTitle (function)",
858860
"resolveSettingsDescription (function)",
859861
"resolveSettingsFieldHelp (function)",
860862
"resolveSettingsFieldLabel (function)",

‎packages/spec/export-origins/system.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,8 @@
816816
"resolveSettingsActionConfirm": "src/system/i18n-resolver.ts#resolveSettingsActionConfirm (function)",
817817
"resolveSettingsActionLabel": "src/system/i18n-resolver.ts#resolveSettingsActionLabel (function)",
818818
"resolveSettingsActionSuccess": "src/system/i18n-resolver.ts#resolveSettingsActionSuccess (function)",
819+
"resolveSettingsBannerText": "src/system/i18n-resolver.ts#resolveSettingsBannerText (function)",
820+
"resolveSettingsBannerTitle": "src/system/i18n-resolver.ts#resolveSettingsBannerTitle (function)",
819821
"resolveSettingsDescription": "src/system/i18n-resolver.ts#resolveSettingsDescription (function)",
820822
"resolveSettingsFieldHelp": "src/system/i18n-resolver.ts#resolveSettingsFieldHelp (function)",
821823
"resolveSettingsFieldLabel": "src/system/i18n-resolver.ts#resolveSettingsFieldLabel (function)",

‎packages/spec/src/system/i18n-resolver.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3676,6 +3676,40 @@ export function resolveSettingsActionSuccess(
36763676
);
36773677
}
36783678

3679+
/**
3680+
* Resolve an `info_banner` title under `settings.<namespace>.banners.<bannerId>.title`,
3681+
* keyed by the specifier's `id`; falls back to the specifier's `label`.
3682+
*/
3683+
export function resolveSettingsBannerTitle(
3684+
bundle: PlatformTranslationBundle | undefined,
3685+
namespace: string,
3686+
bannerId: string,
3687+
fallback: string,
3688+
opts?: ResolveOptions,
3689+
): string {
3690+
return (
3691+
resolveOptionalString(bundle, namespace, (e) => e.banners?.[bannerId]?.title, opts)
3692+
?? fallback
3693+
);
3694+
}
3695+
3696+
/**
3697+
* Resolve an `info_banner` body under `settings.<namespace>.banners.<bannerId>.text`,
3698+
* keyed by the specifier's `id`; falls back to the specifier's `bannerText`.
3699+
*/
3700+
export function resolveSettingsBannerText(
3701+
bundle: PlatformTranslationBundle | undefined,
3702+
namespace: string,
3703+
bannerId: string,
3704+
fallback: string | undefined,
3705+
opts?: ResolveOptions,
3706+
): string | undefined {
3707+
return (
3708+
resolveOptionalString(bundle, namespace, (e) => e.banners?.[bannerId]?.text, opts)
3709+
?? fallback
3710+
);
3711+
}
3712+
36793713
// ────────────────────────────────────────────────────────────────────────────
36803714
// SettingsCommon — cross-namespace UI strings (source badges, etc.)
36813715
// ────────────────────────────────────────────────────────────────────────────
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { PlatformTranslationDataSchema, type PlatformTranslationBundle } from './translation.zod';
5+
import { resolveSettingsBannerText, resolveSettingsBannerTitle } from './i18n-resolver';
6+
7+
/**
8+
* The `info_banner` slot of the platform settings translation group:
9+
* `settings.<namespace>.banners.<banner_id>.{title,text}`, keyed by the
10+
* specifier's `id`, beside `groups` / `keys` / `actions`.
11+
*/
12+
describe('settings.<namespace>.banners — the info_banner translation slot', () => {
13+
const banners = {
14+
beta_notice: { title: '注意', text: 'Beta 功能可能随时变更。' },
15+
};
16+
17+
it('accepts a banner title and text on the platform face', () => {
18+
const result = PlatformTranslationDataSchema.safeParse({ settings: { feature_flags: { banners } } });
19+
expect(result.success).toBe(true);
20+
});
21+
22+
it.each([
23+
['the specifier spelling `bannerText`', { bannerText: 'x' }, 'text'],
24+
['the specifier spelling `label`', { label: 'x' }, 'title'],
25+
['the group body spelling `description`', { description: 'x' }, 'text'],
26+
])('refuses %s on a banner and names the leaf that holds it', (_what, leaf, expected) => {
27+
const result = PlatformTranslationDataSchema.safeParse({
28+
settings: { feature_flags: { banners: { beta_notice: leaf } } },
29+
});
30+
expect(result.success).toBe(false);
31+
const issue = result.error?.issues.find((i) => i.code === 'unrecognized_keys');
32+
expect(issue?.path).toEqual(['settings', 'feature_flags', 'banners', 'beta_notice']);
33+
expect(issue?.message).toContain(`→ \`${expected}\``);
34+
});
35+
36+
it('refuses the singular `banner` group and names `banners`', () => {
37+
const result = PlatformTranslationDataSchema.safeParse({
38+
settings: { feature_flags: { banner: banners } },
39+
});
40+
expect(result.success).toBe(false);
41+
const issue = result.error?.issues.find((i) => i.code === 'unrecognized_keys');
42+
expect(issue?.path).toEqual(['settings', 'feature_flags']);
43+
expect(issue?.message).toContain('→ `banners`');
44+
});
45+
});
46+
47+
describe('resolveSettingsBannerTitle / resolveSettingsBannerText', () => {
48+
const bundle: PlatformTranslationBundle = {
49+
'zh-CN': {
50+
settings: {
51+
feature_flags: { banners: { beta_notice: { title: '注意', text: 'Beta 功能可能随时变更。' } } },
52+
},
53+
},
54+
};
55+
56+
it('resolves the requested locale', () => {
57+
const opts = { locale: 'zh-CN' };
58+
expect(resolveSettingsBannerTitle(bundle, 'feature_flags', 'beta_notice', 'Heads up', opts)).toBe('注意');
59+
expect(resolveSettingsBannerText(bundle, 'feature_flags', 'beta_notice', 'Beta features…', opts))
60+
.toBe('Beta 功能可能随时变更。');
61+
});
62+
63+
it('walks the declared fallback chain when the requested locale has no entry', () => {
64+
const opts = { locale: 'ja-JP', fallbackChain: ['zh-CN'] };
65+
expect(resolveSettingsBannerTitle(bundle, 'feature_flags', 'beta_notice', 'Heads up', opts)).toBe('注意');
66+
expect(resolveSettingsBannerText(bundle, 'feature_flags', 'beta_notice', 'Beta features…', opts))
67+
.toBe('Beta 功能可能随时变更。');
68+
});
69+
70+
it('falls back to the manifest literal when no locale in the chain has the banner', () => {
71+
const opts = { locale: 'ja-JP' };
72+
expect(resolveSettingsBannerTitle(bundle, 'feature_flags', 'beta_notice', 'Heads up', opts)).toBe('Heads up');
73+
expect(resolveSettingsBannerText(bundle, 'feature_flags', 'beta_notice', 'Beta features…', opts))
74+
.toBe('Beta features…');
75+
expect(resolveSettingsBannerText(bundle, 'feature_flags', 'other_notice', undefined, { locale: 'zh-CN' }))
76+
.toBeUndefined();
77+
expect(resolveSettingsBannerTitle(undefined, 'feature_flags', 'beta_notice', 'Heads up')).toBe('Heads up');
78+
});
79+
});

‎packages/spec/src/system/settings-manifest.zod.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,8 +393,10 @@ export const SpecifierSchema = lazySchema(() => z.object({
393393
/**
394394
* Stable identifier (snake_case) used for i18n translation lookup,
395395
* action routing, and test selectors. Orthogonal to `key` (which is
396-
* the storage path). Recommended for `group` and `action_button`
397-
* specifiers so their labels can be translated and so action
396+
* the storage path). Recommended for `group`, `info_banner` and
397+
* `action_button` specifiers so their text can be translated
398+
* (`settings.<namespace>.groups.<id>`, `.banners.<id>` and
399+
* `.actions.<id>` in the platform translation bundle) and so action
398400
* handlers have stable hook keys. Must be unique within a manifest.
399401
*/
400402
id: SnakeCaseIdentifierSchema.optional().describe('Stable identifier (snake_case)'),

‎packages/spec/src/system/translation.zod.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1626,11 +1626,20 @@ const platformSettingsShape = () => ({
16261626
* settings.<namespace>.actions.<action_id>.label
16271627
* settings.<namespace>.actions.<action_id>.confirmText
16281628
* settings.<namespace>.actions.<action_id>.successMessage
1629+
* settings.<namespace>.banners.<banner_id>.title
1630+
* settings.<namespace>.banners.<banner_id>.text
1631+
*
1632+
* `banners` is keyed by an `info_banner` specifier's `id`, as `groups` is
1633+
* keyed by a `group` specifier's. Its leaves follow the nearest sibling:
1634+
* the specifier's `label` translates as `title` (the heading spelling
1635+
* `groups` already uses for the same field), and its `bannerText` as
1636+
* `text`. A banner without an `id` has no address here and renders its
1637+
* manifest literals.
16291638
*/
16301639
settings: z.record(z.string(), strictObject({
16311640
surface: 'this settings manifest translation',
16321641
history: TRANSLATION_HISTORY,
1633-
aliases: { label: 'title', name: 'title', sections: 'groups', fields: 'keys', settings: 'keys' },
1642+
aliases: { label: 'title', name: 'title', sections: 'groups', fields: 'keys', settings: 'keys', banner: 'banners', notices: 'banners', alerts: 'banners' },
16341643
}, {
16351644
title: z.string().optional().describe('Translated settings manifest title'),
16361645
description: z.string().optional().describe('Translated settings manifest description'),
@@ -1669,6 +1678,17 @@ const platformSettingsShape = () => ({
16691678
confirmText: z.string().optional().describe('Translated confirmation prompt'),
16701679
successMessage: z.string().optional().describe('Translated success toast/message'),
16711680
})).optional().describe('Action button translations keyed by action id'),
1681+
banners: z.record(z.string(), strictObject({
1682+
surface: 'this settings banner translation',
1683+
history: TRANSLATION_HISTORY,
1684+
// The specifier's own spellings (`label`, `bannerText`) and the group
1685+
// body's (`description`) are the likely borrowings; each names the leaf
1686+
// that holds the same string here.
1687+
aliases: { label: 'title', name: 'title', heading: 'title', bannerText: 'text', body: 'text', description: 'text', message: 'text' },
1688+
}, {
1689+
title: z.string().optional().describe('Translated banner title (the info_banner specifier label)'),
1690+
text: z.string().optional().describe('Translated banner body, markdown (the info_banner specifier bannerText)'),
1691+
})).optional().describe('Info banner translations keyed by the info_banner specifier id'),
16721692
})).optional().describe('Settings manifest translations keyed by namespace'),
16731693
});
16741694

0 commit comments

Comments
 (0)