From 757aadb573e8e221cc4393324c459fd78c04e75a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:36:44 +0000 Subject: [PATCH 1/4] =?UTF-8?q?test(spec,lint,cli):=20repro=20=E2=80=94=20?= =?UTF-8?q?a=20page:tabs=20item=20label=20has=20no=20translation=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pack key pages.PAGE.components.TABS.items.VALUE.label is refused by the schema, never applied by translatePage, not judged by objectstack validate and not offered by the extractor. Tests first; the fix follows. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .../test/i18n-page-tab-item-coverage.test.ts | 111 ++++++++++++++ .../validate-translation-references.test.ts | 114 ++++++++++++++ .../spec/src/system/i18n-resolver.test.ts | 145 +++++++++++++++++- packages/spec/src/system/translation.test.ts | 27 ++++ 4 files changed, 391 insertions(+), 6 deletions(-) create mode 100644 packages/cli/test/i18n-page-tab-item-coverage.test.ts diff --git a/packages/cli/test/i18n-page-tab-item-coverage.test.ts b/packages/cli/test/i18n-page-tab-item-coverage.test.ts new file mode 100644 index 00000000000..a2444715afe --- /dev/null +++ b/packages/cli/test/i18n-page-tab-item-coverage.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// A `page:tabs` item's `label` had no translation key. `pages..components` +// is keyed by component id, a tab item is not a component and carries no id, +// and `translatePage` never read `items[].label`, so a localized page kept its +// tab strip in the authoring language (measured on HotCRM's +// `sales_home_page`: three English tabs under a translated header). +// +// The key is `pages..components..items..label`: the tabs +// component's own id, then the item's `value`, the stable `?tab=` token the +// renderer already reads. Unlike a key with no reader, `translatePage` reads +// this one, so the extractor offers the slot and coverage demands it. An item +// without a `value` gets no key: its renderer token is index-derived, and an +// array index is never a bundle key (the identity-first direction of the +// #14749 ruling). + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage'; +import { TranslationDataSchema, translatePage } from '@objectstack/spec/system'; + +const homePage = () => ({ + name: 'sales_home_page', + label: 'Sales Home', + regions: [{ + name: 'main', + components: [{ + type: 'page:tabs', + id: 'home_tabs', + properties: { + items: [ + { label: 'My Leads', value: 'my_leads', children: [{ type: 'record:related_list', id: 'home_my_leads', properties: { title: 'Leads' } }] }, + { label: { en: 'My Open Deals', 'zh-CN': '我的商机' }, value: 'my_deals', children: [] }, + // No `value`: no key, whatever its label says. + { label: 'My Open Tasks', children: [] }, + ] as any[], + }, + }], + }], +}); + +/** Every `…components..items.*` path the walker emits, as dot-paths. */ +const itemEntries = (config: any) => + collectExpectedEntries(config).filter((e) => e.path[0] === 'pages' && e.path[4] === 'items'); + +describe('the extractor scaffolds a key for every valued tab item', () => { + it('emits `pages..components..items..label`, and nothing for an item without a `value`', () => { + expect(itemEntries({ pages: [homePage()] }).map((e) => e.path.join('.'))).toEqual([ + 'pages.sales_home_page.components.home_tabs.items.my_leads.label', + 'pages.sales_home_page.components.home_tabs.items.my_deals.label', + ]); + }); + + it('seeds a plain label, and records an inline locale map as authored with no seed', () => { + const [leads, deals] = itemEntries({ pages: [homePage()] }); + expect(leads.sourceValue).toBe('My Leads'); + expect(deals.sourceValue).toBeUndefined(); + expect(deals.inlineLocales).toEqual({ en: 'My Open Deals', 'zh-CN': '我的商机' }); + }); + + it('CONTROL: the panel children keep their component keys', () => { + const paths = collectExpectedEntries({ pages: [homePage()] }).map((e) => e.path.join('.')); + expect(paths).toContain('pages.sales_home_page.components.home_my_leads.title'); + }); + + it('emits nothing for `items` on a component that is not `page:tabs`', () => { + const page = homePage(); + (page.regions[0].components[0] as any).type = 'page:accordion'; + expect(itemEntries({ pages: [page] })).toEqual([]); + }); + + it('emits nothing for a tabs component with no id', () => { + const page = homePage(); + delete (page.regions[0].components[0] as any).id; + expect(itemEntries({ pages: [page] })).toEqual([]); + }); +}); + +describe('the emitted key is the key the schema declares and the resolver reads', () => { + it('`TranslationDataSchema` accepts a bundle written at the extracted path, and `translatePage` applies it', () => { + const out = extractTranslations({ pages: [homePage()] }, { locales: ['zh-CN'] }); + const zh = out.bundles['zh-CN'] as any; + expect(zh?.pages?.sales_home_page?.components?.home_tabs?.items?.my_leads).toBeDefined(); + + const data = { pages: { sales_home_page: { components: { home_tabs: { items: { my_leads: { label: '我的线索' } } } } } } }; + expect(TranslationDataSchema.safeParse(data).success).toBe(true); + const translated: any = translatePage(homePage(), { 'zh-CN': data } as any, { locale: 'zh-CN' }); + expect(translated.regions[0].components[0].properties.items[0].label).toBe('我的线索'); + }); +}); + +describe('coverage', () => { + const config = (items: Record) => ({ + pages: [homePage()], + i18n: { supportedLocales: ['en', 'zh-CN'] }, + translations: [{ 'zh-CN': { pages: { sales_home_page: { components: { home_tabs: { items } } } } } }], + }); + + const itemIssues = (report: { issues: Array<{ key: string }> }) => + report.issues.filter((i) => i.key.includes('.items.')).map((i) => i.key); + + it('reports an untranslated tab label', () => { + expect(itemIssues(computeI18nCoverage(config({})))).toEqual([ + 'pages.sales_home_page.components.home_tabs.items.my_leads.label', + ]); + }); + + it('stays quiet once the pack keys it — and about the inline-map item that covers its locale', () => { + expect(itemIssues(computeI18nCoverage(config({ my_leads: { label: '我的线索' } })))).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 455ef802895..eb718b64c16 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -2347,6 +2347,113 @@ describe('validateTranslationReferences — flow approval steps (`approvals..items..label`)', () => { + /** + * HotCRM's measured shape: a home page whose `page:tabs` holds the three + * tabs, beside an accordion and a related list, so each verdict below is + * judged against the same metadata as the clean run and differs only in + * the bundle. + */ + const homeStack = { + pages: [ + { + name: 'sales_home_page', + label: 'Sales Home', + regions: [{ + name: 'main', + components: [ + { + type: 'page:tabs', + id: 'home_tabs', + properties: { + items: [ + { label: 'My Leads', value: 'my_leads', children: [{ type: 'record:related_list', id: 'home_my_leads', properties: { title: 'Leads' } }] }, + { label: 'My Open Deals', value: 'my_deals', children: [] }, + { label: 'My Open Tasks', children: [] }, + ], + }, + }, + { type: 'page:accordion', id: 'faq', properties: { items: [{ label: 'Pricing', children: [] }] } }, + ], + }], + }, + ], + }; + const bundle = (pages: unknown) => ({ ...homeStack, translations: [{ 'zh-CN': { pages } }] }); + const tabs = (items: unknown, extra: Record = {}) => + bundle({ sales_home_page: { components: { home_tabs: { items }, ...extra } } }); + + it('reports nothing for the label of a valued tab item', () => { + expect(validateTranslationReferences(tabs({ my_leads: { label: '我的线索' }, my_deals: { label: '我的商机' } }))).toEqual([]); + }); + + it('controls: the component keys of the same page still resolve beside it, judged by nothing new', () => { + expect( + validateTranslationReferences( + tabs({ my_leads: { label: '我的线索' } }, { home_my_leads: { title: '线索' }, faq: { label: '常见问题' } }), + ), + ).toEqual([]); + }); + + it('refuses an item key over an UNKNOWN value, with the nearest tab value offered', () => { + const findings = validateTranslationReferences(tabs({ my_lead: { label: 'x' } })); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN); + expect(findings[0].severity).toBe('error'); + expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.sales_home_page.components.home_tabs.items.my_lead'); + expect(findings[0].message).toBe( + 'Translations are keyed to tab "my_lead", which `page:tabs` "home_tabs" on page "sales_home_page" gives ' + + 'no item as its `value`, so the tab keeps its source-locale label. Did you mean "my_leads"?', + ); + expect(findings[0].hint).toBe( + "Tab-item translations are keyed by the item's `value`; give the item a `value` if it has none.", + ); + }); + + it('refuses an item key over an index token, which no key ever reaches', () => { + const findings = validateTranslationReferences(tabs({ 'tab-2': { label: 'x' } })); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.sales_home_page.components.home_tabs.items.tab-2'); + }); + + it('refuses `items` on a `page:accordion`, pointing at the inline locale map', () => { + const findings = validateTranslationReferences(bundle({ sales_home_page: { components: { faq: { items: { pricing: { label: 'x' } } } } } })); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.sales_home_page.components.faq.items'); + expect(findings[0].message).toBe( + 'Translations carry tab items for "faq", which page "sales_home_page" declares as a `page:accordion`, ' + + 'whose panels have no stable key, so nothing resolves them.', + ); + expect(findings[0].hint).toBe('Localize an accordion panel label in place, as an inline locale map on `items[].label`.'); + }); + + it('refuses `items` on a component of another type, by name', () => { + const findings = validateTranslationReferences( + bundle({ sales_home_page: { components: { home_my_leads: { items: { leads: { label: 'x' } } } } } }), + ); + expect(findings).toHaveLength(1); + expect(findings[0].message).toBe( + 'Translations carry tab items for "home_my_leads", which page "sales_home_page" declares as a ' + + '`record:related_list`, not a `page:tabs`, so nothing resolves them.', + ); + expect(findings[0].hint).toBe('Tab-item translations go under the `id` of a `page:tabs` component.'); + }); + + it('refuses `items` under a component id the page does not declare', () => { + const findings = validateTranslationReferences(bundle({ sales_home_page: { components: { home_tab: { items: { my_leads: { label: 'x' } } } } } })); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.sales_home_page.components.home_tab.items'); + expect(findings[0].message).toBe( + 'Translations carry tab items for "home_tab", a component id page "sales_home_page" does not declare, ' + + 'so nothing resolves them. Did you mean "home_tabs"?', + ); + }); + + it('control: a page this stack does not declare is not judged by this check', () => { + expect(validateTranslationReferences(bundle({ other_page: { components: { t: { items: { a: { label: 'x' } } } } } }))).toEqual([]); + }); +}); + describe('validateTranslationReferences — flow toasts and screen-field options (#22507)', () => { /** * hotclm's measured shape, trimmed: a screen flow that authors a completion @@ -4035,6 +4142,10 @@ describe('[#22161] one-line verdicts — translation-target-unknown, translation '`resultDialog.fields` does not declare', 'declares no `fields` (it renders the whole response as JSON)', 'Translations are keyed to picklist "', + 'gives no item as its `value`', + 'whose panels have no stable key', + 'not a `page:tabs`, so nothing resolves them', + 'a component id page "', ]; const OPTION_ARMS = [ 'which declares no `options` at all (field type', @@ -4186,6 +4297,9 @@ describe('[#22161] one-line fixes — translation-target-unknown', () => { 'Move the `resultDialog` translations under the action', "Match the key to a declared field's literal `path`", "Match the key to a picklist's `name`", + "Tab-item translations are keyed by the item's `value`", + 'Localize an accordion panel label in place', + 'Tab-item translations go under the `id` of a `page:tabs` component', ]; it('every `fix:` line the cases above fired is one line of at most 200 characters', () => { diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index a9fbcdde42a..c5af37ec360 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -2499,6 +2499,42 @@ describe('walkAddressedPageComponents — the one addressed-component walk, shar expect(doc.regions[0].components[0].properties.items[0].children[0].properties.title).toBe('Tab kid'); expect(doc.slots.header.properties.title).toBe('Header'); }); + + it("keeps a visitor's edit to a panel's OWN keys, and still descends the AUTHORED panel children", () => { + const doc: any = { + regions: [{ + name: 'main', + components: [{ + id: 'tabs', + type: 'page:tabs', + properties: { + items: [ + { label: 'One', value: 'one', children: [{ id: 'kid', type: 'object-metric', properties: { title: 'Kid' } }] }, + { label: 'Two', value: 'two' }, + ], + }, + }], + }], + }; + const { regions } = walkAddressedPageComponents(doc, (component, { id }) => (id !== 'tabs' + ? { ...component, properties: { ...component.properties, title: `visited:${id}` } } + : { + ...component, + properties: { + ...component.properties, + // The visitor relabels each panel and drops its children; the walk + // owns the descent, so the authored children come back visited. + items: component.properties.items.map((item: any) => ({ label: `relabelled:${item.value}`, value: item.value })), + }, + })) as any; + const items = regions[0].components[0].properties.items; + expect(items[0]).toEqual({ + label: 'relabelled:one', value: 'one', + children: [{ id: 'kid', type: 'object-metric', properties: { title: 'visited:kid' } }], + }); + expect(items[1]).toEqual({ label: 'relabelled:two', value: 'two' }); + expect(doc.regions[0].components[0].properties.items[0].label).toBe('One'); + }); }); // ──────────────────────────────────────────────────────────────────────────── @@ -2634,12 +2670,10 @@ describe('translatePage — slotted page roots and tab panels', () => { expect(other.slots).toEqual(contractPage().slots); }); - it('BOUNDARY: a tab panel `label` (`items[].label`) has no bundle key — its route is the inline locale map', () => { - // Recorded, not endorsed. `PageTabsProps.items[].label` is an - // `I18nLabelSchema`, so a tab strip is localisable at its authoring site; - // a bundle key for the panel (which carries no id and whose `value` is - // optional) is a naming decision this change does not make. If a key is - // added, this pin is the one to flip. + it('CONTROL: with no `items` entry in the bundle, an inline locale map on a tab label is left intact', () => { + // `PageTabsProps.items[].label` is an `I18nLabelSchema`, so a tab strip + // is also localisable at its authoring site. A bundle that does not key + // the item must leave that map alone rather than flatten it. const page = contractPage(); (page.slots.tabs.properties.items[0] as any).label = { en: 'Parties', 'zh-CN': '当事方' }; const out = translatePage(page, bundle, { locale: 'zh-CN' }); @@ -2648,6 +2682,105 @@ describe('translatePage — slotted page roots and tab panels', () => { }); }); +// ──────────────────────────────────────────────────────────────────────────── +// `pages..components..items..label` — a tab item's label +// ──────────────────────────────────────────────────────────────────────────── + +describe('translatePage — `page:tabs` item labels, keyed by the item `value`', () => { + /** + * HotCRM's measured shape: a home page whose `page:tabs` holds three tabs, + * each panel embedding a list. The pack translated the lists (keyed by + * their component ids) and had no key for the tab labels. + */ + const homePage = () => ({ + name: 'sales_home_page', + label: 'Sales Home', + regions: [{ + name: 'main', + components: [{ + type: 'page:tabs', + id: 'home_tabs', + properties: { + items: [ + { label: 'My Leads', value: 'my_leads', icon: 'user', children: [{ type: 'record:related_list', id: 'home_my_leads', properties: { title: 'Leads' } }] }, + { label: 'My Open Deals', value: 'my_deals', children: [{ type: 'record:related_list', id: 'home_my_deals', properties: { title: 'Deals' } }] }, + // No `value`: the renderer derives an index token, so no key reaches it. + { label: 'My Open Tasks', children: [] }, + ] as any[], + }, + }], + }], + }); + + const pack = (items: Record, extra: Record = {}): TranslationBundle => ({ + 'zh-CN': { + pages: { + sales_home_page: { + components: { + home_tabs: { items }, + home_my_leads: { title: '线索' }, + ...extra, + }, + }, + }, + } as any, + }); + + const items = (out: any): any[] => out.regions[0].components[0].properties.items; + + it('translates a tab label from its item key', () => { + const out = translatePage(homePage(), pack({ my_leads: { label: '我的线索' }, my_deals: { label: '我的商机' } }), { locale: 'zh-CN' }); + expect(items(out).map((i) => i.label)).toEqual(['我的线索', '我的商机', 'My Open Tasks']); + }); + + it('CONTROL: the panel children keep their own component keys, and the item keeps its other keys', () => { + const out = translatePage(homePage(), pack({ my_leads: { label: '我的线索' } }), { locale: 'zh-CN' }); + expect(items(out)[0].children[0].properties.title).toBe('线索'); + expect(items(out)[1].children[0].properties.title).toBe('Deals'); + expect(items(out)[0]).toMatchObject({ value: 'my_leads', icon: 'user' }); + }); + + it('a bundle entry wins over an inline locale map; an item the bundle does not key keeps its map', () => { + const page = homePage(); + page.regions[0].components[0].properties.items[0].label = { en: 'My Leads', 'zh-CN': '线索(内联)' }; + page.regions[0].components[0].properties.items[1].label = { en: 'My Open Deals', 'zh-CN': '商机(内联)' }; + const out = translatePage(page, pack({ my_leads: { label: '我的线索' } }), { locale: 'zh-CN' }); + expect(items(out)[0].label).toBe('我的线索'); + expect(items(out)[1].label).toEqual({ en: 'My Open Deals', 'zh-CN': '商机(内联)' }); + }); + + it('falls back along the locale chain, key by key', () => { + const bundle: TranslationBundle = { + ...pack({ my_leads: { label: '我的线索' } }), + en: { pages: { sales_home_page: { components: { home_tabs: { items: { my_deals: { label: 'Deals (en)' } } } } } } } as any, + }; + const out = translatePage(homePage(), bundle, { locale: 'zh-CN', fallbackChain: ['en'] }); + expect(items(out).map((i) => i.label)).toEqual(['我的线索', 'Deals (en)', 'My Open Tasks']); + }); + + it('reaches no item without a `value` — an index token is never a key', () => { + const out = translatePage(homePage(), pack({ 'tab-2': { label: 'MUST-NOT-APPLY' } }), { locale: 'zh-CN' }); + expect(items(out)[2].label).toBe('My Open Tasks'); + }); + + it('CONTROL: the same `items` key on a component that is not `page:tabs` translates nothing', () => { + const page = homePage(); + (page.regions[0].components[0] as any).type = 'page:accordion'; + const out = translatePage(page, pack({ my_leads: { label: 'MUST-NOT-APPLY' } }), { locale: 'zh-CN' }); + expect(items(out)[0].label).toBe('My Leads'); + }); + + it('CONTROL: the tabs component\'s own `label` key and its item keys compose', () => { + const page = homePage(); + (page.regions[0].components[0].properties as any).label = 'Work'; + const bundle = pack({ my_leads: { label: '我的线索' } }); + (bundle['zh-CN'] as any).pages.sales_home_page.components.home_tabs.label = '工作'; + const out: any = translatePage(page, bundle, { locale: 'zh-CN' }); + expect(out.regions[0].components[0].properties.label).toBe('工作'); + expect(items(out)[0].label).toBe('我的线索'); + }); +}); + // ──────────────────────────────────────────────────────────────────────────── // #5377 — filter-preset tab labels (`objects.._tabs..label`) // ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 0cff6ae7536..04f47d65c10 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -986,6 +986,33 @@ describe('translation unknown-key strictness', () => { expect(declared).toEqual([...PAGE_COMPONENT_COPY_KEYS].sort()); }); + it('accepts a `page:tabs` item label keyed by the item `value` under the tabs component id', () => { + const result = parse({ + home_tabs: { label: 'Work', items: { my_leads: { label: 'My Leads' }, my_deals: { label: 'My Open Deals' } } }, + }); + expect(result.success).toBe(true); + }); + + it('keeps the tab-item entry closed — `label` is its one key', () => { + const result = parse({ home_tabs: { items: { my_leads: { label: 'My Leads', icon: 'user' } } } }); + expect(result.success).toBe(false); + const issue = result.error?.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue?.message).toContain('this page tab item translation'); + expect(issue?.path).toEqual(['pages', 'sales_home_page', 'components', 'home_tabs', 'items', 'my_leads']); + }); + + it('names `title` and `name` on a tab item as renames onto `label`', () => { + const result = parse({ home_tabs: { items: { my_leads: { title: 'My Leads' } } } }); + expect(result.success).toBe(false); + expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message).toContain('`title` → `label`'); + }); + + it('names `tabs` on a component entry as the rename onto `items`', () => { + const result = parse({ home_tabs: { tabs: { my_leads: { label: 'My Leads' } } } }); + expect(result.success).toBe(false); + expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message).toContain('`tabs` → `items`'); + }); + it('carries the `label`/`title` trap alias the dashboard widget face carries', () => { // A page's headline is `label`; a component's is `title`. One level // apart, opposite spellings — the same trap `dashboards.widgets` names. From 818b89e3514f7ca441baf063ac15ccc50d34c1a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:39:17 +0000 Subject: [PATCH 2/4] =?UTF-8?q?wip:=20tab=20item=20key=20=E2=80=94=20schem?= =?UTF-8?q?a,=20resolver,=20lint,=20extractor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- packages/cli/src/utils/i18n-extract.ts | 19 ++ .../src/validate-translation-references.ts | 117 ++++++++++++ packages/spec/src/system/i18n-resolver.ts | 166 ++++++++++++++++-- packages/spec/src/system/translation.zod.ts | 39 +++- 4 files changed, 323 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 72b2eb71860..2e952b1f2af 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -103,6 +103,7 @@ * pages..label / .description * pages..title / .subtitle (from the page's `page:header` component) * pages..components.. (per-component copy, #6080) + * pages..components..items..label (a `page:tabs` item label) * flows..label * flows..screens..title (#7646 / #11287) * flows..screens..description (#22507) @@ -137,7 +138,9 @@ import { FLOW_SCREEN_COPY_KEYS, FLOW_SCREEN_FIELD_COPY_KEYS, FLOW_TERMINAL_MESSAGE_KEYS, + PAGE_TABS_COMPONENT_TYPE, globalFilterKey, + pageTabItemKey, walkAddressedPageComponents, } from '@objectstack/spec/system'; import { FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation'; @@ -1169,6 +1172,22 @@ function emitPageComponentCopy(out: ExpectedEntry[], page: any, name: string): v // records nothing (#14749). pushEntry(out, ['pages', name, 'components', id as string, key], value, 'page'); } + // A `page:tabs` component's tab labels, keyed by each item's `value` + // under the tabs component's id — the key `translatePage` reads. An + // item without a `value` gets no slot: its renderer token is + // index-derived, and an array index is never a bundle key (#14749). + if (component.type === PAGE_TABS_COMPONENT_TYPE && Array.isArray(props.items)) { + for (const item of props.items as unknown[]) { + const value = pageTabItemKey(item); + if (value === undefined) continue; + pushEntry( + out, + ['pages', name, 'components', id as string, 'items', value, 'label'], + (item as Record).label, + 'page', + ); + } + } } return component; }); diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index e8329170008..d12b608d809 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -209,8 +209,11 @@ import { expandViewContainer } from '@objectstack/spec'; import { APPROVAL_NODE_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation'; import { FLOW_TERMINAL_MESSAGE_KEYS, + PAGE_TABS_COMPONENT_TYPE, hasPlatformObjectPrefix, isPlatformProvidedObjectName, + pageTabItemKey, + walkAddressedPageComponents, } from '@objectstack/spec/system'; import { walkFlowNodes } from './flow-walk.js'; import { packagesOf, recordsOf, suggestName } from './object-graph.js'; @@ -236,6 +239,15 @@ const SCREEN_NODE_TYPE = 'screen'; * `'refused'` a member of the schema's `outcome` enum, neither exported by name. */ const END_NODE_TYPE = 'end'; + +/** + * The component a misplaced `components..items` key most often sits on: a + * `page:accordion` declares `items[].label` too, but its panels carry no + * author-settable identity (the renderer derives `panel-`), so the + * verdict points at the inline locale map rather than at a key. A local + * const for {@link SCREEN_NODE_TYPE}'s reason. + */ +const PAGE_ACCORDION_COMPONENT_TYPE = 'page:accordion'; const REFUSED_OUTCOME = 'refused'; export const TRANSLATION_TARGET_UNKNOWN = 'translation-target-unknown'; @@ -461,6 +473,22 @@ interface Universe { * rung 2b of the object ladder applied to a list (#23566). */ extendedPicklists: Set; + /** + * Page name (`Page.name`) → every component id the page ADDRESSES + * (`pages..components.`), with that component's type and, for a + * `page:tabs`, the item `value`s a `components..items.` key may + * name. Built on spec's `walkAddressedPageComponents`, the walk + * `translatePage` itself runs, so an id here is the component the resolver + * reads the entry for. + */ + pages: Map>; +} + +/** What the `items` check knows about one addressed page component. */ +interface PageComponentFacts { + type: string | undefined; + /** The `page:tabs` item `value`s (`pageTabItemKey`); empty for any other type. */ + tabValues: Set; } function emptyFacts(): ObjectFacts { @@ -1239,6 +1267,38 @@ function buildUniverse(stack: AnyRec): Universe { for (const page of recordsOf(stack.pages)) collectPageRecord(page); for (const page of artifactProvidedRecords(stack, 'pages')) collectPageRecord(page); + // ── Pages: the components `pages..components.` addresses, for the + // `page:tabs` item key (`components..items..label`) ── + // + // Walked with spec's `walkAddressedPageComponents`, NOT lint's + // `walkPageComponents`: the key is read by `translatePage`, which runs that + // walk (roots, descent, collision arbitration), so the component an id + // names here is the one the resolver applies the entry to. Where both this + // stack and a sibling package declare a page name, the declaration in hand + // keeps the slot — the dashboards rung's `ownDeclaration` decision. + const pages = new Map>(); + const collectPageComponents = (page: AnyRec, { ownDeclaration }: { ownDeclaration: boolean }) => { + const pageName = strName(page.name); + if (!pageName) return; + if (!ownDeclaration && pages.has(pageName)) return; + const components = new Map(); + walkAddressedPageComponents(page as Parameters[0], (component, { id, addressed }) => { + if (!addressed || id === undefined) return component; + const tabValues = new Set(); + if (component.type === PAGE_TABS_COMPONENT_TYPE && isRec(component.properties)) { + for (const item of Array.isArray(component.properties.items) ? component.properties.items : []) { + const value = pageTabItemKey(item); + if (value !== undefined) tabValues.add(value); + } + } + components.set(id, { type: strName(component.type), tabValues }); + return component; + }); + pages.set(pageName, components); + }; + for (const page of recordsOf(stack.pages)) collectPageComponents(page, { ownDeclaration: true }); + for (const page of artifactProvidedRecords(stack, 'pages')) collectPageComponents(page, { ownDeclaration: false }); + // ── Actions: object-bound ones join their object; the rest are global ── // // …from the stack in hand and from the artifact's other entries (#19349). @@ -1539,6 +1599,7 @@ function buildUniverse(stack: AnyRec): Universe { actionOwners, picklists, extendedPicklists, + pages, }; } @@ -1873,6 +1934,62 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind } } + // ── pages..components..items. ──────────────────── + // + // Only the `items` member is judged here: it is the one page key whose + // target this check can see whole — a declared tabs component's item + // `value`s. A page this stack does not declare is skipped (no rung + // judges `pages.` itself yet), and so is every other member. + for (const [pageName, rawPage] of Object.entries(asRecord(rawData.pages))) { + const page = universe.pages.get(pageName); + if (!page || !isRec(rawPage)) continue; + const pagePath = `${base}.pages.${pageName}`; + for (const [componentId, rawComponent] of Object.entries(asRecord(rawPage.components))) { + if (!isRec(rawComponent) || rawComponent.items === undefined) continue; + const itemsPath = `${pagePath}.components.${componentId}.items`; + const where = `${inLocale} · page "${pageName}" · component "${componentId}"`; + const tabsHint = 'Tab-item translations go under the `id` of a `page:tabs` component.'; + const component = page.get(componentId); + if (!component) { + orphan( + where, + itemsPath, + `Translations carry tab items for "${componentId}", a component id page "${pageName}" does not ` + + `declare, so nothing resolves them.` + + suggest(componentId, [...page].filter(([, c]) => c.type === PAGE_TABS_COMPONENT_TYPE).map(([id]) => id)), + tabsHint, + ); + continue; + } + if (component.type !== PAGE_TABS_COMPONENT_TYPE) { + orphan( + where, + itemsPath, + component.type === PAGE_ACCORDION_COMPONENT_TYPE + ? `Translations carry tab items for "${componentId}", which page "${pageName}" declares as a ` + + `\`page:accordion\`, whose panels have no stable key, so nothing resolves them.` + : `Translations carry tab items for "${componentId}", which page "${pageName}" declares as a ` + + `\`${component.type ?? 'component'}\`, not a \`page:tabs\`, so nothing resolves them.`, + component.type === PAGE_ACCORDION_COMPONENT_TYPE + ? 'Localize an accordion panel label in place, as an inline locale map on `items[].label`.' + : tabsHint, + ); + continue; + } + for (const value of Object.keys(asRecord(rawComponent.items))) { + if (component.tabValues.has(value)) continue; + orphan( + `${where} · tab "${value}"`, + `${itemsPath}.${value}`, + `Translations are keyed to tab "${value}", which \`page:tabs\` "${componentId}" on page ` + + `"${pageName}" gives no item as its \`value\`, so the tab keeps its source-locale label.` + + suggest(value, component.tabValues), + "Tab-item translations are keyed by the item's `value`; give the item a `value` if it has none.", + ); + } + } + } + // ── picklists.[.options.] (#23566) ─────────────────── for (const [listName, rawList] of Object.entries(asRecord(rawData.picklists))) { const listPath = `${base}.picklists.${listName}`; diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 998bc298868..d7f2179ef0a 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -2152,6 +2152,79 @@ function lookupPageComponentCopy( return found; } +/** + * The one component type whose `items[]` labels + * `pages..components..items..label` addresses. + * + * Exported for {@link PAGE_COMPONENT_COPY_KEYS}' reason: `translatePage`, + * the CLI extractor and `objectstack validate` must agree on which component + * carries the key, and one constant is how they cannot drift. + */ +export const PAGE_TABS_COMPONENT_TYPE = 'page:tabs'; + +/** + * The key a `page:tabs` item is addressed by under + * `pages..components..items` — its authored `value` + * (`PageTabsProps.items[].value`, the stable `?tab=` token) — or `undefined` + * for an item that authors none. Such an item has NO key: the renderer's + * fallback token is index-derived (`tab-`), and an array index is never a + * bundle key, because reordering two tabs would then silently swap their + * translations. + * + * Exported so the resolver, the CLI extractor and the lint check address an + * item the same way. + */ +export function pageTabItemKey(item: unknown): string | undefined { + if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined; + const value = (item as Record).value; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** + * The translated label of one `page:tabs` item, resolved across the locale + * chain — `pages..components..items..label`. + */ +function lookupPageTabItemLabel( + bundle: TranslationBundle | undefined, + name: string, + id: string, + value: string, + opts?: ResolveOptions, +): string | undefined { + if (!bundle) return undefined; + for (const code of localeChain(opts)) { + const candidate = pickData(bundle, code)?.pages?.[name]?.components?.[id]?.items?.[value]?.label; + if (typeof candidate === 'string' && candidate.length > 0) return candidate; + } + return undefined; +} + +/** + * Overlay a `page:tabs` component's item labels from the bundle. Returns the + * component unchanged when no item resolves, so a tab strip the bundle does + * not cover — an inline locale map included — is left exactly as authored. + */ +function translatePageTabItems( + component: PageComponentLike, + bundle: TranslationBundle | undefined, + name: string, + id: string, + opts?: ResolveOptions, +): PageComponentLike { + const props = component.properties; + if (!props || typeof props !== 'object' || !Array.isArray(props.items)) return component; + let changed = false; + const items = (props.items as unknown[]).map((item) => { + const value = pageTabItemKey(item); + if (value === undefined) return item; + const label = lookupPageTabItemLabel(bundle, name, id, value, opts); + if (label === undefined) return item; + changed = true; + return { ...(item as Record), label }; + }); + return changed ? { ...component, properties: { ...props, items } } : component; +} + /** * How many levels of composition nesting — every authorable slot position * `pageComponentSlotPositions()` names: `properties.children` (commit 901355c3b), @@ -2278,6 +2351,9 @@ export type AddressedPageRoots = Pick; * visitor runs, the walk re-attaches the node's rebuilt slot arrays (`children`, * `footer`, and the panel list `items` with each panel's `children` rebuilt) * in place of the existing keys, so the visitor never needs to recurse itself. + * The descent reads the AUTHORED node; a panel's own keys (`label`, `value`, + * …) are the visitor's, when its return keeps the panel list's authored shape, + * so a visitor may overlay a panel's label but never re-route the descent. * Enumeration-only consumers return the component unchanged and ignore the * walk's return value. The input document is never mutated. Entries of a slot * that are not component objects (bare id strings, `null` — every slot is @@ -2343,6 +2419,27 @@ export function walkAddressedPageComponents( !!entry && typeof entry === 'object' && !Array.isArray(entry) && Array.isArray((entry as Record)[panelKey]); + /** + * The visitor's version of a panel list, when it is safe to take each + * panel's own keys from it: an array of the authored length with a plain + * object at every position the authored list holds a panel. Anything else + * — no list, a different length, a panel replaced by a non-object — falls + * back to the authored list, so a visitor can overlay a panel's keys but + * can never drop or reorder a panel the walk descends. + */ + const visitorPanelList = ( + visited: PageComponentLike, + key: string, + authored: unknown[], + panelKey: string, + ): unknown[] => { + const candidate = visited?.properties?.[key]; + if (!Array.isArray(candidate) || candidate.length !== authored.length) return authored; + const keepsShape = authored.every((entry, i) => !isPanel(entry, panelKey) + || (!!candidate[i] && typeof candidate[i] === 'object' && !Array.isArray(candidate[i]))); + return keepsShape ? candidate : authored; + }; + /** * The component's rebuilt composition slots — one entry per slot key the * component carries (`properties.children`, `properties.footer`, and the @@ -2352,10 +2449,19 @@ export function walkAddressedPageComponents( * A panel list is descended (and rebuilt WHOLE) only when at least one of * its entries is a panel; anything else in it (an option row of some other * component, a bare string) passes through untouched. + * + * The descent always reads the AUTHORED `component`; `visited` is the + * visitor's return for it. A panel's OWN keys (`label`, `value`, `icon`, …) + * are taken from `visited` when the visitor returned the panel list in the + * authored shape (same length, a plain object at every panel position), so + * a visitor may overlay them — `translatePage` writes a `page:tabs` item's + * translated `label` there. Its `children` are still the authored panel's, + * descended: the walk owns the descent, never the visitor. */ const walkComposition = ( component: PageComponentLike, depth: number, + visited: PageComponentLike, ): Record | undefined => { if (depth >= MAX_NESTED_COMPONENT_DEPTH) return undefined; const props = component.properties; @@ -2374,15 +2480,21 @@ export function walkAddressedPageComponents( // Read the list already rebuilt for this key when two positions share // it (two panel keys on one panel list), so they compose. const list = rebuilt[key] ?? (bag[key] as unknown[]); - rebuilt[key] = panelKey === undefined - ? list.map((child) => visitComponent(child as PageComponentLike, depth + 1)) - : list.map((entry) => (isPanel(entry, panelKey) - ? { - ...entry, - [panelKey]: (entry[panelKey] as unknown[]).map((child) => - visitComponent(child as PageComponentLike, depth + 1)), - } - : entry)); + if (panelKey === undefined) { + rebuilt[key] = list.map((child) => visitComponent(child as PageComponentLike, depth + 1)); + continue; + } + // Each panel's own keys: the visitor's, when it kept the list's shape + // (see the docblock); otherwise the authored entry. Once a position + // has rebuilt this list, the next one composes onto that rebuild. + const own = rebuilt[key] === undefined ? visitorPanelList(visited, key, list, panelKey) : list; + rebuilt[key] = list.map((entry, i) => (isPanel(entry, panelKey) + ? { + ...(own[i] as Record), + [panelKey]: (entry[panelKey] as unknown[]).map((child) => + visitComponent(child as PageComponentLike, depth + 1)), + } + : own[i])); } return rebuilt; } finally { @@ -2407,19 +2519,21 @@ export function walkAddressedPageComponents( // Pre-order: the visitor sees the parent before its children, so a // consumer that emits in visit order emits in document order. The rebuilt // composition slots land on the RETURNED node afterwards, and they are - // rebuilt from the ORIGINAL component, never from `next`. The walk owns + // DESCENDED from the ORIGINAL component, never from `next`. The walk owns // the `properties` keys of its slot positions, each only when the ORIGINAL // node carries it: a direct slot (`children`, `footer`) rebuilt entry by // entry, and — on a node carrying at least one panel (an `items` entry - // with a `children` array) — the WHOLE panel list, panels rebuilt and - // every other entry carried across exactly as it was authored. So a - // visitor's edit to any other `items[*]` key (a panel's own `label`, say) - // is overwritten; on a node with no panel `items` is not rebuilt at all - // and such an edit stands. Everything else the visitor returns — every - // other `properties` key, every top-level key — is kept. + // with a `children` array) — the WHOLE panel list, each panel's + // `children` rebuilt from the authored panel. A panel's OTHER keys (its + // `label`, say) and every non-panel entry come from the visitor's return + // when it kept the list's authored shape, so a visitor's edit to them + // stands (`translatePage` overlays a `page:tabs` item label that way); + // a visitor that changed the list's shape gets the authored list back. + // Everything else the visitor returns — every other `properties` key, + // every top-level key — is kept. let next = visit(component, { id, nested, depth, addressed }); - const rebuilt = walkComposition(component, depth); + const rebuilt = walkComposition(component, depth, next); if (rebuilt !== undefined) { next = { ...next, properties: { ...next.properties, ...rebuilt } }; } @@ -2536,6 +2650,15 @@ export function walkAddressedPageComponents( * walk this resolver and the CLI extractor both consume (commit c45d8e6b4); this * function owns only what happens AT each component it hands back. * + * A `page:tabs` component's tab strip is translated per item: each item's + * `label` overlays from `pages..components..items..label`, + * addressed by the tabs component's own id and the item's `value` (the + * stable `?tab=` token, {@link pageTabItemKey}). A tab item is not a + * component and carries no id, so before this key the strip stayed in the + * authoring language on a translated page. An item without a `value` has no + * key (an index token is never one), and an item the bundle does not key — + * an inline locale map included — is left as authored. + * * A list page's filter-preset tab bar * (`interfaceConfig.userFilters.tabs[].label`) is translated too, against * `objects.._tabs..label` — see @@ -2615,6 +2738,15 @@ export function translatePage( }; } + // A `page:tabs` component's tab labels, keyed by each item's `value` under + // the tabs component's own id (`pages..components..items`). Read + // on the same `addressed` arbitration as the component copy above, so an + // entry belongs to one tabs component. The walk keeps a visitor's edit to + // a panel's own keys and still descends the authored panel children. + if (addressed && component.type === PAGE_TABS_COMPONENT_TYPE) { + next = translatePageTabItems(next, bundle, name, id as string, opts); + } + // The page-name header route addresses THE page's header, so it stops at // root level — nested components are reached by the id route only. if (nested) return next; diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index d0bc611f495..23e5966360f 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -1064,6 +1064,9 @@ const appTranslationDataShape = () => ({ * * pages..components.. * → that component's `properties.` (#6080) + * pages..components..items..label + * → that `page:tabs` item's `label`, keyed by the + * item's `value` (see `items` below) * * When `title` is omitted, the header keeps the title its author wrote. * `label` stands in for it only where the header authors no title, or where @@ -1185,7 +1188,7 @@ const appTranslationDataShape = () => ({ // 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' }, + aliases: { name: 'title', heading: 'title', text: 'label', caption: 'description', help: 'description', helpText: 'description', empty: 'emptyText', emptyState: 'emptyText', tabs: 'items' }, guidance: { submitLabel: '`submitLabel` was removed in @objectstack/spec 17 (ADR-0049) — its only ' @@ -1206,6 +1209,40 @@ const appTranslationDataShape = () => ({ 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`)'), + /** + * A `page:tabs` component's tab labels, keyed by each item's `value` + * (`PageTabsProps.items[].value`) — read by `translatePage`, offered by + * `os i18n extract`, judged by `objectstack validate`. + * + * **Why under the tabs component, keyed by `value`.** A tab item is not + * a component: it carries no `id`, so the `components.` route could + * never reach it, and a HotCRM home page rendered its three tabs in + * English under a translated header. Two addresses were open — give + * items an `id`, or key them under the tabs component they belong to. + * The item already declares the identifier this needs: `value` is the + * stable `?tab=` token the renderer reads and restores, so it is unique + * within the strip by construction, and the ruling that settled the + * inline-map question (#14749) recorded `tabs item.value` as the + * identity a page key builds on. A second identifier would give one tab + * two names. The component half stays the tabs component's own `id`, + * so `pages..components.` keeps meaning one component. + * + * **An item without a `value` has no key.** Its renderer token is + * `tab-`, and an array index is never a bundle key: reordering + * two tabs would silently swap their translations. Author a `value`, + * or localize that label in place with an inline locale map. + * + * Only `page:tabs` items are addressed. A `page:accordion` panel has + * no author-settable identity (its renderer derives `panel-`), + * so its label stays an inline-locale-map surface. + */ + items: z.record(z.string(), strictObject({ + surface: 'this page tab item translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label', text: 'label' }, + }, { + label: z.string().optional().describe("Translated tab label — overlays the item's own `label` (`PageTabsProps.items[].label`)"), + })).optional().describe('`page:tabs` item labels keyed by the item `value` (`PageTabsProps.items[].value`); an item without a `value` has no key'), })).optional().describe('Per-component copy keyed by component id (`PageComponentSchema.id`)'), })).optional().describe('Page translations keyed by page name'), From 9e14303d77eee9a2f81b7c970797a3704c6d01b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:37:25 +0000 Subject: [PATCH 3/4] feat(spec,lint,cli): a page:tabs item label is keyed pages.PAGE.components.TABS.items.VALUE.label The tabs component's own id, then the item's value (the stable ?tab= token). translatePage overlays the item label; walkAddressedPageComponents keeps a visitor's edit to a panel's own keys; objectstack validate judges the key; os i18n extract offers the slot and coverage demands it. Regenerated the api-surface, export-origins and strictness-ledger counts. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .changeset/24968-page-tabs-item-label-key.md | 16 ++++++++++++++++ .../system.md | 2 +- packages/spec/api-surface/system.json | 2 ++ packages/spec/export-origins/system.json | 2 ++ packages/spec/src/system/i18n-resolver.test.ts | 2 +- 5 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 .changeset/24968-page-tabs-item-label-key.md diff --git a/.changeset/24968-page-tabs-item-label-key.md b/.changeset/24968-page-tabs-item-label-key.md new file mode 100644 index 00000000000..29a0fe79b16 --- /dev/null +++ b/.changeset/24968-page-tabs-item-label-key.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +'@objectstack/cli': minor +--- + +feat(spec): a `page:tabs` item label gains a translation key, `pages..components..items..label` + +Clause-②: yes (widening) + +- **Before.** `pages..components` is keyed by component id, and a `page:tabs` item is not a component and carries no id, so a tab label had no key. `items` was refused as an unknown key, and `translatePage` never read `items[].label`. A localized page kept its tab strip in the authoring language. +- **Now.** `pages..components..items..label` is a declared key. It is addressed by the tabs component's own `id` and the item's `value` (the stable `?tab=` token), and `translatePage` overlays it onto that item's `label`, across the locale chain. The entry is closed: `title`, `name` and `text` are renames onto `label`, and `tabs` on a component entry is the rename onto `items`. An item without a `value` has no key, because its renderer token is index-derived and an array index is never a bundle key. Author a `value` on the item, or keep localizing that label with an inline locale map. +- **`walkAddressedPageComponents`.** A visitor's edit to a panel's own keys (its `label`) now stands when the visitor keeps the panel list's authored shape. Panel children are still descended from the authored document. +- **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts the key over a declared `page:tabs` item `value`. It refuses it as `translation-target-unknown` over an unknown value (with "Did you mean"), over a component of another type (a `page:accordion` is pointed at the inline locale map, since its panels have no stable key), and under a component id the page does not declare. +- **`os i18n extract` / coverage.** The extractor writes a slot for every valued tab item, and the coverage check demands it. An inline locale map on the item label is counted as authored, as before. +- **Unchanged.** Every existing `pages` key, and an inline locale map on a tab label that the bundle does not key. diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md index 63e71ca418e..4951ca0e4b6 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `system/` | 376 | +| `system/` | 377 | diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 2540e5f18dd..d9b6ccd41af 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -500,6 +500,7 @@ "OtelExporterType (const)", "OtelExporterType (type)", "PAGE_COMPONENT_COPY_KEYS (const)", + "PAGE_TABS_COMPONENT_TYPE (const)", "PKG_CONVENTIONS (const)", "PLATFORM_OBJECTS_BY_PACKAGE (const)", "PLATFORM_OBJECT_PREFIXES (const)", @@ -838,6 +839,7 @@ "objectValidationMessageKey (function)", "operationMessageTranslationKey (function)", "organizationOwnershipShapeGenerationOf (function)", + "pageTabItemKey (function)", "preferredLocaleFromHeader (function)", "renderOperationMessage (function)", "renderValidationMessage (function)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index ffbde5c794f..f9facfd9347 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -479,6 +479,7 @@ "OrganizationOwnershipSupplementSchema": "src/system/fate-supplement.zod.ts#OrganizationOwnershipSupplementSchema (const)", "OtelExporterType": "src/system/tracing.zod.ts#OtelExporterType (type)", "PAGE_COMPONENT_COPY_KEYS": "src/system/i18n-resolver.ts#PAGE_COMPONENT_COPY_KEYS (const)", + "PAGE_TABS_COMPONENT_TYPE": "src/system/i18n-resolver.ts#PAGE_TABS_COMPONENT_TYPE (const)", "PKG_CONVENTIONS": "src/system/constants/paths.ts#PKG_CONVENTIONS (const)", "PLATFORM_OBJECTS_BY_PACKAGE": "src/system/constants/platform-object-names.ts#PLATFORM_OBJECTS_BY_PACKAGE (const)", "PLATFORM_OBJECT_PREFIXES": "src/system/constants/platform-object-names.ts#PLATFORM_OBJECT_PREFIXES (const)", @@ -799,6 +800,7 @@ "objectValidationMessageKey": "src/system/i18n-resolver.ts#objectValidationMessageKey (function)", "operationMessageTranslationKey": "src/system/operation-message.ts#operationMessageTranslationKey (function)", "organizationOwnershipShapeGenerationOf": "src/system/migration.zod.ts#organizationOwnershipShapeGenerationOf (function)", + "pageTabItemKey": "src/system/i18n-resolver.ts#pageTabItemKey (function)", "preferredLocaleFromHeader": "src/system/i18n-resolver.ts#preferredLocaleFromHeader (function)", "renderOperationMessage": "src/system/operation-message.ts#renderOperationMessage (function)", "renderValidationMessage": "src/system/validation-message.ts#renderValidationMessage (function)", diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index c5af37ec360..1dc3df8da70 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -2524,7 +2524,7 @@ describe('walkAddressedPageComponents — the one addressed-component walk, shar ...component.properties, // The visitor relabels each panel and drops its children; the walk // owns the descent, so the authored children come back visited. - items: component.properties.items.map((item: any) => ({ label: `relabelled:${item.value}`, value: item.value })), + items: (component.properties?.items as any[]).map((item: any) => ({ label: `relabelled:${item.value}`, value: item.value })), }, })) as any; const items = regions[0].components[0].properties.items; From f18a2f55dd050513dc8fe2dfdfab4217b5f48dff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 00:44:51 +0000 Subject: [PATCH 4/4] fix(spec): drop the tabs alias on a page component translation entry; the docs table names the tab-item key The alias put a `tabs` key beside `emptyState` in one object literal, which the list-view tabs retirement's tree-scoped absence pin reads as a list-view payload carrying `tabs` (translation.zod.ts:1191). The alias was not part of the card's ask, so it goes rather than widening the pin's exclusions. content/docs/ui/translations.mdx gains the pages.NAME.components.TABS.items.VALUE.label row. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .changeset/24968-page-tabs-item-label-key.md | 2 +- content/docs/ui/translations.mdx | 1 + packages/spec/src/system/translation.test.ts | 6 ------ packages/spec/src/system/translation.zod.ts | 2 +- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.changeset/24968-page-tabs-item-label-key.md b/.changeset/24968-page-tabs-item-label-key.md index 29a0fe79b16..70c68ecf921 100644 --- a/.changeset/24968-page-tabs-item-label-key.md +++ b/.changeset/24968-page-tabs-item-label-key.md @@ -9,7 +9,7 @@ feat(spec): a `page:tabs` item label gains a translation key, `pages..comp Clause-②: yes (widening) - **Before.** `pages..components` is keyed by component id, and a `page:tabs` item is not a component and carries no id, so a tab label had no key. `items` was refused as an unknown key, and `translatePage` never read `items[].label`. A localized page kept its tab strip in the authoring language. -- **Now.** `pages..components..items..label` is a declared key. It is addressed by the tabs component's own `id` and the item's `value` (the stable `?tab=` token), and `translatePage` overlays it onto that item's `label`, across the locale chain. The entry is closed: `title`, `name` and `text` are renames onto `label`, and `tabs` on a component entry is the rename onto `items`. An item without a `value` has no key, because its renderer token is index-derived and an array index is never a bundle key. Author a `value` on the item, or keep localizing that label with an inline locale map. +- **Now.** `pages..components..items..label` is a declared key. It is addressed by the tabs component's own `id` and the item's `value` (the stable `?tab=` token), and `translatePage` overlays it onto that item's `label`, across the locale chain. The entry is closed: `title`, `name` and `text` are renames onto `label`. An item without a `value` has no key, because its renderer token is index-derived and an array index is never a bundle key. Author a `value` on the item, or keep localizing that label with an inline locale map. - **`walkAddressedPageComponents`.** A visitor's edit to a panel's own keys (its `label`) now stands when the visitor keeps the panel list's authored shape. Panel children are still descended from the authored document. - **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts the key over a declared `page:tabs` item `value`. It refuses it as `translation-target-unknown` over an unknown value (with "Did you mean"), over a component of another type (a `page:accordion` is pointed at the inline locale map, since its panels have no stable key), and under a component id the page does not declare. - **`os i18n extract` / coverage.** The extractor writes a slot for every valued tab item, and the coverage check demands it. An inline locale map on the item label is counted as authored, as before. diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 48abeebf15b..959e254effc 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -80,6 +80,7 @@ export default defineStack({ | Dataset dimension and measure labels | `datasets..dimensions..label` / `datasets..measures..label` | | Page labels and `page:header` copy | `pages..label` / `description` / `title` / `subtitle` — on a `kind: 'slotted'` page the header under `slots.header` is the page's header | | Page component copy, by component id | `pages..components..title` / `description` / `label` / `placeholder` / `emptyText` — reached under `regions[].components[]` and `slots.`, through `properties.children` and a `page:tabs` / `page:accordion` panel's `items[].children` | +| Page tab labels (`page:tabs` items) | `pages..components..items..label` — keyed by the tabs component's `id` and the item's `value`; an item without a `value` has no key (localize it with an inline locale map), and `page:accordion` panels have none | | Screen-flow wizards (flow label, screen headings and body text, screen field copy) | `flows..label` / `flows..screens..title` / `.description` / `.fields..label` / `.placeholder` / `.inlineHelpText` — a screen field's help text is `inlineHelpText`, the key the field itself declares, not `help`; see the boundary note below | | Global actions, messages | `globalActions`, `messages` | | Settings UI shell copy (the source badge on a settings row) | `settingsCommon.sourceLabels.` — the per-namespace settings copy under `settings` is **platform-only**: an app bundle carrying it is refused by name, and the platform's own strings are translated in `@objectstack/service-settings`'s bundle | diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 04f47d65c10..1d3a307f803 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -1007,12 +1007,6 @@ describe('translation unknown-key strictness', () => { expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message).toContain('`title` → `label`'); }); - it('names `tabs` on a component entry as the rename onto `items`', () => { - const result = parse({ home_tabs: { tabs: { my_leads: { label: 'My Leads' } } } }); - expect(result.success).toBe(false); - expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message).toContain('`tabs` → `items`'); - }); - it('carries the `label`/`title` trap alias the dashboard widget face carries', () => { // A page's headline is `label`; a component's is `title`. One level // apart, opposite spellings — the same trap `dashboards.widgets` names. diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 23e5966360f..61725c1f9dd 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -1188,7 +1188,7 @@ const appTranslationDataShape = () => ({ // 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', tabs: 'items' }, + 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 (ADR-0049) — its only '