diff --git a/src/apps/officer-web/lib/I18nBridge.tsx b/src/apps/officer-web/lib/I18nBridge.tsx index 22ffd3c2..da9637fb 100644 --- a/src/apps/officer-web/lib/I18nBridge.tsx +++ b/src/apps/officer-web/lib/I18nBridge.tsx @@ -1,11 +1,30 @@ +import { useEffect } from 'react'; import type { ReactNode } from 'react'; import type { TranslationMap } from 'i18n'; -import { I18nProvider } from 'i18n'; +import { I18nProvider, localeDirection } from 'i18n'; +import { panelCatalogs } from 'officerdev'; +import { uiCatalogs } from '@/components/locales'; import { useSettings } from 'state/useSettings'; -import en from '../locales/en.json'; -import pt from '../locales/pt.json'; +import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales'; +import appEnGB from '../locales/en-GB.json'; +import appPtPT from '../locales/pt-PT.json'; -const translations: TranslationMap = { en, pt }; +const appCatalogs: TranslationMap = { 'en-GB': appEnGB, 'pt-PT': appPtPT }; + +/** + * Deep-merges the three per-workspace catalogs (D6) into one map per shipped locale — `app.*` from + * officer-web, `panel.*` from officerdev's panel apps, `ui.*` from shared components. + */ +const translations: TranslationMap = Object.fromEntries( + SHIPPED_LOCALES.map(({ code }) => [ + code, + { + app: appCatalogs[code] ?? {}, + panel: panelCatalogs[code] ?? {}, + ui: uiCatalogs[code] ?? {}, + }, + ]), +); type I18nBridgeProps = { children: ReactNode; @@ -15,8 +34,14 @@ export const I18nBridge = ({ children }: I18nBridgeProps) => { const { settings } = useSettings(); const locale = settings.languages.default; + // Prepared for RTL (D3) — every shipped locale is `ltr` today, but the wiring exists so a future + // RTL catalog needs no second pass over the tree. + useEffect(() => { + document.documentElement.dir = localeDirection(locale); + }, [locale]); + return ( - + {children} ); diff --git a/src/apps/officer-web/lib/i18n.ts b/src/apps/officer-web/lib/i18n.ts index 905a309a..1de5c1fd 100644 --- a/src/apps/officer-web/lib/i18n.ts +++ b/src/apps/officer-web/lib/i18n.ts @@ -1,12 +1,14 @@ import type { TranslationKeys } from 'i18n'; import type { InterpolationParams } from 'i18n'; import { useTranslation as useTranslationBase } from 'i18n'; -import type en from '../locales/en.json'; +import type en from '../locales/en-GB.json'; type AppTranslationKey = TranslationKeys; +// The `app.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they +// pass the same bare key the JSON file uses. export const useTranslation = () => { const { t: baseT, locale } = useTranslationBase(); - const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(key, params); + const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(`app.${key}`, params); return { t, locale }; }; diff --git a/src/apps/officer-web/lib/locales.ts b/src/apps/officer-web/lib/locales.ts new file mode 100644 index 00000000..cb85db44 --- /dev/null +++ b/src/apps/officer-web/lib/locales.ts @@ -0,0 +1,14 @@ +/** + * The UI locales that actually ship — regional BCP 47 tags, per docs/localization.md D2. This is the + * one list `I18nBridge` merges catalogs for and `Languages.tsx` offers for UI localization; it is + * deliberately separate from the broader "languages you speak" / "translate to" lists, which are a + * different feature (see the doc's open question 4) and are not limited to locales with a catalog. + */ +export type ShippedLocale = { code: string; label: string }; + +export const SHIPPED_LOCALES: ShippedLocale[] = [ + { code: 'en-GB', label: 'English (UK)' }, + { code: 'pt-PT', label: 'Português (Portugal)' }, +]; + +export const FALLBACK_LOCALE = 'en-GB'; diff --git a/src/apps/officer-web/locales/en-GB.json b/src/apps/officer-web/locales/en-GB.json new file mode 100644 index 00000000..c6b82535 --- /dev/null +++ b/src/apps/officer-web/locales/en-GB.json @@ -0,0 +1,68 @@ +{ + "common": { + "save": "Save", + "saving": "Saving...", + "cancel": "Cancel" + }, + "settings": { + "profile": { + "searchPlaceholder": "Search settings...", + "sections": { + "profileTitle": "Profile", + "profileDescription": "Update your name and avatar.", + "changePasswordTitle": "Change Password", + "changePasswordDescription": "Update your account password.", + "tasksTitle": "Tasks", + "tasksDescription": "Default model for file browser tasks.", + "languagesTitle": "Languages", + "languagesDescription": "Set your spoken languages and translation preferences." + }, + "userData": { + "emailLabel": "Email", + "nameLabel": "Name", + "namePlaceholder": "Your name", + "profileUpdated": "Profile updated", + "profileUpdateFailed": "Failed to update profile", + "selectImageFile": "Please select an image file", + "imageTooLarge": "Image must be smaller than 384KB" + }, + "changePassword": { + "currentPasswordLabel": "Current Password", + "currentPasswordPlaceholder": "Current password", + "newPasswordLabel": "New Password", + "newPasswordPlaceholder": "New password", + "confirmPasswordLabel": "Confirm New Password", + "confirmPasswordPlaceholder": "Confirm new password", + "changeButton": "Change Password", + "changingButton": "Changing...", + "passwordChanged": "Password changed", + "passwordChangeFailed": "Failed to change password" + }, + "languages": { + "spokenLabel": "Languages you speak", + "addLanguage": "Add a language...", + "defaultLabel": "Default language", + "defaultHint": "Used for UI localization.", + "translateToLabel": "Translate to", + "translateToHint": "Target language when translating content you don't speak." + } + } + }, + "header": { + "userMenu": { + "profile": "Profile", + "systemSettings": "System Settings", + "signOut": "Sign Out" + } + }, + "dock": { + "chat": "Chat", + "files": "Files", + "terminal": "Terminal", + "plans": "Plans", + "skills": "Skills", + "tasks": "Tasks", + "processes": "Processes", + "logs": "Logs" + } +} diff --git a/src/apps/officer-web/locales/parity.test.ts b/src/apps/officer-web/locales/parity.test.ts new file mode 100644 index 00000000..1e69955f --- /dev/null +++ b/src/apps/officer-web/locales/parity.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from 'bun:test'; +import { findMissingKeys } from 'i18n'; +import en from './en-GB.json'; +import ptPT from './pt-PT.json'; + +// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and pt-PT lacks is a red build, +// not a warning — there is no regional fallback to paper over the gap. +describe('officer-web (app.*) locale catalogs', () => { + test('pt-PT has every key en-GB has', () => { + expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]); + }); +}); diff --git a/src/apps/officer-web/locales/pt-PT.json b/src/apps/officer-web/locales/pt-PT.json new file mode 100644 index 00000000..ae68936b --- /dev/null +++ b/src/apps/officer-web/locales/pt-PT.json @@ -0,0 +1,68 @@ +{ + "common": { + "save": "Guardar", + "saving": "A guardar...", + "cancel": "Cancelar" + }, + "settings": { + "profile": { + "searchPlaceholder": "Pesquisar definições...", + "sections": { + "profileTitle": "Perfil", + "profileDescription": "Atualiza o teu nome e avatar.", + "changePasswordTitle": "Alterar Palavra-passe", + "changePasswordDescription": "Atualiza a palavra-passe da tua conta.", + "tasksTitle": "Tarefas", + "tasksDescription": "Modelo predefinido para tarefas do explorador de ficheiros.", + "languagesTitle": "Idiomas", + "languagesDescription": "Define os teus idiomas e preferências de tradução." + }, + "userData": { + "emailLabel": "Email", + "nameLabel": "Nome", + "namePlaceholder": "O teu nome", + "profileUpdated": "Perfil atualizado", + "profileUpdateFailed": "Falha ao atualizar perfil", + "selectImageFile": "Por favor seleciona um ficheiro de imagem", + "imageTooLarge": "A imagem deve ter menos de 384KB" + }, + "changePassword": { + "currentPasswordLabel": "Palavra-passe Atual", + "currentPasswordPlaceholder": "Palavra-passe atual", + "newPasswordLabel": "Nova Palavra-passe", + "newPasswordPlaceholder": "Nova palavra-passe", + "confirmPasswordLabel": "Confirmar Nova Palavra-passe", + "confirmPasswordPlaceholder": "Confirmar nova palavra-passe", + "changeButton": "Alterar Palavra-passe", + "changingButton": "A alterar...", + "passwordChanged": "Palavra-passe alterada", + "passwordChangeFailed": "Falha ao alterar palavra-passe" + }, + "languages": { + "spokenLabel": "Idiomas que falas", + "addLanguage": "Adicionar idioma...", + "defaultLabel": "Idioma predefinido", + "defaultHint": "Usado para localização da interface.", + "translateToLabel": "Traduzir para", + "translateToHint": "Idioma alvo ao traduzir conteúdo que não falas." + } + } + }, + "header": { + "userMenu": { + "profile": "Perfil", + "systemSettings": "Definições do Sistema", + "signOut": "Terminar Sessão" + } + }, + "dock": { + "chat": "Chat", + "files": "Ficheiros", + "terminal": "Terminal", + "plans": "Planos", + "skills": "Skills", + "tasks": "Tarefas", + "processes": "Processos", + "logs": "Registos" + } +}