diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 26fabb5b57..2d37178fb1 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -487,9 +487,12 @@ long transcript or a busy terminal. - Every user-facing string goes through `useI18n()` (`src/i18n/context.tsx`). No literals in JSX. -- **Update all locales together** — `en`, `ja`, `zh`, `zh-hant`. A string change - in `en.ts` that skips the others is a regression (drifted punctuation, - stale labels). Keep trailing-punctuation and tone consistent across all four. +- **Update all locales together** — every catalog registered in + `src/i18n/catalog.ts`. A string change in `en.ts` that skips the others is a + regression (drifted punctuation, stale labels). Keep trailing-punctuation and + tone consistent across all of them. `fr`, `de`, and `es` are complete + `Translations` objects, so a key missing there fails the type check; the + `defineLocale()` overlays fall back to English instead. ## State (TypeScript) @@ -535,7 +538,7 @@ The detailed state contract lives in the scoped - [ ] Hot interactions avoid broad subscriptions, layout thrash, and `transition-all`? - [ ] Keyboard ownership and single-action `Esc` behavior are correct? -- [ ] All four locales updated for any new/changed string? +- [ ] All registered locales updated for any new/changed string? - [ ] `cursor-pointer`, focus ring, and `Esc`-to-close behave? - [ ] Touched a primitive, token, or variant? Its named-contract entry in this file is updated in the same change. diff --git a/apps/desktop/src/app/chat/composer/hooks/slash-localization.test.tsx b/apps/desktop/src/app/chat/composer/hooks/slash-localization.test.tsx index 19529f7d66..68c982754a 100644 --- a/apps/desktop/src/app/chat/composer/hooks/slash-localization.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/slash-localization.test.tsx @@ -114,7 +114,7 @@ describe('desktop slash description localization', () => { ) for (const query of ['', 'ne']) { - for (const locale of ['en', 'zh', 'ja', 'zh-hant', 'ar', 'ru', 'en'] as const) { + for (const locale of ['en', 'zh', 'ja', 'zh-hant', 'ar', 'ru', 'fr', 'de', 'es', 'en'] as const) { await act(async () => { await api.setLocale!(locale) }) diff --git a/apps/desktop/src/components/chat/intro.test.tsx b/apps/desktop/src/components/chat/intro.test.tsx index a98090987b..9945146795 100644 --- a/apps/desktop/src/components/chat/intro.test.tsx +++ b/apps/desktop/src/components/chat/intro.test.tsx @@ -7,6 +7,8 @@ import type { I18nContextValue } from '@/i18n' import { Intro } from './intro' import stock from './intro-copy.jsonl?raw' +const CJK_LOCALES = new Set(['zh', 'zh-hant', 'ja']) + let i18n: I18nContextValue function Controls() { @@ -38,7 +40,7 @@ it('translates every shipped stock body at the same personality and rotation pos const { container, rerender } = render() - for (const locale of ['zh', 'zh-hant', 'ja'] as const) { + for (const locale of ['zh', 'zh-hant', 'ja', 'fr', 'de', 'es'] as const) { await act(() => i18n.setLocale(locale)) const indices = new Map() @@ -55,7 +57,10 @@ it('translates every shipped stock body at the same personality and rotation pos const body = container.querySelector('[data-slot="aui_intro"] > div > p:last-child')!.textContent expect(body).toBeTruthy() expect(body).not.toBe(entry.body) - expect(body).toMatch(/[\u3040-\u30ff\u3400-\u9fff]/) + + if (CJK_LOCALES.has(locale)) { + expect(body).toMatch(/[\u3040-\u30ff\u3400-\u9fff]/) + } } } @@ -76,4 +81,11 @@ it('localizes the custom-personality fallback without translating its user-suppl expect(container.querySelector('[data-slot="aui_intro"] > div > p:last-child')!.textContent).toContain( 'My Custom Voice' ) + + for (const locale of ['fr', 'de', 'es'] as const) { + await act(() => i18n.setLocale(locale)) + const body = container.querySelector('[data-slot="aui_intro"] > div > p:last-child')!.textContent + expect(body).not.toBe(english) + expect(body).toContain('My Custom Voice') + } }) diff --git a/apps/desktop/src/i18n/catalog-completeness.test.ts b/apps/desktop/src/i18n/catalog-completeness.test.ts new file mode 100644 index 0000000000..ad8479b06c --- /dev/null +++ b/apps/desktop/src/i18n/catalog-completeness.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' + +import { TRANSLATIONS } from './catalog' +import type { Locale } from './types' + +// Locales shipped as full catalogs (not `defineLocale` overlays), so every +// English key must be present in the catalog object itself. +const COMPLETE_LOCALES = ['fr', 'de', 'es'] as const satisfies readonly Locale[] + +type Leaf = { path: string; value: unknown } + +function leaves(value: unknown, path = ''): Leaf[] { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return Object.entries(value).flatMap(([key, child]) => leaves(child, path ? `${path}.${key}` : key)) + } + + return [{ path, value }] +} + +// Arguments that are identifiers rather than display text; translations +// branch on them (`capability === 'search' ? … : …`) instead of echoing them. +const IDENTIFIER_ARGS: Record = { + 'settings.toolsets.webCapabilitySelectedMessage': [1] +} + +const kindOf = (value: unknown) => (Array.isArray(value) ? 'array' : typeof value) + +// `intro` is display-only: English lives in intro-copy.jsonl, so its catalog +// entry is an empty shell. intro.test.tsx covers the translated rotation. +const catalogLeaves = (locale: Locale) => + new Map( + leaves(TRANSLATIONS[locale]) + .filter(leaf => !leaf.path.startsWith('intro.')) + .map(leaf => [leaf.path, leaf.value]) + ) + +const english = catalogLeaves('en') + +describe.each(COMPLETE_LOCALES)('%s desktop catalog', locale => { + const catalog = catalogLeaves(locale) + + it('covers exactly the English key set with matching value kinds', () => { + expect([...catalog.keys()].sort()).toEqual([...english.keys()].sort()) + + for (const [path, value] of english) { + expect({ path, kind: kindOf(catalog.get(path)) }).toEqual({ path, kind: kindOf(value) }) + } + }) + + it('keeps every interpolated argument that English renders', () => { + for (const [path, value] of english) { + if (typeof value !== 'function') { + continue + } + + const translated = catalog.get(path) as (...args: unknown[]) => unknown + const probes = Array.from({ length: value.length }, (_, index) => `⟦${index}⟧`) + let englishOut: string + + try { + englishOut = JSON.stringify(value(...probes)) + } catch { + continue // needs structured arguments; the type checker covers the signature + } + + expect(translated.length, path).toBe(value.length) + const translatedOut = JSON.stringify(translated(...probes)) + + const identifiers = new Set(IDENTIFIER_ARGS[path]?.map(index => probes[index])) + + for (const probe of probes.filter(probe => englishOut.includes(probe) && !identifiers.has(probe))) { + expect(translatedOut, `${path} drops ${probe}`).toContain(probe) + } + } + }) + + it('keeps list-shaped copy the same length as English', () => { + for (const [path, value] of english) { + if (Array.isArray(value)) { + expect((catalog.get(path) as unknown[]).length, path).toBe(value.length) + } + } + }) +}) diff --git a/apps/desktop/src/i18n/catalog.ts b/apps/desktop/src/i18n/catalog.ts index 220a88b7f2..02c45d0a1c 100644 --- a/apps/desktop/src/i18n/catalog.ts +++ b/apps/desktop/src/i18n/catalog.ts @@ -1,5 +1,8 @@ import { ar } from './ar' +import { de } from './de' import { en } from './en' +import { es } from './es' +import { fr } from './fr' import { ja } from './ja' import { ru } from './ru' import type { Locale, Translations } from './types' @@ -12,5 +15,8 @@ export const TRANSLATIONS: Record = { 'zh-hant': zhHant, ja, ar, - ru + ru, + fr, + de, + es } diff --git a/apps/desktop/src/i18n/context.test.tsx b/apps/desktop/src/i18n/context.test.tsx index 2b24ca31e5..4790107cfe 100644 --- a/apps/desktop/src/i18n/context.test.tsx +++ b/apps/desktop/src/i18n/context.test.tsx @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { HermesConfigRecord } from '@/hermes' +import { TRANSLATIONS } from './catalog' import { type I18nConfigClient, I18nProvider, useI18n } from './context' import type { Locale } from './types' @@ -76,6 +77,31 @@ describe('I18nProvider', () => { expect(configClient.saveConfig).not.toHaveBeenCalled() }) + it.each([ + ['fr', 'fr'], + ['de-DE', 'de'], + ['es', 'es'] + ] as const)('loads display.language=%s and renders the %s catalog', async (configured, locale) => { + const configClient: I18nConfigClient = { + getConfig: vi.fn().mockResolvedValue({ display: { language: configured } }), + saveConfig: vi.fn() + } + + render( + + + + ) + + await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false')) + + expect(screen.getByTestId('locale').textContent).toBe(locale) + expect(screen.getByTestId('label').textContent).toBe(TRANSLATIONS[locale].language.label) + expect(screen.getByTestId('label').textContent).not.toBe(TRANSLATIONS.en.language.label) + expect(screen.getByTestId('save').textContent).not.toBe(TRANSLATIONS.en.common.save) + expect(configClient.saveConfig).not.toHaveBeenCalled() + }) + it('keeps English usable when config loading fails', async () => { const configClient: I18nConfigClient = { getConfig: vi.fn().mockRejectedValue(new Error('config unavailable')), @@ -97,7 +123,7 @@ describe('I18nProvider', () => { it('does not overwrite unsupported configured languages', async () => { const configClient: I18nConfigClient = { - getConfig: vi.fn().mockResolvedValue({ display: { language: 'de' } }), + getConfig: vi.fn().mockResolvedValue({ display: { language: 'it' } }), saveConfig: vi.fn() } diff --git a/apps/desktop/src/i18n/languages.test.ts b/apps/desktop/src/i18n/languages.test.ts index 7648a1b427..f2692d30e8 100644 --- a/apps/desktop/src/i18n/languages.test.ts +++ b/apps/desktop/src/i18n/languages.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' -import { DEFAULT_LOCALE, isLocale, isSupportedLocaleValue, normalizeLocale } from './languages' +import { TRANSLATIONS } from './catalog' +import { DEFAULT_LOCALE, isLocale, isSupportedLocaleValue, LOCALE_OPTIONS, normalizeLocale } from './languages' describe('desktop i18n languages', () => { it('normalizes supported locale aliases', () => { @@ -22,12 +23,24 @@ describe('desktop i18n languages', () => { expect(normalizeLocale('RU-RU')).toBe('ru') expect(normalizeLocale(' ru_ru ')).toBe('ru') expect(normalizeLocale('Русский')).toBe('ru') + expect(normalizeLocale('fr')).toBe('fr') + expect(normalizeLocale('FR-CA')).toBe('fr') + expect(normalizeLocale(' fr_fr ')).toBe('fr') + expect(normalizeLocale('Français')).toBe('fr') + expect(normalizeLocale('de')).toBe('de') + expect(normalizeLocale('DE-AT')).toBe('de') + expect(normalizeLocale(' de_ch ')).toBe('de') + expect(normalizeLocale('Deutsch')).toBe('de') + expect(normalizeLocale('es')).toBe('es') + expect(normalizeLocale('ES-419')).toBe('es') + expect(normalizeLocale(' es_mx ')).toBe('es') + expect(normalizeLocale('Español')).toBe('es') }) it('falls back to English for empty or unsupported values', () => { expect(normalizeLocale(null)).toBe(DEFAULT_LOCALE) expect(normalizeLocale('')).toBe(DEFAULT_LOCALE) - expect(normalizeLocale('de')).toBe(DEFAULT_LOCALE) + expect(normalizeLocale('it')).toBe(DEFAULT_LOCALE) }) it('distinguishes exact locale ids from supported config aliases', () => { @@ -35,12 +48,25 @@ describe('desktop i18n languages', () => { expect(isSupportedLocaleValue('zh-TW')).toBe(true) expect(isSupportedLocaleValue('ja-JP')).toBe(true) expect(isSupportedLocaleValue('ru-RU')).toBe(true) - expect(isSupportedLocaleValue('de')).toBe(false) + expect(isSupportedLocaleValue('de-DE')).toBe(true) + expect(isSupportedLocaleValue('it')).toBe(false) expect(isLocale('zh-CN')).toBe(false) expect(isLocale('zh')).toBe(true) expect(isLocale('zh-hant')).toBe(true) expect(isLocale('ja')).toBe(true) expect(isLocale('ar')).toBe(true) expect(isLocale('ru')).toBe(true) + expect(isLocale('fr')).toBe(true) + expect(isLocale('de')).toBe(true) + expect(isLocale('es')).toBe(true) + }) + + it('round-trips every picker option through its display.language value to a registered catalog', () => { + for (const option of LOCALE_OPTIONS) { + expect(normalizeLocale(option.configValue)).toBe(option.id) + expect(TRANSLATIONS[option.id]).toBeDefined() + } + + expect(Object.keys(TRANSLATIONS).sort()).toEqual(LOCALE_OPTIONS.map(option => option.id).sort()) }) }) diff --git a/apps/desktop/src/i18n/languages.ts b/apps/desktop/src/i18n/languages.ts index 778a664869..83cac13045 100644 --- a/apps/desktop/src/i18n/languages.ts +++ b/apps/desktop/src/i18n/languages.ts @@ -42,6 +42,24 @@ export const LOCALE_OPTIONS = [ name: LOCALE_ENDONYMS.ru, englishName: 'Russian', configValue: 'ru' + }, + { + id: 'fr', + name: LOCALE_ENDONYMS.fr, + englishName: 'French', + configValue: 'fr' + }, + { + id: 'de', + name: LOCALE_ENDONYMS.de, + englishName: 'German', + configValue: 'de' + }, + { + id: 'es', + name: LOCALE_ENDONYMS.es, + englishName: 'Spanish', + configValue: 'es' } ] as const satisfies readonly { configValue: string; englishName: string; id: Locale; name: string }[] @@ -96,7 +114,40 @@ const LOCALE_ALIASES: Record = { russian: 'ru', 'russian-russian': 'ru', русский: 'ru', - руский: 'ru' + руский: 'ru', + fr: 'fr', + 'fr-fr': 'fr', + fr_fr: 'fr', + 'fr-be': 'fr', + fr_be: 'fr', + 'fr-ca': 'fr', + fr_ca: 'fr', + 'fr-ch': 'fr', + fr_ch: 'fr', + french: 'fr', + français: 'fr', + francais: 'fr', + de: 'de', + 'de-de': 'de', + de_de: 'de', + 'de-at': 'de', + de_at: 'de', + 'de-ch': 'de', + de_ch: 'de', + german: 'de', + deutsch: 'de', + es: 'es', + 'es-es': 'es', + es_es: 'es', + 'es-mx': 'es', + es_mx: 'es', + 'es-ar': 'es', + es_ar: 'es', + 'es-419': 'es', + es_419: 'es', + spanish: 'es', + español: 'es', + espanol: 'es' } export function isLocale(value: unknown): value is Locale { diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 04d2b55077..3ba6192076 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -8,7 +8,7 @@ import type { ErrorCodeKey } from '@/lib/error-surface' import type { TipId } from '@/lib/tips/catalog' -export type Locale = 'en' | 'zh' | 'zh-hant' | 'ja' | 'ar' | 'ru' +export type Locale = 'en' | 'zh' | 'zh-hant' | 'ja' | 'ar' | 'ru' | 'fr' | 'de' | 'es' /** One error-card entry: a short title and one plain sentence. Either may * take the failing provider's display name (falls back to "the AI service"). */