Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .changeset/translation-submit-label-retired.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
---
"@objectstack/spec": minor
---

feat(spec): retire the component-translation `submitLabel` copy key (#10926, ADR-0049)

<!-- adr-0087: registered translation-component-submit-label-removed -->

**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
launch-window convention ships it as `minor`; the migration prescription is
registered under protocol major 18, where `os migrate meta` users will look).

The `pages.<name>.components.<id>` copy face is measured, not mirrored: each
key exists because some component in `ComponentPropsMap` declares it.
`submitLabel`'s only declarer was `element:form`, and #9249 retired that
element whole — so the key had no declared component left to translate, and
the resolver overlay was its only reader. The maintainer ruled retire over
re-anchor (#10926): the live form surface (`object-form`) speaks `submitText`
(`I18nLabelSchema`), localizable at its own authoring site, so re-anchoring
would have widened the face for one word. The acknowledged cost is that the
bespoke-component route loses that one word.

**What is refused:** `submitLabel` in any `pages.<name>.components.<id>`
translation entry, and its `submit` alias spelling — both now land on a
`guidance` prescription in the strict unknown-key rejection (the face is
`.strict()`, so the strict-delete route applies: no `retiredKey()` tombstone,
the shape simply no longer declares the key).

**What stays:** the other five copy keys (`title`, `description`, `label`,
`placeholder`, `emptyText`), the bespoke-component route for them, and the
shared `PAGE_COMPONENT_COPY_KEYS` list (now five entries) that drives both
`translatePage`'s overlay and the CLI `i18n-extract` skeleton — one list, both
sides import it, so extractor and resolver narrow together.

The retirement kit:

- strict-delete at the schema (`packages/spec/src/system/translation.zod.ts`):
key and `submit` alias dropped; `guidance` tombstones carry the prescription
- `PAGE_COMPONENT_COPY_KEYS` drops the slot
(`packages/spec/src/system/i18n-resolver.ts`) — the resolver no longer
overlays the key and the extractor no longer offers it
- ADR-0087 registration: D2 conversion
`translation-component-submit-label-removed` (protocol 18), wired into the
step-18 chain — `os migrate meta --from 17` strips the key from stored
translation bundles and items (pure lossless delete; nothing read it since
#9249)
- pin tests flipped, not deleted (`translation.test.ts` refusal pins assert
the prescription; `i18n-resolver.test.ts` pins that an off-spec bundle entry
carrying the retired key is ignored, not overlaid)
- generated baselines/docs follow the schema (json-schema manifest,
spec-changes, upgrade guide, api-surface signatures, reference docs)

## FROM → TO

```ts
// before — a component-translation entry could carry a submit label
translations: [{
'zh-CN': {
pages: {
sales_home_page: {
components: { new_lead_form: { submitLabel: '创建' } },
},
},
},
}]

// after — delete the key (nothing has read it since #9249); submit copy for
// the live form surface is authored on the component itself, where it is
// localizable inline
{
type: 'object-form',
properties: {
objectName: 'lead',
submitText: { en: 'Create', 'zh-CN': '创建' },
},
}
```
134 changes: 134 additions & 0 deletions packages/spec/src/conversions/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7176,6 +7176,139 @@ const elementFormRemoved: MetadataConversion = {
},
};

/**
* `translation.pages.<name>.components.<id>.submitLabel` — the component-copy
* key retired with its only declarer (protocol 18, #10926, ADR-0049).
*
* The face is measured, not mirrored: each copy key exists because some
* component in `ComponentPropsMap` declares it, and `submitLabel`'s only
* declarer was `element:form` — retired whole by #9249 (`element-form-removed`
* above). The maintainer ruled retire over re-anchor (#10926): the live form
* surface's submit copy is `object-form`'s `submitText` (`I18nLabelSchema`),
* localizable at its own authoring site, so re-anchoring would have widened
* the face for one word. The key, its `submit` alias and its
* `PAGE_COMPONENT_COPY_KEYS` slot are gone; the schema rejection carries the
* prescription.
*
* Pure lossless delete — since #9249 no resolver overlaid the key, so a stored
* translation kept a string nothing read. Both authored shapes are walked: a
* bundle entry (locale → data map, `stack.translations`' declared shape) and a
* bare data/item entry (groups at the top level — the shape stored `translation`
* items replay through, the `translation-validation-messages-removed`
* precedent).
*/
const translationComponentSubmitLabelRemoved: MetadataConversion = {
id: 'translation-component-submit-label-removed',
toMajor: 18,
retiredFromLoadPath: true,
surface: 'translation.pages.components.submitLabel',
summary:
"translation component-copy key 'submitLabel' removed (#10926 — its only declared carrier, "
+ "'element:form', retired whole in #9249, so the resolver no longer overlays it and a stored "
+ "string was read by nothing; the live form surface's submit copy is 'object-form''s "
+ "'submitText', localized at its own authoring site)",
apply(stack, emit) {
const stripFromData = (data: Record<string, unknown>, path: string): Record<string, unknown> => {
const pages = data.pages;
if (!isDict(pages)) return data;
let pagesChanged = false;
const nextPages: Record<string, unknown> = { ...pages };
for (const [pageName, page] of Object.entries(pages)) {
if (!isDict(page) || !isDict(page.components)) continue;
let componentsChanged = false;
const nextComponents: Record<string, unknown> = { ...page.components };
for (const [id, entry] of Object.entries(page.components)) {
if (!isDict(entry)) continue;
const stripped = stripKeys(entry, ['submitLabel'], emit, `${path}.pages.${pageName}.components.${id}`);
if (stripped === entry) continue;
nextComponents[id] = stripped;
componentsChanged = true;
}
if (!componentsChanged) continue;
nextPages[pageName] = { ...page, components: nextComponents };
pagesChanged = true;
}
return pagesChanged ? { ...data, pages: nextPages } : data;
};
return mapCollection(stack, 'translations', (entry, path) => {
// Bare data/item shape: the groups sit at the entry's top level.
let next = stripFromData(entry, path);
// Bundle shape: locale code → data. Judged structurally (a dict whose
// `pages` is a dict) rather than by key spelling — `LocaleSchema` is an
// open string, so the locale keys cannot be enumerated. A strip-only
// walk makes a false positive a no-op: it removes nothing unless the
// exact `pages.<name>.components.<id>.submitLabel` path is present.
for (const [locale, data] of Object.entries(next)) {
if (!isDict(data) || !isDict(data.pages)) continue;
const stripped = stripFromData(data, `${path}.${locale}`);
if (stripped === data) continue;
next = next === entry ? { ...entry } : next;
next[locale] = stripped;
}
return next;
});
},
fixture: {
before: {
translations: [
{
// The bundle shape `stack.translations` declares.
'zh-CN': {
pages: {
sales_home_page: {
components: {
new_lead_form: { submitLabel: '创建' },
// Live keys on a neighbor ride through untouched.
quick_create: { title: '快速新建' },
},
},
},
},
},
{
// The bare item shape stored `translation` rows replay through.
name: 'ja_jp',
locale: 'ja-JP',
pages: {
sales_home_page: {
components: { new_lead_form: { submitLabel: '作成' } },
},
},
},
],
},
after: {
translations: [
{
'zh-CN': {
pages: {
sales_home_page: {
components: {
new_lead_form: {},
quick_create: { title: '快速新建' },
},
},
},
},
},
{
name: 'ja_jp',
locale: 'ja-JP',
pages: {
sales_home_page: {
components: { new_lead_form: {} },
},
},
},
],
},
// One per stripped key instance: one in the bundle-shaped entry, one in
// the item-shaped entry. The emptied component bag stays — the conversion
// strips KEYS, and deleting the bag would be a second, unprescribed edit.
expectedNotices: 2,
},
};

/**
* `field.inlineColumns[]` / `field.relatedListColumns[]` — the mechanical half
* of the #9227 strict-element narrowing (protocol 18).
Expand DownExpand Up@@ -7752,6 +7885,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConv
metricFiltersRemoved,
recordHighlightsFieldIconRemoved,
mappingLookupParamsRemoved,
translationComponentSubmitLabelRemoved,
],
};

Expand Down
15 changes: 14 additions & 1 deletion packages/spec/src/migrations/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5143,7 +5143,19 @@ const step18: MigrationStep = {
'fails the row (`import_reference_not_found`), with or without the key. The eleven ' +
'alias spellings convert to guidance so every spelling lands on the prescription; the ' +
'mechanical conversion strips the four keys from stored sources (pure lossless ' +
'deletes — none ever had an effect to lose).',
'deletes — none ever had an effect to lose). ' +
'Finally, it retires the component-translation copy key ' +
'`pages.<name>.components.<id>.submitLabel` and its `submit` alias (#10926, ADR-0049; ' +
'maintainer ruling 2026-08-22): the face is measured, not mirrored — each copy key ' +
'exists because some component in `ComponentPropsMap` declares it — and ' +
'`submitLabel`\'s only declarer was `element:form`, retired whole above (#9249), so ' +
'the key had no declared component left to translate and the resolver overlay was ' +
'its only reader. Retire won over re-anchor because the live form surface ' +
'(`object-form`) speaks `submitText` (`I18nLabelSchema`), localizable at its own ' +
'authoring site; re-anchoring would have widened the face for one word. The ' +
'mechanical conversion strips the key from stored bundles and items (pure lossless ' +
'delete — nothing read it since #9249), at the acknowledged cost of dropping the ' +
'bespoke-component route for that one word.',
conversionIds: [
'field-malformed-scale-precision-removed',
'record-chatter-position-vocabulary',
Expand All@@ -5154,6 +5166,7 @@ const step18: MigrationStep = {
'metric-filters-removed',
'record-highlights-field-icon-removed',
'mapping-lookup-params-removed',
'translation-component-submit-label-removed',
],
semantic: [
// One file per entry under `entries/semantic/`, concatenated here sorted by
Expand Down
32 changes: 25 additions & 7 deletions packages/spec/src/system/i18n-resolver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1078,7 +1078,6 @@ describe('translatePage', () => {
kpi_revenue_won: { label: '已赢收入' },
ai_briefing: { title: '询问 AI 助手', description: '从右侧边缘打开助手面板。' },
lead_picker: { placeholder: '搜索线索…', emptyText: '暂无记录' },
new_lead_form: { submitLabel: '创建' },
},
},
},
Expand All@@ -1103,11 +1102,11 @@ describe('translatePage', () => {
{ type: 'element:kpi', id: 'kpi_revenue_won', properties: { label: 'Revenue (Won)', value: 42 } },
{ type: 'page:card', id: 'ai_briefing', properties: { title: 'Ask the AI Assistant', description: 'Open the assistant panel from the right edge…' } },
{ type: 'element:record_picker', id: 'lead_picker', properties: { object: 'lead', placeholder: 'Search leads…', emptyText: 'No records' } },
// Was `element:form` until #9249 retired that element whole; the
// resolver is id-addressed and type-agnostic, and the copy-key face
// documents bespoke component types as a legal route for the same
// vocabulary — so the `submitLabel` pin rides one of those, pending
// the #10926 carrier decision.
// Was `element:form` until #9249 retired that element whole, then a
// bespoke type carrying the `submitLabel` pin pending #10926. That
// ruling retired the key from the copy face, so the node now pins
// the NEGATIVE: a bespoke component's `submitLabel` is no longer
// overlaid, however the bundle spells it.
{ type: 'hotcrm:quick_form', id: 'new_lead_form', properties: { object: 'lead', submitLabel: 'Create' } },
{ type: 'page:card', id: 'untranslated_card', properties: { title: 'Still English' } },
],
Expand All@@ -1129,7 +1128,26 @@ describe('translatePage', () => {
const out = translatePage(homePage(), homeBundle, { locale: 'zh-CN' });
expect(byId(out, 'lead_picker').properties.placeholder).toBe('搜索线索…');
expect(byId(out, 'lead_picker').properties.emptyText).toBe('暂无记录');
expect(byId(out, 'new_lead_form').properties.submitLabel).toBe('创建');
});

it('no longer overlays `submitLabel` — the key retired from the copy face (#10926)', () => {
// Flipped, not deleted: this used to assert the overlay ('创建'). The
// schema now refuses `submitLabel` in a bundle, but the resolver is
// deliberately schema-independent (it reads whatever object it is
// handed — stored rows predating the retirement reach it via the raw
// sync path), so the negative is worth pinning on its own: an off-spec
// entry carrying the retired key must be IGNORED, not overlaid.
const offSpecBundle = {
'zh-CN': {
pages: {
sales_home_page: {
components: { new_lead_form: { submitLabel: '创建' } },
},
},
},
} as unknown as TranslationBundle;
const out = translatePage(homePage(), offSpecBundle, { locale: 'zh-CN' });
expect(byId(out, 'new_lead_form').properties.submitLabel).toBe('Create');
});

it('preserves non-copy properties alongside the overlay', () => {
Expand Down
7 changes: 4 additions & 3 deletions packages/spec/src/system/i18n-resolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -882,11 +882,12 @@ function lookupPageAttr(
* skeleton bundle. Two hand-maintained copies of this list would drift into the
* classic pair of failures — the extractor offering a key the resolver ignores,
* or omitting one it reads — so there is one list and both sides import it.
* `translation.zod.ts` declares the same six; `translation.test.ts` pins the
* two in agreement.
* `translation.zod.ts` declares the same five; `translation.test.ts` pins the
* two in agreement. (`submitLabel` retired with its only declarer,
* `element:form` — #9249 / #10926.)
*/
export const PAGE_COMPONENT_COPY_KEYS = [
'title', 'description', 'label', 'placeholder', 'emptyText', 'submitLabel',
'title', 'description', 'label', 'placeholder', 'emptyText',
] as const;

export type PageComponentCopyKey = typeof PAGE_COMPONENT_COPY_KEYS[number];
Expand Down
25 changes: 24 additions & 1 deletion packages/spec/src/system/translation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -831,11 +831,34 @@ describe('translation unknown-key strictness (#4001)', () => {
kpi_revenue_won: { label: 'Revenue (Won)' },
ai_briefing: { title: 'Ask the AI', description: 'Open the panel.' },
lead_picker: { placeholder: 'Search…', emptyText: 'No records' },
new_lead_form: { submitLabel: 'Create' },
});
expect(result.success).toBe(true);
});

