refactor(i18n): key messages by English text; add Russian

Replace the semantic keys with the English source text, so code reads as the
UI does and English needs no catalog: t('Save keys'), tn(count, one, other)
for plurals, msg() for text stored in data, and bare data-i18n* markup that
translates the element's own English. Other languages are one flat JSON each.

- Simplify src/i18n: drop namespaces, the configurable locale pair
  (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE) and its build defines. Locale
  comes from ?lang=, the stored choice, then browser languages.
- Add scripts/i18n.mjs (npm run i18n): extracts every message from t/tn/msg
  and markup, and fails on missing, unused, placeholder-mismatched or
  wrongly-shaped plural entries. npm test runs it via catalog.test.mjs.
- Add Russian (src/i18n/locales/ru.json, 833 messages), started from the
  community catalog in #312 and reviewed against the current English.
- Fix data-i18n placed on parents that would wipe their children (cockpit
  LEVEL and local weather cells, first-run EXPLORE MANUALLY tile), and a
  doubled degree sign in the TRACK ACQUIRED signal.
- Rewrite docs/TRANSLATORS.md for the new workflow; drop sections the earlier
  merge resurrected in docs/CURRENT-STATE.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-12 13:48:56 +04:00
1 parent dfbaeafbd8
commit 13efd411b6
112 files changed
+4419 -4856

No files matched your search

