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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
84
apps/desktop/src/i18n/catalog-completeness.test.ts
Normal file
84
apps/desktop/src/i18n/catalog-completeness.test.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"). */
|
||||
|
||||
Reference in New Issue
Block a user