11 KiB
name, description
| name | description |
|---|---|
| i18n | Rules for user-visible text in Qortal Hub. Read BEFORE writing or editing any component, dialog, menu, toast, tooltip, aria-label, placeholder, or error message. Every user-facing string must go through i18next — 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
Qortal Hub ships in 12 languages. A hardcoded English literal is a bug, not a shortcut. This applies to new code and to any line you touch in existing code.
What counts as user-visible
Translate all of these — the last three are the ones most often missed:
- JSX text content,
Typography,Button,MenuItemchildren label,placeholder,title,helperText,altaria-labeland every other accessibility string- Error and status messages, including ones thrown/rejected from async code
- Strings built in plain
.tshelper modules that end up on screen
Do NOT translate: log messages, event names passed to executeEvent /
subscribeToEvent, API field names, test fixtures, data-* attributes.
The pattern in components
import { useTranslation } from 'react-i18next';
const { t } = useTranslation(['auth', 'core', 'group', 'question']);
<Button>
{t('core:action.close', { postProcess: 'capitalizeFirstChar' })}
</Button>;
Declare only the namespaces the component actually uses. Always prefix the key
with its namespace (core:, group:, …) — the default namespace is core, but
be explicit anyway.
The pattern outside components
Non-React modules import the instance directly rather than using the hook:
import i18n from '../i18n/i18n';
throw new Error(
i18n.t('auth:message.error.invalid_uint8', {
postProcess: 'capitalizeFirstChar',
})
);
For pure helper modules that return display text (see
src/components/QortalLand/games/gameDialogText.ts for the anti-pattern), prefer
taking t as a parameter so the caller's namespaces and language reactivity
apply. Return a key + params object instead of a sentence when that is cleaner.
Casing
A value may start uppercase or lowercase — both are accepted (see
docs/i18n_languages.md). Pick per case:
- lowercase source +
postProcessfor short reusable labels, so one key can render asClose,CLOSEorclosedepending on the call site - natural capitalization for full sentences shown only one way
Be consistent within a subtree. Never do t(...).toUpperCase() in JSX — use a
post-processor, so other languages capitalize by their own rules.
Available processors (see src/i18n/processors.ts):
| Processor | Effect |
|---|---|
capitalizeFirstChar |
close → Close — the default choice |
capitalizeFirstWord |
first word uppercased entirely |
capitalizeEachFirstChar |
Title Case |
capitalizeSentenceStarts |
capitalizes after ./!/? and newlines |
capitalizeAll |
ALL CAPS — for stat labels and similar |
Never hand-edit 12 locale files
Every locale operation has a script, and they ship with this skill in
.agents/skills/i18n/. Editing the JSON by hand is how keys go missing
from one language, so reach for these instead:
| Task | Command |
|---|---|
| Find what needs migrating | python3 .agents/skills/i18n/i18n_scan_hardcoded.py <path> |
| Seed new keys into all 12 locales | python3 .agents/skills/i18n/i18n_add_keys.py <ns> <patch.json> |
| Apply the translations | python3 .agents/skills/i18n/i18n_apply_translations.py <translations.json> |
| Check nothing is left | python3 .agents/skills/i18n/i18n_apply_translations.py --audit |
| Sort / verify sorting | python3 .agents/skills/i18n/i18n_sort.py [--check] |
All four run from the repo root and take --help. They locate the locales
themselves, so the working directory only matters for the paths you pass in.
.claude/skills/i18n/… works too — it is the same folder through the
symlink — but prefer the .agents/ path, which exists in a fresh clone.
Locale files are sorted alphabetically at every nesting level, and that is
mandatory. i18n_add_keys.py and i18n_apply_translations.py re-sort the file
they write, so following the workflow keeps it true automatically. If you ever
edit a locale file by hand, run i18n_sort.py afterwards; --check exits 1 on
anything unsorted and is what CI should call.
The workflow
1. Find the strings
python3 .agents/skills/i18n/i18n_scan_hardcoded.py src/components/Chat # summary per file
python3 .agents/skills/i18n/i18n_scan_hardcoded.py --detail <file> # every hit
python3 .agents/skills/i18n/i18n_scan_hardcoded.py -o /tmp/hits.json src/ # machine-readable
Hits are candidates, not confirmed defects — CSS values and API field names show up too. Read them before acting.
2. Seed the keys
Write a patch file mirroring the locale structure, values in English:
{
"expiry": {
"no_expiry": "no expiry",
"maximum": "maximum {{duration}}"
}
}
Then seed all 12 locales at once:
python3 .agents/skills/i18n/i18n_add_keys.py reticulum /tmp/new-keys.json --dry-run # preview
python3 .agents/skills/i18n/i18n_add_keys.py reticulum /tmp/new-keys.json
It never overwrites an existing value, so it is safe to re-run as the patch
grows, and it re-sorts the file on write — never hand-append a key to the end of
a block. Nest values by topic. Pick the namespace by domain:
core for shared UI vocabulary and generic messages, reticulum for the
Reticulum Q-Chat surface (channels, calls, invites, image viewer, user card,
chat settings), group for everything else about Qortal groups and the
onboarding/home dashboard, auth, node, question for their own areas.
A new namespace also has to be registered in the namespaces array in
src/i18n/i18n.ts and in NAMESPACES in i18n_add_keys.py.
At this point all 12 locales hold English. That is a deliberate, temporary state — it keeps the key set complete so nothing crashes 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 — it renders
as English to that user, which is the exact bug this skill exists to prevent.
Translate the whole set in one pass, language by language — that is what keeps terminology consistent, so the same button label is not rendered three different ways in one locale. Write them as namespace → language → dotted key → value:
{
"reticulum": {
"de": { "expiry.no_expiry": "kein ablauf" },
"fi": { "expiry.no_expiry": "ei vanhenemista" }
}
}
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 — see below.
The task is not finished while any non-English locale still holds English for a key you introduced.
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 to suit the target language's word order. - Leave proper nouns and protocol terms untranslated:
Qortal,QORT,QDN,Reticulum,Q-App,Q-Tube,Quitter,LXMF. aris right-to-left; write natural Arabic and let the UI handle direction.- For
jaandzh, do not insert spaces between characters the way the English source has them. - Match the register of the existing translations in that file rather than translating word for word — check a neighbouring key before inventing a term.
5. Verify
python3 .agents/skills/i18n/i18n_apply_translations.py --audit
Lists values still identical to English. It compares against en, so genuine
cognates (message and microphone in French, level in German) appear as
false positives — confirm before "fixing" them.
Key naming
Follow the existing shape in the JSON files:
- Actions:
core:action.close,core:action.add_reaction - Errors:
core:message.error.generic,group:message.error.qortals_required - Questions/confirms:
core:message.question.delete_chat_image - Plurals: base key plus
_other(core:admin/core:admin_other) — i18next selects via thecountoption.
Use snake_case, no abbreviations, name by meaning rather than by the English
wording so the key survives a copy edit.
Interpolation
t('group:message.error.qortals_required', {
quantity: 4,
postProcess: 'capitalizeFirstChar',
});
Never concatenate translated fragments to build a sentence — word order differs across languages. Put the whole sentence in one key with placeholders.
Known gaps — do not imitate the neighbours
Large parts of the newer UI were written without i18n and are being migrated:
- Most
Reticulum*components insrc/components/Chat/ - The whole of
src/components/QortalLand/(includinggames/andproximity/) - Leftover literals inside otherwise-translated files such as
ChatGroup.tsxandMessageItem.tsx
"Match the surrounding code" does not apply here. When editing these files, use
t() for the lines you touch, and mention any remaining hardcoded strings you
noticed rather than silently leaving them.
Run the scanner over the file you are about to touch to see what you are walking into:
python3 .agents/skills/i18n/i18n_scan_hardcoded.py --detail src/components/Chat/ChatGroup.tsx
Before you finish
Both of these must come back clean — they are the definition of done:
python3 .agents/skills/i18n/i18n_scan_hardcoded.py --detail <file> # no user-readable English left
python3 .agents/skills/i18n/i18n_apply_translations.py --audit # no English in non-English locales
python3 .agents/skills/i18n/i18n_sort.py --check # locale files still sorted
Anything the first returns that a user can read must become a t() call. Any key
you added that the second still reports means the batch translation is unfinished
— and so is the task. Also run npx vitest run <dir> and
npx eslint <changed files> before reporting.