Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/page-header-page-name-route-canonical.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@objectstack/spec": minor
---

`translatePage` now reads a region-level `page:header` by **page name only**. The id route
(`pages.<page>.components.<headerId>.*`) is no longer read for that component, even when it carries an `id`.

**Behaviour change, stated plainly:** a bundle that overrode a region-level header's title through
`pages.<page>.components.<headerId>.title` now falls back to `pages.<page>.title` (which itself falls back to
`pages.<page>.label`). The components key still parses — nothing is removed from `TranslationBundleSchema` — it is
simply no longer the address for this one component.

```
FROM pages.<page>.components.<headerId>.title # region-level page:header — no longer read
TO pages.<page>.title # …and .subtitle for the subtitle
```

Fix in one line: move the string from the components entry up to the page's own `title` key, and delete the
components entry for that header id. `os i18n extract` has always offered exactly the `TO` key, so a bundle
generated or checked by the CLI already writes it.

**Blast radius, as measured on the card (inherited, not re-measured here):** HotCRM found **zero** such overrides —
all five of its region-level headers carry ids and none writes the components key.

**Why.** Both sides were deliberate and they disagreed. The extractor skips a region-level `page:header` on purpose
(its copy is offered under `pages.<page>.title` / `.subtitle`, and emitting it twice would put one string under two
keys); the resolver read the id route on purpose (the more specific route wins). Together they produced the exact
failure `walkAddressedPageComponents` was extracted to prevent — the resolver reading an id the extractor omits — so
the key an author reached for won silently while the key the tooling reported as translated lost. The maintainer
ruled (2026-09-06, decision batch #58, verbatim 「同意」) that the page-name route is canonical: one component, one
address. `title` and `subtitle` now follow the same rule, closing the asymmetry where `title` had two addresses and
`subtitle` — never in `PAGE_COMPONENT_COPY_KEYS` — had one.

Unchanged: a `page:header` **nested** inside a container is reached by the id route only, as it always has been; and
a region-level header's id still claims its bundle entry and still blocks a nested namesake, which is what the
extractor does too.

Not `major`: nothing an author can write is removed or renamed. `pages.<page>.components.<id>` remains a declared,
parsing, resolving address for every other component — including a nested `page:header` — so there is no key to
tombstone and no ADR-0087 conversion to register. Recorded here so the choice is checkable rather than assumed.
160 changes: 150 additions & 10 deletions packages/cli/test/platform-page-i18n-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,14 +352,18 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => {
// Both directions, named separately so a failure says WHICH half broke.
expect({ offeredButIgnored: [...offered].filter((id) => !applied.has(id)).sort() })
.toEqual({ offeredButIgnored: [] });
// The ONE standing exception, pre-dating this card and deliberate: a
// region-level `page:header`'s copy is offered under `pages.PAGE.title` /
// `.subtitle` instead, because emitting it here too would offer one string
// under two keys. The resolver still honours the id route for it, so it
// shows up as applied-not-offered — listed explicitly rather than filtered
// out of the fixture, so the exception stays visible and bounded to one id.
// NO standing exception any more. This list used to read `['hdr']` — a
// region-level `page:header` carrying an id, whose copy the extractor
// deliberately offers under `pages.PAGE.title` / `.subtitle` while the
// resolver went on honouring the id route for it and PREFERRING it. That
// was the second half of the failure pair `walkAddressedPageComponents`
// exists to prevent (the resolver reading an id the extractor omits), and
// the 2026-09-06 ruling (decision batch #58) closed it by making the
// page-name route canonical for this component. Empty in BOTH directions
// is now the invariant; an entry reappearing here is a regression, not an
// exception to document.
expect({ appliedButNotOffered: [...applied].filter((id) => !offered.has(id)).sort() })
.toEqual({ appliedButNotOffered: ['hdr'] });
.toEqual({ appliedButNotOffered: [] });
});

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

// ── The ruled invariant, pinned directly (decision batch #58, 2026-09-06) ──
//
// Maintainer 「同意」, option 1, verbatim: "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; a `page:header` nested inside a container stays id-only, as its doc
// already says. One component, one address — `title` and `subtitle` now
// follow the same rule."
//
// The set comparisons above measure `title` alone, because that is the key
// both fixtures carry. This case walks the WHOLE shared key list and asserts
// BOTH verbs of the failure pair on one component, in one place: the id key
// is neither OFFERED nor READ. It also pins the three things that must NOT
// move with it — the page-name route still translates the header, a nested
// `page:header` is still id-addressed, and a bundle carrying both routes
// resolves to the page-name one — so a regression that simply stops
// translating region-level headers cannot pass here either.
const headerRoutePage = (): Record<string, any> => ({
name: 'header_route_page',
regions: [
{
name: 'top',
components: [
// Region level, WITH an id — the shape the card measured (hotcrm's
// five headers all carry one).
{ id: 'home_header', type: 'page:header', properties: { title: 'Sales Home', subtitle: 'Welcome back' } },
],
},
{
name: 'main',
components: [
{
id: 'wrap',
type: 'page:card',
properties: {
title: 'Wrap',
children: [
// Nested — the page-name route does not reach it, so the id
// route is the ONLY route it has. Unchanged by the ruling.
{ id: 'inner_header', type: 'page:header', properties: { title: 'Inner header' } },
],
},
},
],
},
],
});

