From 3534d0340901fb9bb65095dc53295780cb0e02e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:40:28 +0000 Subject: [PATCH 1/4] fix(spec): a bundle page label no longer replaces an authored page:header title that says something else translatePage resolved the header title as the bundle's page title, else the bundle's page label, and spread it over the authored title at a region root and a slot root alike. A pack that translated the label and omitted the title turned a dynamic '{name}' heading into the page's static name. The order is now: bundle title, then the authored title, then the bundle label only where the header authors no title or its title restates the page's own label (the case the CLI extractor dedupes, offering label alone). Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- content/docs/ui/translations.mdx | 10 ++- packages/spec/liveness/translation.json | 4 +- .../spec/src/system/i18n-resolver.test.ts | 87 +++++++++++++++++++ packages/spec/src/system/i18n-resolver.ts | 84 ++++++++++++++++-- packages/spec/src/system/translation.zod.ts | 14 ++- 5 files changed, 184 insertions(+), 15 deletions(-) diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 1ca1c6200b0..f3dd7be9762 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -126,9 +126,13 @@ saying so. **Page headers are keyed by page name (#3589).** A page's `page:header` component has no stable id, so its `properties.title` / `properties.subtitle` are addressed through the page itself: `pages..title` / `subtitle`. -`title` falls back to `pages..label`, so a page whose header title -matches its nav label needs only `label`. Every `page:header` in the page's -regions receives the same copy. +When `title` is omitted, the header keeps the title its author wrote, and +`pages..label` stands in only where the header authors no title or its +title restates the page's `label`. So a page whose header title matches its +nav label needs only `label`, while a header whose title says something else, +such as a dynamic `'{name}'`, keeps it until the bundle supplies a `title`. +Every root-level `page:header` (a region's entry or a `slots.` entry) +is resolved by this one rule. diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index e8bc04eb2d8..2f226946925 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -109,8 +109,8 @@ "pages": { "status": "live", "verifiedAt": "2026-08-28", - "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupPageAttr (`pages..` — label/description/title/subtitle, title falling back to label); packages/spec/src/system/i18n-resolver.ts#lookupPageComponentCopy (`pages..components.` — header copy keyed by page name because page:header instances carry no stable id)", - "note": "translatePage: label/description/title/subtitle (title falls back to label; header copy keyed by page name because page:header instances carry no stable id). 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED — `:636` had rotted onto `function lookupAppAttr`, the APP group's reader, ~226 lines above the page ones. Re-closed by hand against 8cb96ec41." + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupPageAttr (`pages..` — label/description/title/subtitle; an omitted title keeps the authored header title, the label standing in only where the header authors none or restates the page label); packages/spec/src/system/i18n-resolver.ts#lookupPageComponentCopy (`pages..components.` — header copy keyed by page name because page:header instances carry no stable id)", + "note": "translatePage: label/description/title/subtitle (an omitted title keeps the authored header title, the label standing in only where the header authors none or restates the page label; header copy keyed by page name because page:header instances carry no stable id). 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED — `:636` had rotted onto `function lookupAppAttr`, the APP group's reader, ~226 lines above the page ones. Re-closed by hand against 8cb96ec41." }, "flows": { "status": "live", diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 7844e25eb27..d3445b3ae6b 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -2722,6 +2722,93 @@ describe('resolveTabLabel — filter-preset tab labels, keyed under `_tabs`', () }); }); +// #22508: the header title used to be "the bundle's page `title`, else the +// bundle's page `label`", spread over whatever the header authored. So a pack +// carrying `label` and no `title` (the natural partial translation) turned a +// record page's dynamic `'{name}'` heading into the page's static name in that +// locale, with no diagnostic. The order is now: bundle `title`, then the +// AUTHORED title, then the bundle `label` only where the header authors no +// title or its title restates the page's own `label`. Pinned at both roots, +// because both reach the one resolution through the same visitor. +describe('translatePage — a bundle label never replaces a header title that says something else', () => { + const PAGE = 'account_detail_page'; + type Root = 'region' | 'slot'; + const ROOTS: readonly Root[] = ['region', 'slot']; + + const pageWith = (root: Root, header: Record, label: any = 'Account Detail'): any => { + const component = { type: 'page:header', properties: header }; + return root === 'region' + ? { name: PAGE, label, regions: [{ name: 'header', components: [component] }] } + : { name: PAGE, label, kind: 'slotted', regions: [], slots: { header: component } }; + }; + const headerOf = (root: Root, page: any): Record => + root === 'region' ? page.regions[0].components[0].properties : page.slots.header.properties; + + /** The measured hotcrm shape: `label` in the pack, no `title`. */ + const labelOnly: TranslationBundle = { + en: { pages: { [PAGE]: { label: 'Account Detail' } } }, + 'zh-CN': { pages: { [PAGE]: { label: '客户详情', subtitle: '客户概览' } } }, + }; + const withTitle: TranslationBundle = { + 'zh-CN': { pages: { [PAGE]: { label: '客户详情', title: '客户 {name}' } } }, + }; + + for (const root of ROOTS) { + describe(`at a ${root} root`, () => { + it('keeps an authored template title (`{name}`) when the pack carries `label` and no `title`', () => { + for (const locale of ['en', 'zh-CN']) { + const out = translatePage(pageWith(root, { title: '{name}' }), labelOnly, { locale }); + expect({ locale, title: headerOf(root, out).title }).toEqual({ locale, title: '{name}' }); + } + // The page's own label still translates; only the header title is guarded. + expect(translatePage(pageWith(root, { title: '{name}' }), labelOnly, { locale: 'zh-CN' }).label).toBe('客户详情'); + }); + + it('keeps an authored plain-string title that differs from the page label', () => { + const out = translatePage(pageWith(root, { title: 'Customer overview' }), labelOnly, { locale: 'zh-CN' }); + expect(headerOf(root, out).title).toBe('Customer overview'); + }); + + it('keeps an authored inline locale map title, and still applies the bundle subtitle beside it', () => { + const title = { en: '{name}', 'zh-CN': '{name}(客户)' }; + const out = translatePage(pageWith(root, { title, subtitle: 'Overview' }), labelOnly, { locale: 'zh-CN' }); + expect(headerOf(root, out)).toEqual({ title, subtitle: '客户概览' }); + }); + + it('CONTROL: a pack with `title` replaces the authored title', () => { + const out = translatePage(pageWith(root, { title: '{name}' }), withTitle, { locale: 'zh-CN' }); + expect(headerOf(root, out).title).toBe('客户 {name}'); + }); + + it('CONTROL: a header with no authored title still shows the bundle `label`', () => { + for (const header of [{}, { title: '' }, { subtitle: 'Overview' }]) { + const out = translatePage(pageWith(root, header), labelOnly, { locale: 'zh-CN' }); + expect({ header, title: headerOf(root, out).title }).toEqual({ header, title: '客户详情' }); + } + }); + + it('a header title that restates the page label takes the label translation (the extractor offers no `title` key for it)', () => { + const out = translatePage(pageWith(root, { title: 'Account Detail' }), labelOnly, { locale: 'zh-CN' }); + expect(headerOf(root, out).title).toBe('客户详情'); + + // The same rule for the inline-map form: one map restating the other, + // entry for entry, in a different key order. + const label = { en: 'Account Detail', 'zh-CN': '客户详情页' }; + const restated = { 'zh-CN': '客户详情页', en: 'Account Detail' }; + const mapped = translatePage(pageWith(root, { title: restated }, label), labelOnly, { locale: 'zh-CN' }); + expect(headerOf(root, mapped).title).toBe('客户详情'); + }); + }); + } + + it('serves the same answer through `translateMetadataDocument`, the function the REST page doors call', () => { + for (const root of ROOTS) { + const out = translateMetadataDocument('page', pageWith(root, { title: '{name}' }), labelOnly, { locale: 'en' }); + expect({ root, title: headerOf(root, out).title }).toEqual({ root, title: '{name}' }); + } + }); +}); + describe('translatePage — filter-preset tab bar', () => { const bundle: TranslationBundle = { 'zh-CN': { diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index a8eade91831..a0b369f7234 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1842,6 +1842,67 @@ function lookupPageAttr( return undefined; } +/** + * Authored text in either `I18nLabel` form: a non-empty string, or the + * non-empty entries of an inline locale map. `undefined` when the value + * authors no text, which is how a `page:header` with no `title` (or an empty + * one) reads: the renderer draws no heading of its own for it either. + */ +function authoredLabelText(value: unknown): string | Record | undefined { + if (typeof value === 'string') return value.length > 0 ? value : undefined; + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const entries = Object.entries(value as Record) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +/** + * Do two authored values say the same text? Strings compare as strings; two + * inline locale maps compare as sets of entries (key order is not text). + * Mirrors the CLI extractor's `sameAuthored`, which decides whether + * `os i18n extract` offers `pages..title` at all. + */ +function sameAuthoredText(a: string | Record, b: unknown): boolean { + const other = authoredLabelText(b); + if (other === undefined) return false; + if (typeof a === 'string' || typeof other === 'string') return a === other; + const aKeys = Object.keys(a).sort(); + const bKeys = Object.keys(other).sort(); + return aKeys.length === bKeys.length && aKeys.every((k, i) => bKeys[i] === k && a[k] === other[k]); +} + +/** + * The ONE header-title resolution for a root-level `page:header`. A region's + * entry and a `slots.` entry both reach it through the same visitor in + * {@link translatePage}, so the two roots cannot disagree. + * + * A bundle translates an attribute. It does not put one attribute in place of + * another over text the author wrote. The order: + * + * 1. the bundle's `pages..title`, across the locale chain; + * 2. the header's AUTHORED title, left as written: a template such as + * `'{name}'`, a plain string, or an inline locale map; + * 3. the bundle's `pages..label`, in two cases only: the header authors + * no title, or its title restates the page's own authored `label`. In the + * second case the label's translation IS a translation of that text, and + * `os i18n extract` offers no `title` key for such a header (it offers the + * label alone), so reading the label here is what translates it. + * + * `undefined` means "leave the authored title alone". + */ +function resolvePageHeaderTitle( + bundleTitle: string | undefined, + bundleLabel: string | undefined, + authoredTitle: unknown, + authoredPageLabel: unknown, +): string | undefined { + if (bundleTitle !== undefined) return bundleTitle; + if (bundleLabel === undefined) return undefined; + const own = authoredLabelText(authoredTitle); + if (own === undefined || sameAuthoredText(own, authoredPageLabel)) return bundleLabel; + return undefined; +} + /** * The copy keys `pages..components.` carries (#6080). Measured * against `ComponentPropsMap` — see the schema's own note for which component @@ -2197,9 +2258,13 @@ export function walkAddressedPageComponents( * mutated. * * Header copy is addressed by **page name** rather than by component id because - * `page:header` instances carry no stable id in practice. `title` falls back to - * `pages..label` so translators need not repeat a string that is normally - * identical to the page's nav label. + * `page:header` instances carry no stable id in practice. The header title is + * `pages..title`; when the bundle omits it, the header keeps its + * AUTHORED title, and `pages..label` stands in only where the header + * authors no title or its title restates the page's own `label`, so a + * translator need not repeat a string identical to the page's nav label + * ({@link resolvePageHeaderTitle}). A label never replaces a header title + * that says something else, a dynamic `'{name}'` above all. * * **One component, one address.** A ROOT-LEVEL `page:header` — an entry of a * region's `components[]`, or of a `slots.` on a `kind: 'slotted'` page @@ -2283,10 +2348,12 @@ export function translatePage( const name = doc.name; if (!name || !bundle) return doc; - const label = lookupPageAttr(bundle, name, 'label', opts) ?? doc.label; + const bundleLabel = lookupPageAttr(bundle, name, 'label', opts); + const label = bundleLabel ?? doc.label; const description = lookupPageAttr(bundle, name, 'description', opts) ?? doc.description; - const headerTitle = lookupPageAttr(bundle, name, 'title', opts) - ?? lookupPageAttr(bundle, name, 'label', opts); + // The header title is decided per header, against what that header authors + // (`resolvePageHeaderTitle`); only the bundle half is page-wide. + const bundleHeaderTitle = lookupPageAttr(bundle, name, 'title', opts); const headerSubtitle = lookupPageAttr(bundle, name, 'subtitle', opts); // The traversal — roots, descent, depth cap, cycle guard, collision @@ -2349,6 +2416,11 @@ export function translatePage( // root level — nested components are reached by the id route only. if (nested) return next; if (next.type !== PAGE_HEADER_COMPONENT) return next; + // One call for a region root and a slot root alike: both arrive here with + // `nested: false`, so the fallback order cannot differ between them. + const headerTitle = resolvePageHeaderTitle( + bundleHeaderTitle, bundleLabel, next.properties?.title, doc.label, + ); if (headerTitle === undefined && headerSubtitle === undefined) return next; return { ...next, diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 2d70234867c..076e346f176 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -1040,9 +1040,15 @@ const appTranslationDataShape = () => ({ * pages..components.. * → that component's `properties.` (#6080) * - * `title` falls back to `label` when omitted, since a page's header title - * and its nav/breadcrumb label are usually the same string — translators - * only author `title` separately when the two genuinely differ. + * 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 + * its title restates the page's own `label`: a page's header title and its + * nav/breadcrumb label are usually the same string, so translators only + * author `title` separately when the two genuinely differ (and + * `os i18n extract` offers `title` exactly then). A label never replaces a + * header title that says something else, such as a dynamic `'{name}'`: a + * bundle translates an attribute, it does not swap one attribute for + * another over authored text. * * Header copy lives at the TOP level here rather than under `components` * because `page:header` instances carry no stable `id`; the page name is the @@ -1066,7 +1072,7 @@ const appTranslationDataShape = () => ({ }, { label: z.string().optional().describe('Translated page label (nav / breadcrumb)'), description: z.string().optional().describe('Translated page description'), - title: z.string().optional().describe('Translated `page:header` title (defaults to `label`)'), + title: z.string().optional().describe('Translated `page:header` title. When omitted the header keeps its authored title; `label` stands in only where the header authors no title or its title restates the page label'), subtitle: z.string().optional().describe('Translated `page:header` subtitle'), /** * Per-component copy, keyed by the component's `id` From ded67296ca705ccbcef097a1dbbdaab38487b351 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:41:23 +0000 Subject: [PATCH 2/4] test(spec): the batch #58 header fixture declares the page label its title restates Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- packages/spec/src/system/i18n-resolver.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index d3445b3ae6b..2255a2c8466 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -1724,8 +1724,12 @@ describe('translatePage', () => { // widened to the open bag it is: the overlay adds keys the literal does // not spell out, and inferring it as `{title}` alone would make reading // the result a type error. + // `label` is `PageSchema`'s required key, and the header title restates + // it, exactly as on `homePage()`: that is the shape whose title the + // page-name route answers from `pages..label` (#22508). const regionHeaderWithId = () => ({ name: 'sales_home_page', + label: 'Sales Home', regions: [{ name: 'header', components: [{ From 201956e6380613c297fa4519aa8f1f92d0decbde Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:44:15 +0000 Subject: [PATCH 3/4] docs(spec): regenerate the translation reference for the page title describe Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- content/docs/references/system/translation.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 4e28995a8ea..bf5a7a93a15 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -249,7 +249,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated page label (nav / breadcrumb) | | **description** | `string` | optional | Translated page description | -| **title** | `string` | optional | Translated `page:header` title (defaults to `label`) | +| **title** | `string` | optional | Translated `page:header` title. When omitted the header keeps its authored title; `label` stands in only where the header authors no title or its title restates the page label | | **subtitle** | `string` | optional | Translated `page:header` subtitle | | **components** | `Record` | optional | Per-component copy keyed by component id (`PageComponentSchema.id`) | @@ -447,7 +447,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated page label (nav / breadcrumb) | | **description** | `string` | optional | Translated page description | -| **title** | `string` | optional | Translated `page:header` title (defaults to `label`) | +| **title** | `string` | optional | Translated `page:header` title. When omitted the header keeps its authored title; `label` stands in only where the header authors no title or its title restates the page label | | **subtitle** | `string` | optional | Translated `page:header` subtitle | | **components** | `Record` | optional | Per-component copy keyed by component id (`PageComponentSchema.id`) | @@ -606,7 +606,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated page label (nav / breadcrumb) | | **description** | `string` | optional | Translated page description | -| **title** | `string` | optional | Translated `page:header` title (defaults to `label`) | +| **title** | `string` | optional | Translated `page:header` title. When omitted the header keeps its authored title; `label` stands in only where the header authors no title or its title restates the page label | | **subtitle** | `string` | optional | Translated `page:header` subtitle | | **components** | `Record` | optional | Per-component copy keyed by component id (`PageComponentSchema.id`) | From 194157f5e16b955ece5f95ad2a518d1d5679139e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:44:35 +0000 Subject: [PATCH 4/4] chore(changeset): spec patch for the page header title fallback order Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .../22508-page-header-title-keeps-authored.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/22508-page-header-title-keeps-authored.md diff --git a/.changeset/22508-page-header-title-keeps-authored.md b/.changeset/22508-page-header-title-keeps-authored.md new file mode 100644 index 00000000000..57065af7fde --- /dev/null +++ b/.changeset/22508-page-header-title-keeps-authored.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': patch +--- + +A locale bundle's page `label` no longer replaces a `page:header` title that says something else, such as a dynamic `'{name}'` + +Clause-②: no + +`translatePage` resolved a root-level `page:header` title as the bundle's `pages..title`, else the bundle's `pages..label`, and wrote it over whatever the header authored. A pack that translated the page label and left out the title therefore turned a record page's `'{name}'` heading into the page's static name in that locale, with no warning at build, lint or serve. + +The header title now resolves in this order, at a region root and a `slots.` root alike: + +1. the bundle's `pages..title`; +2. the title the header authors (a template, a plain string or an inline locale map), left as written; +3. the bundle's `pages..label`, only where the header authors no title, or where its title restates the page's own `label`. That second case is the one `os i18n extract` already covers by offering `label` alone, so those headers keep their translation. + +The doors that serve the translated page are `GET /api/v1/meta/page/:name` and `GET /api/v1/meta/page`, through `translateMetadataDocument('page', …)`, and the console reads them. What changes for an author: a header whose title differs from the page label, in a locale whose pack carries `label` but no `title`, now shows the authored title. To translate that header, add `pages..title` to the pack (`os i18n extract` offers that key for such a header). No schema, key, accepted value or export changes. The `pages..title` describe and the reference page now state this order.