Skip to content

Commit cc8968f

Browse files
committed
fix(spec): the page-name route is canonical for a region-level page:header
`translatePage` stops reading the id route (`pages.PAGE.components.HEADERID.*`) for a `page:header` at region level. That route was live and PREFERRED there, while the CLI extractor deliberately offers nothing under it for the same component -- so every key read through it was a key no tooling ever offered, counted or reported, and the header's `title` had two addresses while its `subtitle` had one. The gate mirrors the extractor's emission exception in `collectExpectedEntries` (same shape, opposite verb), so the failure pair `walkAddressedPageComponents` exists to prevent cannot reopen from the resolver side. A `page:header` nested inside a container is unchanged: the page-name route does not reach it, so it stays id-only. The walk's `addressed` arbitration is untouched -- a region-level header's id still claims its entry and still blocks a nested namesake, which is what the extractor does too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
1 parent b0529e1 commit cc8968f

4 files changed

Lines changed: 226 additions & 23 deletions

File tree

packages/cli/test/platform-page-i18n-parity.test.ts

Lines changed: 150 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -352,14 +352,18 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => {
352352
// Both directions, named separately so a failure says WHICH half broke.
353353
expect({ offeredButIgnored: [...offered].filter((id) => !applied.has(id)).sort() })
354354
.toEqual({ offeredButIgnored: [] });
355-
// The ONE standing exception, pre-dating this card and deliberate: a
356-
// region-level `page:header`'s copy is offered under `pages.PAGE.title` /
357-
// `.subtitle` instead, because emitting it here too would offer one string
358-
// under two keys. The resolver still honours the id route for it, so it
359-
// shows up as applied-not-offered — listed explicitly rather than filtered
360-
// out of the fixture, so the exception stays visible and bounded to one id.
355+
// NO standing exception any more. This list used to read `['hdr']` — a
356+
// region-level `page:header` carrying an id, whose copy the extractor
357+
// deliberately offers under `pages.PAGE.title` / `.subtitle` while the
358+
// resolver went on honouring the id route for it and PREFERRING it. That
359+
// was the second half of the failure pair `walkAddressedPageComponents`
360+
// exists to prevent (the resolver reading an id the extractor omits), and
361+
// the 2026-09-06 ruling (decision batch #58) closed it by making the
362+
// page-name route canonical for this component. Empty in BOTH directions
363+
// is now the invariant; an entry reappearing here is a regression, not an
364+
// exception to document.
361365
expect({ appliedButNotOffered: [...applied].filter((id) => !offered.has(id)).sort() })
362-
.toEqual({ appliedButNotOffered: ['hdr'] });
366+
.toEqual({ appliedButNotOffered: [] });
363367
});
364368

365369
it('pins the two sets by name, so a shape that stops being reachable is visible', () => {
@@ -369,11 +373,141 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => {
369373
]);
370374
// `card_body_child`, `card_footer_child`, `tab_child` and `slot_child` are
371375
// absent from BOTH sides — the shapes `translatePage` does not descend.
376+
// `hdr` — the region-level `page:header` — is absent from BOTH sides since
377+
// the ruling. `nested_header` stays: a `page:header` inside a container is
378+
// reached by the id route only, so the id key is the only key it has.
372379
expect([...idsResolverApplies(page)].sort()).toEqual([
373-
'card', 'hdr', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric',
380+
'card', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric',
374381
]);
375382
});
376383