/** Every key of the shared list under one id, sentinel-valued. */
const everyKeyFor = (id: string): Record<string, string> =>
Object.fromEntries(PAGE_COMPONENT_COPY_KEYS.map((k) => [k, `ID::${id}::${k}`]));

it('neither offers nor reads the id key for a region-level `page:header` (batch #58)', () => {
const page = headerRoutePage();

// Half one — the extractor offers NOTHING under `components.home_header`,
// for any key of the shared list.
expect(componentRows(page).filter((r) => r.key.startsWith('home_header.'))).toEqual([]);

// Half two — the resolver reads nothing there either. The bundle offers
// every key of the shared list under that id; a sentinel reaching the
// output would mean the id route is still live for this component.
const idBundle = {
en: {
pages: {
header_route_page: {
components: { home_header: everyKeyFor('home_header'), wrap: everyKeyFor('wrap') },
},
},
},
} as any;
const viaId = translatePage(page as any, idBundle, { locale: 'en' });
const header = viaId.regions[0].components[0];
expect(header.properties).toEqual({ title: 'Sales Home', subtitle: 'Welcome back' });
expect(header.label).toBeUndefined();
// Positive control for that empty: the SAME bundle shape does reach a
// component the id route serves, so the two assertions above are a reading
// and not an inert fixture.
expect(viaId.regions[1].components[0].properties.title).toEqual('ID::wrap::title');

// Half three — the page-name route still translates the header. The fix is
// "one address", not "no address".
const nameBundle = {
en: { pages: { header_route_page: { title: 'BY-NAME', subtitle: 'SUB-BY-NAME' } } },
} as any;
const viaName = translatePage(page as any, nameBundle, { locale: 'en' });
expect(viaName.regions[0].components[0].properties)
.toEqual({ title: 'BY-NAME', subtitle: 'SUB-BY-NAME' });
// ...and it stops at region level: the nested header keeps its literal.
expect(viaName.regions[1].components[0].properties.children[0].properties.title)
.toEqual('Inner header');

// Half four — a nested `page:header` is still reached, by the id route.
const nestedBundle = {
en: {
pages: {
header_route_page: { title: 'BY-NAME', components: { inner_header: { title: 'ID::inner' } } },
},
},
} as any;
const viaNested = translatePage(page as any, nestedBundle, { locale: 'en' });
expect(viaNested.regions[1].components[0].properties.children[0].properties.title)
.toEqual('ID::inner');
// The extractor offers that nested id, which is the other half of "stays
// id-only" — the two sides agree about it as much as about the region one.
expect(componentRows(page).filter((r) => r.key === 'inner_header.title'))
.toEqual([{ key: 'inner_header.title', value: 'Inner header' }]);

// Half five — the card's Leg A shape 3, inverted. Both routes present:
// BEFORE the ruling the id route won here ("ZH-by-ID"); the page-name
// route is canonical now, so the components key is inert on this component
// and the bundle that overrode a header title through it falls back.
const bothBundle = {
en: {
pages: {
header_route_page: {
title: 'BY-NAME',
subtitle: 'SUB-BY-NAME',
components: { home_header: { title: 'BY-ID' } },
},
},
},
} as any;
expect(translatePage(page as any, bothBundle, { locale: 'en' }).regions[0].components[0].properties)
.toEqual({ title: 'BY-NAME', subtitle: 'SUB-BY-NAME' });
});

