Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 6 additions & 10 deletions apps/desktop/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,11 @@ 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** — `en`, `de`, `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 five.
Partial locales use `defineLocale()` so untranslated keys fall back to English
while staying type-checked.

## State (TypeScript)

Expand Down Expand Up @@ -299,13 +301,7 @@ The detailed state contract lives in the scoped
- [ ] Keybind hints read from the store via `useKeybindHint` / `TipKeybindLabel`?
- [ ] Overlay uses `shadow-nous` + `border-(--stroke-nous)`, no hard border?
- [ ] Flat — no card-in-card, no gratuitous row dividers?
- [ ] No automatic navigation, focus steal, or pane opening from background
events?
- [ ] Direct manipulation paints immediately and rolls back cleanly on failure?
- [ ] 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 five 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.
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/catalog.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { en } from './en'
import { de } from './de'
import { ja } from './ja'
import type { Locale, Translations } from './types'
import { zh } from './zh'
import { zhHant } from './zh-hant'

export const TRANSLATIONS: Record<Locale, Translations> = {
en,
de,
zh,
'zh-hant': zhHant,
ja
Expand Down
21 changes: 20 additions & 1 deletion apps/desktop/src/i18n/context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,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: 'xx' } }),
saveConfig: vi.fn()
}

Expand All @@ -152,6 +152,25 @@ describe('I18nProvider', () => {
expect(configClient.saveConfig).not.toHaveBeenCalled()
})

it('loads de from display.language config', async () => {
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue({ display: { language: 'de' } }),
saveConfig: vi.fn()
}

render(
<I18nProvider configClient={configClient} initialLocale="en">
<LanguageProbe />
</I18nProvider>
)

await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))

expect(screen.getByTestId('locale').textContent).toBe('de')
expect(screen.getByTestId('label').textContent).toBe('Sprache')
expect(configClient.saveConfig).not.toHaveBeenCalled()
})

it('reads latest config before saving language and preserves unrelated values', async () => {
const saveConfig = vi.fn().mockResolvedValue({ ok: true })

Expand Down
58 changes: 58 additions & 0 deletions apps/desktop/src/i18n/de.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'

import { de } from './de'
import { en } from './en'

// Guards the German locale against the regressions the hermes-sweeper flagged:
// visible copy left in English, and import/export label collisions. These are
// behaviour contracts (relationships), not snapshots of specific wording.
describe('de locale content', () => {
it('translates visible boot-failure and update copy', () => {
expect(de.boot.failure.title).not.toBe(en.boot.failure.title)
expect(de.boot.failure.description).not.toBe(en.boot.failure.description)
expect(de.notifications.seeWhatsNew).not.toBe(en.notifications.seeWhatsNew)
})

it('renders keybind action labels in German, not English identifiers', () => {
// These values are display copy shown in the command palette, not IDs.
for (const key of Object.keys(en.keybinds.actions) as (keyof typeof en.keybinds.actions)[]) {
expect(de.keybinds.actions[key]).not.toBe(en.keybinds.actions[key])
}
})

it('does not collide import and export labels', () => {
expect(de.settings.importConfig).not.toBe(de.settings.exportConfig)
expect(de.settings.importConfig).not.toBe(en.settings.importConfig)
})

it('contains no corrupted placeholders or untranslated English leaves', () => {
// Walks every string leaf in the German catalog and rejects corruption
// markers that would surface verbatim to the user (e.g. "***", TODO,
// leftover English sentences). A locale that degrades should fall back to
// English via defineLocale(), never render a placeholder.
const corruption = /\*\*\*|FIXME|XXX|PLACEHOLDER|lorem ipsum/i
const englishSentence = /\b(the|is|are|was|were|your|you|this|that|with|from|click|settings|open|close|save)\b/i

const visit = (node: unknown, path: string): void => {
if (typeof node === 'string') {
expect(corruption.test(node), `corruption in ${path}`).toBe(false)
// flag stray English only for leaf strings that look like full sentences
if (node.trim().length > 0 && /\s/.test(node) && englishSentence.test(node)) {
// allow legitimate English proper nouns / codes by checking word ratio
const words = node.split(/\s+/).filter(Boolean)
const englishWords = words.filter(w => englishSentence.test(w)).length
if (words.length >= 4 && englishWords / words.length > 0.5) {
throw new Error(`possible untranslated English leaf at ${path}: "${node}"`)
}
}
return
}
if (node && typeof node === 'object') {
for (const [k, v] of Object.entries(node)) {
visit(v, path ? `${path}.${k}` : k)
}
}
}
visit(de as unknown as Record<string, unknown>, 'de')
})
})
Loading