# i18n Contribution Guide > Foundation work for #72 i18n-foundation. Targets contributors adding new > UI surfaces or extending existing copy. The infrastructure is in place > (ICU plurals, Intl helpers, RTL Tailwind variants, audit script, locale > picker, Storybook toolbar) — what follows is the playbook. ## When to translate Translate every user-facing string that: - Renders in a React component that ships to the browser - Renders in a transactional email body or subject (see `packages/mail/src/templates-react/`) - Renders in an error/help/empty-state surface accessible to a tenant user Do **not** translate: - Brand names (`ToolCTRL`, `Stripe`, `GitHub`, `Scira AI` etc.) — they're proper nouns and stay constant across locales - Developer-only tooling labels (Storybook globals, CLI prompts, log messages, internal admin operator screens marked as such) - Protocol identifiers (Authentik group names, SDK constants, etc.) ## Where translations live ``` apps/web/public/locales/ ├── de/ │ ├── translations.json # Legacy single-file namespace (4000+ keys) │ ├── errors.json │ ├── mail.json │ ├── common.json # Shared UI (buttons, generic labels) + ICU plurals │ ├── settings.json # Per-area: settings surfaces │ ├── billing.json # Per-area: billing surfaces │ ├── crm.json # Per-area: CRM surfaces │ ├── auth.json # Per-area: authentication surfaces │ └── emailMarketing.json # Per-area: email marketing surfaces └── en/ (same structure) ``` `translations.json` is the historical bag-of-keys. **New work should land in the most specific existing namespace**: `common`, `settings`, `billing`, `crm`, `auth`, or `emailMarketing`. Use namespace names exactly as registered in `packages/i18n/src/index.ts`; the email marketing namespace is camelCase (`emailMarketing`), not kebab-case. When you add a brand-new namespace: 1. Create `apps/web/public/locales/{de,en}/.json` 2. Add the namespace to `i18nNamespaces` in `packages/i18n/src/index.ts` 3. Run `pnpm verify:i18n` so the registered namespace is checked for locale/file parity ## Key naming - Scope-first, dotted: `locale.title`, `action.save`, `impersonation.actingAs` - Keep keys stable; if copy changes drastically, mint a new key rather than reusing the old one (helps with translation memory) - Avoid embedding tokens in keys: prefer `memberCount` (plural-aware) over `oneMember` + `manyMembers` ## ICU plurals Use the ICU-MessageFormat syntax for pluralisation. The CLDR ruleset covers 60+ languages; never re-implement. ```json { "itemsSelected": "{count, plural, =0 {No items selected} one {# item selected} other {# items selected}}" } ``` In components: ```tsx import { useTranslation } from "react-i18next"; import { formatIcu } from "@toolctrl/i18n"; function SelectionToolbar({ count }: { count: number }) { const { t, i18n } = useTranslation("common"); return {formatIcu(t("itemsSelected"), { count }, { locale: i18n.language })}; } ``` A `createUseTranslate()` factory in `@toolctrl/i18n` returns a hook that auto-detects ICU syntax and routes to FormatJS — useful when most of your keys are ICU-shaped. Plain (non-ICU) messages keep working through i18next's `{{name}}` interpolation, so a gradual migration is safe. ### Pluralisation per language | Locale | Cases used by CLDR | | ------ | -------------------------------- | | en | one, other | | de | one, other | | pl | one, few, many, other | | ar | zero, one, two, few, many, other | | ja, zh | other (no plural distinction) | Polish and Arabic are the canonical test cases for "is the ICU pipeline actually wired up correctly?" — see `packages/i18n/src/icu/plural.test.ts`. ## Intl helpers The `@toolctrl/i18n` package exports locale-aware Intl-API wrappers: ```tsx import { formatCurrency, formatDate, formatNumber, formatPercent, formatList, formatRelativeTime } from "@toolctrl/i18n"; formatCurrency(1234.5, { locale, currency: "EUR" }); // "1.234,50 €" / "$1,234.50" formatDate(new Date(), { locale, dateStyle: "medium" }); formatNumber(0.42, { locale, style: "decimal" }); formatPercent(0.25, { locale }); // "25%" / "25 %" formatList(["A", "B", "C"], { locale }); // Oxford-comma aware formatRelativeTime(earlier, { locale, relativeTo: new Date() }); ``` All helpers accept an explicit `locale` so they're SSR-safe and produce identical output on server + client. ## Locale resolution When a request arrives, the server picks a locale in this order: 1. `User.locale` (set via the profile locale-picker) 2. `Tenant.defaultLocale` 3. `Accept-Language` header (browser default) 4. App fallback (`de`, see `packages/i18n/src/index.ts#defaultLocale`) The resolved locale is stored in the i18n cookie (`_i18n`) and surfaced to React via `useTranslation().i18n.language`. The picker UI lives in `apps/web/app/modules/users/components/UserProfileSettings.tsx` (rendered from `/app//settings/profile`) and POSTs to `/preferences`, which calls the root `setLocale` action. ## RTL considerations - `` is set server-side in `root.tsx` - Tailwind ships `rtl:` and `ltr:` modifier variants (registered in `tailwind.config.ts`); use them where direction matters: ```tsx
…
``` - For text, prefer logical CSS properties (`ps-4` / `pe-4`) when adding new layouts — even simpler than `rtl:` overrides - Bidi-spoofing: never trust user-controlled strings to be LTR. The display layer must strip Unicode bidi-override characters (`U+202A`–`U+202E`, `U+2066`–`U+2069`) — see `compliance-audit-export` and the security notes in the foundation spec ## Adding a new locale 1. Add the locale code to `supportedLocales` in `packages/i18n/src/index.ts`. If it's RTL, also add it to `rtlLocales` 2. Create `apps/web/public/locales//{translations,errors,mail,common,settings,billing,crm,auth,emailMarketing}.json` by copying the English files 3. Run `pnpm verify:i18n` — the parity check enforces all namespaces have matching keys 4. Have the translation reviewed before merging ## Auditing hardcoded strings The `scripts/i18n-audit.mjs` script scans web routes, modules, route modules, and components for hardcoded English/German JSX text, then records locale-sensitive formatting signals that should move to the `@toolctrl/i18n` helpers over time. Run it before starting an extraction sprint to baseline the count, and again after to measure progress: ```bash node scripts/i18n-audit.mjs # → docs/contributing/i18n-audit-.md ``` The current audit snapshot is `docs/contributing/i18n-audit-2026-05-25.md`. Its re-run section is the active product-UI baseline: 0 hardcoded route/component strings and 0 locale-formatting signals as of 2026-05-29. The 2026-05-25 findings remain in that file as historical disposition only. The repo-wide `pnpm verify:i18n` script enforces three guards: - `scripts/i18n-check.ts` — per-locale namespace key parity - `scripts/verify-hardcoded-strings.mjs` — repo-wide hardcoded-string ratchet (active historical baseline: 14 brand/protocol/package identifiers; JSX text, prop literals, and response literals are 0) - `eslint --max-warnings=0` on the design-system packages ## Storybook The Storybook `preview.tsx` exposes a `Locale` toolbar global. Switching the value re-renders all stories with `` updated, so `rtl:` Tailwind variants light up immediately. The default value is `en`; `ar` is included as an RTL preview even though we don't ship Arabic translations yet. ## Reference checklist for a new translated component - [ ] Strings live in a per-area namespace (`common`, `settings`, …) - [ ] DE and EN are both filled in (parity test enforces this) - [ ] Plural-aware copy uses ICU syntax - [ ] Currency/date/number rendering uses `@toolctrl/i18n` helpers, passing the active locale - [ ] Direction-sensitive Tailwind classes use `rtl:` / `ltr:` modifiers (or logical CSS properties) - [ ] No hardcoded Latin-alphabet copy in JSX text (audit clean for that file)