it('carries the whole shared key list down into nesting, label either/or included', () => {
const rows = componentRows(walkParityPage());
// `label` authored at the component's top level, the same either/or
Expand Down Expand Up @@ -414,7 +548,10 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => {
.toEqual([{ key: 'twice.title', value: 'First nested wins' }]);
// A region-level `page:header` emits nothing here, but its id still BLOCKS
// a nested namesake — the resolver counts it as region-level, so offering
// the nested one would be a key the resolver ignores.
// the nested one would be a key the resolver ignores. The ruling changed
// what the resolver READS for that id, not who OWNS it: the arbitration is
// a property of the document and both consumers still decide it the same
// way, which is why this half is unchanged.
expect(rows.filter((r) => r.key.startsWith('hdr_id.'))).toEqual([]);

// And the resolver agrees about which component the entry lands on.
Expand All @@ -432,7 +569,10 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => {
const translated = translatePage(page as any, bundle, { locale: 'en' });
const region = translated.regions[0].components;
expect(region[0].properties.title).toEqual('S');
expect(region[1].properties.title).toEqual('H');
// `hdr_id` is the region-level `page:header`: the bundle's `H` under
// `components.hdr_id.title` is NOT read, and this page has no
// `pages.walk_collision_page.title` either, so the authored literal stands.
expect(region[1].properties.title).toEqual('Header holds this id');
const nested = region[2].properties.children;
expect(nested.map((c: any) => c.properties.title)).toEqual([
'Nested namesake loses',
Expand Down
95 changes: 81 additions & 14 deletions packages/spec/src/system/i18n-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1368,26 +1368,93 @@ describe('translatePage', () => {
expect(byId(out, 'ai_briefing').properties.description).toBe('Open the assistant panel.');
});

it('lets the id-addressed route win over the page-name route on a header that has an id', () => {
const doc = {
// Ruled 2026-09-06 (maintainer, verbatim 「同意」, decision batch #58,
// option 1): "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; a `page:header` nested inside a container stays id-only, as its
// doc already says. One component, one address — `title` and `subtitle`
// now follow the same rule."
//
// The ruling has two halves, and one bundle cannot measure both — so two
// pins. `homeBundle` carries `pages.sales_home_page.label`, and the
// page-name overlay is applied AFTER the id route and wins for `title`
// whether or not the id route was read: against that bundle the header's
// `title` reads `销售看板` on a resolver that still reads the id route.
// That is a real invariant (precedence when both routes are present) and
// the first pin names it as exactly that — it is NOT evidence that the id
// route is closed. The second pin hands the resolver an id-ONLY bundle —
// no `pages.<name>.label`, no `pages.<name>.title` — so the page-name
// overlay has nothing to win with and only the id route could move the
// title. Measured with the gate reverted to `if (addressed)`: the first
// pin stays green, the second goes red.
//
// A region-level `page:header` that carries an id. `properties` is
// widened to the open bag it is: the overlay adds keys the literal does
// not spell out, and inferring it as `{title}` alone would make reading
// the result a type error.
const regionHeaderWithId = () => ({
name: 'sales_home_page',
regions: [{
name: 'header',
components: [{
type: 'page:header',
id: 'quick_create',
properties: { title: 'Sales Home' } as Record<string, string>,
}],
}],
});

// Previously this asserted the OPPOSITE (`title` resolving to `快速新建`,
// the id route beating the page-name one). Inverted, not deleted, because
// the inversion IS the behaviour change the ruling records: the id route
// was live for this component and preferred, and a bundle that used it
// now falls back to the page-name key.
it('prefers the page-name route over the id route for a region-level `page:header` when a bundle carries both (batch #58)', () => {
const out = translatePage(regionHeaderWithId(), homeBundle, { locale: 'zh-CN' });
// `components.quick_create.title` (`快速新建`) does not win here — the
// page-name route does, falling back to `pages.<name>.label` for `title`.
expect(out.regions[0].components[0].properties.title).toBe('销售看板');
expect(out.regions[0].components[0].properties.subtitle).toBe('欢迎回来');
// Control: the very same bundle entry still reaches the component that
// actually owns it, so the assertion above is about the header's route
// and not about a bundle that stopped resolving.
expect(byId(translatePage(homePage(), homeBundle, { locale: 'zh-CN' }), 'quick_create')
.properties.title).toBe('快速新建');
});

it('does not read the id route for a region-level `page:header` — an id-only bundle leaves its authored title alone (batch #58)', () => {
// No `pages.<name>.label` / `title` / `subtitle`: the page-name route
// resolves nothing for this page, so a translated title on the header
// could only have come from `components.quick_create.title`.
const idOnly: TranslationBundle = {
'zh-CN': { pages: { sales_home_page: { components: { quick_create: { title: '快速新建' } } } } },
};
const out = translatePage(regionHeaderWithId(), idOnly, { locale: 'zh-CN' });
expect(out.regions[0].components[0].properties.title).toBe('Sales Home');
// Control 1: the same id-only bundle DOES translate the component that
// owns the id, so the authored title above is the header's id route
// being closed and not a bundle nothing can resolve.
expect(byId(translatePage(homePage(), idOnly, { locale: 'zh-CN' }), 'quick_create')
.properties.title).toBe('快速新建');
// Control 2: a `page:header` NESTED in a container stays id-addressed —
// the ruling closes the id route at region level only, and the id route
// is the only one that reaches a nested header.
const nestedDoc = {
name: 'sales_home_page',
regions: [{
name: 'header',
// `properties` widened to the open bag it is: the overlay adds keys
// the literal does not spell out, and inferring it as `{title}` alone
// would make reading the result a type error.
name: 'main',
components: [{
type: 'page:header',
id: 'quick_create',
properties: { title: 'Sales Home' } as Record<string, string>,
type: 'page:card',
id: 'wrapper',
properties: {
children: [{ type: 'page:header', id: 'quick_create', properties: { title: 'Sales Home' } as Record<string, string> }],
},
}],
}],
};
const out = translatePage(doc, homeBundle, { locale: 'zh-CN' });
// `components.quick_create.title` is more specific than `pages.<name>.label`.
expect(out.regions[0].components[0].properties.title).toBe('快速新建');
// The page-name route still supplies what the id route did not.
expect(out.regions[0].components[0].properties.subtitle).toBe('欢迎回来');
const nested = translatePage(nestedDoc, idOnly, { locale: 'zh-CN' });
expect((nested.regions[0].components[0].properties.children as any[])[0].properties.title).toBe('快速新建');
});

it('does not mutate the input page', () => {
Expand Down
Loading
Loading