From 04ea21ec55a0de0194199db9de456017ea66f291 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 18:27:11 +0000 Subject: [PATCH] feat(spec): retire the component-translation submitLabel copy key (#10926, ADR-0049) Option A per the maintainer ruling on #10926 (2026-08-22): drop the key, the submit alias and the PAGE_COMPONENT_COPY_KEYS slot; strict-delete route with guidance tombstones (the face is strictObject); D2 conversion translation-component-submit-label-removed wired into the step-18 chain; pins flipped, not deleted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01T9cDbY2NBiVJWYx3BpWfH2 --- .../translation-submit-label-retired.md | 77 ++++++++++ packages/spec/src/conversions/registry.ts | 134 ++++++++++++++++++ packages/spec/src/migrations/registry.ts | 15 +- .../spec/src/system/i18n-resolver.test.ts | 32 ++++- packages/spec/src/system/i18n-resolver.ts | 7 +- packages/spec/src/system/translation.test.ts | 25 +++- packages/spec/src/system/translation.zod.ts | 29 +++- 7 files changed, 304 insertions(+), 15 deletions(-) create mode 100644 .changeset/translation-submit-label-retired.md diff --git a/.changeset/translation-submit-label-retired.md b/.changeset/translation-submit-label-retired.md new file mode 100644 index 0000000000..5dfa483a2a --- /dev/null +++ b/.changeset/translation-submit-label-retired.md @@ -0,0 +1,77 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): retire the component-translation `submitLabel` copy key (#10926, ADR-0049) + + + +**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..components.` 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..components.` +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': '创建' }, + }, +} +``` diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 0e5eeb8f57..d6208afc08 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7176,6 +7176,139 @@ const elementFormRemoved: MetadataConversion = { }, }; +/** + * `translation.pages..components..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, path: string): Record => { + const pages = data.pages; + if (!isDict(pages)) return data; + let pagesChanged = false; + const nextPages: Record = { ...pages }; + for (const [pageName, page] of Object.entries(pages)) { + if (!isDict(page) || !isDict(page.components)) continue; + let componentsChanged = false; + const nextComponents: Record = { ...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..components..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). @@ -7752,6 +7885,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly.components..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', @@ -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 diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index bc9d1dfc34..e9b6d97e60 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -1078,7 +1078,6 @@ describe('translatePage', () => { kpi_revenue_won: { label: '已赢收入' }, ai_briefing: { title: '询问 AI 助手', description: '从右侧边缘打开助手面板。' }, lead_picker: { placeholder: '搜索线索…', emptyText: '暂无记录' }, - new_lead_form: { submitLabel: '创建' }, }, }, }, @@ -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' } }, ], @@ -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', () => { diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 2b3263a2c2..dea0881eb4 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -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]; diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index d2e0999ed4..4edf914381 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -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); diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index dca3c63ed4..f0a2953b14 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -573,7 +573,17 @@ const translationDataShape = () => ({ * | `description` | `element:text_input` | * | `placeholder` | `element:record_picker`, `element:text_input` | * | `emptyText` | `element:record_picker` | - * | `submitLabel` | none since #9249 retired `element:form`, its only declarer — kept pending the #10926 decision (retire vs re-anchor); the bespoke-component route below still speaks it | + * + * `submitLabel` LEFT this face in @objectstack/spec 17 (#10926, ADR-0049): + * its only declarer, `element:form`, retired whole (#9249), which under + * this table's own measured-not-mirrored rule left the key with no + * declared component to translate. The maintainer ruled retire over + * re-anchor — the live form surface's submit copy is `object-form`'s + * `submitText` (`I18nLabelSchema`), localizable at its own authoring site, + * and adding it here would be a face widening. The rejection carries the + * prescription (`guidance` below); the + * `translation-component-submit-label-removed` conversion strips the key + * from stored bundles. * * Two deliberate exclusions, both of which a mirror of the issue's proposed * shape would have got wrong: @@ -608,14 +618,27 @@ const translationDataShape = () => ({ // A page component's headline is `title`; the PAGE's is `label`. Same // document one level apart with opposite spellings — the same trap // `dashboards.widgets` names, so it gets the same alias table. - aliases: { name: 'title', heading: 'title', text: 'label', caption: 'description', help: 'description', helpText: 'description', empty: 'emptyText', emptyState: 'emptyText', submit: 'submitLabel' }, + aliases: { name: 'title', heading: 'title', text: 'label', caption: 'description', help: 'description', helpText: 'description', empty: 'emptyText', emptyState: 'emptyText' }, + guidance: { + submitLabel: + '`submitLabel` was removed in @objectstack/spec 17 (#10926, ADR-0049) — its only ' + + 'spec-declared carrier, `element:form`, retired whole (#9249), so no component in ' + + '`ComponentPropsMap` declares it and the resolver no longer overlays it. The live form ' + + "surface's submit copy is `object-form`'s `submitText` (`I18nLabelSchema`), localizable " + + 'at its own authoring site. Delete the key. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + submit: + '`submit` was the alias spelling of `submitLabel`, which was removed in ' + + '@objectstack/spec 17 (#10926, ADR-0049) — no component in `ComponentPropsMap` ' + + 'declares a submit label since `element:form` retired whole (#9249). Delete the key; ' + + "the live form surface's submit copy is `object-form`'s `submitText`.", + }, }, { title: z.string().optional().describe('Translated component title (`page:card`, `record:related_list`, …)'), description: z.string().optional().describe('Translated component description / supporting copy'), label: z.string().optional().describe("Translated component label — overlays the component's own `label` when it declares one, else `properties.label`"), placeholder: z.string().optional().describe('Translated input placeholder (`element:record_picker`, `element:text_input`)'), emptyText: z.string().optional().describe('Translated empty-state text (`element:record_picker`)'), - submitLabel: z.string().optional().describe('Translated submit button label — no spec-declared carrier since `element:form` retired (#9249); still the route for a bespoke component speaking the same vocabulary, pending #10926'), })).optional().describe('Per-component copy keyed by component id (`PageComponentSchema.id`)'), })).optional().describe('Page translations keyed by page name'),