Files
Q-Wallets/.agents/skills/i18n/SKILL.md
2026-08-09 07:39:39 +02:00

13 KiB

name, description
name description
i18n 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

import { useTranslation } from 'react-i18next';

const { t } = useTranslation(['core']);

<Button>
  {t('core:address_book_save', { postProcess: 'capitalizeFirstChar' })}
</Button>;

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:

// 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, which throws English sentences that 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 closeClose — 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 but never passed to .use() in 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.<table>.<column>.
  • 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 <path>
Seed new keys into all 11 locales python3 .agents/skills/i18n/i18n_add_keys.py <patch.json>
Apply the translations python3 .agents/skills/i18n/i18n_apply_translations.py <translations.json>
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

python3 .agents/skills/i18n/i18n_scan_hardcoded.py src/components/AddressBook
python3 .agents/skills/i18n/i18n_scan_hardcoded.py --detail <file>
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:

{
  "address_book_ui": {
    "unnamed_contact": "Unnamed contact",
    "save_without_name": "Save without a registered name"
  }
}
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:

{
  "de": { "address_book_ui.unnamed_contact": "Unbenannter Kontakt" },
  "fr": { "address_book_ui.unnamed_contact": "Contact sans nom" }
}
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

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:

npx prettier --check src/i18n/locales

Interpolation

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.

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 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

python3 .agents/skills/i18n/i18n_scan_hardcoded.py --detail <file>      # nothing user-readable left
python3 .agents/skills/i18n/i18n_apply_translations.py --audit --keys <your,new,keys>
npx prettier --check src/i18n/locales
npx tsc -b && npx eslint <changed files>
npx vitest run <affected dirs>

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.