A review of the Russian UI screenshots found ~90 places where longer words
broke the layout or text was still English. Fixed so they hold for any
language, then shortened where a control is short by design.
- Layout: label columns that size to their text (weather and wind settings,
Recent Imagery slots and opacity, style sliders, display panel), layer
list names and icons, buttons that wrap instead of overflowing (CCTV,
scenes, Bhote Koshi), voice header that always shows the cost, styled
EN | ES | RU chips, voice error tray above the dock.
- t() context: t('CLEAR', { context: 'action' }) is «СБРОСИТЬ» while the sky
stays «ЯСНО»; also used for radio categories, voice states and directions.
- Units and dates follow the locale through src/ui/unitLabels.js
(formatQuantity: localized number, translated unit, non-breaking space):
wind, pressure, cyclones, directions, CCTV, SDR gain, scene timing, flight
tooltip, Recent Imagery dates and resolution.
- Newly translated: directions turn-by-turn steps (built from the maneuver;
English matches the server word for word), HUD mode and summary, voice
card states, radio categories on the globe, CCTV status, layer source
lines, scene dialogs (Cancel, file pickers), Bhote Koshi statuses, titles,
comparison dates and caveat, Recent Imagery notices. Stored data and text
for AI tools stay English.
- es and ru: 1,885 messages each.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
6.6 KiB
UI text and translation
Every piece of text a user can see goes through t(), tn() or msg() in code, or a data-i18n* attribute in markup. The English text itself is the key. English needs no catalog; other languages live in src/i18n/locales/<code>.json. Translators work from TRANSLATORS.md.
Which one to use
| Translates | Use for | |
|---|---|---|
t(text, values) |
now | any text shown right where it is written |
tn(count, one, other, values) |
now, picking the form for count |
text that changes with a number |
msg(text) |
never: returns text unchanged |
text stored in data and passed to t() later |
import { t, tn, msg } from '../i18n/index.js';
status.textContent = t('Save keys');
status.textContent = t('Saved to {store}', { store: storeLabel() });
chip.textContent = tn(missing, '{count} KEY WAITING', '{count} KEYS WAITING');
// en: 1 KEY / 3 KEYS ru: 1 КЛЮЧ / 3 КЛЮЧА / 5 КЛЮЧЕЙ
const FEED_STATE_LABELS = { loading: msg('LOADING'), stale: msg('STALE') };
label.textContent = t(FEED_STATE_LABELS[state]);
msg() exists for two reasons. npm run i18n finds messages only as string literals inside t(…), tn(…) and msg(…), so t(FEED_STATE_LABELS[state]) alone tells it nothing. And a t() call at module load runs before the page has picked its language, so it would freeze the English.
Markup
<span data-i18n>Normal</span>
<button title="Pin visual presets" data-i18n-title>…</button>
<input placeholder="Search any location..." data-i18n-placeholder />
data-i18n replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. data-i18n-title, data-i18n-aria-label and data-i18n-placeholder translate that attribute. Markup is translated once at boot, and switching language reloads the page.
Rules
- Pass literals.
t('Close'), nott(label)(unlesslabelholds amsg()value) and never a template literal with${…}. - Use placeholders, not concatenation.
t('Flying to {place}…', { place }), nott('Flying to') + ' ' + place. Word order differs between languages. - Use
tn()for counts, notcount === 1 ? … : …. Russian has three forms. - Translate the label, not the value. Layer ids, enums, share-link parameters, API fields, voice tool schemas and feed data (callsigns, vessel and place names) stay as they are; translate only the text shown for them.
- No
t()at module top level. Keep the English inmsg()and callt()when rendering. - Numbers and dates go through
formatNumber,formatDateandformatRelativeTimefromsrc/i18n/index.js, which follow the active language. - Identical English means identical translation, unless you give it a context:
t('CLEAR', { context: 'action' })(a button) is its own catalog entry,action|CLEAR, whilet('CLEAR')(the sky) stays separate. English output is the same. - Text that should stay English (a brand, a code, a unit symbol) is simply not wrapped.
After you add or change text
npm run i18n # what each language lacks or no longer uses, and unmarked UI text
npm run i18n -- --missing ru # the missing ru entries as JSON
npm run i18n -- --prune # drop entries whose English no longer exists
Changing the English changes the key, so the old translation goes unused and the new text shows as missing. Add the translation to src/i18n/locales/ru.json (or ask a translator), prune, and run npm test, which runs the same check and fails until every language is complete.
To see a language, open the app with ?lang=ru, or use the language switch in the Visual Presets dock.
Text the check reports as unmarked
npm run i18n also lists UI text that is not marked at all, and fails while there is any; npm test runs the same check:
- Markup: element text without
data-i18n, andtitle,aria-labelorplaceholderwithout the matchingdata-i18n-*. - Code: string literals that reach the page without
t(),tn()ormsg(): assigned totextContent,innerText,innerHTML,title,placeholder; passed tosetAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …),createTextNode,insertAdjacentHTML,showToast,toast,setHint,announce; or kept inlabel:,title:,tooltip:,placeholder:,hint:,ariaLabel:,caption:,subtitle:,kicker:,emptyText:,blurb:,countLabel:properties.
Fix it by marking the text. When the text must stay as it is, say so where it lives:
| Case | How |
|---|---|
Codes, units, product names in markup (0.9 dB, ADS-B · 1090, OpenSky) |
translate="no" on the element; it covers everything inside, as in HTML |
Markup whose text code sets through t() (a status that starts as OFF) |
data-i18n-runtime on the element |
| A literal in code that is not UI copy (a product name, sample data) | // i18n-ignore: <reason> on the line or the line above |
Icons (aria-hidden="true", Material Symbols), comments, CSS values and lowercase identifiers are skipped automatically, and so is src/tools/, whose text is written for AI clients rather than the page.
Where things live
| File | What it holds |
|---|---|
src/i18n/index.js |
t, tn, msg, the Intl formatters, markup translation, switching language |
src/i18n/locale.js |
shipped languages, their names, choosing the language at boot |
src/i18n/locales/<code>.json |
one catalog per language, keyed by English text |
scripts/i18n.mjs |
npm run i18n: extracts messages, checks the catalogs, finds unmarked UI text |
src/i18n/catalog.test.mjs |
runs that check under npm test |