Files
gods-eye-view/docs/I18N.md
T
German KhokhlovandClaude Opus 5.5 3129e531e6 feat(i18n): translate the whole interface
Mark every UI string the unmarked-text check reported (534) plus text it
cannot see (helpers, joined labels, runtime-built panels), and translate
the result to Spanish and Russian: 1,679 messages each.

- Kept on purpose, with the reason where the text lives: provider and
  product names, place and camera names, codes and units, dB values, the HUD
  terminal readouts, demo shot titles that saved projects look up by title,
  and text for AI tools and the voice model.
- Modules shared with the server or AI tools stay free of the i18n import
  (local msg); the wind reading and voice session stay English and are
  translated where the page shows them.
- npm test now runs the unmarked-text check.
- Fixes on the way: a duplicated radio privacy paragraph, the contact panel
  toggle saying Expand while open, the CSS filter mistaking & for CSS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 19:24:42 +04:00

6.5 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'), not t(label) (unless label holds a msg() value) and never a template literal with ${…}.
  • Use placeholders, not concatenation. t('Flying to {place}…', { place }), not t('Flying to') + ' ' + place. Word order differs between languages.
  • Use tn() for counts, not count === 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 in msg() and call t() when rendering.
  • Numbers and dates go through formatNumber, formatDate and formatRelativeTime from src/i18n/index.js, which follow the active language.
  • Identical English means identical translation. If two places need different words, make the English differ.
  • 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, and title, aria-label or placeholder without the matching data-i18n-*.
  • Code: string literals that reach the page without t(), tn() or msg(): assigned to textContent, innerText, innerHTML, title, placeholder; passed to setAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …), createTextNode, insertAdjacentHTML, showToast, toast, setHint, announce; or kept in label:, 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