it('refuses `submitLabel` with the retirement prescription (#10926)', () => {
// Flipped, not deleted: until #10926 this case pinned `submitLabel` as
// an accepted copy key (latterly on a bespoke component type, after
// #9249 retired `element:form`, its only spec-declared carrier). The
// maintainer ruled retire over re-anchor, so the same authored shape now
// pins the rejection — and the rejection must carry the upgrade.
const result = parse({ new_lead_form: { submitLabel: 'Create' } });
expect(result.success).toBe(false);
const message = result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message;
expect(message).toContain('`submitLabel` was removed in @objectstack/spec 17 (#10926');
expect(message).toContain('`submitText`');
});

it('refuses the retired `submit` alias spelling with the same story', () => {
// `submit` was an alias (rejection-path suggestion) pointing at
// `submitLabel`; with the target retired the alias converts to guidance
// so the spelling lands on the prescription instead of a dangling
// rename suggestion.
const result = parse({ new_lead_form: { submit: 'Create' } });
expect(result.success).toBe(false);
const message = result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message;
expect(message).toContain('`submit` was the alias spelling of `submitLabel`');
});

it('stays `.strict()` — an invented key is still refused', () => {
const result = parse({ quick_create: { tooltip: 'Create a record' } });
expect(result.success).toBe(false);
Expand Down
Loading
Loading