Skip to content

Commit db9af10

Browse files
fix(app-shell,components): resolve a page's inline locale label and description to the viewer's language (objectui#13488) (#13501)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DRqbNi4iARYYtqZmwLSPhaR)_ - [x] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) Fixes #13488 Clause-②: no Metadata-protocol: none ## What changed `PageSchema` in `@objectstack/spec` types a page's `label` and `description` as `I18nLabel`, so an inline locale map such as `{ 'zh-CN', en }` is valid metadata, and the `/meta` read serves it unchanged. Two packages drew it raw on a page route, and React refused the object as a child (error #31), so the route was blank for every viewer: - **`@object-ui/app-shell`, `AppHeader`'s page branch.** It handed the served label to `pageLabel` as its fallback, and `pageLabel` returns an unmatched fallback untouched, so the map became the phone bar's title. It now resolves through `metadataText` in the viewer's language (`useObjectTranslation().language`), the call the report branch has used since objectui#13470, and still passes through the `pages.NAME.label` bundle lookup. The humanized slug stays the fallback when no label resolves. - **`@object-ui/components`, `PageRenderer`.** The page's own implicit heading (`schema.title ?? schema.label`), the line under it (`schema.description`), and the description it hands a titled `page:header` through `PageDescriptionContext` were all drawn raw. Each now resolves through `pickLocalized` in the viewer's language, the resolver `page:header` already uses for its own title in the same package. With only the app bar fixed, an `app`, `home` or `utility` page with a map label stayed blank through this heading (measured, below), so the claim was widened to this file (claim amendment on #13488). - **Swept, same file, same call:** `AppHeader`'s dashboard branch drew a served dashboard label straight into the phone bar's title. `DashboardSchema.label` is `I18nLabel` too, so it is now `metadataText(…) ?? dashboardLabel(…)`. A plain-string label or description draws exactly as before: `metadataText` and `pickLocalized` both pass a string through, and an empty one still falls to the slug or hides the description. Studio's page previews (`SourcePageEditor`, `PagePreview`) render through the same `PageRenderer`, so they get the fix too. One changeset: `@object-ui/app-shell` and `@object-ui/components` patch. ## Tests New pins, each mounting the real component under a real `I18nProvider`, each leg in `en` and `zh-CN` reading different strings out of one map, with plain strings as the control: - `packages/app-shell/src/layout/__tests__/AppHeader.pageDashboardTitleInlineLocale-13488.test.tsx`: the `AppHeader.reportCrumbInlineLocale-13470` harness with `useObjectLabel` real. Legs `PAGE_LABEL_MAP_RESOLVES`, `DASHBOARD_LABEL_MAP_RESOLVES`, two `PLAIN_STRING_CONTROL`s. - `packages/components/src/__tests__/page-inline-locale-label-13488.test.tsx`: an `app` page through the real `SchemaRenderer` and `PageRenderer`. Legs `HEADING_MAP_RESOLVES`, `DESCRIPTION_MAP_RESOLVES`, `HEADER_DESCRIPTION_MAP_RESOLVES` (titled `page:header`), `PLAIN_STRING_CONTROL`. Readings, final head `2c82e03` unless stated: - The two new files: Test Files 2 passed (2), Tests 16 passed (16). - Every test file that names `AppHeader`, `PageView`, `PageRenderer`, `SourcePageEditor`, `PagePreview` or `pageKindNode`, plus every `packages/components` test that names `PageRenderer`, `page:header`, `pageType`, the page renderer module or an `app`/`home`/`utility` page node (212 files, found by `git grep -lE` over test files), with `pnpm exec vitest run --maxWorkers=2`: Test Files 212 passed (212), Tests 2161 passed (2161). Run at `bf37518`; the only later commit, `2c82e03`, changes the type annotation of one new test's fixture, and both new files were re-run at `2c82e03` above. - `turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/components --concurrency=2 --continue`: 31 successful, 31 total. Both packages' `type-check` chain `tsconfig.test.json`, so the new test files are type-checked. - eslint on the five touched files: 0 errors. Every warning is on a line this PR does not add; the one on a changed line in `page.tsx` is the `(schema as any).label` read the old line already had. - `node scripts/check-changeset-presence.mjs`: pass (4 source files of 2 released packages, 1 changeset). `node scripts/check-changeset-no-major.mjs`: pass. `pnpm check:new-line-citations`: 0 new citations. Narrowed, declared: the whole `packages/app-shell/` and `packages/components/` suites were not run locally; CI's sharded `pnpm test` runs them. The narrowed set's blind spot is a test that mounts these components through another import path without naming them. Probe before the fix, on main `85b6a48`, through the real `SchemaRenderer` and `PageRenderer` (a throwaway file, not committed): an `app` page with a map `label`, one with a map `description`, and one with a titled `page:header` and a map `description` all failed on `Objects are not valid as a React child (found: object with keys {zh-CN, en})`. Ablation, one site at a time on the committed fix, through `scripts/ablation-replace.mjs` (anchor must hit once, blob must change, restore proven by blob equal to `HEAD` and an empty `git diff HEAD`), running the two new files each time: - page branch reverted (`pageDef?.label || humanizeSlug(pageName)`): 2 failed, 14 passed. Only `PAGE_LABEL_MAP_RESOLVES` fails, in both languages, on React error #31. - dashboard branch reverted (`dashboardDef?.label || dashboardLabel(…)`): 2 failed, 14 passed. Only `DASHBOARD_LABEL_MAP_RESOLVES` fails, on the same error. - `PageRenderer` heading reverted (`schema.title ?? schema.label` raw): 2 failed, 14 passed. Only `HEADING_MAP_RESOLVES` fails, on the same error. - `PageRenderer` description reverted (raw `schema.description`): 4 failed, 12 passed. `DESCRIPTION_MAP_RESOLVES` and `HEADER_DESCRIPTION_MAP_RESOLVES` fail, on the same error. ## Acceptance notes - `InterfaceListPage` (an interface page's list surface) reads `typeof page.label === 'string' ? page.label : page.name` for its heading and its record drawer's title, and draws `page.description` only when it is a string. A page with a map label does not blank there, but it shows the page's machine name instead of its label, and a map description is not shown. Read from the code on `85b6a48`, not measured; outside this claim's file surface. Handed to the seat. - `AppHeader`'s view branch hands `viewDef?.label || viewDef?.title` to `viewTabLabel`, and a list view's `label` is `I18nLabel` in the spec as well. The object page's tab bar (`ObjectView`) hands the same label to the same function, so whether a map view label blanks a view route is a question about `ObjectView`, not this header alone. Read from the code, not measured; not changed here. - The page's phone-bar title still passes the resolved label through the `pages.NAME.label` bundle lookup, while the page's own heading draws the served label. That split predates this PR and is unchanged, as on the report route. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_012oUFcAgU5BgTKe6fa7RxD8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 76c560e commit db9af10

5 files changed

Lines changed: 339 additions & 8 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/components': patch
4+
---
5+
6+
Clause-②: no
7+
Metadata-protocol: none
8+
9+
A page whose `label` or `description` is an inline locale map (`{ en, 'zh-CN' }`) now opens, and shows both in the viewer's language (objectui#13488). A dashboard whose `label` is one no longer blanks its route through the app bar.
10+
11+
`@objectstack/spec` types a page's `label` and `description`, and a dashboard's `label`, as `I18nLabel`, so the map is valid metadata and is served unchanged. The page drew it straight into its own heading, the line under it, and the description a titled `page:header` draws, and the app bar drew a page's or a dashboard's label as the phone bar's title: React refused the object (error #31) and the route was blank for every viewer. Each of these now resolves the map to the viewer's language. A plain-string label or description draws exactly as before.

‎packages/app-shell/src/layout/AppHeader.tsx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -682,8 +682,10 @@ export function AppHeader({
682682
// translated (a published edit kept over the packaged catalog), so it
683683
// is drawn as given — the page header draws it the same way. Only the
684684
// humanized slug, which no server translated, goes through the bundle
685-
// (objectui#11295).
686-
routeTitle = dashboardDef?.label || dashboardLabel({ name: dashboardName, label: humanizeSlug(dashboardName) });
685+
// (objectui#11295). The label may be an inline locale map; drawn raw,
686+
// it blanked the route (objectui#13488).
687+
routeTitle = metadataText(dashboardDef?.label, language)
688+
?? dashboardLabel({ name: dashboardName, label: humanizeSlug(dashboardName) });
687689
}
688690
} else if (routeType === 'page') {
689691
// No crumb at all since objectui#12526 (no 「Pages」 type crumb since
@@ -695,7 +697,9 @@ export function AppHeader({
695697
if (pathParts[3]) {
696698
const pageName = pathParts[3];
697699
const pageDef = preferLocal(metadata.pages as any[], pageName, (currentApp as any)?._packageId);
698-
const fallback = pageDef?.label || humanizeSlug(pageName);
700+
// A page's `label` may be an inline locale map; drawn raw, the phone
701+
// bar's title blanked the page (objectui#13488).
702+
const fallback = metadataText(pageDef?.label, language) ?? humanizeSlug(pageName);
699703
routeTitle = pageLabel({ name: pageName, label: fallback });
700704
}
701705
} else if (routeType === 'report') {
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#13488 — the phone bar's title on a page route, and on a dashboard
5+
* route, names a page or dashboard whose `label` is the spec's inline locale
6+
* map (`I18nLabel`, `{ 'zh-CN', en }`) in the viewer's language.
7+
*
8+
* `PageSchema.label` and `DashboardSchema.label` are `string | I18nLabel`. The
9+
* page branch handed the served label to `pageLabel` as its fallback, which
10+
* returns an unmatched fallback untouched, and the dashboard branch drew the
11+
* served label as given: either way the map reached JSX as the phone bar's
12+
* title and React refused it (error #31), so the bar, and the route under it,
13+
* went blank. It is the page-route twin of the report crumb
14+
* (`AppHeader.reportCrumbInlineLocale-13470`).
15+
*
16+
* Harness: `AppHeader.reportCrumbInlineLocale-13470`'s, with `useObjectLabel`
17+
* REAL: the bundle carries no `pages` or `dashboards` key, so the title is the
18+
* resolved served label. The zh and en legs read different strings out of one
19+
* map; `PLAIN_STRING_CONTROL` is a page and a dashboard labelled with a plain
20+
* string, drawn unchanged.
21+
*/
22+
import '@testing-library/jest-dom/vitest';
23+
import { describe, it, expect, vi, afterEach } from 'vitest';
24+
import { render, cleanup } from '@testing-library/react';
25+
import React from 'react';
26+
27+
const route = vi.hoisted(() => ({ pathname: '/apps/srm/page/welcome' }));
28+
29+
vi.mock('react-router-dom', () => ({
30+
useLocation: () => ({ pathname: route.pathname, search: '', hash: '', state: null, key: 't' }),
31+
useParams: () => ({ appName: 'srm' }),
32+
useNavigate: () => vi.fn(),
33+
useSearchParams: () => [new URLSearchParams(), vi.fn()] as const,
34+
Link: ({ children, to, ...p }: Record<string, unknown> & { children?: React.ReactNode }) => (
35+
<a href={String(to)} {...p}>{children}</a>
36+
),
37+
}));
38+
39+
vi.mock('@object-ui/components', async (importOriginal) => {
40+
const actual = await importOriginal<Record<string, unknown>>();
41+
const strip = (p: Record<string, unknown> | undefined) => {
42+
const { asChild: _a, variant: _v, size: _s, align: _al, sideOffset: _so, onOpenChange: _o, ...rest } = p ?? {};
43+
return rest;
44+
};
45+
const Pass = ({ children, ...p }: Record<string, unknown> & { children?: React.ReactNode }) => (
46+
<div {...strip(p)}>{children}</div>
47+
);
48+
return {
49+
...actual,
50+
Button: ({ children, asChild: _a, variant: _v, size: _s, ...p }: Record<string, unknown> & { children?: React.ReactNode }) => (
51+
<button type="button" {...p}>{children}</button>
52+
),
53+
DropdownMenu: Pass,
54+
DropdownMenuTrigger: Pass,
55+
DropdownMenuContent: () => null,
56+
DropdownMenuItem: () => null,
57+
DropdownMenuLabel: () => null,
58+
DropdownMenuSeparator: () => null,
59+
DropdownMenuGroup: Pass,
60+
Avatar: Pass,
61+
AvatarImage: () => null,
62+
AvatarFallback: Pass,
63+
Popover: Pass,
64+
PopoverTrigger: Pass,
65+
PopoverContent: () => null,
66+
cn: (...c: unknown[]) => c.filter(Boolean).join(' '),
67+
};
68+
});
69+
70+
vi.mock('lucide-react', () => {
71+
const Icon = () => <span />;
72+
return new Proxy({ __esModule: true } as Record<string | symbol, unknown>, {
73+
get: (target, prop) => {
74+
if (prop === 'then' || prop === '__esModule' || typeof prop === 'symbol') return target[prop];
75+
return Icon;
76+
},
77+
has: (_target, prop) => prop !== 'then',
78+
});
79+
});
80+
81+
vi.mock('@object-ui/react', async (importOriginal) => ({
82+
...(await importOriginal<Record<string, unknown>>()),
83+
useOffline: () => ({ isOnline: true }),
84+
}));
85+
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
86+
...(await importOriginal<Record<string, unknown>>()),
87+
PresenceAvatars: () => null,
88+
useTenantPresence: () => [],
89+
}));
90+
vi.mock('../ModeToggle', () => ({ ModeToggle: () => null }));
91+
vi.mock('../LocaleSwitcher', () => ({ LocaleSwitcher: () => null }));
92+
vi.mock('../ConnectionStatus', () => ({ ConnectionStatus: () => null }));
93+
vi.mock('../AppSwitcher', () => ({ AppSwitcher: () => null }));
94+
vi.mock('../LocalizedSidebarTrigger', () => ({ LocalizedSidebarTrigger: () => null }));
95+
vi.mock('../PreviewBadge', () => ({ PreviewBadge: () => null }));
96+
vi.mock('../WorkspaceSwitcher', () => ({ WorkspaceSwitcher: () => null }));
97+
98+
// ONE module-scope fetch double, never torn down: the header's pollers fire and
99+
// forget, and may read after a test body returns (see the sibling suites).
100+
vi.stubGlobal(
101+
'fetch',
102+
vi.fn(async () =>
103+
new Response(JSON.stringify({ data: [], items: [], agents: [], requests: [] }), {
104+
status: 200,
105+
headers: { 'Content-Type': 'application/json' },
106+
}),
107+
),
108+
);
109+
110+
/** The page's inline map, and what each viewer must read out of it. */
111+
const PAGE_LABEL_MAP = { 'zh-CN': '供应商门户首页', en: 'Supplier Portal Home' };
112+
/** The dashboard's inline map. */
113+
const DASHBOARD_LABEL_MAP = { 'zh-CN': '供应商绩效看板', en: 'Supplier Performance Board' };
114+
const PLAIN_PAGE_LABEL = 'Welcome';
115+
const PLAIN_DASHBOARD_LABEL = 'Ops Board';
116+
117+
vi.mock('../../providers/MetadataProvider', () => ({
118+
useMetadata: () => ({
119+
apps: [{ name: 'srm', label: 'SRM' }],
120+
dashboards: [
121+
{ name: 'srm_performance', label: DASHBOARD_LABEL_MAP },
122+
{ name: 'ops_board', label: PLAIN_DASHBOARD_LABEL },
123+
],
124+
pages: [
125+
{ name: 'welcome', type: 'app', label: PAGE_LABEL_MAP },
126+
{ name: 'welcome_plain', type: 'app', label: PLAIN_PAGE_LABEL },
127+
],
128+
reports: [],
129+
}),
130+
}));
131+
vi.mock('../../context/NavigationContext.js', () => ({
132+
useNavigationContext: () => ({ currentAppName: 'srm', recordTitle: undefined }),
133+
}));
134+
vi.mock('@object-ui/auth', async (importOriginal) => {
135+
// ONE stable identity — a fresh closure per render re-arms every
136+
// `[getAuthConfig]` effect forever (see the sibling suites).
137+
const getAuthConfig = () => Promise.resolve({ features: { multiOrgEnabled: false } });
138+
return {
139+
...(await importOriginal<Record<string, unknown>>()),
140+
useAuth: () => ({
141+
user: { id: 'u1', name: 'Zhang San', email: 'zs@example.com' },
142+
signOut: vi.fn(),
143+
isAuthEnabled: true,
144+
organizations: [],
145+
activeOrganization: null,
146+
isOrganizationsLoading: false,
147+
switchOrganization: vi.fn(),
148+
getAuthConfig,
149+
}),
150+
getUserInitials: () => 'ZS',
151+
useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }),
152+
};
153+
});
154+
const fakeAdapter = { find: () => Promise.resolve({ data: [] }), getClient: () => undefined };
155+
vi.mock('../../providers/AdapterProvider', () => ({ useAdapter: () => fakeAdapter }));
156+
157+
import { I18nProvider } from '@object-ui/i18n';
158+
import { AppHeader } from '../AppHeader';
159+
160+
afterEach(cleanup);
161+
162+
type Lang = 'en' | 'zh-CN';
163+
164+
function renderAt(pathname: string, lang: Lang) {
165+
route.pathname = pathname;
166+
return render(
167+
<I18nProvider config={{ defaultLanguage: lang, detectBrowserLanguage: false }} persistLanguage={false}>
168+
<AppHeader variant="app" appName="SRM" activeAppName="srm" objects={[]} />
169+
</I18nProvider>,
170+
);
171+
}
172+
173+
/** The phone bar's compact title: the bar's one `sm:hidden` title span. */
174+
function phoneTitles(): string[] {
175+
return [...document.querySelectorAll('span.sm\\:hidden.truncate')].map((s) => s.textContent?.trim() ?? '');
176+
}
177+
178+
describe('a page route\'s phone title resolves an inline locale label (objectui#13488)', () => {
179+
it.each<[Lang, string]>([
180+
['en', PAGE_LABEL_MAP.en],
181+
['zh-CN', PAGE_LABEL_MAP['zh-CN']],
182+
])('PAGE_LABEL_MAP_RESOLVES — %s: the title is the viewer\'s label', (lang, expected) => {
183+
renderAt('/apps/srm/page/welcome', lang);
184+
expect(phoneTitles()).toEqual([expected]);
185+
});
186+
187+
it.each<Lang>(['en', 'zh-CN'])('PLAIN_STRING_CONTROL — %s: a plain-string page label draws unchanged', (lang) => {
188+
renderAt('/apps/srm/page/welcome_plain', lang);
189+
expect(phoneTitles()).toEqual([PLAIN_PAGE_LABEL]);
190+
});
191+
});
192+
193+
describe('a dashboard route\'s phone title resolves an inline locale label (objectui#13488)', () => {
194+
it.each<[Lang, string]>([
195+
['en', DASHBOARD_LABEL_MAP.en],
196+
['zh-CN', DASHBOARD_LABEL_MAP['zh-CN']],
197+
])('DASHBOARD_LABEL_MAP_RESOLVES — %s: the title is the viewer\'s label', (lang, expected) => {
198+
renderAt('/apps/srm/dashboard/srm_performance', lang);
199+
expect(phoneTitles()).toEqual([expected]);
200+
});
201+
202+
it.each<Lang>(['en', 'zh-CN'])('PLAIN_STRING_CONTROL — %s: a plain-string dashboard label draws unchanged', (lang) => {
203+
renderAt('/apps/srm/dashboard/ops_board', lang);
204+
expect(phoneTitles()).toEqual([PLAIN_DASHBOARD_LABEL]);
205+
});
206+
});
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* objectui#13488 — a page whose `label` and `description` are the spec's
9+
* inline locale map (`I18nLabel`, `{ 'zh-CN', en }`) renders, in the viewer's
10+
* language.
11+
*
12+
* ## The defect
13+
*
14+
* `PageSchema` in `@objectstack/spec` types both keys as `I18nLabelSchema`,
15+
* so the map is valid metadata and the `/meta` read serves it unchanged.
16+
* `PageRenderer` drew `label` as the page's implicit heading and `description`
17+
* under it as given, and handed the raw description to a titled `page:header`
18+
* through `PageDescriptionContext`: React refused the object as a child (error
19+
* #31) and the page was blank for every viewer.
20+
*
21+
* ## Discrimination
22+
*
23+
* Each map leg goes red on the raw read it pins: the heading leg on the h1,
24+
* the description leg on the paragraph under it, the header leg on the
25+
* description the header draws. The zh-CN and en legs read different strings
26+
* out of the same map, which a resolver that took the first entry, or always
27+
* `en`, cannot both pass. `PLAIN_STRING_CONTROL` is the same pages written with
28+
* plain strings, asserted unchanged in both languages.
29+
*/
30+
31+
import '@testing-library/jest-dom/vitest';
32+
import { describe, it, expect, afterEach } from 'vitest';
33+
import { render, screen, cleanup } from '@testing-library/react';
34+
import { ActionProvider, SchemaRenderer } from '@object-ui/react';
35+
import { I18nProvider } from '@object-ui/i18n';
36+
import type { DeclaredNode } from '@object-ui/types';
37+
// Module scope, not a hook: registers PageRenderer (`app`/`home`/`record`/…)
38+
// and `page:header` (AGENTS.md §测试纪律).
39+
import '../renderers';
40+
41+
afterEach(cleanup);
42+
43+
/** Each inline map, and what each viewer must read out of it. */
44+
const LABEL_MAP = { 'zh-CN': '供应商门户首页', en: 'Supplier Portal Home' };
45+
const DESCRIPTION_MAP = { 'zh-CN': '待办的订单与对账一览。', en: 'Open orders and reconciliations at a glance.' };
46+
const HEADER_TITLE = 'Supplier portal';
47+
48+
/** PLAIN_STRING_CONTROL — the same page written with plain strings. */
49+
const PLAIN = { label: 'Welcome', description: 'Shown to suppliers first.' };
50+
51+
type Lang = 'en' | 'zh-CN';
52+
const pick = (map: Record<string, string>, lang: Lang) => (lang === 'zh-CN' ? map['zh-CN'] : map.en);
53+
54+
/**
55+
* An `app` page: not a record page, so it draws its own heading. Its label and
56+
* description arrive as data (a served document), so the literal is cast once
57+
* here rather than typed against the authoring face.
58+
*/
59+
function appPage(fields: Record<string, unknown>, regions: unknown[] = []): DeclaredNode {
60+
return { type: 'app', pageType: 'app', name: 'welcome', regions, ...fields } as unknown as DeclaredNode;
61+
}
62+
63+
/** A header region with a titled `page:header`: the header owns the h1 and draws the description. */
64+
const TITLED_HEADER_REGION = [{ name: 'header', components: [{ type: 'page:header', properties: { title: HEADER_TITLE } }] }];
65+
66+
function renderPage(schema: DeclaredNode, lang: Lang) {
67+
return render(
68+
<I18nProvider config={{ defaultLanguage: lang, detectBrowserLanguage: false }} persistLanguage={false}>
69+
<ActionProvider>
70+
<SchemaRenderer schema={schema} />
71+
</ActionProvider>
72+
</I18nProvider>,
73+
);
74+
}
75+
76+
describe.each<Lang>(['en', 'zh-CN'])('objectui#13488 — a page with inline locale maps, viewer in %s', (lang) => {
77+
it('HEADING_MAP_RESOLVES — the page\'s heading is the viewer\'s label', () => {
78+
renderPage(appPage({ label: LABEL_MAP }), lang);
79+
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(pick(LABEL_MAP, lang));
80+
});
81+
82+
it('DESCRIPTION_MAP_RESOLVES — the line under the heading is the viewer\'s description', () => {
83+
renderPage(appPage({ label: PLAIN.label, description: DESCRIPTION_MAP }), lang);
84+
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(PLAIN.label);
85+
expect(screen.getByText(pick(DESCRIPTION_MAP, lang))).toBeInTheDocument();
86+
});
87+
88+
it('HEADER_DESCRIPTION_MAP_RESOLVES — a titled `page:header` draws the viewer\'s description', () => {
89+
const { container } = renderPage(appPage({ label: PLAIN.label, description: DESCRIPTION_MAP }, TITLED_HEADER_REGION), lang);
90+
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(HEADER_TITLE);
91+
expect(container.querySelector('[data-page-description]')).toHaveTextContent(pick(DESCRIPTION_MAP, lang));
92+
});
93+
94+
it('PLAIN_STRING_CONTROL — a plain-string label and description draw unchanged', () => {
95+
renderPage(appPage(PLAIN), lang);
96+
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(PLAIN.label);
97+
expect(screen.getByText(PLAIN.description)).toBeInTheDocument();
98+
cleanup();
99+
const { container } = renderPage(appPage(PLAIN, TITLED_HEADER_REGION), lang);
100+
expect(container.querySelector('[data-page-description]')).toHaveTextContent(PLAIN.description);
101+
});
102+
});

‎packages/components/src/renderers/layout/page.tsx‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
resolveInlineAriaProps,
2626
} from '@object-ui/react';
2727
import { ComponentRegistry, toDomProps } from '@object-ui/core';
28-
import { useDisplayLocale } from '@object-ui/i18n';
28+
import { pickLocalized, useDisplayLocale, useObjectTranslation } from '@object-ui/i18n';
2929
import { compile, manifestFromConfigs, type Diagnostic } from '@object-ui/sdui-parser';
3030
import { PAGE_TEMPLATE_SLOTS, type PageTemplate } from '@objectstack/spec/ui';
3131
import { ReactKindPage } from './react-page';
@@ -744,7 +744,15 @@ export const PageRenderer: React.FC<{
744744
// Spec PageSchema declares `label` (required); `title` is the objectui
745745
// spelling. Dual-read so a spec-authored page still renders its header
746746
// (framework#1878 §3 naming-drift recheck).
747-
const pageTitle = schema.title ?? (schema as any).label;
747+
//
748+
// `label` and `description` are the spec's `string | I18nLabel`, so an
749+
// inline locale map (`{ en, 'zh-CN' }`) is valid metadata. Drawn raw, React
750+
// refused the object and the page went blank (objectui#13488). Both resolve
751+
// to the viewer's language through `pickLocalized`, as `page:header`
752+
// resolves its own title; a plain string passes through unchanged.
753+
const { language } = useObjectTranslation();
754+
const pageTitle = pickLocalized(schema.title ?? (schema as any).label, language);
755+
const pageDescription = pickLocalized(schema.description, language);
748756

749757
// What may become an attribute on the wrapper <div>, and nothing else.
750758
//
@@ -905,7 +913,7 @@ export const PageRenderer: React.FC<{
905913
const showPageTitle = !!pageTitle && !headerOwnsTitle;
906914
// The description is the page's own prose, not a duplicate of the header's
907915
// `subtitle`, so delegating the heading does not delete it.
908-
const showPageDescription = !!schema.description && pageType !== 'record';
916+
const showPageDescription = !!pageDescription && pageType !== 'record';
909917
// When the header owns the title, it draws the description too, under the
910918
// title (objectui#12624). Drawn here it sat above the regions, so above the
911919
// title the header draws inside them.
@@ -945,13 +953,13 @@ export const PageRenderer: React.FC<{
945953
</h1>
946954
)}
947955
{drawDescriptionHere && (
948-
<p className="text-muted-foreground">{schema.description}</p>
956+
<p className="text-muted-foreground">{pageDescription}</p>
949957
)}
950958
</div>
951959
)}
952960

953961
{/* Page body — type-specific layout */}
954-
<PageDescriptionContext.Provider value={headerDrawsDescription ? schema.description : null}>
962+
<PageDescriptionContext.Provider value={headerDrawsDescription ? pageDescription : null}>
955963
<PageWidthProvider store={pageWidthStore}>{layoutElement}</PageWidthProvider>
956964
</PageDescriptionContext.Provider>
957965
</div>

0 commit comments

Comments
 (0)