diff --git a/.changeset/zh-branch-migration-part1.md b/.changeset/zh-branch-migration-part1.md new file mode 100644 index 000000000..b8c63067c --- /dev/null +++ b/.changeset/zh-branch-migration-part1.md @@ -0,0 +1,37 @@ +--- +"@object-ui/i18n": patch +"@object-ui/components": patch +"@object-ui/app-shell": patch +--- + +fix(i18n): retire four hand-rolled zh/en branches (objectui#2871, part 1) + +Four surfaces decided their language with a hand-written `startsWith('zh')` +check instead of the locale packs, so the other eight shipped languages +silently rendered English and the strings could never be translated without a +code change. + +- **`RecordTitleChip`** carried a private zh-CN/zh-TW dictionary behind a + comment claiming "components is i18n-free". That is not true — + `@object-ui/components` declares `@object-ui/i18n` and its sibling + `containers.tsx` already uses it. All four of its keys (`detail.copied`, + `detail.copyRecordId`, `detail.addToFavorites`, `detail.removeFromFavorites`) + already existed in **all ten packs**, so this deletes ~35 lines and fixes ten + locales with zero new translations. It renders on every record detail page. +- **`EnvironmentListToolbar`**'s three state-aware CTA labels move to a new + `environment.*` namespace. This surface had already regressed once for the + same reason (#844) and was fixed then with inline `{en,zh}` pairs. +- **`StudioAiCopilot`**'s dock title moves to the Studio catalog as + `engine.studio.aiCopilot`. +- **`StudioHomePage.relativeTime`** now uses `Intl.RelativeTimeFormat` with + `numeric: 'auto'` instead of five `zh ? … : …` ternaries. This is strictly + better than adding ten catalog keys: it covers every locale, applies the + correct plural rules, and yields "yesterday" / 「昨天」 rather than "1d ago". + Arabic gets its dual form («أسبوعين») — something a ternary cannot express. + +The new `environment.*` keys are added to all ten packs, so this does not widen +the gap tracked by objectui#2872 part (a). + +`EnvironmentListToolbar`'s tests now render inside a real `I18nProvider` pinned +to `en`. Without one, `t()` returns the raw key, so the previous assertions on +literal English would have been asserting nothing. diff --git a/packages/app-shell/src/environment/EnvironmentListToolbar.tsx b/packages/app-shell/src/environment/EnvironmentListToolbar.tsx index 1cf27a1fb..1b0b59a7f 100644 --- a/packages/app-shell/src/environment/EnvironmentListToolbar.tsx +++ b/packages/app-shell/src/environment/EnvironmentListToolbar.tsx @@ -25,6 +25,7 @@ import { useEffect, useRef, useState } from 'react'; import { SchemaRenderer } from '@object-ui/react'; import { Button } from '@object-ui/components'; import { Plus } from 'lucide-react'; +import { useObjectTranslation } from '@object-ui/i18n'; import { decideEnvironmentCta, upgradeDialogSpec, @@ -34,13 +35,6 @@ import { const CREATE_ACTION = 'create_environment'; -/** Resolve an inline {en,zh} label against the document locale. */ -function pick(label: { en: string; zh: string }): string { - const lang = - (typeof document !== 'undefined' && document.documentElement.getAttribute('lang')) || 'en'; - return lang.toLowerCase().startsWith('zh') ? label.zh : label.en; -} - /** * Deep-link support (#844): `?runAction=create_environment` on the * environments route auto-opens the create dialog once entitlements have @@ -89,6 +83,7 @@ interface Props { } export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Props) { + const { t } = useObjectTranslation(); const toolbarActions = (actions || []).filter((a: any) => a?.locations?.includes('list_toolbar')); const ctaKind = entitlements?.ready ? decideEnvironmentCta(entitlements) : null; const autoRunCreate = useAutoRunCreate(toolbarActions.length > 0 ? ctaKind : null); @@ -124,7 +119,7 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro data-testid="environment-add-upgrade" > - {pick({ en: 'Add environment', zh: '新建环境' })} + {t('environment.addEnvironment')} ); @@ -134,13 +129,15 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro // the create action's label (and promoting production setup to a primary CTA). // Labels are locale-aware — the metadata label is already localized by the // caller, but these state-aware overrides used to be hard-coded English, - // which flashed an English button in a zh console (#844). + // which flashed an English button in a zh console (#844). They now resolve + // from the locale packs, so all ten languages work rather than just zh + // (objectui#2871). const renderedActions = toolbarActions.map((a: any) => { if (a?.name !== CREATE_ACTION || ctaKind == null) return a; if (ctaKind === 'setup_production') { return { ...a, - label: pick({ en: 'Set up your production environment', zh: '创建你的生产环境' }), + label: t('environment.setUpProduction'), variant: 'primary', ...(autoRunCreate ? { autoTrigger: true } : {}), }; @@ -148,7 +145,7 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro // add_development return { ...a, - label: pick({ en: 'Add development environment', zh: '新建开发环境' }), + label: t('environment.addDevelopment'), ...(autoRunCreate ? { autoTrigger: true } : {}), }; }); diff --git a/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx b/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx index e17d1c210..5b9758726 100644 --- a/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx +++ b/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx @@ -20,9 +20,23 @@ vi.mock('@object-ui/react', async (importActual) => ({ ), })); +import { I18nProvider } from '@object-ui/i18n'; import { EnvironmentListToolbar } from '../EnvironmentListToolbar'; import type { EnvironmentEntitlementsState } from '../entitlements'; +/** + * The state-aware CTA labels resolve from the locale packs (objectui#2871), + * so these renders need a real i18n context — asserting the English strings + * without one would only be asserting the raw key. Pinned to `en` and + * `detectBrowserLanguage: false` so the expectations stay deterministic. + */ +const renderToolbar = (ui: React.ReactElement) => + render( + + {ui} + , + ); + const CREATE = { name: 'create_environment', label: 'Create Environment', type: 'api', variant: 'primary', locations: ['list_toolbar'], @@ -32,7 +46,7 @@ const st = (o: Partial): EnvironmentEntitlementsSt describe('EnvironmentListToolbar', () => { it('no production env → "Set up your production environment" (primary)', () => { - render(); + renderToolbar(); const btn = screen.getByText('Set up your production environment'); expect(btn).toBeTruthy(); expect(btn.getAttribute('data-variant')).toBe('primary'); @@ -40,14 +54,14 @@ describe('EnvironmentListToolbar', () => { }); it('has prod + dev allowed (paid) → "Add development environment"', () => { - render(); + renderToolbar(); expect(screen.getByText('Add development environment')).toBeTruthy(); expect(screen.queryByTestId('environment-add-upgrade')).toBeNull(); }); it('has prod + dev locked (free) → upgrade button, NO create POST affordance', () => { const onUpgrade = vi.fn(); - render(); + renderToolbar(); // The create action is NOT rendered as a bar button (no POST-then-403). expect(screen.queryByText('Create Environment')).toBeNull(); expect(screen.queryByText('Add development environment')).toBeNull(); @@ -57,7 +71,7 @@ describe('EnvironmentListToolbar', () => { }); it('still resolving (null) → neutral default label, no upgrade button', () => { - render(); + renderToolbar(); expect(screen.getByText('Create Environment')).toBeTruthy(); expect(screen.queryByTestId('environment-add-upgrade')).toBeNull(); }); @@ -72,7 +86,7 @@ describe('EnvironmentListToolbar — ?runAction=create_environment deep link (#8 it('marks the create action autoTrigger once entitlements resolve, then strips the param', async () => { withRunActionParam(); - render(); + renderToolbar(); const btn = screen.getByText('Set up your production environment'); // The SchemaRenderer stub surfaces autoTrigger as data-autotrigger. expect(btn.getAttribute('data-autotrigger')).toBe('true'); @@ -85,7 +99,7 @@ describe('EnvironmentListToolbar — ?runAction=create_environment deep link (#8 it('upgrade state: deep link opens the upgrade prompt instead of a create POST', async () => { withRunActionParam(); const onUpgrade = vi.fn(); - render(); + renderToolbar(); await vi.waitFor(() => expect(onUpgrade).toHaveBeenCalledTimes(1)); expect(onUpgrade.mock.calls[0][0]).toMatchObject({ code: 'DEV_ENV_PLAN_LOCKED' }); }); diff --git a/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx b/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx index bddbaca5f..e3cb33b94 100644 --- a/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx +++ b/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx @@ -108,20 +108,32 @@ function greetingKey(): string { return 'engine.home.greetingEvening'; } +/** + * Relative timestamp for the "recently visited" list. + * + * Uses `Intl.RelativeTimeFormat` rather than a hand-written en/zh branch + * (objectui#2871): the platform already knows how to say "3 days ago" in every + * locale the console ships, with the right plural rules and the right word + * order — none of which a `zh ? … : …` ternary can express. This also drops + * the last hard-coded strings in this file. + * + * `numeric: 'auto'` is what turns "1 day ago" into "yesterday" / 「昨天」. + */ function relativeTime(iso: string, locale: string): string { const then = new Date(iso).getTime(); if (Number.isNaN(then)) return ''; - const diff = Date.now() - then; - const zh = locale.startsWith('zh'); - const mins = Math.floor(diff / 60000); - if (mins < 1) return zh ? '刚刚' : 'just now'; - if (mins < 60) return zh ? `${mins} 分钟前` : `${mins}m ago`; + const mins = Math.floor((Date.now() - then) / 60000); + + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + // Negative values read as past ("3 minutes ago"); pick the coarsest unit + // that still has a whole number, matching the previous behaviour. + if (mins < 1) return rtf.format(0, 'minute'); + if (mins < 60) return rtf.format(-mins, 'minute'); const hrs = Math.floor(mins / 60); - if (hrs < 24) return zh ? `${hrs} 小时前` : `${hrs}h ago`; + if (hrs < 24) return rtf.format(-hrs, 'hour'); const days = Math.floor(hrs / 24); - if (days < 7) return zh ? `${days} 天前` : `${days}d ago`; - const weeks = Math.floor(days / 7); - return zh ? `${weeks} 周前` : `${weeks}w ago`; + if (days < 7) return rtf.format(-days, 'day'); + return rtf.format(-Math.floor(days / 7), 'week'); } export function StudioHomePage() { diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 19108b176..7eaebefc6 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1207,6 +1207,7 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.toggleRail': 'Toggle sidebar', 'engine.studio.home': 'Back to home', // Pillar tab labels + 'engine.studio.aiCopilot': 'AI copilot', 'engine.studio.pillar.data': 'Data', 'engine.studio.pillar.automations': 'Automations', 'engine.studio.pillar.interfaces': 'Interfaces', @@ -2741,6 +2742,7 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.toggleRail': '切换侧栏', 'engine.studio.home': '返回主页', // Pillar tab labels + 'engine.studio.aiCopilot': 'AI 副驾', 'engine.studio.pillar.data': '数据', 'engine.studio.pillar.automations': '自动化', 'engine.studio.pillar.interfaces': '界面', diff --git a/packages/app-shell/src/views/studio-design/StudioAiCopilot.tsx b/packages/app-shell/src/views/studio-design/StudioAiCopilot.tsx index e612500f7..53c5d81b3 100644 --- a/packages/app-shell/src/views/studio-design/StudioAiCopilot.tsx +++ b/packages/app-shell/src/views/studio-design/StudioAiCopilot.tsx @@ -8,6 +8,7 @@ import { useAgents } from '@object-ui/plugin-chatbot'; import { useChatConversation } from '../../hooks/useChatConversation'; import { chatConversationScope, chatProductOfAgent } from '../../hooks/chatScope'; import { resolveSurfaceAgent } from '../../hooks/surfaceAgent'; +import { t } from '../metadata-admin/i18n'; import { ChatPane, resolveApiBase, type PendingFirstMessage } from '../../console/ai/AiChatPage'; import { ChatDockPanel, @@ -127,7 +128,6 @@ export interface StudioChatDockProps { * (ADR-0037 — the preview needs more width than the rail has). */ export function StudioChatDock({ packageId, locale }: StudioChatDockProps): React.ReactElement | null { - const zh = (locale ?? '').toLowerCase().startsWith('zh'); const navigate = useNavigate(); const location = useLocation(); const apiBase = React.useMemo(() => resolveApiBase(), []); @@ -176,7 +176,7 @@ export function StudioChatDock({ packageId, locale }: StudioChatDockProps): Reac > = { - 'zh-CN': { - addToFavorites: '加入收藏', - removeFromFavorites: '从收藏移除', - copyRecordId: '复制记录 ID', - copied: '已复制', - }, - 'zh-TW': { - addToFavorites: '加入收藏', - removeFromFavorites: '從收藏移除', - copyRecordId: '複製記錄 ID', - copied: '已複製', - }, -}; - -const detectLocale = (): string => { - if (typeof document !== 'undefined') { - const docLang = document.documentElement?.lang; - if (docLang) return docLang; - } - if (typeof navigator !== 'undefined' && navigator.language) { - return navigator.language; - } - return 'en'; -}; - -const tt = (key: keyof typeof T['zh-CN'], fallback: string): string => { - const locale = detectLocale(); - const exact = T[locale]; - const base = locale.split('-')[0]; - const dict = exact || (base === 'zh' ? T['zh-CN'] : undefined); - return (dict && dict[key]) || fallback; -}; - export interface RecordTitleChipProps { /** Resolved title text (already interpolated against the record). */ title: string; @@ -102,6 +67,7 @@ export const RecordTitleChip: React.FC = ({ className, inlineExtras, }) => { + const { t } = useObjectTranslation(); const [internalFav, setInternalFav] = React.useState(false); const [idCopied, setIdCopied] = React.useState(false); const isFavorite = isFavoriteProp ?? internalFav; @@ -126,11 +92,9 @@ export const RecordTitleChip: React.FC = ({ }, [resourceId]); const favLabel = isFavorite - ? tt('removeFromFavorites', 'Remove from favourites') - : tt('addToFavorites', 'Add to favourites'); - const copyLabel = idCopied - ? tt('copied', 'Copied') - : tt('copyRecordId', 'Copy record ID'); + ? t('detail.removeFromFavorites') + : t('detail.addToFavorites'); + const copyLabel = idCopied ? t('detail.copied') : t('detail.copyRecordId'); return ( diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 1abf44bb5..ca4ac884b 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1876,6 +1876,11 @@ const ar = { done: 'تم — إخفاء', }, }, + environment: { + addEnvironment: "إضافة بيئة", + setUpProduction: "إعداد بيئة الإنتاج", + addDevelopment: "إضافة بيئة تطوير", + }, cloudConnection: { checking: "جارٍ التحقق من الاتصال…", retry: "إعادة المحاولة", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 62a27895a..7ac153415 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1878,6 +1878,11 @@ const de = { done: 'Fertig — ausblenden', }, }, + environment: { + addEnvironment: "Umgebung hinzufügen", + setUpProduction: "Produktionsumgebung einrichten", + addDevelopment: "Entwicklungsumgebung hinzufügen", + }, cloudConnection: { checking: "Verbindung wird geprüft…", retry: "Erneut versuchen", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 652747d83..e4d506759 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2534,6 +2534,11 @@ const en = { done: 'Done — hide it', }, }, + environment: { + addEnvironment: 'Add environment', + setUpProduction: 'Set up your production environment', + addDevelopment: 'Add development environment', + }, cloudConnection: { checking: 'Checking connection…', retry: 'Try again', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index c9aabef43..dbd96cae7 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1878,6 +1878,11 @@ const es = { done: 'Listo — ocultar', }, }, + environment: { + addEnvironment: "Añadir entorno", + setUpProduction: "Configure su entorno de producción", + addDevelopment: "Añadir entorno de desarrollo", + }, cloudConnection: { checking: "Comprobando la conexión…", retry: "Reintentar", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 0ff09c2e4..f6f4d8fa0 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1876,6 +1876,11 @@ const fr = { done: 'Terminé — masquer', }, }, + environment: { + addEnvironment: "Ajouter un environnement", + setUpProduction: "Configurer votre environnement de production", + addDevelopment: "Ajouter un environnement de développement", + }, cloudConnection: { checking: "Vérification de la connexion…", retry: "Réessayer", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index bd070af34..054e1d2da 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1878,6 +1878,11 @@ const ja = { done: '完了 — 非表示にする', }, }, + environment: { + addEnvironment: "環境を追加", + setUpProduction: "本番環境をセットアップ", + addDevelopment: "開発環境を追加", + }, cloudConnection: { checking: "接続を確認しています…", retry: "再試行", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 36d94f76c..8c74957e3 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1878,6 +1878,11 @@ const ko = { done: '완료 — 숨기기', }, }, + environment: { + addEnvironment: "환경 추가", + setUpProduction: "프로덕션 환경 설정", + addDevelopment: "개발 환경 추가", + }, cloudConnection: { checking: "연결을 확인하는 중…", retry: "다시 시도", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index c72d18631..c7169c3ba 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1876,6 +1876,11 @@ const pt = { done: 'Concluído — ocultar', }, }, + environment: { + addEnvironment: "Adicionar ambiente", + setUpProduction: "Configure seu ambiente de produção", + addDevelopment: "Adicionar ambiente de desenvolvimento", + }, cloudConnection: { checking: "Verificando a conexão…", retry: "Tentar novamente", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index f02453d48..a8af8a16b 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1876,6 +1876,11 @@ const ru = { done: 'Готово — скрыть', }, }, + environment: { + addEnvironment: "Добавить окружение", + setUpProduction: "Настройте продакшн-окружение", + addDevelopment: "Добавить окружение разработки", + }, cloudConnection: { checking: "Проверка подключения…", retry: "Повторить", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 023219484..98387e7ca 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -2524,6 +2524,11 @@ const zh = { done: '完成——隐藏', }, }, + environment: { + addEnvironment: '新建环境', + setUpProduction: '创建你的生产环境', + addDevelopment: '新建开发环境', + }, cloudConnection: { checking: '正在检查连接…', retry: '重试',