Skip to content
Closed
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
28 changes: 28 additions & 0 deletions .changeset/30511-console-packs-platform-locale-tags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@object-ui/i18n': patch
'@object-ui/components': patch
'@object-ui/console': patch
---

feat(i18n)!: the Console's language packs are the platform locale table's, keyed by its tags, and Traditional Chinese (`zh-Hant`) ships, generated from Simplified (#30511)

Clause-②: yes (narrowing) — widens: a `zh-Hant` pack (the `zhHant` export of `@object-ui/i18n/locales`, the `@object-ui/i18n/locales/zh-Hant` module), the `resolveBuiltInLanguage` export of `@object-ui/i18n`, and every stored or browser code the platform locale table reads as one of its tags (`zh-TW`, `zh-HK`, `zh-MO` → `zh-Hant`; `zh`, `zh-CN`, `zh-SG` → `zh-Hans`; `pt` → `pt-BR`). Narrows: the Simplified Chinese and Portuguese packs are keyed `zh-Hans` and `pt-BR`, no longer `zh` and `pt`, in `builtInLocales`, `BUILT_IN_LANGUAGE_CODES`, `getLoadedBuiltInLocales()` and `getAvailableLanguages()`, and their modules `@object-ui/i18n/locales/zh` and `@object-ui/i18n/locales/pt` are `@object-ui/i18n/locales/zh-Hans` and `@object-ui/i18n/locales/pt-BR`.
Metadata-protocol: none
Skills-docs: follow-up owed — `skills/objectui/guides/i18n.md` (its "Built-in locales" list names ten packs, with `zh` and `pt`)

<!-- adr-0087: not-required (no-migration-prescription) No metadata changes shape: no spec key, object definition, stored row or authorable spelling is added, removed or renamed, so `objectstack migrate meta` has nothing to rewrite and no ledger entry could project. The FROM/TO lines below are source rewrites for TypeScript and JavaScript consumers of `@object-ui/i18n`'s `./locales` subpath (two members of `builtInLocales`, two modules). That is the class `runtime-export-retired` exists for, but it reads a package's root entry only and refuses a subpath operand, and the root entry retires nothing here; so this category is held, and the gap is named here rather than left to the detector. -->

**BREAKING** for code that reads a built-in pack by its old key or module path.

- **Why.** The Console's packs follow the platform locale table (`PLATFORM_LOCALES` in `@objectstack/spec/system`, ruling F of #29349) instead of a list of their own: one pack per table tag, keyed by the tag, in the table's order. A language the table gains is a pack the parity and drift suites demand on the run that first sees it.
- **Traditional Chinese.** `src/locales/zh-Hant.ts` is generated from `zh-Hans.ts` by `pnpm --filter @object-ui/i18n gen:zh-hant` (OpenCC `s2twp`, pinned, with the phrase rules of the www.objectos.ai converter) and is never edited by hand; a package test fails when it is not what the generator writes. It carries no `console.ai.*` gate-only outbound keys, exactly as the other non-gate packs.
- **Stored codes keep working.** A `sys_user.locale` value, a saved device language or an `Accept-Language` tag written before the table is read through the table's aliases: `zh-CN` still renders Simplified Chinese, `pt` Portuguese (Brazil), and `zh-TW` / `zh-HK` / `zh-MO` now render Traditional Chinese instead of Simplified. `loadBuiltInLocale`, `isBuiltInLanguage` and i18next's fallback chain accept every alias.
- **Studio.** The Studio namespace still ships `en` and `zh` only; a `zh-Hant` reader sees its Simplified catalogue there.

The fix, for code that names a pack:

- FROM `builtInLocales.zh` TO `builtInLocales['zh-Hans']`
- FROM `builtInLocales.pt` TO `builtInLocales['pt-BR']`
- FROM `import zh from '@object-ui/i18n/locales/zh'` TO `import zh from '@object-ui/i18n/locales/zh-Hans'`
- FROM `import pt from '@object-ui/i18n/locales/pt'` TO `import pt from '@object-ui/i18n/locales/pt-BR'`
- FROM comparing a code from `BUILT_IN_LANGUAGE_CODES`, `getAvailableLanguages()` or the keys of `getLoadedBuiltInLocales()` with `'zh'` or `'pt'` TO comparing `resolveBuiltInLanguage(code)` with `'zh-Hans'` or `'pt-BR'`
11 changes: 11 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions scripts/regen-artifacts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,21 @@ export const NOT_DRIVER_MANAGED = Object.freeze([
+ 'serves that day and drop every edit the patch list does not carry, and it needs the network, '
+ 'which a merge must not. A conflict in a vendored component stays a human\'s; never routed.',
},
// objectstack#30511 (ruling F of #29349): the Console's Traditional Chinese pack.
{
path: 'ui/packages/i18n/src/locales/zh-Hant.ts',
gen: 'gen:zh-hant',
owner: '@object-ui/i18n',
why:
'generated WHOLE from `zh-Hans.ts` by `gen:zh-hant`, so "discard both sides and re-run" '
+ 'loses no human decision. Still not routed: a `REGEN_ARTIFACTS` row needs a `check:` script '
+ 'as its currency proof, and ruling F of #29349 adds no gate to this phase. The currency proof '
+ 'is the package test `src/__tests__/zh-hant-generated.test.ts`, which reds a pack that is not '
+ 'byte-for-byte the generator\'s output wherever the ui unit tests run, and the driver is local '
+ 'anyway (never the queue\'s merge). The pack is line-aligned with `zh-Hans.ts`, one line per '
+ 'line, so two branches editing different keys text-merge apart; a conflict is resolved by '
+ 'taking either side and running `pnpm --filter @object-ui/i18n gen:zh-hant`.',
},
]);

