feat(desktop): offer French, German and Spanish in the language picker

Register fr/de/es in the Locale type, catalog and Appearance picker, map
display.language values and regional aliases (fr-CA, de-AT, es-419, ...)
to them, and add behavioral coverage: catalog key/kind/placeholder parity
with English, config round-trip for every picker option, display.language
loading, intro rotation, and slash descriptions after a locale switch.

Fixes #75282
Fixes #89980
This commit is contained in:
brooklyn!
2026-09-23 19:10:54 -05:00
parent 7612a15ad9
commit d44bd702cf
9 changed files with 222 additions and 14 deletions

View File

@@ -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.

View File

@@ -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)
})

View File

@@ -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(<Fixture />)
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<string, number>()
@@ -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')
}
})

View File

@@ -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<string, number[]> = {
'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)
}
}
})
})

View File

@@ -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<Locale, Translations> = {
'zh-hant': zhHant,
ja,
ar,
ru
ru,
fr,
de,
es
}

View File

@@ -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(
<I18nProvider configClient={configClient}>
<LanguageProbe />
</I18nProvider>
)
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()
}

View File

@@ -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())
})
})

View File

@@ -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<string, Locale> = {
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 {

View File

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