384+
// ── The ruled invariant, pinned directly (decision batch #58, 2026-09-06) ──
385+
//
386+
// Maintainer 「同意」, option 1, verbatim: "The page-name route is canonical
387+
// for a region-level `page:header`. `translatePage` stops reading the id
388+
// route (`pages.PAGE.components.HEADERID.*`) for a `page:header` at region
389+
// level; a `page:header` nested inside a container stays id-only, as its doc
390+
// already says. One component, one address — `title` and `subtitle` now
391+
// follow the same rule."
392+
//
393+
// The set comparisons above measure `title` alone, because that is the key
394+
// both fixtures carry. This case walks the WHOLE shared key list and asserts
395+
// BOTH verbs of the failure pair on one component, in one place: the id key
396+
// is neither OFFERED nor READ. It also pins the three things that must NOT
397+
// move with it — the page-name route still translates the header, a nested
398+
// `page:header` is still id-addressed, and a bundle carrying both routes
399+
// resolves to the page-name one — so a regression that simply stops
400+
// translating region-level headers cannot pass here either.
401+
const headerRoutePage = (): Record<string, any> => ({
402+
name: 'header_route_page',
403+
regions: [
404+
{
405+
name: 'top',
406+
components: [
407+
// Region level, WITH an id — the shape the card measured (hotcrm's
408+
// five headers all carry one).
409+
{ id: 'home_header', type: 'page:header', properties: { title: 'Sales Home', subtitle: 'Welcome back' } },
410+
],
411+
},
412+
{
413+
name: 'main',
414+
components: [
415+
{
416+
id: 'wrap',
417+
type: 'page:card',
418+
properties: {
419+
title: 'Wrap',
420+
children: [
421+
// Nested — the page-name route does not reach it, so the id
422+
// route is the ONLY route it has. Unchanged by the ruling.
423+
{ id: 'inner_header', type: 'page:header', properties: { title: 'Inner header' } },
424+
],
425+
},
426+
},
427+
],
428+
},
429+
],
430+
});
431+
432+
/** Every key of the shared list under one id, sentinel-valued. */
433+
const everyKeyFor = (id: string): Record<string, string> =>
434+
Object.fromEntries(PAGE_COMPONENT_COPY_KEYS.map((k) => [k, `ID::${id}::${k}`]));
435+
436+
it('neither offers nor reads the id key for a region-level `page:header` (batch #58)', () => {
437+
const page = headerRoutePage();
438+
439+
// Half one — the extractor offers NOTHING under `components.home_header`,
440+
// for any key of the shared list.
441+
expect(componentRows(page).filter((r) => r.key.startsWith('home_header.'))).toEqual([]);
442+
443+
// Half two — the resolver reads nothing there either. The bundle offers
444+
// every key of the shared list under that id; a sentinel reaching the
445+
// output would mean the id route is still live for this component.
446+
const idBundle = {
447+
en: {
448+
pages: {
449+
header_route_page: {
450+
components: { home_header: everyKeyFor('home_header'), wrap: everyKeyFor('wrap') },
451+
},
452+
},
453+
},
454+
} as any;
455+
const viaId = translatePage(page as any, idBundle, { locale: 'en' });
456+
const header = viaId.regions[0].components[0];
457+
expect(header.properties).toEqual({ title: 'Sales Home', subtitle: 'Welcome back' });
458+
expect(header.label).toBeUndefined();
459+
// Positive control for that empty: the SAME bundle shape does reach a
460+
// component the id route serves, so the two assertions above are a reading
461+
// and not an inert fixture.
462+
expect(viaId.regions[1].components[0].properties.title).toEqual('ID::wrap::title');
463+
464+
// Half three — the page-name route still translates the header. The fix is
465+
// "one address", not "no address".
466+
const nameBundle = {
467+
en: { pages: { header_route_page: { title: 'BY-NAME', subtitle: 'SUB-BY-NAME' } } },
468+
} as any;
469+
const viaName = translatePage(page as any, nameBundle, { locale: 'en' });
470+
expect(viaName.regions[0].components[0].properties)
471+
.toEqual({ title: 'BY-NAME', subtitle: 'SUB-BY-NAME' });
472+
// ...and it stops at region level: the nested header keeps its literal.
473+
expect(viaName.regions[1].components[0].properties.children[0].properties.title)
474+
.toEqual('Inner header');
475+
476+
// Half four — a nested `page:header` is still reached, by the id route.
477+
const nestedBundle = {
478+
en: {
479+
pages: {
480+
header_route_page: { title: 'BY-NAME', components: { inner_header: { title: 'ID::inner' } } },
481+
},
482+
},
483+
} as any;
484+
const viaNested = translatePage(page as any, nestedBundle, { locale: 'en' });
485+
expect(viaNested.regions[1].components[0].properties.children[0].properties.title)
486+
.toEqual('ID::inner');
487+
// The extractor offers that nested id, which is the other half of "stays
488+
// id-only" — the two sides agree about it as much as about the region one.
489+
expect(componentRows(page).filter((r) => r.key === 'inner_header.title'))
490+
.toEqual([{ key: 'inner_header.title', value: 'Inner header' }]);
491+
492+
// Half five — the card's Leg A shape 3, inverted. Both routes present:
493+
// BEFORE the ruling the id route won here ("ZH-by-ID"); the page-name
494+
// route is canonical now, so the components key is inert on this component
495+
// and the bundle that overrode a header title through it falls back.
496+
const bothBundle = {
497+
en: {
498+
pages: {
499+
header_route_page: {
500+
title: 'BY-NAME',
501+
subtitle: 'SUB-BY-NAME',
502+
components: { home_header: { title: 'BY-ID' } },
503+
},
504+
},
505+
},
506+
} as any;
507+
expect(translatePage(page as any, bothBundle, { locale: 'en' }).regions[0].components[0].properties)
508+
.toEqual({ title: 'BY-NAME', subtitle: 'SUB-BY-NAME' });
509+
});
510+
377511
it('carries the whole shared key list down into nesting, label either/or included', () => {
378512
const rows = componentRows(walkParityPage());
379513
// `label` authored at the component's top level, the same either/or
@@ -414,7 +548,10 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => {
414548
.toEqual([{ key: 'twice.title', value: 'First nested wins' }]);
415549
// A region-level `page:header` emits nothing here, but its id still BLOCKS
416550
// a nested namesake — the resolver counts it as region-level, so offering
417-
// the nested one would be a key the resolver ignores.
551+
// the nested one would be a key the resolver ignores. The ruling changed
552+
// what the resolver READS for that id, not who OWNS it: the arbitration is
553+
// a property of the document and both consumers still decide it the same
554+
// way, which is why this half is unchanged.
418555
expect(rows.filter((r) => r.key.startsWith('hdr_id.'))).toEqual([]);
419556

420557
// And the resolver agrees about which component the entry lands on.
@@ -432,7 +569,10 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => {
432569
const translated = translatePage(page as any, bundle, { locale: 'en' });
433570
const region = translated.regions[0].components;
434571
expect(region[0].properties.title).toEqual('S');
435-
expect(region[1].properties.title).toEqual('H');
572+
// `hdr_id` is the region-level `page:header`: the bundle's `H` under
573+
// `components.hdr_id.title` is NOT read, and this page has no
574+
// `pages.walk_collision_page.title` either, so the authored literal stands.
575+
expect(region[1].properties.title).toEqual('Header holds this id');
436576
const nested = region[2].properties.children;
437577
expect(nested.map((c: any) => c.properties.title)).toEqual([
438578
'Nested namesake loses',

packages/spec/src/system/i18n-resolver.test.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,7 +1368,20 @@ describe('translatePage', () => {
13681368
expect(byId(out, 'ai_briefing').properties.description).toBe('Open the assistant panel.');
13691369
});
13701370

1371-
it('lets the id-addressed route win over the page-name route on a header that has an id', () => {
1371+
// Ruled 2026-09-06 (maintainer, verbatim 「同意」, decision batch #58,
1372+
// option 1): "The page-name route is canonical for a region-level
1373+
// `page:header`. `translatePage` stops reading the id route
1374+
// (`pages.PAGE.components.HEADERID.*`) for a `page:header` at region
1375+
// level; a `page:header` nested inside a container stays id-only, as its
1376+
// doc already says. One component, one address — `title` and `subtitle`
1377+
// now follow the same rule."
1378+
//
1379+
// This test previously asserted the OPPOSITE (`title` resolving to
1380+
// `快速新建`, the id route beating the page-name one). It is inverted, not
1381+
// deleted, because the inversion IS the behaviour change the ruling
1382+
// records: the id route was live for this component and preferred, and a
1383+
// bundle that used it now falls back to the page-name key.
1384+
it('reads a region-level `page:header` by page name only, id or no id (batch #58)', () => {
13721385
const doc = {
13731386
name: 'sales_home_page',
13741387
regions: [{
@@ -1384,10 +1397,15 @@ describe('translatePage', () => {
13841397
}],
13851398
};
13861399
const out = translatePage(doc, homeBundle, { locale: 'zh-CN' });
1387-
// `components.quick_create.title` is more specific than `pages.<name>.label`.
1388-
expect(out.regions[0].components[0].properties.title).toBe('快速新建');
1389-
// The page-name route still supplies what the id route did not.
1400+
// `components.quick_create.title` (`快速新建`) is NOT read here — the
1401+
// page-name route is, falling back to `pages.<name>.label` for `title`.
1402+
expect(out.regions[0].components[0].properties.title).toBe('销售看板');
13901403
expect(out.regions[0].components[0].properties.subtitle).toBe('欢迎回来');
1404+
// Control: the very same bundle entry still reaches the component that
1405+
// actually owns it, so the assertion above is about the header's route
1406+
// and not about a bundle that stopped resolving.
1407+
expect(byId(translatePage(homePage(), homeBundle, { locale: 'zh-CN' }), 'quick_create')
1408+
.properties.title).toBe('快速新建');
13911409
});
13921410

13931411
it('does not mutate the input page', () => {

packages/spec/src/system/i18n-resolver.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1663,12 +1663,25 @@ export function walkAddressedPageComponents(
16631663
* `pages.<name>.label` so translators need not repeat a string that is normally
16641664
* identical to the page's nav label.
16651665
*
1666+
* **One component, one address.** A REGION-LEVEL `page:header` is addressed by
1667+
* page name and by nothing else: the id route
1668+
* (`pages.<name>.components.<id>.*`) is NOT read for it, even when it carries
1669+
* an id (ruled 2026-09-06, decision batch #58 — the page-name route is
1670+
* canonical). It used to be read there and to WIN, which put the header's
1671+
* `title` under two addresses while its `subtitle` — not in
1672+
* {@link PAGE_COMPONENT_COPY_KEYS} — only ever had one; the ruling makes the
1673+
* two keys of one component follow the same rule. It is also the half of the
1674+
* failure pair {@link walkAddressedPageComponents} exists to prevent that was
1675+
* still open: the CLI extractor deliberately offers nothing under
1676+
* `components.<id>` for this component, so every key read here was a key no
1677+
* tooling ever offered, counted or reported.
1678+
*
16661679
* Every OTHER component is addressed by its own `id` through
16671680
* `pages.<name>.components.<id>` (#6080), which overlays that component's
16681681
* `properties` — the page half of what `dashboards.<name>.widgets.<id>` has
1669-
* always given dashboards. Because the id route is the more specific of the
1670-
* two, it wins wherever both could apply (a `page:header` that does carry an
1671-
* id).
1682+
* always given dashboards. That includes a `page:header` NESTED in a
1683+
* container, which the page-name route does not reach (below) and which is
1684+
* therefore id-only.
16721685
*
16731686
* Components nested in a container's declared `properties.children` array are
16741687
* visited too, recursively (#12961, ruled 2026-08-29). This REVERSES the
@@ -1740,8 +1753,24 @@ export function translatePage<T extends PageLike>(
17401753
// within one call `lookupPageComponentCopy` is a pure function of the id
17411754
// (bundle, page name and options are fixed), so the walk's claim-on-first-
17421755
// sighting selects the same component a claim-on-resolved-lookup would.
1756+
//
1757+
// The one component this route does NOT serve is a REGION-LEVEL
1758+
// `page:header`: its copy is addressed by page name below, and reading
1759+
// `components.<id>` for it too would give one string two addresses. The
1760+
// condition is written to MIRROR the extractor's emission exception in
1761+
// `collectExpectedEntries` (`packages/cli`) — same shape, opposite verb —
1762+
// so the pair the shared walk exists to prevent cannot reopen from this
1763+
// side. `nested` keeps a `page:header` inside a container on the id route,
1764+
// which is the only route that reaches it.
1765+
//
1766+
// The walk's `addressed` arbitration is deliberately NOT touched: a
1767+
// region-level `page:header`'s id still CLAIMS its bundle entry and still
1768+
// blocks a nested namesake. Which component owns an id is a property of
1769+
// the document, decided identically for every consumer of the walk; only
1770+
// whether this consumer READS the entry changes here, and the extractor
1771+
// blocks the namesake the same way.
17431772
let copy: Partial<Record<PageComponentCopyKey, string>> | undefined;
1744-
if (addressed) {
1773+
if (addressed && (nested || component.type !== PAGE_HEADER_COMPONENT)) {
17451774
copy = lookupPageComponentCopy(bundle, name, id as string, opts);
17461775
}
17471776

@@ -1774,9 +1803,11 @@ export function translatePage<T extends PageLike>(
17741803
...next,
17751804
properties: {
17761805
...next.properties,
1777-
// The id-addressed copy above is more specific — do not overwrite what
1778-
// it already resolved for this header.
1779-
...(headerTitle !== undefined && copy?.title === undefined ? { title: headerTitle } : {}),
1806+
// No `copy?.title` guard: the id route is not read for a region-level
1807+
// `page:header` at all, so there is nothing here to defer to. A guard
1808+
// that can never fire is a phantom check — it would read as "the id
1809+
// route still wins sometimes", which is exactly what the ruling ended.
1810+
...(headerTitle !== undefined ? { title: headerTitle } : {}),
17801811
...(headerSubtitle !== undefined ? { subtitle: headerSubtitle } : {}),
17811812
},
17821813
};

packages/spec/src/system/translation.zod.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,16 @@ const translationDataShape = () => ({
835835
* because `page:header` instances carry no stable `id`; the page name is the
836836
* only addressable identifier for them. Every other component does have one,
837837
* which is what `components` addresses — see its own note.
838+
*
839+
* **One component, one address.** For a REGION-LEVEL `page:header` the two
840+
* keys above are the ONLY route, even when the component happens to carry an
841+
* `id`: `translatePage` does not read `components.<id>` for it (ruled
842+
* 2026-09-06, decision batch #58 — the page-name route is canonical). Before
843+
* that ruling the id route was read here and WON, so a bundle could address
844+
* one header `title` two ways while `subtitle` — excluded from `components`
845+
* for exactly this reason, see its note below — only ever had one. A
846+
* `page:header` NESTED inside a container is a different component: the
847+
* page-name route does not reach it, so it stays id-only.
838848
*/
839849
pages: z.record(z.string(), strictObject({
840850
surface: 'this page translation',
@@ -868,7 +878,7 @@ const translationDataShape = () => ({
868878
*
869879
* | key | declared by |
870880
* |:---|:---|
871-
* | `title` | `page:card`, `record:related_list` (and `page:header`, see below) |
881+
* | `title` | `page:card`, `record:related_list` (and a NESTED `page:header` — a region-level one is page-name-addressed, see the `subtitle` note below) |
872882
* | `label` | `page:tabs`, `page:accordion`, `record:details`, `record:related_list`, `record:path`, `element:button`, `element:record_picker`, `element:text_input` |
873883
* | `description` | `element:text_input` |
874884
* | `placeholder` | `element:record_picker`, `element:text_input` |
@@ -896,7 +906,11 @@ const translationDataShape = () => ({
896906
* - **`subtitle` is not here** — `page:header` is its only declarer, and
897907
* that component is addressed by page name above. Declaring it in both
898908
* places would give one string two spellings, which is how the
899-
* dashboards/pages asymmetry started.
909+
* dashboards/pages asymmetry started. The 2026-09-06 ruling settled the
910+
* same question for its sibling `title` in the same direction: a
911+
* region-level `page:header` is read by page name only, so the component
912+
* now has ONE address for both of its keys rather than two for one of
913+
* them.
900914
* - **`content` is not here** — `element:text`'s one authored string is
901915
* declared `content: I18nLabelSchema` (`ui/component.zod.ts`), so it is
902916
* localizable at its own authoring site, and adding it to this face would

0 commit comments

Comments
 (0)