---
name: i18n
description: Rules for user-visible text in Q-Wallets. Read BEFORE writing or editing any component, dialog, form, toast, tooltip, aria-label, placeholder, helper text, or error message. Every user-facing string must go through i18next in all 11 locales — never hardcode English. Triggers on any UI work, any new component, any string a user could read.
---
# i18n is mandatory for every user-visible string
Q-Wallets ships in **11 languages** (`ar de en es et fr it ja pt ru zh`) with a
**single namespace, `core`**. A hardcoded English literal is a bug, not a shortcut.
This applies to new code and to any line you touch in existing code.
Every locale currently holds the same 248 keys with real translations. Keep it that
way: a key added to `en` only is a key that renders as English to ten of eleven users.
## What counts as user-visible
Translate all of these — the last three are the ones most often missed:
- JSX text content, `Typography`, `Button`, `MenuItem` children
- `label`, `placeholder`, `title`, `helperText`, `alt`
- **`aria-label` and every other accessibility string**
- **Error and status messages**, including ones thrown from `utils/` and surfaced
as `saveError` or snackbar text
- **Strings built in plain `.ts` helper modules** that end up on screen
Do NOT translate: `console.*` messages, `qortalRequest` action names, localStorage
keys, QDN identifiers, coin tickers and enum values (`Coin.BTC`), API field names,
`data-*` attributes, test fixtures.
## The pattern in components
```tsx
import { useTranslation } from 'react-i18next';
const { t } = useTranslation(['core']);
;
```
`core` is the only namespace and also the default, but **always write the `core:`
prefix** — every existing call site does.
## The pattern outside components
There is no established `i18n.t()` call site outside React here, and adding one is a
trap: the i18n instance is initialized only as a **side effect** of
`useIframeListener.tsx` importing `supportedLanguages` from `../i18n/i18n`. Anything
importing the singleton earlier gets an uninitialized instance.
So for non-component code that produces display text, **take `t` as a parameter** or
**return a key plus params** and let the component render it:
```ts
// utils/…
export const validateNote = (value: string) =>
value.length > ADDRESSBOOK_NOTE_LENGTH
? {
key: 'core:address_book_note_max_length',
params: { max_note: ADDRESSBOOK_NOTE_LENGTH },
}
: null;
```
Do not follow the existing anti-pattern in
[src/utils/addressBookStorage.ts](../../../src/utils/addressBookStorage.ts), which
`throw`s English sentences that
[AddressBookDialog.tsx](../../../src/components/AddressBook/AddressBookDialog.tsx)
then string-matches (`errorMessage === 'Address already exists in the address book'`)
to swap in a translated key. Every other thrown message reaches the user untranslated.
## Casing and post-processors
Two conventions coexist in `core.json`, split by age:
- **Legacy, lowercase source + `postProcess`** — the flat `address_book_*`,
`action.*`, `message.*` keys. One key renders as `Close`, `CLOSE` or `close`
depending on the call site.
- **Newer nested blocks written in natural Sentence case** and used with **no**
post-processor — `wallet.*`, `send.*`, `filters.*`, `address_book_ui.*`, `app.*`,
`common.*`.
**Match the block you are adding to.** Do not lowercase an existing natural-case
block or bolt a post-processor onto it. Never write `t(...).toUpperCase()` in JSX —
use a post-processor so other languages capitalize by their own rules.
| Processor | Effect | Registered? |
| ------------------------- | -------------------------------------- | ----------- |
| `capitalizeFirstChar` | `close` → `Close` — the default choice | yes |
| `capitalizeFirstWord` | first word uppercased entirely | yes |
| `capitalizeAll` | ALL CAPS — pagination/stat labels | yes |
| `capitalizeEachFirstChar` | Title Case | **no** |
`capitalizeEachFirstChar` is exported from
[src/i18n/processors.ts](../../../src/i18n/processors.ts) but **never passed to
`.use()`** in [src/i18n/i18n.ts](../../../src/i18n/i18n.ts) — asking for it does
nothing. Register it there first if you genuinely need Title Case.
## Key naming and file organisation
`core.json` is **grouped by topic, not sorted alphabetically**, and newer feature
blocks were appended at the end. All 11 locales mirror that order key for key.
- Add a key to the **nested block that owns the feature** (`wallet`, `send`,
`filters`, `address_book_ui`, `app`, `message.error`, `action`, …). The flat
top-level `address_book_*` keys are legacy — do not grow that list.
- `snake_case`, no abbreviations; name by meaning, not by the English wording, so the
key survives a copy edit.
- Shapes already in use: `action.close`, `message.error.loading_balance`,
`message.generic.validating`, `table_headers.
.`.
- **Do not re-sort the files.** `i18n_add_keys.py` and `i18n_apply_translations.py`
preserve key order on write (this differs from their Qortal Hub originals, which
alphabetize). Sorting would reorder 248 keys across 11 files and bury your change.
## The scripts
All four live beside this file and run from the repo root with `--help`.
| Task | Command |
| ------------------------------------ | ---------------------------------------------------------------------------- |
| Find what needs migrating | `python3 .agents/skills/i18n/i18n_scan_hardcoded.py ` |
| Seed new keys into all 11 locales | `python3 .agents/skills/i18n/i18n_add_keys.py ` |
| Apply the translations | `python3 .agents/skills/i18n/i18n_apply_translations.py ` |
| Check parity + leftover English | `python3 .agents/skills/i18n/i18n_apply_translations.py --audit` |
| Alphabetize (**not the convention**) | `python3 .agents/skills/i18n/i18n_sort.py` |
**Never hand-edit 11 locale files.** That is how a key goes missing from one
language. The scripts write all locales in one pass and validate as they go.
`i18n_sort.py` is kept only for a future convention change or a deliberate
standalone sorting commit — read its docstring before running it.
## The workflow
### 1. Find the strings
```bash
python3 .agents/skills/i18n/i18n_scan_hardcoded.py src/components/AddressBook
python3 .agents/skills/i18n/i18n_scan_hardcoded.py --detail
python3 .agents/skills/i18n/i18n_scan_hardcoded.py -o /tmp/hits.json src/
```
Hits are candidates, not confirmed defects — coin names (`Pirate Chain`), CSS values
and API fields show up too. Read them before acting. The scanner is line-based, so it
also **misses** template literals like `` `No ${visual.symbol} contacts yet` ``; scan
the file, then read it.
### 2. Seed the keys
Write a patch file mirroring the locale structure, values in English:
```json
{
"address_book_ui": {
"unnamed_contact": "Unnamed contact",
"save_without_name": "Save without a registered name"
}
}
```
```bash
python3 .agents/skills/i18n/i18n_add_keys.py /tmp/new-keys.json --dry-run
python3 .agents/skills/i18n/i18n_add_keys.py /tmp/new-keys.json
```
It never overwrites an existing value, so it is safe to re-run as the patch grows,
and it appends into the owning block in every locale. Nest by topic; match the casing
convention of the block you are extending.
At this point all 11 locales hold English. That is a deliberate, temporary state — it
keeps the key set complete so nothing falls back mid-task. Keep the list of keys you
added; step 4 needs it.
### 3. Migrate the components
Replace the literals with `t()` calls. Do not stop to translate — that fragments the
work and produces inconsistent wording across a feature.
### 4. Translate, in one batch, at the end
**Every value must be written in the language of its folder.** `de/core.json` holds
German, `ja/core.json` Japanese, `ar/core.json` Arabic. English sitting in a
non-English locale is an unfinished key, not a valid placeholder.
Translate the whole set in one pass, language by language — that is what keeps
terminology consistent. Write them as language → dotted key → value:
```json
{
"de": { "address_book_ui.unnamed_contact": "Unbenannter Kontakt" },
"fr": { "address_book_ui.unnamed_contact": "Contact sans nom" }
}
```
```bash
python3 .agents/skills/i18n/i18n_apply_translations.py /tmp/translations.json
```
It aborts before writing anything if a value drops or renames a `{{placeholder}}`, is
empty, or names a key that does not exist. Casing is not checked. The Hub's
`namespace → language → key` form is accepted too.
#### Translation rules
- Casing follows the English source's style for that key. Do not force lowercase on
languages that capitalize by rule — German nouns, for instance.
- Preserve every `{{placeholder}}` name exactly; reorder them freely within the
sentence. Both `{{ hash }}` and `{{coinType}}` spacing styles exist — copy the
spacing of the `en` value.
- Leave proper nouns and protocol terms untranslated: `Qortal`, `QORT`, `QDN`,
`Q-App`, `Q-Wallets`, and every coin name/ticker (`Bitcoin`, `BTC`, `Pirate Chain`,
`ARRR`, …). The `coins.*` block is intentionally identical in all locales.
- `ar` is right-to-left; write natural Arabic and let the UI handle direction.
- For `ja` and `zh`, do not copy the English inter-word spacing.
- Match the register of neighbouring keys in that file rather than translating word
for word — check a sibling key before inventing a term.
### 5. Verify
```bash
python3 .agents/skills/i18n/i18n_apply_translations.py --audit
python3 .agents/skills/i18n/i18n_apply_translations.py --audit --keys a.b,c.d
```
The audit reports two different things:
- **Key-set problems** (a key missing from a locale, or present in a locale but not
in `en`) — always defects, and they set the exit code.
- **Values identical to English** — warnings. Proper nouns and genuine cognates
legitimately match, so read them; do not auto-"fix" them.
`--keys` narrows it to just the keys you added, which is the useful form while the
pre-existing findings below are still outstanding.
Locale JSON is Prettier-formatted like the rest of the repo. The scripts already
write in that exact style, but confirm:
```bash
npx prettier --check src/i18n/locales
```
## Interpolation
```tsx
t('core:address_book_note_max_length', {
max_note: ADDRESSBOOK_NOTE_LENGTH,
postProcess: 'capitalizeFirstChar',
});
```
Never concatenate translated fragments to build a sentence — word order differs
across languages. Put the whole sentence in one key with placeholders.
## Tests
Component tests mock `react-i18next` so `t` returns the key itself; assertions match
on `core:...` strings (`screen.getByLabelText(/core:address_book_name/)`). When you
rename a key, the tests reference the key, not the English text.
The mocked `t` **must have a stable identity across renders** (define it outside the
`vi.mock` factory). An inline `t: (k) => k` makes every `t`-dependent effect re-run
forever and hangs the test run with no output. See the testing section of
[AGENTS.md](../../../AGENTS.md).
## Known gaps — do not imitate the neighbours
`i18n_scan_hardcoded.py` reports ~46 candidates across 15 files today. The confirmed
ones:
- **`WalletAddressBookPanel`** in
[src/components/WalletWorkspace/index.tsx](../../../src/components/WalletWorkspace/index.tsx)
is largely untranslated: `'No matching contacts found'`,
`'Try a different name, address or note.'`, `'Copy address'`,
`aria-label="copy address"`, `'Could not save contact.'`, plus template literals
(`` `No ${visual.symbol} contacts yet` ``) the scanner cannot see.
- **English thrown from utils.** `addressBookStorage.ts` throws
`Name must be 50 characters or less`, `Note must be …` and
`Address already exists in the address book`; only the last is mapped to a key by
the dialog.
- **Orphan key `message.generic.welcome`** exists in `ja`, `pt`, `ru` and `zh` but
not in `en`, so it is unreachable — and it makes a repo-wide `--audit` exit 1
today. Pre-existing; don't propagate it, and don't let it mask your own errors
(use `--keys`).
- **Untranslated leftovers**, e.g. `ja` still holds English for
`message.generic.no_address`, `message.generic.no_transactions`,
`message.error.loading_address`, `message.error.loading_balance`; `fr` has 11.
"Match the surrounding code" does not apply to these. Use `t()` for the lines you
touch, and mention any remaining hardcoded strings you noticed rather than silently
leaving them.
## Before you finish
```bash
python3 .agents/skills/i18n/i18n_scan_hardcoded.py --detail # nothing user-readable left
python3 .agents/skills/i18n/i18n_apply_translations.py --audit --keys
npx prettier --check src/i18n/locales
npx tsc -b && npx eslint
npx vitest run
```
The task is not done while any non-English locale still holds English for a key you
introduced, or any string a user can read is still an English literal in `.tsx`.