/** Marker file (inside the git dir, never the worktree) listing paths the driver deferred. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ import { App } from '../App';
import { loadLanguage } from '../loadLanguage';
import { loadLocales } from '../loadLocales';

const zh = builtInLocales.zh;
const zh = builtInLocales['zh-Hans'];
const TOKEN = 'tok-12034';
const USER = { id: 'u_12034', email: 'ada@example.com', name: 'Ada' } as unknown as AuthUser;
const SESSION = { token: TOKEN, expiresAt: new Date(Date.now() + 3_600_000) };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ describe('/studio refused: the sentence arrives in the caller language (objectui
});

describe('CONTROL: a caller with Studio access enters Studio as today', () => {
it.each(['en', 'zh'] as const)('%s', async (lang) => {
it.each(['en', 'zh-Hans'] as const)('%s', async (lang) => {
inLanguage(lang);
systemPermissions = [STUDIO_ENTRY_CAPABILITY];
renderStudio('/studio/');
Expand Down
2 changes: 1 addition & 1 deletion ui/apps/console/src/pages/SharedRecordPage-26767.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ describe('SharedRecordPage — a shared non-chat record renders as read-only row
});

it('under zh the value words come from the zh pack', async () => {
const { zh, en } = builtInLocales;
const { 'zh-Hans': zh, en } = builtInLocales;
// Guard: these are real zh rows, not English copies.
expect(zh.common.yes).not.toBe(en.common.yes);
expect(zh.sharedRecord.redactedFields).not.toBe(en.sharedRecord.redactedFields);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,12 +345,12 @@ describe('objectui#3811 — console routes DefaultAcceptInvitationPage', () => {
renderRoute({ lang: 'zh' });
await screen.findByTestId('accept-invitation-page');

const zh = builtInLocales.zh.organization.accept as Record<string, string>;
const zh = builtInLocales['zh-Hans'].organization.accept as Record<string, string>;
expect(zh.accept).toBe('接受邀请');
expect(screen.getByRole('button', { name: zh.accept })).toBeInTheDocument();
expect(screen.getByText(zh.organization)).toBeInTheDocument();
expect(screen.getByText(zh.expiresAt)).toBeInTheDocument();
// The pack that used to serve this screen's other half is gone entirely.
expect((builtInLocales.zh as Record<string, unknown>).acceptInvitation).toBeUndefined();
expect((builtInLocales['zh-Hans'] as Record<string, unknown>).acceptInvitation).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ async function expectSsoCopy(expected: Record<keyof typeof ENGLISH, string>) {

describe('console LoginPage — SSO copy under zh (objectui#12762)', () => {
it('draws the zh bundle value of each SSO label, and no English default', async () => {
const zh = builtInLocales.zh.auth.login;
const zh = builtInLocales['zh-Hans'].auth.login;
for (const key of SSO_KEYS) {
expect(typeof zh[key], `zh auth.login.${key}`).toBe('string');
expect(zh[key], `zh auth.login.${key}`).not.toBe(ENGLISH[key]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ function labelMembers(): string[] {
}

const MEMBERS = labelMembers();
const ZH_LOGIN = builtInLocales.zh.auth.login as Record<string, unknown>;
const ZH_LOGIN = builtInLocales['zh-Hans'].auth.login as Record<string, unknown>;

function createMockClient(): AuthClient {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ function refusingServer(code: string, message: string): typeof fetch {
}) as typeof fetch;
}

type Lang = 'zh' | 'en';
type Lang = 'zh-Hans' | 'en';

/** Labels as the page renders them in `lang`, read from that pack. */
function registerLabels(lang: Lang) {
Expand Down Expand Up @@ -114,7 +114,7 @@ afterEach(() => {
vi.restoreAllMocks();
});

const zhErrors = builtInLocales.zh.auth.register.errors;
const zhErrors = builtInLocales['zh-Hans'].auth.register.errors;
const enErrors = builtInLocales.en.auth.register.errors;

describe('console RegisterPage — audience refusals under zh-CN (objectui#10998)', () => {
Expand All @@ -125,7 +125,7 @@ describe('console RegisterPage — audience refusals under zh-CN (objectui#10998

it('SELF_REGISTRATION_CLOSED shows the localized text, not the server message', async () => {
const text = await submitRefused(
'zh',
'zh-Hans',
'SELF_REGISTRATION_CLOSED',
SERVER_MESSAGE.SELF_REGISTRATION_CLOSED,
);
Expand All @@ -136,7 +136,7 @@ describe('console RegisterPage — audience refusals under zh-CN (objectui#10998

it('EMAIL_DOMAIN_NOT_ALLOWED shows the localized text, not the server message', async () => {
const text = await submitRefused(
'zh',
'zh-Hans',
'EMAIL_DOMAIN_NOT_ALLOWED',
SERVER_MESSAGE.EMAIL_DOMAIN_NOT_ALLOWED,
);
Expand All @@ -146,7 +146,7 @@ describe('console RegisterPage — audience refusals under zh-CN (objectui#10998

it('a code the page does not map still shows the raw server message', async () => {
const raw = 'The server refused this sign-up for a reason the console has no text for.';
const text = await submitRefused('zh', 'UNMAPPED_REFUSAL_FOR_TEST', raw);
const text = await submitRefused('zh-Hans', 'UNMAPPED_REFUSAL_FOR_TEST', raw);
expect(text).toBe(raw);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ afterEach(() => {
vi.unstubAllGlobals();
});

const { zh, en } = builtInLocales;
const { 'zh-Hans': zh, en } = builtInLocales;
const zhAudit = zh.console.system.auditLog;
const zhFlow = zh.console.developer.flowRuns;
const zhAgent = zh.console.developer.agentConnect;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ afterEach(() => {
vi.unstubAllGlobals();
});

const { zh, en } = builtInLocales;
const { 'zh-Hans': zh, en } = builtInLocales;
const zhDev = zh.console.developer;
const enDev = en.console.developer;
const zhSys = zh.console.system;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { FlowRunsPage } from './FlowRunsPage';

afterEach(() => cleanup());

const { zh } = builtInLocales;
const { 'zh-Hans': zh } = builtInLocales;
const zhFlow = zh.console.developer.flowRuns;

/** A client that serves one flow but has no automation surface at all. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function renderRow(language: Lang, spec: Specifier, resolved?: ResolvedSettingVa
);
}

const view = builtInLocales.zh.console.settingsView as unknown as Record<string, string>;
const view = builtInLocales['zh-Hans'].console.settingsView as unknown as Record<string, string>;
const viewEn = builtInLocales.en.console.settingsView as unknown as Record<string, string>;
const LATIN = /\p{Script=Latin}/u;

Expand Down
23 changes: 13 additions & 10 deletions ui/apps/console/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1188,7 +1188,7 @@ export default defineConfig({
// (lifting `framework` above these two) is still refused, for the
// reason it gave.
//
// ## Why there are ELEVEN i18n groups and not one (objectui#7479)
// ## Why there is one i18n group PER CATALOGUE and not one (objectui#7479)
//
// objectui#7399 gave the catalogues one `i18n-locales` chunk so the
// `framework` ceiling stopped budgeting them. That chunk held all
Expand All @@ -1201,8 +1201,10 @@ export default defineConfig({
// would be real in the source and absent in the bundle, which is
// the failure mode this whole card exists to avoid.
//
// So: one group per catalogue, ten single-module chunks, plus a
// group for the i18n RUNTIME (provider, hooks, formatters) which
// So: one group per catalogue — one per tag of the platform locale
// table (`PLATFORM_LOCALES` in `@objectstack/spec/system`; the file
// is named for its tag, objectstack#30511), each a single-module
// chunk — plus a group for the i18n RUNTIME (provider, hooks, formatters) which
// stays eager and is a hundredth of the size. The names are the
// gate's handle — `scripts/check-eager-locale-catalogues.mjs` reads
// `i18n-locale-<code>` out of the BUILT `eager-closure.json` and
Expand All @@ -1214,15 +1216,16 @@ export default defineConfig({
// `scripts/__tests__/check-eager-closure-budget.test.ts` that pins
// this attribution.
{ name: 'i18n-locale-en', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]en\.ts$/, priority: 84 },
{ name: 'i18n-locale-zh', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]zh\.ts$/, priority: 84 },
{ name: 'i18n-locale-ja', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ja\.ts$/, priority: 84 },
{ name: 'i18n-locale-ko', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ko\.ts$/, priority: 84 },
{ name: 'i18n-locale-de', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]de\.ts$/, priority: 84 },
{ name: 'i18n-locale-fr', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]fr\.ts$/, priority: 84 },
{ name: 'i18n-locale-zh-Hans', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]zh-Hans\.ts$/, priority: 84 },
{ name: 'i18n-locale-zh-Hant', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]zh-Hant\.ts$/, priority: 84 },
{ name: 'i18n-locale-es', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]es\.ts$/, priority: 84 },
{ name: 'i18n-locale-pt', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]pt\.ts$/, priority: 84 },
{ name: 'i18n-locale-ru', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ru\.ts$/, priority: 84 },
{ name: 'i18n-locale-ar', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ar\.ts$/, priority: 84 },
{ name: 'i18n-locale-fr', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]fr\.ts$/, priority: 84 },
{ name: 'i18n-locale-pt-BR', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]pt-BR\.ts$/, priority: 84 },
{ name: 'i18n-locale-ru', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ru\.ts$/, priority: 84 },
{ name: 'i18n-locale-de', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]de\.ts$/, priority: 84 },
{ name: 'i18n-locale-ja', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ja\.ts$/, priority: 84 },
{ name: 'i18n-locale-ko', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ko\.ts$/, priority: 84 },
//
// The Studio designer's `studio` namespace (objectstack#25922): the
// same one-group-per-catalogue rule, for the same reason. Its `zh`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ describe('LoadingScreen boot copy (objectui#12187)', () => {
// not waiting for a re-render that nothing schedules any more.
expect(first[0].text).toBe(`${PRODUCT}${zh.console.initializing}`);
// The provider does write it afterwards; the screen no longer depends on that.
expect(document.documentElement.lang).toBe('zh');
// The table tag a zh-CN browser reads as (objectstack#30511).
expect(document.documentElement.lang).toBe('zh-Hans');
});

it('control: an en browser paints the en line on the first commit', () => {
Expand Down
16 changes: 9 additions & 7 deletions ui/packages/app-shell/src/chrome/LoadingScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { Spinner, Button } from '@object-ui/components';
import { Database, Loader2, AlertCircle, RefreshCw } from 'lucide-react';
import { getProductName, getLogoUrl } from '../runtime-config.js';
import { en as enLocale, getLoadedBuiltInLocales, useObjectTranslation } from '@object-ui/i18n';
import { en as enLocale, getLoadedBuiltInLocales, resolveBuiltInLanguage, useObjectTranslation } from '@object-ui/i18n';

interface LoadingScreenProps {
/** Optional message override */
Expand Down Expand Up @@ -75,14 +75,16 @@ interface StartupStrings {
}

function getStartupStrings(language: string): StartupStrings {
const resident = getLoadedBuiltInLocales();
// Exact tag first, then its base language — the registry's own order. An
// empty language has resolved nothing, so it has no catalogue and no line.
// The snapshot is keyed by table tag, so the language is read as the tag it
// means first — the registry's own resolution (`zh-CN` and `zh` → `zh-Hans`,
// `zh-TW` → `zh-Hant`, `fr-CA` → `fr`). An empty language has resolved
// nothing, so it has no catalogue and no line.
// `as unknown as` because a catalogue is typed by SHAPE here while
// `enLocale` carries `en`'s literal VALUES — the same cast `createI18n` makes.
const catalogue = (
language ? (resident[language] ?? resident[language.toLowerCase().split('-')[0]]) : undefined
) as unknown as { console?: Partial<ConsoleStrings> } | undefined;
const tag = language ? resolveBuiltInLanguage(language) : null;
const catalogue = (tag ? getLoadedBuiltInLocales()[tag] : undefined) as unknown as
| { console?: Partial<ConsoleStrings> }
| undefined;
const pack = catalogue?.console;
return {
line: pack?.initializing ?? null,
Expand Down
Loading