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"). */