---
name: i18n
description: Rules and workflow for user-facing strings — wrapping new text in Lingui macros, updating catalogs, and translating new entries. Use whenever adding, editing, or removing any user-visible text (components, hooks, toasts, route titles, server errors).
---

# i18n workflow (Lingui v6)

Every user-facing string must go through Lingui. English source text IS the
translation key (msgid); catalogs live in `src/locales/<locale>/messages.po`
(en, es, de, ja — es is Mexican Spanish). msgstr mirrors msgid in en.po; that
is normal for the source locale.

## Wrapping rules

Allowed APIs — context-bound only (SSR-safe):

- `import { Trans, Plural, useLingui } from "@lingui/react/macro"`
- `import { msg } from "@lingui/core/macro"` (descriptor only)
- NEVER `t`/`plural`/`select` from `@lingui/core/macro` — they bind a global
  i18n and bleed locale across SSR requests.
- Carve-out: a component that only needs `i18n._()` (no `t`` macro) may use
  `useLingui` from plain `@lingui/react` (see header.tsx, mantine-provider) —
  it's the same context-bound hook, just without macro sugar.

Patterns:

| Context | Pattern |
|---|---|
| JSX text | `<Trans>Match starts soon</Trans>` |
| String props / expressions in a component or hook | `const { t } = useLingui()` at TOP LEVEL (unconditional — React Compiler), then `t`Saved!`` |
| Countable text in JSX | `<Plural value={n} one="# point" other="# points" />` |
| Countable text in a string expression | `i18n._({ ...msg`{n, plural, one {# team} other {# teams}}`, values: { n } })` — a bare `t`Teams (${n})`` gives translators no plural branch |
| Route header titles (loader/beforeLoad) | `title: msg`Manage {name}`, titleValues: { name }` — resolved by `Header` |
| Plain .ts utils | take an `i18n: I18n` param and use msg descriptors (see `src/features/predictions/utils.ts`) |
| Server code (API routes, push, meta) | `localizedFor(context.metadata)` from `src/lib/i18n/server-messages.ts` (or `createI18n(resolveLocale(...))`) + msg descriptors |
| Server-fn error toasts | localized centrally via the ErrorType map in `src/lib/i18n/error-messages.ts` — add new ErrorTypes there, not per-call-site |

v6 signature: values ride inside the descriptor — `i18n._({ ...msg`Hi {name}`,
values: { name } })`. `i18n._(descriptor, values)` does NOT exist in v6.

Homographs: when the same English word means different things in different
places (e.g. "Home" = nav screen vs. home team), give one of them a context so
translators get separate entries: `t({ message: "Home", context: "match team" })`
— Spanish needs "Inicio" vs "Local" there. Same-spelling msgids silently share
one translation otherwise.

Never wrap: logger/console output, query keys, route paths, URLs, CSS values,
PB collection/field names, ids/slugs, `===`-compared values, object keys,
brand names FLXN/Flexxon/Spotify, data-derived names, thrown errors that are
only logged (mapKnownError discards their text — only the ErrorType map's
strings reach users).

## Architecture invariants (violating these breaks the app, not just a string)

- **Provider order**: `LinguiProvider` must stay ABOVE `AuthProvider`
  (`src/features/core/components/providers.tsx`). The localized query hooks
  (`useServerQuery`/`useMe`/etc.) call `useLingui`, so anything using them —
  including AuthProvider — must render inside it. LinguiProvider therefore
  reads the auth query with raw `useQuery`, never `useAuth`/`useMe`.
- **`__root` head() must not read `match`/context** — doing so creates
  circular route-type inference that breaks `beforeLoad` typing in child
  routes. Meta stays DEFAULT_LOCALE (crawlers have no session anyway).
- **Babel order** in vite.config.ts: lingui macro plugin BEFORE
  react-compiler. Reversed order fails the build on `<Plural>`.
- Catalog loading: `__root` beforeLoad preloads the active locale on authed
  routes; `LinguiProvider` self-loads a missing catalog client-side (public
  routes). Unauthenticated non-en visitors get an English SSR first paint on
  /login that corrects after hydration — known, accepted.
- `useMemo`/`useCallback` bodies that CACHE a computed string from `t`/`i18n`
  must list them in deps. (Callbacks that merely *call* `t` when invoked read
  the live locale and are safe.)
- Tab labels: `SwipeableTabs` items need a stable `value` slug; `label` is
  the translated display string.

## Catalog workflow — run after any string change

1. `bun run extract` — regenerates ALL locale .po files from source, adds new
   msgids, removes stale ones. Never hand-edit msgids or en.po.
2. Translate the new empty `msgstr ""` entries in es/de/ja. Spawn one
   translation agent per locale (batch ~60 entries; have them RETURN the
   translated entries and merge by msgid — don't let parallel agents edit one
   file). Translation rules for agents:
   - msgid byte-identical; translate msgstr only, single line.
   - Preserve ICU placeholders/plural structure (translate only the words
     inside branches) and `<0>`/`</0>` tags exactly.
   - Watch invisible characters: msgids can contain non-breaking spaces
     (\xa0) that agents silently normalize — verify by byte comparison,
     not visual.
   - Registers: es = informal tú (Mexican), de = informal du, ja =
     polite-casual です/ます. FLXN/Flexxon/Spotify untranslated.
3. `bun run i18n:check` — fails if any locale has missing translations. CI
   (`.gitea/workflows/ci-cd.yaml`, `i18n-check` job) enforces this AND fails
   if committed catalogs are stale vs. source — always commit `src/locales`
   with string changes.
4. `bun run build` runs extract automatically, so a forgotten extract can't
   ship hash-ids — but only step 3 catches untranslated entries.

Production strips English defaults from components; text lives only in the
hash-keyed compiled catalog, so a msgid missing from the catalog renders as
its hash. Non-en catalogs are separate lazy chunks — English users download
nothing extra.
