From dc1dd305bcec8e92abf7ae24c82f253eaa2c2d16 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 02:01:30 +0000 Subject: [PATCH 1/3] fix(spec): label precedence on an overridden item, one rule across the eight document translators A translation the paired .custom package ships wins, then the override's own text, then the base's translation (ADR-0146 D2). page, dataset, app and book now take TranslateDocumentOptions and judge every string against packagedBase, as view, dashboard, report and object already did. The new optional pairBundle option carries the pair's own translations; without it every translator answers as before. Claude-Session: https://claude.ai/code/session_01WGufzhytXa3Yi3uzE5pvuv Co-authored-by: Claude --- packages/spec/src/system/i18n-resolver.ts | 715 +++++++++++++++++----- 1 file changed, 546 insertions(+), 169 deletions(-) diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 76f843de623..0ec72ccf45e 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -372,8 +372,126 @@ export interface TranslateDocumentOptions extends ResolveOptions { * evidence that a value was authored. * * See {@link translateObject} for the rule it feeds. + * + * For a declared override from the base's paired `.custom` package + * (ADR-0146 D2) it is the BASE package's item — the document the override + * replaces, as the read would serve it without the override — never the + * override itself. Every document translator reads it, each string against + * its packaged counterpart: see {@link labelCatalogs} for the order. */ packagedBase?: unknown; + /** + * The translations the served item's paired `.custom` package ships itself + * (ADR-0146 D2/D3), locale-keyed as `bundle` is — that package's own + * `translation` items and nothing else. + * + * Needed because `bundle` cannot say where a string came from: the serving + * layer hands the translators one catalog per locale, every package's code + * bundle and every authored `translation` row deep-merged with the package + * ids dropped. Only this option lets a translation the pair ships outrank + * the override's own text, which outranks the base's translation — the + * order {@link labelCatalogs} applies to every translatable string of the + * document translators. + * + * Absent, every translator answers exactly as it does without it. + */ + pairBundle?: TranslationBundle; +} + +/** + * [ADR-0146 D2] The two catalogs a document translator reads one string from, + * built once per document by {@link labelCatalogs}. + */ +interface LabelCatalogs { + /** + * For a string the served document did NOT change against its packaged + * base: the pair's translations laid over the deployment catalog, locale + * by locale along the chain. + */ + readonly unchanged: TranslationBundle | undefined; + /** + * For a string the override changed: the pair's own translations, and no + * other catalog. + */ + readonly changed: TranslationBundle | undefined; +} + +/** + * [ADR-0146 D2] Label precedence on an overridden item — one order for every + * translatable string of every document translator (ruling record + * 6631250894): + * + * 1. a translation the paired `.custom` package ships + * ({@link TranslateDocumentOptions.pairBundle}); + * 2. the override's own text; + * 3. the base's translation. + * + * A string the override did not change still takes the base's translation: + * whether a string changed is the ONE comparison every translator already + * asks, {@link valueOverridesPackagedBase} against + * {@link TranslateDocumentOptions.packagedBase}, and {@link catalogFor} turns + * its answer into the catalog the string is read from: + * + * - **changed** — the pair's own translations only, along the locale chain; + * no entry keeps the override's text, because the deployment catalog + * translates the BASE's text; + * - **unchanged** — locale by locale, the pair's translation and then the + * deployment catalog's. Interleaved, not pair-first across the chain, so a + * pair that ships only `en` never displaces the base's `zh-CN` on a string + * it left alone. + * + * With no `pairBundle` the unchanged catalog IS `bundle` (the same reference) + * and the changed one is empty, which is exactly what every translator did + * before the option existed: a changed string keeps its text, an unchanged + * one reads `bundle`. With no `packagedBase` nothing counts as changed. + */ +function labelCatalogs( + bundle: TranslationBundle | undefined, + opts: TranslateDocumentOptions | undefined, +): LabelCatalogs { + const pair = opts?.pairBundle; + if (!pair) return { unchanged: bundle, changed: undefined }; + // Keyed by the chain's own codes, each resolved against both bundles the + // way `pickData` resolves it, so every lookup on the result hits exactly. + // A code neither bundle answers gets `{}`: a bare miss would let + // `pickData` re-resolve the code against the OTHER codes kept here. + const unchanged: Record = {}; + for (const code of localeChain(opts)) { + unchanged[code] = overlayTranslationData(pickData(bundle, code), pickData(pair, code)) ?? {}; + } + return { unchanged: unchanged as TranslationBundle, changed: pair }; +} + +/** The catalog a string is read from: {@link labelCatalogs}' rule, by whether the override changed it. */ +function catalogFor(catalogs: LabelCatalogs, changed: boolean): TranslationBundle | undefined { + return changed ? catalogs.changed : catalogs.unchanged; +} + +/** + * `top` laid over `under`, one locale's translation data: nested groups merge + * key by key, and a non-empty leaf of `top` replaces `under`'s. An empty + * string is no translation (every lookup here reads only non-empty strings), + * so it never masks the leaf beneath it. Neither input is mutated; a branch + * `top` does not reach keeps `under`'s reference. + */ +function overlayTranslationData( + under: TranslationData | undefined, + top: TranslationData | undefined, +): TranslationData | undefined { + if (top === undefined) return under; + if (under === undefined) return top; + return overlayRecord(under as Record, top as Record) as TranslationData; +} + +function overlayRecord(under: Record, top: Record): Record { + const next: Record = { ...under }; + for (const [key, value] of Object.entries(top)) { + if (value === undefined || value === '') continue; + const below = asRecord(under[key]); + const above = asRecord(value); + next[key] = below !== undefined && above !== undefined ? overlayRecord(below, above) : value; + } + return next; } /** @@ -995,13 +1113,13 @@ function lookupBulkActionText( */ function translateBulkActionDefs( defs: unknown, - bundle: TranslationBundle | undefined, + catalogs: LabelCatalogs, objectName: string, viewKey: string, opts: ResolveOptions | undefined, packagedConfig: Record | undefined, ): unknown { - if (!Array.isArray(defs) || !bundle) return defs; + if (!Array.isArray(defs) || !catalogs.unchanged) return defs; let changed = false; const next = defs.map((raw) => { const def = raw as BulkActionDefLike; @@ -1010,16 +1128,18 @@ function translateBulkActionDefs( } const defName = def.name; const packagedDef = packagedPart(packagedConfig, 'bulkActionDefs', (candidate) => candidate.name === defName); - // The catalog's string for `attr`, unless the authored value diverged from - // `packaged`'s — then `undefined`, which leaves the authored value in place. + // The catalog's string for `attr`, read from the catalog `labelCatalogs` + // assigns by whether the authored value diverged from `packaged`'s; + // `undefined` leaves the authored value in place. const text = ( packaged: Record | undefined, attr: string, authored: unknown, pick: (node: NonNullable>) => unknown, - ) => valueOverridesPackagedBase(packaged, attr, authored) - ? undefined - : lookupBulkActionText(bundle, objectName, viewKey, defName, pick, opts); + ) => lookupBulkActionText( + catalogFor(catalogs, valueOverridesPackagedBase(packaged, attr, authored)), + objectName, viewKey, defName, pick, opts, + ); const label = text(packagedDef, 'label', def.label, (n) => n.label); // `confirmText` is a template slot (#24798): the authored side and the @@ -1082,12 +1202,13 @@ function translateBulkActionDefs( * caller then leaves the authored value in place. */ function lookupSectionText( - bundle: TranslationBundle, + bundle: TranslationBundle | undefined, objectName: string, sectionName: string, attr: 'label' | 'description', opts: ResolveOptions | undefined, ): string | undefined { + if (!bundle) return undefined; for (const code of localeChain(opts)) { const candidate = pickData(bundle, code)?.objects?.[objectName]?._sections?.[sectionName]?.[attr]; if (typeof candidate === 'string' && candidate.length > 0) return candidate; @@ -1102,13 +1223,14 @@ function lookupSectionText( * leaves the authored value in place. */ function lookupSectionEntryText( - bundle: TranslationBundle, + bundle: TranslationBundle | undefined, objectName: string, sectionName: string, fieldName: string, attr: FormSectionEntryAttr, opts: ResolveOptions | undefined, ): string | undefined { + if (!bundle) return undefined; for (const code of localeChain(opts)) { const candidate = pickData(bundle, code)?.objects?.[objectName]?._sections?.[sectionName] ?.fields?.[fieldName]?.[attr]; @@ -1178,7 +1300,7 @@ function formSectionObjectName(config: Record, viewObject: stri */ function translateFormSections( sections: unknown, - bundle: TranslationBundle, + catalogs: LabelCatalogs, objectName: string, opts: ResolveOptions | undefined, packagedConfig: Record | undefined, @@ -1191,15 +1313,18 @@ function translateFormSections( const sectionName = section.name; const packagedSection = packagedPart(packagedConfig, 'sections', (candidate) => candidate.name === sectionName); const text = (attr: 'label' | 'description', authored: unknown): string | undefined => - !sectionCopyAuthored(authored) || valueOverridesPackagedBase(packagedSection, attr, authored) + !sectionCopyAuthored(authored) ? undefined - : lookupSectionText(bundle, objectName, sectionName, attr, opts); + : lookupSectionText( + catalogFor(catalogs, valueOverridesPackagedBase(packagedSection, attr, authored)), + objectName, sectionName, attr, opts, + ); const label = text('label', section.label); const description = text('description', section.description); const fields = translateFormSectionEntries( section.fields, - bundle, + catalogs, objectName, sectionName, opts, @@ -1236,7 +1361,7 @@ function translateFormSections( */ function translateFormSectionEntries( entries: unknown, - bundle: TranslationBundle, + catalogs: LabelCatalogs, objectName: string, sectionName: string, opts: ResolveOptions | undefined, @@ -1250,9 +1375,12 @@ function translateFormSectionEntries( const fieldName = entry.field; const packagedEntry = packagedPart(packagedSection, 'fields', (candidate) => candidate.field === fieldName); const text = (attr: FormSectionEntryAttr): string | undefined => - !sectionCopyAuthored(entry[attr]) || valueOverridesPackagedBase(packagedEntry, attr, entry[attr]) + !sectionCopyAuthored(entry[attr]) ? undefined - : lookupSectionEntryText(bundle, objectName, sectionName, fieldName, attr, opts); + : lookupSectionEntryText( + catalogFor(catalogs, valueOverridesPackagedBase(packagedEntry, attr, entry[attr])), + objectName, sectionName, fieldName, attr, opts, + ); const label = text('label'); const placeholder = text('placeholder'); @@ -1291,15 +1419,15 @@ function translateFormSectionEntries( */ function translateFormTitle( title: unknown, - bundle: TranslationBundle, + catalogs: LabelCatalogs, objectName: string, viewKey: string, opts: ResolveOptions | undefined, packagedConfig: Record | undefined, ): unknown { if (typeof title !== 'string' || title.length === 0) return title; - if (valueOverridesPackagedBase(packagedConfig, 'title', title)) return title; - return lookupViewText(bundle, objectName, viewKey, 'title', opts) ?? title; + const catalog = catalogFor(catalogs, valueOverridesPackagedBase(packagedConfig, 'title', title)); + return lookupViewText(catalog, objectName, viewKey, 'title', opts) ?? title; } /** @@ -1333,7 +1461,8 @@ function translateFormTitle( */ function translateFormSubmitBehavior( behavior: unknown, - lookup: (attr: SubmitBehaviorCopyKey) => string | undefined, + catalogs: LabelCatalogs, + lookup: (catalog: TranslationBundle | undefined, attr: SubmitBehaviorCopyKey) => string | undefined, packagedConfig: Record | undefined, ): unknown { const served = asRecord(behavior); @@ -1342,8 +1471,7 @@ function translateFormSubmitBehavior( const text = (attr: SubmitBehaviorCopyKey): string | undefined => { const value = served[attr]; if (typeof value !== 'string' || value.length === 0) return undefined; - if (valueOverridesPackagedBase(packaged, attr, value)) return undefined; - return lookup(attr); + return lookup(catalogFor(catalogs, valueOverridesPackagedBase(packaged, attr, value)), attr); }; const title = text('title'); const message = text('message'); @@ -1363,12 +1491,13 @@ type SubmitBehaviorCopyKey = 'title' | 'message'; * locale chain — `undefined` when no locale carries it. */ function lookupSubmitBehaviorText( - bundle: TranslationBundle, + bundle: TranslationBundle | undefined, objectName: string, viewKey: string, attr: SubmitBehaviorCopyKey, opts: ResolveOptions | undefined, ): string | undefined { + if (!bundle) return undefined; for (const code of localeChain(opts)) { const candidate = pickData(bundle, code)?.objects?.[objectName]?._views?.[viewKey]?.submitBehavior?.[attr]; if (typeof candidate === 'string' && candidate.length > 0) return candidate; @@ -1483,22 +1612,30 @@ export function translateView( opts?: TranslateDocumentOptions, ): T { const base = asRecord(opts?.packagedBase); - const label = valueOverridesPackagedBase(base, 'label', view.label) - ? (view.label as string) - : resolveViewLabel(bundle, view, opts); - const description = valueOverridesPackagedBase(base, 'description', view.description) - ? view.description - : resolveViewDescription(bundle, view, opts); + const catalogs = labelCatalogs(bundle, opts); + // A changed string reads the pair's catalog only, and falls back to its own + // value: `resolveViewLabel` / `resolveViewDescription` answer the view's + // own text on a miss. + const label = resolveViewLabel( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'label', view.label)), + view, + opts, + ); + const description = resolveViewDescription( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'description', view.description)), + view, + opts, + ); let config = view.config; const objectName = viewObjectName(view); - if (config && typeof config === 'object' && bundle && objectName) { + if (config && typeof config === 'object' && catalogs.unchanged && objectName) { // The base's own `config` — `{}` when the base is known but carries none, // so every def and section on the served view then counts as authored. const packagedConfig = base === undefined ? undefined : asRecord(base.config) ?? {}; const defs = translateBulkActionDefs( config.bulkActionDefs, - bundle, + catalogs, objectName, viewTranslationKey(view, objectName), opts, @@ -1506,14 +1643,14 @@ export function translateView( ); const sections = translateFormSections( config.sections, - bundle, + catalogs, formSectionObjectName(config, objectName), opts, packagedConfig, ); const title = translateFormTitle( config.title, - bundle, + catalogs, objectName, viewTranslationKey(view, objectName), opts, @@ -1522,7 +1659,8 @@ export function translateView( const viewKey = viewTranslationKey(view, objectName); const submitBehavior = translateFormSubmitBehavior( config.submitBehavior, - (attr) => lookupSubmitBehaviorText(bundle, objectName, viewKey, attr, opts), + catalogs, + (catalog, attr) => lookupSubmitBehaviorText(catalog, objectName, viewKey, attr, opts), packagedConfig, ); if ( @@ -1743,6 +1881,31 @@ function lookupNavLabel( return undefined; } +/** + * The packaged base's navigation nodes by `id` — the key + * `apps..navigation.` addresses a node by, at any depth — with + * {@link packagedPart}'s three answers per node: `undefined` for every id when + * no base was supplied, `{}` for an id the known base does not carry (a node + * authored after the fact), the packaged node otherwise. The first node of an + * id, depth-first, wins; the walk stops at {@link MAX_NESTED_COMPONENT_DEPTH} + * levels, since `children` is authored data. + */ +function packagedNavNodes(base: Record | undefined): (id: string) => Record | undefined { + if (base === undefined) return () => undefined; + const byId = new Map>(); + const visit = (nodes: unknown, depth: number): void => { + if (!Array.isArray(nodes) || depth >= MAX_NESTED_COMPONENT_DEPTH) return; + for (const raw of nodes) { + const node = asRecord(raw); + if (node === undefined) continue; + if (typeof node.id === 'string' && !byId.has(node.id)) byId.set(node.id, node); + visit(node.children, depth + 1); + } + }; + visit(base.navigation, 0); + return (id) => byId.get(id) ?? {}; +} + /** * Apply the active locale to an app metadata document — translates the app's * `label` / `description` and walks the (possibly nested) `navigation` tree, @@ -1752,24 +1915,45 @@ function lookupNavLabel( * Translation keys are addressed by the stable navigation-node `id` * (e.g. `group_overview`, `nav_users`) — the same flat keyspace used by the * `apps..navigation` map, regardless of tree depth. + * + * ## An override's own text — ADR-0146 D2 + * + * An app is overridable from its base's paired `.custom` package, and the + * catalog translates the BASE's text. So every string here follows + * {@link labelCatalogs}' order, judged against + * {@link TranslateDocumentOptions.packagedBase} by + * {@link valueOverridesPackagedBase}: the app's `label` and `description` + * against the base's, each navigation node's `label` against the base's node + * of the same `id` ({@link packagedNavNodes}). A string the override changed + * reads only the pair's translations and otherwise keeps its own text; a + * string it left alone takes the pair's translation, then the base's. No base + * supplied: the catalog applies to every string, as before. */ export function translateApp( doc: T, bundle: TranslationBundle | undefined, - opts?: ResolveOptions, + opts?: TranslateDocumentOptions, ): T { if (!doc || typeof doc !== 'object') return doc; const appName = doc.name; - if (!appName || !bundle) return doc; + const catalogs = labelCatalogs(bundle, opts); + if (!appName || !catalogs.unchanged) return doc; - const label = lookupAppAttr(bundle, appName, 'label', opts) ?? doc.label; - const description = lookupAppAttr(bundle, appName, 'description', opts) ?? doc.description; + const base = asRecord(opts?.packagedBase); + const label = lookupAppAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'label', doc.label)), appName, 'label', opts, + ) ?? doc.label; + const description = lookupAppAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'description', doc.description)), appName, 'description', opts, + ) ?? doc.description; + const packagedNode = packagedNavNodes(base); const translateNav = (node: NavNodeLike): NavNodeLike => { if (!node || typeof node !== 'object') return node; const next: NavNodeLike = { ...node }; if (typeof node.id === 'string') { - const translated = lookupNavLabel(bundle, appName, node.id, opts); + const changed = valueOverridesPackagedBase(packagedNode(node.id), 'label', node.label); + const translated = lookupNavLabel(catalogFor(catalogs, changed), appName, node.id, opts); if (translated) next.label = translated; } if (Array.isArray(node.children)) { @@ -1922,7 +2106,9 @@ function isOptionValue(value: unknown): value is string | number | boolean { */ function packagedPart( parent: Record | undefined, - key: 'widgets' | 'globalFilters' | 'options' | 'bulkActionDefs' | 'params' | 'sections' | 'fields' | 'blocks', + key: + | 'widgets' | 'globalFilters' | 'options' | 'bulkActionDefs' | 'params' | 'sections' | 'fields' | 'blocks' + | 'dimensions' | 'measures' | 'groups' | 'items' | 'tabs', matches: (candidate: Record) => boolean, ): Record | undefined { if (parent === undefined) return undefined; @@ -1978,7 +2164,7 @@ function packagedObjectField( */ function translateGlobalFilter( filter: GlobalFilterLike, - bundle: TranslationBundle, + catalogs: LabelCatalogs, dashboardName: string, opts: ResolveOptions | undefined, packagedFilter: Record | undefined, @@ -1987,9 +2173,10 @@ function translateGlobalFilter( if (key === undefined) return filter; let next = filter; - const label = valueOverridesPackagedBase(packagedFilter, 'label', filter.label) - ? undefined - : lookupGlobalFilterLabel(bundle, dashboardName, key, opts); + const label = lookupGlobalFilterLabel( + catalogFor(catalogs, valueOverridesPackagedBase(packagedFilter, 'label', filter.label)), + dashboardName, key, opts, + ); if (label !== undefined) next = { ...next, label }; if (Array.isArray(filter.options)) { @@ -2007,8 +2194,10 @@ function translateGlobalFilter( 'options', (candidate) => isOptionValue(candidate.value) && String(candidate.value) === String(value), ); - if (valueOverridesPackagedBase(packagedOption, 'label', option.label)) return option; - const translated = lookupGlobalFilterOption(bundle, dashboardName, key, String(value), opts); + const translated = lookupGlobalFilterOption( + catalogFor(catalogs, valueOverridesPackagedBase(packagedOption, 'label', option.label)), + dashboardName, key, String(value), opts, + ); if (translated === undefined) return option; changed = true; return { ...option, label: translated }; @@ -2105,15 +2294,16 @@ export function translateDashboard( ): T { if (!doc || typeof doc !== 'object') return doc; const name = doc.name; - if (!name || !bundle) return doc; + const catalogs = labelCatalogs(bundle, opts); + if (!name || !catalogs.unchanged) return doc; const base = asRecord(opts?.packagedBase); - const label = valueOverridesPackagedBase(base, 'label', doc.label) - ? doc.label - : lookupDashboardAttr(bundle, name, 'label', opts) ?? doc.label; - const description = valueOverridesPackagedBase(base, 'description', doc.description) - ? doc.description - : lookupDashboardAttr(bundle, name, 'description', opts) ?? doc.description; + const label = lookupDashboardAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'label', doc.label)), name, 'label', opts, + ) ?? doc.label; + const description = lookupDashboardAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'description', doc.description)), name, 'description', opts, + ) ?? doc.description; let globalFilters: GlobalFilterLike[] | undefined; if (Array.isArray(doc.globalFilters)) { @@ -2126,7 +2316,7 @@ export function translateDashboard( 'globalFilters', (candidate) => key !== undefined && globalFilterKey(candidate) === key, ); - const next = translateGlobalFilter(filter, bundle, name, opts, packagedFilter); + const next = translateGlobalFilter(filter, catalogs, name, opts, packagedFilter); if (next !== filter) changed = true; return next; }); @@ -2138,13 +2328,15 @@ export function translateDashboard( if (!w || typeof w !== 'object' || typeof w.id !== 'string') return w; const next: WidgetLike = { ...w }; const packagedWidget = packagedPart(base, 'widgets', (candidate) => candidate.id === w.id); - const title = valueOverridesPackagedBase(packagedWidget, 'title', w.title) - ? undefined - : lookupWidgetAttr(bundle, name, w.id, 'title', opts); + const title = lookupWidgetAttr( + catalogFor(catalogs, valueOverridesPackagedBase(packagedWidget, 'title', w.title)), + name, w.id, 'title', opts, + ); if (title) next.title = title; - const desc = valueOverridesPackagedBase(packagedWidget, 'description', w.description) - ? undefined - : lookupWidgetAttr(bundle, name, w.id, 'description', opts); + const desc = lookupWidgetAttr( + catalogFor(catalogs, valueOverridesPackagedBase(packagedWidget, 'description', w.description)), + name, w.id, 'description', opts, + ); if (desc) next.description = desc; return next; }) @@ -2214,14 +2406,17 @@ function lookupDatasetMemberLabel( /** * Overlay the bundle's labels onto a dataset's `dimensions[]` / `measures[]`, - * returning the SAME array reference when nothing matched. + * returning the SAME array reference when nothing matched. Each member is + * judged against the packaged base's member of the same `name` + * ({@link packagedPart}) — {@link translateDataset}'s rule, one level down. */ function translateDatasetMembers( members: unknown, - bundle: TranslationBundle | undefined, + catalogs: LabelCatalogs, datasetName: string, group: 'dimensions' | 'measures', - opts?: ResolveOptions, + opts: ResolveOptions | undefined, + base: Record | undefined, ): unknown { if (!Array.isArray(members)) return members; let changed = false; @@ -2230,7 +2425,12 @@ function translateDatasetMembers( if (!member || typeof member !== 'object' || typeof member.name !== 'string' || member.name.length === 0) { return raw; } - const label = lookupDatasetMemberLabel(bundle, datasetName, group, member.name, opts); + const memberName = member.name; + const packagedMember = packagedPart(base, group, (candidate) => candidate.name === memberName); + const label = lookupDatasetMemberLabel( + catalogFor(catalogs, valueOverridesPackagedBase(packagedMember, 'label', member.label)), + datasetName, group, memberName, opts, + ); if (label === undefined) return raw; changed = true; return { ...member, label }; @@ -2266,20 +2466,40 @@ function translateDatasetMembers( * down to one, so — exactly as `translatePage` does — a key the bundle does not * carry leaves the authored value untouched, and the arrays keep their identity * when nothing matched. + * + * ## An override's own text — ADR-0146 D2 + * + * A dataset is overridable from its base's paired `.custom` package, and the + * catalog translates the BASE's text. So every string here follows + * {@link labelCatalogs}' order, judged against + * {@link TranslateDocumentOptions.packagedBase} by + * {@link valueOverridesPackagedBase}: `label` and `description` against the + * base's, each dimension's and measure's `label` against the base's member of + * the same `name`. A string the override changed reads only the pair's + * translations and otherwise keeps its own text; a string it left alone takes + * the pair's translation, then the base's. An inline locale map is never + * "changed" (the comparison reads strings only), so the catalog keeps the + * reading it has today. No base supplied: the catalog applies, as before. */ export function translateDataset( doc: T, bundle: TranslationBundle | undefined, - opts?: ResolveOptions, + opts?: TranslateDocumentOptions, ): T { if (!doc || typeof doc !== 'object') return doc; const name = doc.name; - if (!name || !bundle) return doc; + const catalogs = labelCatalogs(bundle, opts); + if (!name || !catalogs.unchanged) return doc; - const label = lookupDatasetAttr(bundle, name, 'label', opts); - const description = lookupDatasetAttr(bundle, name, 'description', opts); - const dimensions = translateDatasetMembers(doc.dimensions, bundle, name, 'dimensions', opts); - const measures = translateDatasetMembers(doc.measures, bundle, name, 'measures', opts); + const base = asRecord(opts?.packagedBase); + const label = lookupDatasetAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'label', doc.label)), name, 'label', opts, + ); + const description = lookupDatasetAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'description', doc.description)), name, 'description', opts, + ); + const dimensions = translateDatasetMembers(doc.dimensions, catalogs, name, 'dimensions', opts, base); + const measures = translateDatasetMembers(doc.measures, catalogs, name, 'measures', opts, base); return { ...doc, @@ -2437,22 +2657,46 @@ function lookupBookGroupLabel( * console's docs portal builds its sidebar from), and the book tree route * (`GET /meta/book/:name/tree`) translates the book it resolves through the * same table before the spine is rendered. + * + * ## An override's own text — ADR-0146 D2 + * + * A book is overridable from its base's paired `.custom` package, and the + * catalog translates the BASE's text. So every string here follows + * {@link labelCatalogs}' order, judged against + * {@link TranslateDocumentOptions.packagedBase} by + * {@link valueOverridesPackagedBase}: `label` and `description` against the + * base's, each group's `label` against the base's group of the same `key`. A + * string the override changed reads only the pair's translations and + * otherwise keeps its own text; a string it left alone takes the pair's + * translation, then the base's. No base supplied: the catalog applies, as + * before. */ export function translateBook( doc: T, bundle: TranslationBundle | undefined, - opts?: ResolveOptions, + opts?: TranslateDocumentOptions, ): T { if (!doc || typeof doc !== 'object') return doc; const bookName = doc.name; - if (typeof bookName !== 'string' || bookName.length === 0 || !bundle) return doc; - const label = lookupBookAttr(bundle, bookName, 'label', opts); - const description = lookupBookAttr(bundle, bookName, 'description', opts); + const catalogs = labelCatalogs(bundle, opts); + if (typeof bookName !== 'string' || bookName.length === 0 || !catalogs.unchanged) return doc; + const base = asRecord(opts?.packagedBase); + const label = lookupBookAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'label', doc.label)), bookName, 'label', opts, + ); + const description = lookupBookAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'description', doc.description)), bookName, 'description', opts, + ); let groupsMoved = false; const groups = Array.isArray(doc.groups) ? doc.groups.map((group) => { if (!group || typeof group !== 'object' || typeof group.key !== 'string') return group; - const translated = lookupBookGroupLabel(bundle, bookName, group.key, opts); + const groupKey = group.key; + const packagedGroup = packagedPart(base, 'groups', (candidate) => candidate.key === groupKey); + const translated = lookupBookGroupLabel( + catalogFor(catalogs, valueOverridesPackagedBase(packagedGroup, 'label', group.label)), + bookName, groupKey, opts, + ); if (translated === undefined) return group; groupsMoved = true; return { ...group, label: translated }; @@ -2526,7 +2770,7 @@ function lookupReportBlockAttr( */ function translateReportBlocks( blocks: unknown, - bundle: TranslationBundle, + catalogs: LabelCatalogs, name: string, base: Record | undefined, opts?: ResolveOptions, @@ -2538,12 +2782,14 @@ function translateReportBlocks( const blockName = block.name; if (typeof blockName !== 'string' || blockName.length === 0) return block; const packagedBlock = packagedPart(base, 'blocks', (candidate) => candidate.name === blockName); - const label = valueOverridesPackagedBase(packagedBlock, 'label', block.label) - ? undefined - : lookupReportBlockAttr(bundle, name, blockName, 'label', opts); - const description = valueOverridesPackagedBase(packagedBlock, 'description', block.description) - ? undefined - : lookupReportBlockAttr(bundle, name, blockName, 'description', opts); + const label = lookupReportBlockAttr( + catalogFor(catalogs, valueOverridesPackagedBase(packagedBlock, 'label', block.label)), + name, blockName, 'label', opts, + ); + const description = lookupReportBlockAttr( + catalogFor(catalogs, valueOverridesPackagedBase(packagedBlock, 'description', block.description)), + name, blockName, 'description', opts, + ); if (label === undefined && description === undefined) return block; changed = true; return { @@ -2594,16 +2840,17 @@ export function translateReport( ): T { if (!doc || typeof doc !== 'object') return doc; const name = doc.name; - if (typeof name !== 'string' || name.length === 0 || !bundle) return doc; + const catalogs = labelCatalogs(bundle, opts); + if (typeof name !== 'string' || name.length === 0 || !catalogs.unchanged) return doc; const base = asRecord(opts?.packagedBase); - const label = valueOverridesPackagedBase(base, 'label', doc.label) - ? undefined - : lookupReportAttr(bundle, name, 'label', opts); - const description = valueOverridesPackagedBase(base, 'description', doc.description) - ? undefined - : lookupReportAttr(bundle, name, 'description', opts); - const blocks = translateReportBlocks(doc.blocks, bundle, name, base, opts); + const label = lookupReportAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'label', doc.label)), name, 'label', opts, + ); + const description = lookupReportAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'description', doc.description)), name, 'description', opts, + ); + const blocks = translateReportBlocks(doc.blocks, catalogs, name, base, opts); if (label === undefined && description === undefined && blocks === undefined) return doc; return { ...doc, @@ -2733,20 +2980,27 @@ function sameAuthoredText(a: string | Record, b: unknown): boole * `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. * + * The two cases of step 3 read two different labels (ADR-0146 D2). An + * untitled header is filled with the PAGE's label, so `fillLabel` is that + * label's own translation, under whatever rule the page label follows. A + * title that restates a label translates as THAT label: `restated` names the + * label the title is compared with and its translation — the served page's + * when the header's title is the override's own, the base page's when the + * override left the header's title as the base wrote it. + * * `undefined` means "leave the authored title alone". */ function resolvePageHeaderTitle( bundleTitle: string | undefined, - bundleLabel: string | undefined, + fillLabel: string | undefined, + restated: { label: unknown; translation: string | undefined }, authoredTitle: unknown, - authoredPageLabel: unknown, derivesFromRecord: boolean, ): string | undefined { if (bundleTitle !== undefined) return bundleTitle; - if (bundleLabel === undefined) return undefined; const own = authoredLabelText(authoredTitle); - if (own === undefined) return derivesFromRecord ? undefined : bundleLabel; - if (sameAuthoredText(own, authoredPageLabel)) return bundleLabel; + if (own === undefined) return derivesFromRecord ? undefined : fillLabel; + if (sameAuthoredText(own, restated.label)) return restated.translation; return undefined; } @@ -2864,12 +3118,13 @@ export const PAGE_FORM_COMPONENT_TYPE = 'object-form'; * key by key, as {@link lookupPageComponentCopy} is. */ function lookupPageFormSubmitBehaviorText( - bundle: TranslationBundle, + bundle: TranslationBundle | undefined, name: string, id: string, attr: SubmitBehaviorCopyKey, opts: ResolveOptions | undefined, ): string | undefined { + if (!bundle) return undefined; for (const code of localeChain(opts)) { const candidate = pickData(bundle, code)?.pages?.[name]?.components?.[id]?.submitBehavior?.[attr]; if (typeof candidate === 'string' && candidate.length > 0) return candidate; @@ -2918,13 +3173,18 @@ function lookupPageTabItemLabel( * 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. + * + * `packagedProps` is the packaged base's same component's `properties` + * ({@link packagedPageParts}); each item is judged against the packaged item + * of the same `value` ({@link labelCatalogs}). */ function translatePageTabItems( component: PageComponentLike, - bundle: TranslationBundle | undefined, + catalogs: LabelCatalogs, name: string, id: string, - opts?: ResolveOptions, + opts: ResolveOptions | undefined, + packagedProps: Record | undefined, ): PageComponentLike { const props = component.properties; if (!props || typeof props !== 'object' || !Array.isArray(props.items)) return component; @@ -2932,7 +3192,12 @@ function translatePageTabItems( 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); + const packagedItem = packagedPart(packagedProps, 'items', (candidate) => pageTabItemKey(candidate) === value); + const authored = (item as Record).label; + const label = lookupPageTabItemLabel( + catalogFor(catalogs, valueOverridesPackagedBase(packagedItem, 'label', authored)), + name, id, value, opts, + ); if (label === undefined) return item; changed = true; return { ...(item as Record), label }; @@ -3279,6 +3544,45 @@ export function walkAddressedPageComponents( }; } +/** + * Where a page component's copy key lands, read back: a `label` on the + * component's own top-level `label` when it authors a string there, every + * other key (and a `label` otherwise) in `properties` — the one either/or + * {@link translatePage} writes the copy back by. + */ +function pageComponentCopyValue(component: Record, key: PageComponentCopyKey): unknown { + if (key === 'label' && typeof component.label === 'string') return component.label; + return asRecord(component.properties)?.[key]; +} + +/** + * The packaged base page's counterparts, for {@link translatePage}'s rule + * (ADR-0146 D2): each addressed component by `id`, found by the same walk and + * the same collision arbitration the translator reads the served page with + * ({@link walkAddressedPageComponents}), and THE page's header — its first + * root-level `page:header`, the one the page-name route addresses. + * + * {@link packagedPart}'s three answers for each: `undefined` when no base was + * supplied, `{}` when the known base carries no such part, the part itself + * otherwise. + */ +function packagedPageParts(base: Record | undefined): { + component: (id: string) => Record | undefined; + header: Record | undefined; +} { + if (base === undefined) return { component: () => undefined, header: undefined }; + const byId = new Map>(); + let header: Record | undefined; + walkAddressedPageComponents(base as AddressedPageRoots, (component, { id, nested, addressed }) => { + if (addressed && id !== undefined && !byId.has(id)) byId.set(id, component as Record); + if (!nested && header === undefined && component.type === PAGE_HEADER_COMPONENT) { + header = component as Record; + } + return component; + }); + return { component: (id) => byId.get(id) ?? {}, header: header ?? {} }; +} + /** * Apply the active locale to a page metadata document — translates the page's * own `label` / `description` and the `properties.title` / `properties.subtitle` @@ -3389,23 +3693,49 @@ export function walkAddressedPageComponents( * `objects.._tabs..label` — see * {@link translateInterfaceTabs} for why that key is object-scoped and why * only this one of `ViewTabSchema`'s two carriers is covered (#5377). + * + * ## An override's own text — ADR-0146 D2 + * + * A page is overridable from its base's paired `.custom` package, and the + * catalog translates the BASE's text. So every string here follows + * {@link labelCatalogs}' order, judged against + * {@link TranslateDocumentOptions.packagedBase} by + * {@link valueOverridesPackagedBase}, each against its packaged counterpart + * ({@link packagedPageParts}): the page's `label` and `description` against + * the base page's; the root-level header's `title` and `subtitle` against the + * base's root-level header; an addressed component's copy, key by key, against + * the base's component of the same `id`, read where the copy lands; a tab + * item's `label` against the base component's item of the same `value`; an + * `object-form`'s thank-you panel against the base component's; a preset tab's + * `label` against the base's tab of the same `name`. A string the override + * changed reads only the pair's translations and otherwise keeps its own + * text; a string it left alone takes the pair's translation, then the + * base's. No base supplied: the catalog applies to every string, as before. */ export function translatePage( doc: T, bundle: TranslationBundle | undefined, - opts?: ResolveOptions, + opts?: TranslateDocumentOptions, ): T { if (!doc || typeof doc !== 'object') return doc; const name = doc.name; - if (!name || !bundle) return doc; + const catalogs = labelCatalogs(bundle, opts); + if (!name || !catalogs.unchanged) return doc; - const bundleLabel = lookupPageAttr(bundle, name, 'label', opts); + const base = asRecord(opts?.packagedBase); + const packaged = packagedPageParts(base); + const bundleLabel = lookupPageAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'label', doc.label)), name, 'label', opts, + ); const label = bundleLabel ?? doc.label; - const description = lookupPageAttr(bundle, name, 'description', opts) ?? doc.description; - // 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); + const description = lookupPageAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'description', doc.description)), name, 'description', opts, + ) ?? doc.description; + // The header strings are decided per header, against what that header + // authors (`resolvePageHeaderTitle`) and against the base's header. + const packagedHeaderProps = packaged.header === undefined + ? undefined + : asRecord(packaged.header.properties) ?? {}; // The traversal — roots, descent, depth cap, cycle guard, collision // arbitration — is the shared walk (commit c45d8e6b4). This visitor owns only the @@ -3438,9 +3768,27 @@ export function translatePage( // the document, decided identically for every consumer of the walk; only // whether this consumer READS the entry changes here, and the extractor // blocks the namesake the same way. + // The base's component of the same id, and its `properties` + // (`packagedPageParts`' three answers), for every rule below. + const packagedComponent = addressed ? packaged.component(id as string) : undefined; + const packagedProps = packagedComponent === undefined + ? (base === undefined ? undefined : {}) + : asRecord(packagedComponent.properties) ?? {}; + let copy: Partial> | undefined; if (addressed && (nested || component.type !== PAGE_HEADER_COMPONENT)) { - copy = lookupPageComponentCopy(bundle, name, id as string, opts); + // Key by key, each from the catalog its own served value earns: + // compared where the copy lands, the base's value read in the same place. + const fromUnchanged = lookupPageComponentCopy(catalogs.unchanged, name, id as string, opts); + const fromChanged = lookupPageComponentCopy(catalogs.changed, name, id as string, opts); + for (const key of PAGE_COMPONENT_COPY_KEYS) { + const packagedCopy = packagedComponent === undefined + ? undefined + : { [key]: pageComponentCopyValue(packagedComponent, key) }; + const changed = valueOverridesPackagedBase(packagedCopy, key, pageComponentCopyValue(component, key)); + const text = (changed ? fromChanged : fromUnchanged)?.[key]; + if (text !== undefined) (copy ??= {})[key] = text; + } } let next = component; @@ -3469,21 +3817,21 @@ export function translatePage( // 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); + next = translatePageTabItems(next, catalogs, name, id as string, opts, packagedProps); } // An `object-form`'s thank-you panel, under the form component's own id // (`pages..components..submitBehavior`), on the same // `addressed` arbitration. The rules are the form view's panel's, through - // the one function both carriers share; `translatePage` is handed no - // packaged base for any page string, so the catalog applies as it does - // to this component's `title` and `description`. + // the one function both carriers share, judged against the base's same + // component's `properties.submitBehavior`. if (addressed && component.type === PAGE_FORM_COMPONENT_TYPE) { const behavior = next.properties?.submitBehavior; const submitBehavior = translateFormSubmitBehavior( behavior, - (attr) => lookupPageFormSubmitBehaviorText(bundle, name, id as string, attr, opts), - undefined, + catalogs, + (catalog, attr) => lookupPageFormSubmitBehaviorText(catalog, name, id as string, attr, opts), + packagedProps, ); if (submitBehavior !== behavior) { next = { ...next, properties: { ...next.properties, submitBehavior } }; @@ -3496,8 +3844,21 @@ export function translatePage( 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 authoredTitle = next.properties?.title; + const titleChanged = valueOverridesPackagedBase(packagedHeaderProps, 'title', authoredTitle); + const bundleHeaderTitle = lookupPageAttr(catalogFor(catalogs, titleChanged), name, 'title', opts); + const headerSubtitle = lookupPageAttr( + catalogFor(catalogs, valueOverridesPackagedBase(packagedHeaderProps, 'subtitle', next.properties?.subtitle)), + name, 'subtitle', opts, + ); + // A title the override left as the base wrote it restates the BASE's + // label, and translates as that label does; the override's own title + // restates the served label. + const restated = titleChanged || base === undefined + ? { label: doc.label, translation: bundleLabel } + : { label: base.label, translation: lookupPageAttr(catalogs.unchanged, name, 'label', opts) }; const headerTitle = resolvePageHeaderTitle( - bundleHeaderTitle, bundleLabel, next.properties?.title, doc.label, + bundleHeaderTitle, bundleLabel, restated, authoredTitle, headerDerivesFromRecord(doc, next), ); if (headerTitle === undefined && headerSubtitle === undefined) return next; @@ -3515,7 +3876,7 @@ export function translatePage( }; }); - const interfaceConfig = translateInterfaceTabs(doc, bundle, opts); + const interfaceConfig = translateInterfaceTabs(doc, catalogs, opts, base); return { ...doc, @@ -3545,11 +3906,15 @@ export function translatePage( * the records these presets filter — falling back to the page-level `object`. * Same order `i18n-extract` uses when it scaffolds the keys, and the same * "component's own binding first" discipline `_sections` follows. + * + * Each tab is judged against the packaged page's tab of the same `name` + * ({@link labelCatalogs}; `base` is {@link TranslateDocumentOptions.packagedBase}). */ function translateInterfaceTabs( doc: PageLike, - bundle: TranslationBundle | undefined, - opts?: ResolveOptions, + catalogs: LabelCatalogs, + opts: ResolveOptions | undefined, + base: Record | undefined, ): PageLike['interfaceConfig'] | undefined { const cfg = doc.interfaceConfig; if (!cfg || typeof cfg !== 'object') return undefined; @@ -3562,10 +3927,17 @@ function translateInterfaceTabs( : doc.object; if (!objectName) return undefined; + const packagedFilters = base === undefined + ? undefined + : asRecord(asRecord(base.interfaceConfig)?.userFilters) ?? {}; let changed = false; const tabs = userFilters.tabs.map((tab) => { if (!tab || typeof tab !== 'object') return tab; - const label = lookupTabLabel(bundle, objectName, tab, opts); + const packagedTab = packagedPart(packagedFilters, 'tabs', (candidate) => candidate.name === tab.name); + const label = lookupTabLabel( + catalogFor(catalogs, valueOverridesPackagedBase(packagedTab, 'label', tab.label)), + objectName, tab, opts, + ); if (label === undefined) return tab; changed = true; return { ...tab, label }; @@ -4192,12 +4564,12 @@ function authorsInlineHelpText(field: Record | undefined): bool */ function translateObjectListViews( listViews: unknown, - bundle: TranslationBundle | undefined, + catalogs: LabelCatalogs, objectName: string, opts: TranslateDocumentOptions | undefined, ): unknown { const views = asRecord(listViews); - if (views === undefined || !bundle) return listViews; + if (views === undefined || !catalogs.unchanged) return listViews; const base = asRecord(opts?.packagedBase); // The base's own `listViews` — `{}` when the base is known but carries none, // so every view on the served document then counts as authored. @@ -4211,13 +4583,13 @@ function translateObjectListViews( continue; } const packagedView = packagedViews === undefined ? undefined : asRecord(packagedViews[viewKey]) ?? {}; - const text = (attr: 'label' | 'description') => - valueOverridesPackagedBase(packagedView, attr, view[attr]) - ? undefined - : lookupViewText(bundle, objectName, viewKey, attr, opts); + const text = (attr: 'label' | 'description') => lookupViewText( + catalogFor(catalogs, valueOverridesPackagedBase(packagedView, attr, view[attr])), + objectName, viewKey, attr, opts, + ); const label = text('label'); const description = text('description'); - const defs = translateBulkActionDefs(view.bulkActionDefs, bundle, objectName, viewKey, opts, packagedView); + const defs = translateBulkActionDefs(view.bulkActionDefs, catalogs, objectName, viewKey, opts, packagedView); if (label === undefined && description === undefined && defs === view.bulkActionDefs) { next[viewKey] = raw; continue; @@ -4497,14 +4869,17 @@ export function translateObject( // [#8284] `catalog ?? document`, EXCEPT where the document's own value has // diverged from the packaged declaration — there the document is authored - // data and the catalog yields to it. + // data and the deployment catalog yields to it (the pair's own catalog + // still applies, ADR-0146 D2 — see `labelCatalogs`). + const catalogs = labelCatalogs(bundle, opts); const resolveScalar = ( key: 'label' | 'pluralLabel' | 'description', authored: string | undefined, ): string | undefined => - scalarOverridesPackagedBase(opts?.packagedBase, key, authored) - ? authored - : lookupObjectField(bundle, objectName, key, opts) ?? authored; + lookupObjectField( + catalogFor(catalogs, scalarOverridesPackagedBase(opts?.packagedBase, key, authored)), + objectName, key, opts, + ) ?? authored; const label = resolveScalar('label', doc.label); const pluralLabel = resolveScalar('pluralLabel', doc.pluralLabel); @@ -4514,51 +4889,53 @@ export function translateObject( const translateField = (name: string, def: ObjectFieldLike): ObjectFieldLike => { const next: ObjectFieldLike = { ...def }; const packagedField = packagedObjectField(packagedObject, name); - // The field `label` keeps a value that diverged from the packaged field's - // — an extension's `fieldPatches` label, a tenant's rename — under the one - // comparison its sibling slots use (ADR-0029 D9.2a; see the docblock). - if (!valueOverridesPackagedBase(packagedField, 'label', def.label)) { - const translatedLabel = - lookupObjectFieldAttr(bundle, objectName, name, 'label', opts) ?? + // Each slot reads the catalog `labelCatalogs` assigns by whether its + // served value diverged from the packaged field's (ADR-0029 D9.2a, + // ADR-0146 D2; see the docblock). + const slotCatalog = (slot: string, value: unknown): TranslationBundle | undefined => + catalogFor(catalogs, valueOverridesPackagedBase(packagedField, slot, value)); + // The field `label`: a value that diverged from the packaged field's — an + // extension's `fieldPatches` label, a tenant's rename — reads only the + // pair's catalog, and the built-in system-field labels apply only where + // the deployment catalog may. + const labelChanged = valueOverridesPackagedBase(packagedField, 'label', def.label); + const translatedLabel = labelChanged + ? lookupObjectFieldAttr(catalogs.changed, objectName, name, 'label', opts) + : lookupObjectFieldAttr(catalogs.unchanged, objectName, name, 'label', opts) ?? builtinSystemFieldLabel(name, def.label, opts); - if (translatedLabel) next.label = translatedLabel; - } + if (translatedLabel) next.label = translatedLabel; // The bundle's field `help` entry is served on the slot the extractor // seeded it from: the field's own `inlineHelpText` when it authors one — // the slot the console draws first — and `description` only when neither // the served nor the packaged field authors one; never on an undeclared - // `help`. The slot keeps a value that diverged from the packaged field's - // (ADR-0029 D9.2a; see the docblock). - const translatedHelp = lookupObjectFieldAttr(bundle, objectName, name, 'help', opts); - if (translatedHelp) { - const servedInlineHelp = authorsInlineHelpText(def); - if (servedInlineHelp) { - if (!valueOverridesPackagedBase(packagedField, 'inlineHelpText', def.inlineHelpText)) { - next.inlineHelpText = translatedHelp; - } - } else if ( - !authorsInlineHelpText(packagedField) && - !valueOverridesPackagedBase(packagedField, 'description', def.description) - ) { - next.description = translatedHelp; - } - } - // The inline child grid's heading, under the same divergence rule. The - // console's inline-subform pass copies the served value onto the parent's - // form, so this is the one place it is localized. An untranslated heading - // keeps its authored text — never the child's plural label in its place. - if (!valueOverridesPackagedBase(packagedField, 'inlineTitle', def.inlineTitle)) { - const translatedInlineTitle = lookupObjectFieldAttr(bundle, objectName, name, 'inlineTitle', opts); - if (translatedInlineTitle) next.inlineTitle = translatedInlineTitle; + // `help`. + if (authorsInlineHelpText(def)) { + const translatedHelp = lookupObjectFieldAttr( + slotCatalog('inlineHelpText', def.inlineHelpText), objectName, name, 'help', opts, + ); + if (translatedHelp) next.inlineHelpText = translatedHelp; + } else if (!authorsInlineHelpText(packagedField)) { + const translatedHelp = lookupObjectFieldAttr( + slotCatalog('description', def.description), objectName, name, 'help', opts, + ); + if (translatedHelp) next.description = translatedHelp; } + // The inline child grid's heading, under the same rule. The console's + // inline-subform pass copies the served value onto the parent's form, so + // this is the one place it is localized. An untranslated heading keeps its + // authored text — never the child's plural label in its place. + const translatedInlineTitle = lookupObjectFieldAttr( + slotCatalog('inlineTitle', def.inlineTitle), objectName, name, 'inlineTitle', opts, + ); + if (translatedInlineTitle) next.inlineTitle = translatedInlineTitle; // The related list's title on the parent's record page, under the same // rule: the console derives the list from the served child field, so this // is the one place it is localized, and an untranslated title keeps its // authored text. - if (!valueOverridesPackagedBase(packagedField, 'relatedListTitle', def.relatedListTitle)) { - const translatedRelatedListTitle = lookupObjectFieldAttr(bundle, objectName, name, 'relatedListTitle', opts); - if (translatedRelatedListTitle) next.relatedListTitle = translatedRelatedListTitle; - } + const translatedRelatedListTitle = lookupObjectFieldAttr( + slotCatalog('relatedListTitle', def.relatedListTitle), objectName, name, 'relatedListTitle', opts, + ); + if (translatedRelatedListTitle) next.relatedListTitle = translatedRelatedListTitle; if (Array.isArray(def.options)) { // A picklist-bound field is served with its list's options resolved // onto it (`PicklistServedFieldSchema`), and INHERITS the list's option @@ -4578,10 +4955,10 @@ export function translateObject( 'options', (candidate) => isOptionValue(candidate.value) && String(candidate.value) === String(opt.value), ); - if (valueOverridesPackagedBase(packagedOption, 'label', opt.label)) return opt; + const catalog = catalogFor(catalogs, valueOverridesPackagedBase(packagedOption, 'label', opt.label)); const translated = - lookupObjectFieldOption(bundle, objectName, name, opt.value, opts) ?? - (picklist !== undefined ? lookupPicklistOption(bundle, picklist, opt.value, opts) : undefined); + lookupObjectFieldOption(catalog, objectName, name, opt.value, opts) ?? + (picklist !== undefined ? lookupPicklistOption(catalog, picklist, opt.value, opts) : undefined); return translated ? { ...opt, label: translated } : opt; }); } @@ -4616,7 +4993,7 @@ export function translateObject( }) : undefined; - const listViews = translateObjectListViews(doc.listViews, bundle, objectName, opts); + const listViews = translateObjectListViews(doc.listViews, catalogs, objectName, opts); const authoredValidations = (doc as { validations?: unknown }).validations; const validations = translateObjectValidations(authoredValidations, bundle, objectName, opts); From 0d26e1243e65882235c103e3d8c00c5bce493577 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 02:13:24 +0000 Subject: [PATCH 2/3] test(spec): pin the overridden-item label order across all eight translators One table-driven block in i18n-resolver.test.ts, the same rows for view, dashboard, report, object, page, dataset, app and book: the pair's translation wins, with none the override's own label beats the base's translation, an unchanged label keeps the base's translation beside a changed one (nested labels included), and a pair that ships only `en` leaves an untouched label on the base's zh-CN. An empty pairBundle is pinned to give the same answer as none. labelCatalogs now hands back the catalogs it would without a pair when the pair answers none of the chain's locales, so an empty pair keeps the no-bundle reference identity too. Claude-Session: https://claude.ai/code/session_01WGufzhytXa3Yi3uzE5pvuv Co-authored-by: Claude --- .../spec/src/system/i18n-resolver.test.ts | 326 ++++++++++++++++++ packages/spec/src/system/i18n-resolver.ts | 17 +- 2 files changed, 338 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 491c550265d..6d53a4b086c 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -6081,3 +6081,329 @@ describe('resolveMetadataFormSchemaTitles — bundle labels overlaid as JSON Sch expect(out.properties.fields.items.properties.label.title).toBe('行标签'); }); }); + +// ──────────────────────────────────────────────────────────────────────────── +// #30193 — label precedence on an overridden item (ADR-0146 D2) +// ──────────────────────────────────────────────────────────────────────────── + +import { type TranslateDocumentOptions } from './i18n-resolver'; +import { ReportSchema } from '../ui/report.zod'; +import { BookSchema } from './book.zod'; + +describe("label precedence on an overridden item — the pair's translation, then the override's label, then the base's translation (ADR-0146 D2)", () => { + /** One translatable string of a served document, and the bundle address that translates it. */ + interface Slot { + readonly what: string; + readonly read: (doc: any) => unknown; + readonly write: (doc: any, text: string) => void; + /** The translation data addressing this string, holding `text`. */ + readonly entry: (text: string) => Record; + /** The base's `zh-CN` translation, as the deployment catalog carries it. */ + readonly baseZh: string; + } + + /** A type's packaged base and its strings: the top-level label first, then nested ones. */ + interface PrecedenceCase { + readonly base: () => any; + readonly slots: readonly Slot[]; + } + + const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + const merge = (...parts: Record[]): Record => { + const out: Record = {}; + for (const part of parts) { + for (const [key, value] of Object.entries(part)) { + const prior = out[key]; + out[key] = isRecord(prior) && isRecord(value) ? merge(prior, value) : value; + } + } + return out; + }; + const clone = (value: T): T => JSON.parse(JSON.stringify(value)); + /** A slot builder: `path` addresses the string inside the translation data. */ + const slot = ( + what: string, + read: (doc: any) => unknown, + write: (doc: any, text: string) => void, + path: readonly string[], + baseZh: string, + ): Slot => ({ + what, + read, + write, + baseZh, + entry: (text) => path.reduceRight((inner, key) => ({ [key]: inner }), text) as Record, + }); + + const viewContainer = { + listViews: { + in_progress: { + label: 'In Progress', + type: 'grid' as const, + data: { provider: 'object' as const, object: 'showcase_task' }, + columns: [{ field: 'title' }], + bulkActionDefs: [{ name: 'mark_done', label: 'Mark done', operation: 'update' as const, patch: { status: 'done' } }], + }, + }, + }; + const VIEW = ['objects', 'showcase_task', '_views', 'in_progress']; + + const CASES: Record = { + view: { + base: () => expandViewContainer('showcase_task', viewContainer).find((v) => v.name === 'showcase_task.in_progress'), + slots: [ + slot('label', (d) => d.label, (d, t) => { d.label = t; }, [...VIEW, 'label'], '进行中'), + slot('bulk action label', (d) => d.config.bulkActionDefs[0].label, (d, t) => { d.config.bulkActionDefs[0].label = t; }, + [...VIEW, 'bulkActions', 'mark_done', 'label'], '标记完成'), + ], + }, + dashboard: { + base: () => ({ name: 'system_overview', label: 'System Overview', widgets: [{ id: 'widget_total_users', title: 'Total Users' }] }), + slots: [ + slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['dashboards', 'system_overview', 'label'], '系统概览'), + slot('widget title', (d) => d.widgets[0].title, (d, t) => { d.widgets[0].title = t; }, + ['dashboards', 'system_overview', 'widgets', 'widget_total_users', 'title'], '用户总数'), + ], + }, + report: { + base: () => ReportSchema.parse({ + name: 'task_overview', + label: 'Task Overview', + type: 'joined', + blocks: [{ name: 'open_block', label: 'Open Tasks', type: 'summary', dataset: 'tasks', rows: ['status'], values: ['n'] }], + }), + slots: [ + slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['reports', 'task_overview', 'label'], '任务概览'), + slot('block label', (d) => d.blocks[0].label, (d, t) => { d.blocks[0].label = t; }, + ['reports', 'task_overview', 'blocks', 'open_block', 'label'], '未完成任务'), + ], + }, + object: { + base: () => ({ + name: 'showcase_task', + label: 'Task', + fields: { + title: { name: 'title', type: 'text', label: 'Title' }, + status: { name: 'status', type: 'select', label: 'Status', options: [{ value: 'todo', label: 'To Do' }] }, + }, + }), + slots: [ + slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['objects', 'showcase_task', 'label'], '任务'), + slot('field label', (d) => d.fields.title.label, (d, t) => { d.fields.title.label = t; }, + ['objects', 'showcase_task', 'fields', 'title', 'label'], '标题'), + slot('option label', (d) => d.fields.status.options[0].label, (d, t) => { d.fields.status.options[0].label = t; }, + ['objects', 'showcase_task', 'fields', 'status', 'options', 'todo'], '待办'), + ], + }, + page: { + base: () => ({ + name: 'ops_home', + label: 'Ops Home', + regions: [{ + name: 'main', + components: [ + { type: 'page:card', id: 'open_work', properties: { title: 'Open Work' } }, + { type: 'page:tabs', id: 'ops_tabs', properties: { items: [{ label: 'Mine', value: 'mine', children: [] }] } }, + ], + }], + }), + slots: [ + slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['pages', 'ops_home', 'label'], '运营首页'), + slot('component copy', (d) => d.regions[0].components[0].properties.title, + (d, t) => { d.regions[0].components[0].properties.title = t; }, + ['pages', 'ops_home', 'components', 'open_work', 'title'], '进行中的工作'), + slot('tab item label', (d) => d.regions[0].components[1].properties.items[0].label, + (d, t) => { d.regions[0].components[1].properties.items[0].label = t; }, + ['pages', 'ops_home', 'components', 'ops_tabs', 'items', 'mine', 'label'], '我的'), + ], + }, + dataset: { + base: () => DatasetSchema.parse({ + name: 'duty_pulse', + label: 'Duty pulse', + object: 'duly_duty', + dimensions: [{ name: 'owner', field: 'owner_id', label: 'Owner' }], + measures: [{ name: 'untouched_14d', aggregate: 'count', label: 'Untouched > 14 days' }], + }), + slots: [ + slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['datasets', 'duty_pulse', 'label'], '任务脉搏'), + slot('measure label', (d) => d.measures[0].label, (d, t) => { d.measures[0].label = t; }, + ['datasets', 'duty_pulse', 'measures', 'untouched_14d', 'label'], '停滞超过 14 天'), + slot('dimension label', (d) => d.dimensions[0].label, (d, t) => { d.dimensions[0].label = t; }, + ['datasets', 'duty_pulse', 'dimensions', 'owner', 'label'], '负责人'), + ], + }, + app: { + base: () => ({ + name: 'setup', + label: 'Setup', + navigation: [{ + id: 'group_overview', + type: 'group', + label: 'Overview', + children: [{ id: 'nav_users', type: 'object', label: 'Users' }], + }], + }), + slots: [ + slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['apps', 'setup', 'label'], '系统设置'), + slot('nested nav label', (d) => d.navigation[0].children[0].label, (d, t) => { d.navigation[0].children[0].label = t; }, + ['apps', 'setup', 'navigation', 'nav_users', 'label'], '用户'), + ], + }, + book: { + base: () => BookSchema.parse({ + name: 'crm_manual', + label: 'User Manual', + groups: [{ key: 'start', label: 'Getting started', include: 'crm_start_*' }], + }), + slots: [ + slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['books', 'crm_manual', 'label'], '使用手册'), + slot('group label', (d) => d.groups[0].label, (d, t) => { d.groups[0].label = t; }, + ['books', 'crm_manual', 'groups', 'start', 'label'], '快速上手'), + ], + }, + }; + + it('covers every document translator the ruling names — all eight types', () => { + expect(Object.keys(CASES).sort()).toEqual(['app', 'book', 'dashboard', 'dataset', 'object', 'page', 'report', 'view']); + for (const type of Object.keys(CASES)) expect(TRANSLATABLE_METADATA_TYPES.has(type), type).toBe(true); + }); + + for (const [type, c] of Object.entries(CASES)) { + describe(type, () => { + const base = c.base(); + const ownText = (s: Slot) => String(s.read(base)); + const overrideText = (s: Slot) => `${ownText(s)} (ours)`; + const pairText = (s: Slot) => `${s.baseZh}(配对)`; + // The deployment catalog: the base's translations. `en` repeats the + // shipped strings, as an extracted source locale does, so the source + // locale is as able to overwrite an override as `zh-CN` is. + const bundle: TranslationBundle = { + en: merge(...c.slots.map((s) => s.entry(ownText(s)))) as any, + 'zh-CN': merge(...c.slots.map((s) => s.entry(s.baseZh))) as any, + }; + /** The override: the base with `which` strings re-authored. */ + const overridden = (which: readonly Slot[] = c.slots) => { + const doc = clone(base); + for (const s of which) s.write(doc, overrideText(s)); + return doc; + }; + /** What the pair ships: a translation of each `which` string, in `locale`. */ + const pairOf = (locale: string, which: readonly Slot[] = c.slots): TranslationBundle => + ({ [locale]: merge(...which.map((s) => s.entry(pairText(s)))) }) as TranslationBundle; + const run = (doc: any, opts: TranslateDocumentOptions) => + translateMetadataDocument(type, doc, bundle, { packagedBase: base, ...opts }); + + it('composes the fixture this block is pinned to — the catalog translates every string of the base', () => { + const out = run(clone(base), { locale: 'zh-CN' }); + for (const s of c.slots) expect(s.read(out), s.what).toBe(s.baseZh); + }); + + it("the pair wins — its translation beats the override's own label, top-level and nested", () => { + const out = run(overridden(), { locale: 'zh-CN', pairBundle: pairOf('zh-CN') }); + for (const s of c.slots) expect(s.read(out), s.what).toBe(pairText(s)); + }); + + it("with no pair translation the override's own label beats the base's translation — in zh-CN and in the source locale", () => { + for (const locale of ['zh-CN', 'en']) { + // No pair at all (today's call), and a pair that ships nothing for this item. + for (const pairBundle of [undefined, { [locale]: {} } as TranslationBundle]) { + const out = run(overridden(), { locale, pairBundle }); + for (const s of c.slots) expect(s.read(out), `${s.what} · ${locale} · pair ${pairBundle ? 'empty' : 'absent'}`).toBe(overrideText(s)); + } + } + }); + + it("a label the override did not change keeps the base's translation, beside one it changed", () => { + for (const changed of c.slots) { + const out = run(overridden([changed]), { locale: 'zh-CN', pairBundle: pairOf('zh-CN', [changed]) }); + for (const s of c.slots) { + expect(s.read(out), `${changed.what} changed · ${s.what}`).toBe(s === changed ? pairText(s) : s.baseZh); + } + } + }); + + it("an untouched label takes the pair's translation where the pair ships one", () => { + const out = run(clone(base), { locale: 'zh-CN', pairBundle: pairOf('zh-CN') }); + for (const s of c.slots) expect(s.read(out), s.what).toBe(pairText(s)); + }); + + it("per locale: a pair that ships only `en` leaves an untouched label on the base's zh-CN", () => { + const [changed, ...untouched] = c.slots; + const out = run(overridden([changed]), { locale: 'zh-CN', fallbackChain: ['en'], pairBundle: pairOf('en') }); + for (const s of untouched) expect(s.read(out), s.what).toBe(s.baseZh); + // The changed one reads the pair alone, along the chain. + expect(changed.read(out), changed.what).toBe(pairText(changed)); + }); + + it('no packaged base supplied — nothing counts as changed, so every string reads the pair, then the catalog', () => { + const [first, ...rest] = c.slots; + const out = run(overridden(), { locale: 'zh-CN', packagedBase: undefined, pairBundle: pairOf('zh-CN', [first]) }); + expect(first.read(out), first.what).toBe(pairText(first)); + for (const s of rest) expect(s.read(out), s.what).toBe(s.baseZh); + }); + + it('an empty pairBundle is the same answer as none — byte for byte, with and without a base, in every reading', () => { + const readings: TranslateDocumentOptions[] = [ + { locale: 'zh-CN' }, { locale: 'en' }, { locale: 'zh-CN', fallbackChain: ['en'] }, { locale: 'fr-FR' }, + ]; + for (const doc of [clone(base), overridden(), overridden(c.slots.slice(0, 1))]) { + for (const reading of readings) { + for (const packagedBase of [base, undefined]) { + const opts = { ...reading, packagedBase }; + const without = translateMetadataDocument(type, doc, bundle, opts); + for (const pairBundle of [{}, { 'de-DE': { apps: {} } }] as TranslationBundle[]) { + expect(JSON.stringify(translateMetadataDocument(type, doc, bundle, { ...opts, pairBundle }))).toBe(JSON.stringify(without)); + } + } + } + // No catalog at all: the same answer, and the input reference back + // wherever the translator hands it back without a pair. + const bare = translateMetadataDocument(type, doc, undefined, { locale: 'zh-CN', packagedBase: base }); + const withEmpty = translateMetadataDocument(type, doc, undefined, { locale: 'zh-CN', packagedBase: base, pairBundle: {} }); + expect(withEmpty).toStrictEqual(bare); + if (bare === doc) expect(withEmpty).toBe(doc); + } + }); + + it('reads neither input: the served document, the base and the pair come back as they went in', () => { + const doc = overridden(); + const pair = pairOf('zh-CN'); + const before = JSON.stringify([doc, base, pair, bundle]); + run(doc, { locale: 'zh-CN', pairBundle: pair }); + expect(JSON.stringify([doc, base, pair, bundle])).toBe(before); + }); + }); + } + + describe('page — the header title restates the label it was written against', () => { + const base = { + name: 'ops_home', + label: 'Ops Home', + regions: [{ name: 'header', components: [{ type: 'page:header', properties: { title: 'Ops Home' } }] }], + }; + const bundle: TranslationBundle = { 'zh-CN': { pages: { ops_home: { label: '运营首页' } } } as any }; + const title = (doc: any) => doc.regions[0].components[0].properties.title; + const relabelled = (headerToo: boolean) => { + const doc = clone(base); + doc.label = 'Our Ops'; + if (headerToo) doc.regions[0].components[0].properties.title = 'Our Ops'; + return doc; + }; + + it("a header title the override left as the base wrote it translates as the base's label, though the label changed", () => { + const out: any = translatePage(relabelled(false), bundle, { locale: 'zh-CN', packagedBase: base }); + expect(out.label).toBe('Our Ops'); + expect(title(out)).toBe('运营首页'); + }); + + it("a header title the override re-authored restates the override's label — its own text, or the pair's translation of it", () => { + expect(title(translatePage(relabelled(true), bundle, { locale: 'zh-CN', packagedBase: base }))).toBe('Our Ops'); + const pairBundle: TranslationBundle = { 'zh-CN': { pages: { ops_home: { label: '我们的运营' } } } as any }; + const out: any = translatePage(relabelled(true), bundle, { locale: 'zh-CN', packagedBase: base, pairBundle }); + expect(out.label).toBe('我们的运营'); + expect(title(out)).toBe('我们的运营'); + }); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 0ec72ccf45e..5bae7180d0c 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -440,8 +440,9 @@ interface LabelCatalogs { * pair that ships only `en` never displaces the base's `zh-CN` on a string * it left alone. * - * With no `pairBundle` the unchanged catalog IS `bundle` (the same reference) - * and the changed one is empty, which is exactly what every translator did + * With no `pairBundle` — or one that answers none of the chain's locales — the + * unchanged catalog IS `bundle` (the same reference) and the changed one is + * empty, which is exactly what every translator did * before the option existed: a changed string keeps its text, an unchanged * one reads `bundle`. With no `packagedBase` nothing counts as changed. */ @@ -451,14 +452,20 @@ function labelCatalogs( ): LabelCatalogs { const pair = opts?.pairBundle; if (!pair) return { unchanged: bundle, changed: undefined }; + const chain = localeChain(opts); + const pairData = chain.map((code) => pickData(pair, code)); + // A pair that answers none of the chain's locales adds nothing to read, so + // the catalogs are the ones without it — the same references, which is what + // keeps "no bundle" handing back the input document untouched. + if (pairData.every((data) => data === undefined)) return { unchanged: bundle, changed: undefined }; // Keyed by the chain's own codes, each resolved against both bundles the // way `pickData` resolves it, so every lookup on the result hits exactly. // A code neither bundle answers gets `{}`: a bare miss would let // `pickData` re-resolve the code against the OTHER codes kept here. const unchanged: Record = {}; - for (const code of localeChain(opts)) { - unchanged[code] = overlayTranslationData(pickData(bundle, code), pickData(pair, code)) ?? {}; - } + chain.forEach((code, index) => { + unchanged[code] = overlayTranslationData(pickData(bundle, code), pairData[index]) ?? {}; + }); return { unchanged: unchanged as TranslationBundle, changed: pair }; } From 8476878165579f66320292abdddcdbaa156d6149 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 02:17:25 +0000 Subject: [PATCH 3/3] chore(changeset): spec patch for the overridden-item label order Claude-Session: https://claude.ai/code/session_01WGufzhytXa3Yi3uzE5pvuv Co-authored-by: Claude --- .changeset/30193-override-label-pair-precedence.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/30193-override-label-pair-precedence.md diff --git a/.changeset/30193-override-label-pair-precedence.md b/.changeset/30193-override-label-pair-precedence.md new file mode 100644 index 00000000000..87f30db905b --- /dev/null +++ b/.changeset/30193-override-label-pair-precedence.md @@ -0,0 +1,14 @@ +--- +'@objectstack/spec': patch +--- + +feat(i18n): one label order on an overridden item, for all eight document translators — the pair's translation, then the override's own label, then the base's translation + +Clause-②: yes (widening) +Metadata-protocol: none + +- **New option `TranslateDocumentOptions.pairBundle`.** The translations the served item's paired `.custom` package ships itself (ADR-0146 D2/D3), locale-keyed as `bundle` is. The serving layer's `bundle` is one merged catalog per locale with the package ids dropped, so only this option can tell a translation the pair ships from the base's. +- **The order, per translatable string.** A string the override changed (`valueOverridesPackagedBase` against `packagedBase`, the comparison every translator already makes) reads the pair's translations only, along the locale chain, and otherwise keeps its own text: the base's translation is never read for it. A string the override left alone reads, locale by locale, the pair's translation and then the deployment catalog, so a pair that ships only `en` never displaces the base's `zh-CN` on an untouched label. Nested labels follow the same rule: widget titles, filter and option labels, view bulk-action defs and form sections, report blocks, field labels, help and options, page component copy, tab items and the header, dataset members, app navigation nodes and book groups. +- **`translatePage`, `translateDataset`, `translateApp` and `translateBook` now read `packagedBase`** (their third parameter widens from `ResolveOptions` to `TranslateDocumentOptions`), each string judged against the base's counterpart: the page's components by id and its header, the dataset's members by `name`, the app's navigation nodes by `id`, the book's groups by `key`. A page header title the override left as the base wrote it translates as the base's label. +- **Nothing moves for an existing caller.** With no `pairBundle` (or one that answers none of the chain's locales) every translator gives exactly the answer it gave before, and with no `packagedBase` nothing counts as changed. Passing both for a `.custom` override is the serving layer's half (#30062). +- An object's `validations[].message` and its inline `actions` keep reading the merged catalog: the rule evaluator answers the same message from `i18n.t()` with no pair input, and the refusal text must equal the served text.