# Internationalization The UI ships English and Simplified Chinese (`zh-CN`). The locale is a per-device choice, set under **Preferences → Language** on desktop and **You → Language** on mobile, and applied immediately — every component that reads through `useT()` subscribes to the locale store, so there is no reload and no "restart to apply". On first run, with no stored choice, the browser's languages decide: any `zh*` tag lands on Simplified Chinese, anything else on English. ## How it works Three files, no dependencies: | file | role | |---|---| | `src/lib/i18n.ts` | locale store (zustand + `localStorage`, same shape as `stores/sound.ts`), lookup, `{name}` interpolation | | `src/locales/en.ts` | the English catalogue — **source of truth** | | `src/locales/zh-CN.ts` | the Chinese catalogue, typed against `en` | `en` types every other locale, so a translation that misspells a key or invents one fails `tsc` rather than rendering a blank. A key a locale hasn't translated yet falls back to English, which means a partial translation renders a mixed UI instead of an empty one — partial is the normal state of a locale, not a bug. ## Using it in a component ```tsx import { useT } from '@/lib/i18n' function Greeting({ name }: { name: string }) { const t = useT() return
{t('common.greeting', { name })}
} ``` `useT()` is a hook: it re-renders the component when the locale changes. For module scope or an event handler that doesn't render, `t()` (the non-reactive export) reads the current locale once. Data defined at module scope — nav items, preference rows — stores the **message key**, not the string, and resolves it at render: ```tsx const tabs: Array<{ key: string; label: MessageKey }> = [ { key: 'profile', label: 'me.tab.profile' }, ] ``` Note the split between `key` and `label` there. Anything that identifies state (a tab, a preference, a route) must keep an identifier that doesn't move with the language; only the label is translated. ## Adding a string 1. Add the key to `src/locales/en.ts` with the English text. Keys are `area.thing` — the area matches where the string lives (`nav.`, `auth.`, `me.`, `mobileMe.`), shared wording goes under `common.`. 2. Use `t('area.thing')` in the component. 3. Translate it in `src/locales/zh-CN.ts`. Leaving it out is safe — it renders in English until someone gets to it. ## Adding a locale 1. Copy `src/locales/zh-CN.ts` to `src/locales/.ts`, keep the `Partial>` type, translate the values. 2. Register it in `src/lib/i18n.ts`: add the code to the `Locale` union, the dictionary to `DICTS`, and an entry to `LOCALES` (whose `label` is the language's own name — someone looking for Chinese scans for 简体中文, not for "Chinese (Simplified)"). 3. If the language should be auto-detected, extend `detectLocale()`. The picker itself needs no change; it renders from `LOCALES`. ## Current coverage The catalogue is fully mirrored: `zh-CN` carries every key `en` does, and every surface reads through `useT()` / `t()` — the conversation, board, calendar, document, agent and shipping views, the mobile screens, and the admin console included. Components that don't render text of their own (`Select`, `ContextMenu`, the peek panes) receive already-translated strings as props, so there is no hidden English inside them. That doesn't retire the English fallback: a key added to `en` renders in English until its `zh-CN` value lands, which is why new keys should ship with their `zh-CN` translation in the same PR to keep the mirror complete. Server-generated text (agent replies, digests, emails) is a separate problem: it comes from model prompts, not from this catalogue.