+20 -116
View File
@@ -3009,124 +3009,28 @@ test:track` 43 tracking invariants · headless QA harnesses under
> attribution 18/18). New modules: `src/data/{motionModel,aircraftMeta,aircraftClass,aircraftIcons,issPass,routePlausible,dataCredits}.js`.
> The live runtime now declares 30 voice tools; the 17→20 count above is retained only as milestone history.
### Internationalization / catalog locales (September 2026)
### Interface localization (October 2026)
The application-owned UI is fully extracted into message catalogs; **en**
is the only shipped locale in this foundation state — additional locales
land as stacked follow-up PRs, one per locale (Spanish first), each
following the locale-addition recipe in
[`docs/TRANSLATORS.md`](./TRANSLATORS.md). English is the default, the
source catalog, and the fallback. The subsystem lives in `src/i18n/`:
Shipped locales: **en** (source and fallback) and **ru**. The code lives in
`src/i18n/`; the contributor workflow is in [`docs/TRANSLATORS.md`](./TRANSLATORS.md).
- **File map.** `src/i18n/locale.js` owns locale resolution, guarded storage
(`gev:locale:v1`), the one-shot `?lang=` override, and `<html lang>`/`<html
dir>` metadata. `src/i18n/index.js` owns the catalog registry, `t()`
(interpolation + `Intl.PluralRules` plural selection), `formatNumber` /
`formatDate`, `applyDocumentTranslations()`, and
`persistLocaleAndReload()`. The shipped catalog is four flat message maps
— `src/i18n/locales/en/{shell,cockpit,layers,setup}.js`, 912 keys (shell
34, cockpit 369, layers 388, setup 121). Plural entries are the en
`{ one, other }` shape; a future locale may add only cardinal categories
`Intl.PluralRules` reports valid for it, and an entry lacking the selected
category degrades to `other` at runtime. Locale-code normalization
(`es-MX`/`es_419` → `es`, plus fr/ru/uk and variants) ships ahead of the
locales on purpose: knowing a code is hygiene, shipping is catalog-driven
through `CATALOG_LOCALES`.
- **Selector.** The command dock carries a compact locale switch: a static
`.dock-locale-switch` group container in `index.html` whose buttons
(`.dock-locale-btn`) are rendered at runtime by `src/ui/applicationShell.js`
`_initLocaleSelector()` — one per locale in the configured pair; while
only English ships that is a single EN button. A click persists the
choice and reloads the page with the hash preserved, so no live re-apply
of already-rendered dynamic panels is needed.
- **Locale pair config.** `GEV_DEFAULT_LOCALE` and `GEV_SECONDARY_LOCALE` in
`.env`, injected as `import.meta.env` defines by `vite.config.js`; both
values are validated against the shipped catalogs (`CATALOG_LOCALES`),
and the offered set is `dedup([default, secondary, 'en'])`. While English
is the only shipped catalog the built-in pair is en-only, so every
configuration degenerates to a single offered locale; an invalid,
unshipped, or degenerate configured pair falls back to that built-in
en-only shape (dev-only warn). The first secondary-locale PR restores the
built-in en+es pair.
- **Resolution order.** `?lang=<locale>` (search string only; never
persisted, never written into share links; stripped by
`persistLocaleAndReload`; accepted only for offered locales) → stored
`gev:locale:v1` (re-checked against the pair) →
`navigator.languages` (regional variants normalize to the primary tag:
`es-MX`/`es_419` → `es`) → the CONFIGURED default locale. Unsupported
values defer to the next
step rather than forcing English.
- **Gates** (all under `node --test src/i18n/`, 40 tests):
`catalog.test.mjs` enforces per-locale key, placeholder-name, and
plural-shape parity with the strict exact-parity flip ON
(`REQUIRE_FULL_PARITY`; `GEV_I18N_REQUIRE_FULL_LOCALE_PARITY=0` opts out
for staged work; the loops are vacuous while only en ships — each locale
PR meets them from day one);
`markupCoverage.test.mjs` requires every `data-i18n*` attribute in
`index.html` to resolve in every shipped catalog and rejects unknown attribute
spellings; `i18n.test.mjs` pins precedence, guards, fallback,
interpolation, the plural-selection contract (synthetic missing-category
degradation; real-catalog plural pins arrive with each locale PR), and
DOM application; `repairPass.test.mjs` anchors en byte-identity for
extracted literals and rejects a locale-scoped CCTV wrap rule shipping
ahead of its locale (per-locale value anchors arrive with each locale
PR).
- **Accepted deferrals (do not "fix" silently):**
- The military-awareness subject header literal `FLIGHT / VESSEL WINDOW`
(`src/data/militaryAwareness.js`) stays English; the literal is
test-pinned in `src/data/militaryAwareness.test.mjs`.
- The visible cockpit-brief tab tokens `SIG` / `NEWS` / `LOCAL`
(`index.html`) stay untranslated to match the runtime keys; their
`aria-label`s ARE localized (`cockpit.brief.tab*AriaLabel`).
- `src/voice/gevActions.js` tool-result confirmation strings stay English
by contract (voice tool schemas and spoken confirmations are
keep-English; see the Keep-English boundary in `docs/TRANSLATORS.md`).
- Number formatting is still the pre-i18n `toLocaleString` policy at:
`src/ui.js:1442`, `src/data/satellites.js:829`,
`src/data/flights.js:436` (`'en-US'` pinned), and
`src/data/rocketLaunches.js:2281/2299/2364/2371/2374`
(default-locale). A locale-aware number-format policy is deferred; the
`formatNumber` helper exists in `src/i18n/index.js` when that lands.
### Overpass proxy mirror rotation (September 2026)
- `/api/overpass` fans out across four public mirrors. `overpassPayloadIsData()` governs cache reads, writes, and stale fallback: only a 2xx that is neither rate-limited nor a body-level runtime error qualifies. Previously stored refusals are ignored on both fresh and stale reads, so upgrading does not require manually clearing the disk cache.
- HTTP refusals such as 406 now rotate alongside the existing network, rate-limit, and runtime-error cases. A refusal from one mirror no longer prevents reaching healthy alternatives or persists under the seven-day road/month-long boundary cache TTLs. Concurrent identical queries share one mirror sequence; if it fails, both the initiating and joined callers can use the same last-good data.
- A refusal every mirror agrees on is still reported with the first mirror's status and body, so a genuinely malformed query says what upstream said — but only after every mirror has had the chance to answer it. `fetchOverpassPayload` takes injectable endpoints and fetch so the rotation is tested without a live mirror (`src/overpassProxy.test.mjs`).
### Share-link v2 layer state (August 2026)
- Generated share links use a deterministic v2 hash. Existing camera, visual,
HUD, detection, post-processing, celestial, scope, and map-stack fields remain,
with compact fields for enabled layers, allowlisted layer options, panel state,
and the active preset's allowlisted shader controls. An absent layer field uses
deterministic defaults; an explicit empty field means no enabled layers.
- The registry seals only after all 16 production layers register, and every
layer has an explicit serialization disposition. Unknown enabled-layer tokens
reject the layer payload; unknown option tokens are ignored. Restoration
settles independently per layer so one failed or unavailable source cannot
block its siblings.
- Stable visible options are limited to aircraft 3D mode, selected civilian and
military flight IDs, Satellite catalog and selection, CCTV coverage/projection/
auto-hop, and Radio filter/volume. Playback and tuning, live-data health,
calibration, caches, lifecycle state, temporary Context ownership, and derived
effects are deliberately excluded. Radio restore never selects or plays a
station.
- Normal loads restore the last successful explicit UI, voice, or tool choice
from versioned local storage. Any valid camera share wins for the current load
without overwriting recipient preferences. Restore ownership is split by
visibility, option/selection, camera, visual, map, and individual panel lane:
a newer explicit action supersedes only the field it owns. In particular,
navigation cannot turn unrelated layers off, and an option change cannot
#### Keep-English adjudications (post-merge catch-ups)
- `src/ui/visualPresets.js` `'ELASTIC'` — detection-allocation STRATEGY IDENTIFIER (persisted
under `gev:detection-allocation`, compared via `dataset.allocation ===`), not display copy.
- `src/ui/layerPanel.js` guidance meta — the `{source} · {statusMessage}` composition uses a
locale-neutral interpunct; the statusMessage itself IS localized via `installationFeedback`.
- `src/ui/mapSourceControls.js` `'...'` switching label and the orbit `'↻'` glyph —
punctuation/glyphs, identical across locales.
- **Keys are the English text.** `t('Save keys')`, `tn(count, '{count} camera',
'{count} cameras')`, and `msg('Live Flights')` for text stored in data and
translated later. Markup opts in with `data-i18n` (element text) and
`data-i18n-title` / `-aria-label` / `-placeholder` (that attribute's English
value). English has no catalog; every other locale is
`src/i18n/locales/<code>.json`, and a missing entry falls back to English.
- **Locale choice.** `?lang=` (one-shot) → stored choice (`gev:locale:v1`) →
`navigator.languages` → English. The EN/RU switch in the Visual Presets dock
stores the choice and reloads, so nothing re-renders live.
- **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`)
extracts every message and fails on missing, unused, placeholder-mismatched or
wrongly-shaped plural entries.
- **Not translated by design:** layer ids, share-link and hash parameters, API
fields, voice tool schemas, provider names, feed data (callsigns, vessel and
station names) and attribution.
- **Gaps.** Strings added after #601's extraction (for example the Street Level
panel) are still raw English; there is no lint for unmarked UI text yet.
## Canonical Docs Order
+77 -181
View File
@@ -1,198 +1,94 @@
# Translator's guide (i18n foundation — English shipped)
# Translating God's Eye View
This is the working guide for translating God's Eye View. The i18n
foundation ships **English only**: the locale core, the English
catalogs, and the parity gates are in, and every additional locale lands
as its own stacked PR, one locale at a time, following the
[locale-addition recipe](#the-locale-addition-recipe) below. That recipe
is the deliberate design artifact of this phase — a locale is not
"translated into the app", it is *shipped* through the same gate every
time.
Shipped languages: **English** (source and fallback) and **Russian** (`ru`).
## Which locale ships, and which pair is offered
## How it works
One catalog ships: **en** (source of truth and unconditional fallback).
Shipping is catalog-driven (`CATALOG_LOCALES` in `src/i18n/locale.js`):
while only English ships, the built-in locale pair is en-only and the
dock language switch renders a single EN button. Which pair the app
*offers* is configuration, not code: `GEV_DEFAULT_LOCALE` and
`GEV_SECONDARY_LOCALE` in `.env`, injected into the browser via vite
defines. Both values are validated against the shipped catalogs — an
invalid, unshipped, or degenerate pair (e.g. the same locale twice)
degenerates to the built-in en-only shape with a dev-server-only
`console.warn`. English is always resolvable — it is the fallback
catalog — even when not part of the configured pair.
The English text is the key. There is no English catalog: the code is the source.
## How the catalog system works
All application-owned UI text lives in four flat message catalogs (en
today; one directory per locale once shipped):
```text
src/i18n/
locale.js pair config + resolution + storage + <html lang>/<html dir>
index.js catalog registry, t(), Intl formatters, DOM apply
locales/en/{shell,cockpit,layers,setup}.js
```js
t('Save keys'); // plain text
t('Saved to {store}', { store }); // named placeholders
tn(count, '{count} camera', '{count} cameras'); // count-dependent text
msg('Live Flights'); // text kept in data, translated later by t()
```
| Namespace | Surface |
| --- | --- |
| `shell.*` | Document chrome: title, dock actions, panels, global status chip, sync chips, toasts, locale selector |
| `cockpit.*` | Cockpit HUD readouts, Context panel, CCTV scene summary, Display/panel chrome, visual presets, Radio utility |
| `layers.*` | Data-layer presentation: layer names, feed-state labels, row meta, vessel/flight/satellite/mission/CCTV/Radio/awareness cards and readouts |
| `setup.*` | First-run launcher, key setup (POWER UP), scenes/director status, map-stack chips |
```html
<span data-i18n>Normal</span>
<button title="Pin visual presets" data-i18n-title>…</button>
```
Each catalog file exports `NAMESPACE` and a default map of
namespace-relative keys; `mergeNamespace()` prefixes them (`cockpit.…`)
and `buildCatalog()` rejects duplicates. English is the default and the
fallback locale: a key missing in another locale renders its English
value (dev-server-only `console.warn`). The en catalogs hold 912 keys
(shell 34, cockpit 369, layers 388, setup 121).
`data-i18n` translates the element's own text (put it on the element that holds the text, not on a parent with children). `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute's English value.
## Key naming
Every other language is one flat JSON file, `src/i18n/locales/<code>.json`, keyed by the English text:
- Markup surfaces: `<namespace>.<surface>.<element>` — e.g.
`shell.panels.dataLayers`, `cockpit.readout.altitude`.
- Runtime flows/states: `<namespace>.<flow>.<state>` — e.g.
`shell.status.loadingLiveData`, `layers.missions.telemetry.km`.
- Names are semantic and stable. Never rename, reorder, or renumber an
existing key: `data-i18n` attributes and runtime `t()` call sites in other
files reference them.
```json
{
"Save keys": "Сохранить ключи",
"{count} cameras": {
"one": "{count} камера",
"few": "{count} камеры",
"many": "{count} камер",
"other": "{count} камеры"
}
}
```
## The locale-addition recipe
A `tn()` entry is keyed by the English plural form and holds one string per `Intl.PluralRules` category of that language. A missing entry falls back to English.
Every locale PR follows the same steps, in this order. Nothing here is
optional — the gates below fail the build if a step is skipped.
The page picks its language from `?lang=`, then the choice stored by the EN/RU switch in the Visual Presets dock, then the browser languages, then English. Switching reloads the page.
1. **Catalogs.** Copy `src/i18n/locales/en/` to
`src/i18n/locales/<code>/` and translate all four files
(`<code>` is the primary language tag: `es`, not `es-419`). Keep the
en key order, keep key names byte-identical, and translate VALUES
only. A locale may never carry a key en lacks.
2. **Registration.** In `src/i18n/index.js`: add the four import lines
and one `<CODE>_NAMESPACES` array, then one entry in the `CATALOGS`
map. Registration is append-only; nothing else in the file changes.
3. **Shipping.** Append `'<code>'` to `CATALOG_LOCALES` in
`src/i18n/locale.js`. This single constant is what makes the locale
normalizable-to-shipped: pair config, `?lang=`, storage, and the
selector all read from it. Regional variants (`es-MX`, `es_419`)
already normalize — locale-code hygiene is shipped ahead of the
locales on purpose.
4. **Selector labels.** Append
`'locale.<code>.ariaLabel': 'Switch to <Language>'` to the `shell`
catalog of EVERY shipped locale (en included — the en catalog names
the new button; the new catalog names the English one).
`markupCoverage.test.mjs` requires a label for every shipped code in
every catalog, so this cannot be forgotten.
5. **Plurals.** Plural entries are variant objects carrying a `{count}`
placeholder, selected per active locale through `Intl.PluralRules`.
English entries use `{ one, other }`. Your locale must keep every en
variant and may add ONLY categories `Intl.PluralRules` reports valid
for it (e.g. a Slavic locale adds `few`/`many`) —
`catalog.test.mjs` enforces this shape against the live ICU data.
Pin your locale's real-catalog plural agreement in
`src/i18n/i18n.test.mjs` (one/few/many/other counts where relevant).
6. **Layout.** Prefer concise glossary-approved copy over CSS surgery;
fixed-width `nowrap` panels must be checked at narrow viewports. A
locale-scoped wrap rule (`html[lang='<code>'] .cctv-controls {
flex-wrap: wrap; }` in `src/ui/styles/cctv.css`) is added ONLY when
the locale demonstrably clips, with a comment recording the measured
widths — the English layout stays pixel-identical, and
`repairPass.test.mjs` rejects a wrap rule shipping ahead of its
locale.
7. **Gates.** `node --test src/i18n/` must be green with the strict
parity gate ON: exact key-set equality with en, identical
placeholder names, plural-shape superset. While a new catalog is a
staged untranslated seed, CI may run with
`GEV_I18N_REQUIRE_FULL_LOCALE_PARITY=0` (subset mode) — record every
such flip here, and record the flip back.
8. **Docs.** Add the locale's section + glossary to this file, note it
in `docs/CURRENT-STATE.md` (Internationalization) and `CHANGELOG.md`,
and update the shipped-locales wording in `.env.example`.
## Append-only rules
- New keys are appended to the END of the namespace file in EVERY
shipped locale directory in the same change. Never edit another
namespace's files, and never touch the registration arrays in
`src/i18n/index.js` without re-reading this guide first.
- A fifth namespace is a last resort (prefer fitting one of the four); its
registration recipe is the same append-only pattern as a locale (step 2
of the recipe above), applied to every shipped locale at once.
- Parity is enforced by tests, not goodwill: no locale may carry a key en
lacks, and every shared key must keep identical placeholder names
and a plural-variant set that includes every en variant plus only
categories `Intl.PluralRules` reports for that locale
(`src/i18n/catalog.test.mjs`).
## Placeholder, plural, and typography rules
- Interpolation uses named `{camelCase}` placeholders:
`'Flying to {place}…'`. The **same placeholder names must exist in
every locale** — the parity gate fails on a renamed `{name}` because
it would break interpolation at runtime.
- Locale-aware numbers, dates, and relative times go through the
`formatNumber` / `formatDate` / `formatRelativeTime` helpers in
`src/i18n/index.js` — do not hand-format in a catalog call site.
- Style: the English catalog is ALL-CAPS for tactical labels, readouts,
and status text; translations follow the same convention in their
script's norms. The `·` separator used between metadata segments is
part of the surface style and is reproduced in translations.
- Instrument codes stay identical across languages; units (KTS, FT, km,
km²) are never translated.
- Romance languages run ~25% longer than English and Slavic languages
compound that on multi-word tactical labels — plan string budgets and
check fixed-width panels before review (see step 6 above).
## Keep-English boundary (summary)
Never catalog these — they are contracts, not copy:
- Internal layer IDs and registry keys (`military-installations`,
`ais-live-vessels`, stack ids).
- Status enums and machine values (`FEED_STATE_LABELS` keys, lifecycle
states, detection/context modes) — translate only the final label at
the presentation boundary.
- URL/hash contracts: share-link parameters, `?welcome=`, `?lang=`,
storage schemas.
- API fields, voice tool names/schemas, `/api/setup/*` payloads.
- Provider/dataset/product names, callsigns, vessel/satellite/place
names from data, news headlines, license/source attributions.
- Console diagnostics, QA pin scripts, and existing exact-English test
expectations.
- User-authored content (annotations, labels drawn from live data).
Adjudicated exceptions live in "Internationalization" in
[`docs/CURRENT-STATE.md`](./CURRENT-STATE.md) (accepted deferrals — do not
"fix" them silently).
## Glossary
Approved recurring terms per locale. Each locale PR appends its
glossary here (English term · translation · catalog evidence) so later
translators reuse approved copy verbatim. None yet — the first entry
arrives with the first translated locale.
## Running the i18n test gates
## The check
```sh
node --test src/i18n/ # 40 tests: core + pair config, catalog parity, markup coverage, repair-pass anchors
npm test # full suite (see below for the known environmental caveat)
npm run i18n # what is missing, unused or broken, per language
npm run i18n -- --missing ru # missing ru entries as JSON, ready to translate
npm run i18n -- --prune # drop entries the code no longer uses
```
- `src/i18n/i18n.test.mjs` — resolution precedence, guarded storage,
fallback, interpolation, plural selection (contract + synthetic
missing-category degradation; real-catalog plural pins arrive with
each locale PR), DOM application.
- `src/i18n/catalog.test.mjs` — key + placeholder + plural-shape parity for
every shipped locale against en, strict `REQUIRE_FULL_PARITY` gate
(vacuous while only en ships; each locale PR meets it from day one).
- `src/i18n/markupCoverage.test.mjs` — every `data-i18n*` attribute in
`index.html` must resolve in every shipped catalog; unknown attribute
spellings fail loudly.
- `src/i18n/repairPass.test.mjs` — byte-identity anchors for English
literals; per-locale value anchors arrive with each locale PR.
`npm test` runs the same check (`src/i18n/catalog.test.mjs`). It fails when a translation is missing, when an entry is no longer used, when placeholders differ from the English, or when a plural entry lacks a form.
Full-suite caveat: `src/devFreshDotenv.test.mjs`'s external-keys provenance
test fails when a provider key (e.g. `OPENAI_API_KEY`) is exported in the
running shell — an environmental false positive unrelated to i18n.
Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune.
## Fixing a translation
Edit `src/i18n/locales/<code>.json` and run `npm run i18n`. To see your change, open the app with `?lang=<code>`.
## Adding a language
1. Create `src/i18n/locales/<code>.json`, fill it from `npm run i18n -- --missing <code>` (start from `{}`), and translate every entry.
2. Import it in `src/i18n/index.js` and add it to `CATALOGS`.
3. Add the code to `LOCALES` and its own-language name to `LOCALE_NAMES` in `src/i18n/locale.js`. Right-to-left languages also go in `RTL_LOCALES`.
4. Add `src/i18n/locales/<code>.json` next to `ru.json` in `scripts/package-boundaries.json`.
5. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths.
## Rules
- Keep every `{placeholder}` exactly as in the English; you may move it.
- UPPERCASE English stays UPPERCASE. Short labels, chips and HUD readouts should stay short; prefer an abbreviation over a wrapped line.
- Keep the separators `·`, `—`, `→`, `↗`, `…`.
- Leave product, provider and data names in their original form: Google Maps, Cesium ion, OpenAI, ChatGPT, NASA FIRMS, USGS, OpenSky, ADS-B, AIS, CCTV, HUD, TomTom, OpenStreetMap, Mapillary, and keyboard keys (Space, Esc, H, 1–9).
- Units follow the language's convention (Russian: км, м/с, уз, фт).
## Never translated
These are contracts, not copy: layer ids and registry keys, status enums and other machine values (translate only the label shown for them), share-link and hash parameters, `?lang=`, storage schemas, API fields, voice tool names and schemas, provider and dataset names, data from feeds (callsigns, vessel, satellite, station and place names, headlines), attribution, console diagnostics, and user-authored content.
## Russian glossary
| English | Русский |
| ------------------------------- | ---------------------------------- |
| layer / data layer | слой / слой данных |
| feed | поток данных (short: поток) |
| source | источник |
| LIVE (status) / live data | ОНЛАЙН / в реальном времени |
| SIMULATED | СИМУЛЯЦИЯ |
| stale / degraded / unavailable | устарел / с перебоями / недоступен |
| key (API key) / POWER UP | ключ / ЗАПУСК |
| aircraft / flight / vessel | самолёт / рейс / судно |
| launch (rocket) / space mission | пуск / космическая миссия |
| contact / tracking | контакт / отслеживание |
| installation | объект |
| scene / shot / recipe | сцена / кадр / рецепт |
| HDG / BRG / CRS / FOV / DEST | КУРС / ПЕЛ. / ПУТЬ / ОБЗОР / НАЗН. |
| PREV / NEXT | ПРЕД. / СЛЕД. |