From bde1e430efdee543f5a9ff88e341843d8569c2a9 Mon Sep 17 00:00:00 2001 From: Meir Michanie Date: Thu, 24 Sep 2026 20:03:23 +0200 Subject: [PATCH] feat(i18n): locale core + English catalogs + parity gates Add the i18n foundation: src/i18n/locale.js (locale resolution with a configurable GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE pair, guarded preference storage, one-shot ?lang= override, / metadata) and src/i18n/index.js (catalog registry, t(), Intl formatters, applyDocumentTranslations, persistLocaleAndReload). English is the only shipped locale in this PR: the four en catalogs (shell/cockpit/layers/setup) are the reference and the always-shipped fallback, the built-in pair degenerates to en-only, and English output is byte-identical to the strings these replace. Parity gates land with the core (catalog key/placeholder/plural-shape parity, markup coverage of every data-i18n* attribute, repair-pass anchors pinning en byte-identity); additional locales ship as stacked follow-up PRs through the recipe in docs/TRANSLATORS.md. Rebased onto current main: the client defines live in build/vite.js / server/standalone/vite.config.js as before. --- .env.example | 14 + build/vite.js | 10 + server/standalone/vite.config.js | 2 + src/i18n/catalog.test.mjs | 198 +++++++++++ src/i18n/i18n.test.mjs | 457 +++++++++++++++++++++++++ src/i18n/index.js | 348 +++++++++++++++++++ src/i18n/locale.js | 314 +++++++++++++++++ src/i18n/locales/en/cockpit.js | 560 +++++++++++++++++++++++++++++++ src/i18n/locales/en/layers.js | 501 +++++++++++++++++++++++++++ src/i18n/locales/en/setup.js | 211 ++++++++++++ src/i18n/locales/en/shell.js | 82 +++++ src/tooling/viteBuild.test.mjs | 5 + 12 files changed, 2702 insertions(+) create mode 100644 src/i18n/catalog.test.mjs create mode 100644 src/i18n/i18n.test.mjs create mode 100644 src/i18n/index.js create mode 100644 src/i18n/locale.js create mode 100644 src/i18n/locales/en/cockpit.js create mode 100644 src/i18n/locales/en/layers.js create mode 100644 src/i18n/locales/en/setup.js create mode 100644 src/i18n/locales/en/shell.js diff --git a/.env.example b/.env.example index 67a1e74a7..a0f089772 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,20 @@ LL2_API_TOKEN= # Optional: OpenSky credentials JSON file path (alternative to above) # OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json +# Optional: interface language pair (i18n). GEV_DEFAULT_LOCALE is the default +# UI locale; GEV_SECONDARY_LOCALE is the second locale offered in the dock +# language switch. English is ALWAYS shipped as the fallback catalog and is +# resolvable no matter the pair. Only 'en' ships in this PR: both values are +# validated against the shipped catalogs, so a secondary without a shipped +# catalog degenerates the switch to a single English button (and an invalid, +# unshipped, or degenerate pair falls back to that en-only shape with a +# dev-server-only console.warn). Later locale PRs re-open the pair — see +# docs/TRANSLATORS.md. CLIENT-EXPOSED (non-secret): both values reach the +# browser bundle via import.meta.env defines — do not put anything sensitive +# here. +# GEV_DEFAULT_LOCALE=en +# GEV_SECONDARY_LOCALE=es + # Optional: dev server config PORT=4173 # Network binding. Default is localhost — the server is reachable ONLY from diff --git a/build/vite.js b/build/vite.js index cdff4a27c..bf2ea9f98 100644 --- a/build/vite.js +++ b/build/vite.js @@ -7,6 +7,8 @@ export function createBrowserViteConfig({ publicDir, googleApiKey, cesiumToken, + defaultLocale = '', + secondaryLocale = '', host = 'localhost', port = 4173, command, @@ -47,6 +49,14 @@ export function createBrowserViteConfig({ define: { 'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey), 'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken), + // Locale pair (i18n): the client's default + secondary UI locale. These + // are NOT secrets and are read by src/i18n/locale.js, which validates + // them against the shipped catalogs and falls back to the built-in + // en-only pair (while English is the only shipped catalog) for + // empty/invalid/unshipped values. Explicit define entries are required + // because Vite only auto-exposes VITE_-prefixed vars on import.meta.env. + 'import.meta.env.GEV_DEFAULT_LOCALE': JSON.stringify(defaultLocale), + 'import.meta.env.GEV_SECONDARY_LOCALE': JSON.stringify(secondaryLocale), }, build: { chunkSizeWarningLimit: 1500 }, }; diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index ffa5a1e27..ae4a62043 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -16,6 +16,8 @@ export default defineConfig(({ command, mode }) => { plugins: [...localProviderPlugins(), apiNotFoundPlugin()], googleApiKey: process.env.GOOGLE_MAPS_API_KEY, cesiumToken: process.env.CESIUM_ION_TOKEN, + defaultLocale: process.env.GEV_DEFAULT_LOCALE ?? '', + secondaryLocale: process.env.GEV_SECONDARY_LOCALE ?? '', host: process.env.HOST, port: process.env.PORT, command, diff --git a/src/i18n/catalog.test.mjs b/src/i18n/catalog.test.mjs new file mode 100644 index 000000000..9b8997f8d --- /dev/null +++ b/src/i18n/catalog.test.mjs @@ -0,0 +1,198 @@ +// Catalog parity gates: every shipped non-English locale (the es follow-up, +// then one PR per further locale) is checked against en — it may never LEAD en +// (no extra keys), every shared key must keep identical placeholder names, +// and plural entries must carry every en variant plus only categories +// Intl.PluralRules reports valid for that locale (see assertVariantSuperset +// below); the strict gate demands exact key-set equality so a forgotten +// translation cannot ship silently behind the English fallback. While English +// is the only shipped locale these loops are vacuous — the gate machinery +// stays so each locale PR meets it from day one. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { getCatalog, mergeNamespace } from './index.js'; +import { CATALOG_LOCALES } from './locale.js'; + +/* + * PARITY FLIP — how the strict gate is previewed or relaxed. + * ───────────────────────────────────────────────────────────────────────────── + * REQUIRE_FULL_PARITY is on by default: every shipped locale must hold the + * exact en key set. While a NEW catalog is still an untranslated seed that + * lags en, run CI with GEV_I18N_REQUIRE_FULL_LOCALE_PARITY=0 to drop back to + * the subset rules (a locale may be a subset of en, never a superset). The + * pre-generalization name GEV_I18N_REQUIRE_FULL_ES_PARITY is still honored as + * an alias. Record every flip in docs/TRANSLATORS.md. + */ +const PARITY_OPT_OUT = process.env.GEV_I18N_REQUIRE_FULL_LOCALE_PARITY + ?? process.env.GEV_I18N_REQUIRE_FULL_ES_PARITY; +const REQUIRE_FULL_PARITY = PARITY_OPT_OUT !== '0'; + +const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g; + +/** Placeholder names used by one catalog entry (string or plural variants). */ +function placeholdersOf(entry) { + const patterns = typeof entry === 'string' ? [entry] : Object.values(entry || {}); + const names = new Set(); + for (const pattern of patterns) { + if (typeof pattern !== 'string') continue; + for (const match of pattern.matchAll(PLACEHOLDER_PATTERN)) names.add(match[1]); + } + return names; +} + +/* + * PLURAL-SHAPE PARITY, GENERALIZED FOR MULTI-CATEGORY LOCALES + * ───────────────────────────────────────────────────────────────────────────── + * en plural entries carry { one, other } — the only cardinal categories + * Intl.PluralRules reports for English. Slavic locales such as ru/uk select + * one/few/many/other, so exact-shape equality with en would forbid their + * grammars. The rule is now, per locale and shared key: + * (a) the entry must carry EVERY variant en has (they are mandatory — the + * runtime reads entry. ?? entry.other, so dropping an en + * variant strands the fallback), and + * (b) every variant added beyond en's must be a cardinal category + * Intl.PluralRules reports VALID for that locale + * (resolvedOptions().validCategories, spelled pluralCategories on + * older runtimes; e.g. 'few'/'many' are valid for ru/uk but not en). + * en itself can never grow a variant (English's valid set is exactly + * one/other), and a typo'd variant like 'xother' fails loudly for any + * locale. The runtime degrades gracefully the other way: an entry lacking a + * category its locale would select renders entry.other (selectPluralPattern + * in src/i18n/index.js; pinned in i18n.test.mjs). + */ + +/** Cardinal plural categories Intl may select for a locale. */ +function validPluralCategories(locale) { + const { validCategories, pluralCategories } = new Intl.PluralRules(locale).resolvedOptions(); + // The property was renamed pluralCategories → validCategories in newer + // ECMAScript drafts; read whichever the running ICU exposes. + const categories = validCategories ?? pluralCategories; + assert.ok(Array.isArray(categories), `Intl.PluralRules(${locale}) exposes no category set`); + return new Set(categories); +} + +/** Assert the plural-shape rule for one locale entry against its en twin. */ +function assertVariantSuperset(localeEntry, enEntry, locale, key) { + assert.equal( + typeof localeEntry, + typeof enEntry, + `variant-shape drift on ${locale}:${key}: string ↔ plural-object mismatch`, + ); + if (typeof enEntry === 'string') return; + const enVariants = Object.keys(enEntry); + const variants = Object.keys(localeEntry); + const missing = enVariants.filter((variant) => !variants.includes(variant)); + assert.deepEqual( + missing, + [], + `${locale}:${key} must keep every en variant (${enVariants.join(',')})`, + ); + const valid = validPluralCategories(locale); + const invalid = variants.filter((variant) => !enVariants.includes(variant) && !valid.has(variant)); + assert.deepEqual( + invalid, + [], + `${locale}:${key} adds categories Intl.PluralRules(${locale}) never selects: ${invalid.join(',')}`, + ); +} + +const enKeys = Object.keys(getCatalog('en')).sort(); +// Every shipped non-en locale with its sorted key list. +const localeKeys = new Map( + CATALOG_LOCALES + .filter((locale) => locale !== 'en') + .map((locale) => [locale, Object.keys(getCatalog(locale)).sort()]), +); + +test('every shipped locale ships a merged catalog', () => { + // English only in the foundation state; a locale PR appends its code here + // alongside its catalog files (docs/TRANSLATORS.md). + assert.deepEqual([...CATALOG_LOCALES].sort(), ['en']); + for (const locale of CATALOG_LOCALES) { + assert.ok(getCatalog(locale), `merged catalog for ${locale}`); + } +}); + +test('no locale carries keys that en does not have', () => { + for (const [locale, keys] of localeKeys) { + const extras = keys.filter((key) => !enKeys.includes(key)); + assert.deepEqual( + extras, + [], + `extra ${locale} keys leak untranslated-only surfaces: ${extras.join(', ')}`, + ); + } +}); + +test('placeholder names match en; plural variants form an en-superset of Intl-valid categories', () => { + const enCatalog = getCatalog('en'); + for (const [locale, keys] of localeKeys) { + const catalog = getCatalog(locale); + for (const key of keys) { + assert.deepEqual( + [...placeholdersOf(catalog[key])].sort(), + [...placeholdersOf(enCatalog[key])].sort(), + `placeholder drift on ${locale}:${key}: a renamed {name} would break interpolation at runtime`, + ); + assertVariantSuperset(catalog[key], enCatalog[key], locale, key); + } + } +}); + +test('plural-shape rule: ru accepts its full Intl category set and rejects categories it never selects', () => { + const enEntry = { one: 'Cleared {count} layer', other: 'Cleared {count} layers' }; + // ru selects one/few/many/other, so the full four-variant translation of + // an en { one, other } twin is a legal superset (adds only valid ru + // categories) — exactly what stage-B translators must produce… + assert.doesNotThrow(() => assertVariantSuperset( + { one: '…', few: '…', many: '…', other: '…' }, + enEntry, + 'ru', + 'layers.clear.toast.cleared', + )); + // …while a variant Intl never selects for ru (a typo like 'xother') fails. + assert.throws( + () => assertVariantSuperset({ one: '…', other: '…', xother: '…' }, enEntry, 'ru', 'demo.key'), + /xother/, + ); + // Dropping a mandatory en variant fails even when all additions are valid. + assert.throws( + () => assertVariantSuperset({ few: '…', many: '…', other: '…' }, enEntry, 'ru', 'demo.key'), + /every en variant/, + ); + // String ↔ plural-object drift still fails exactly as before. + assert.throws( + () => assertVariantSuperset('plain string', enEntry, 'ru', 'demo.key'), + /string ↔ plural-object/, + ); + // The gate stays honest about what the runtime can select: validCategories + // is read from Intl, not hardcoded per locale. + assert.deepEqual([...validPluralCategories('en')].sort(), ['one', 'other']); + assert.deepEqual([...validPluralCategories('ru')].sort(), ['few', 'many', 'one', 'other']); +}); + +test('exact key parity for every shipped locale once translation is complete (parity flip)', () => { + if (!REQUIRE_FULL_PARITY) { + // Subset mode: every locale key resolves; en-only keys fall back silently. + for (const [locale, keys] of localeKeys) { + for (const key of keys) assert.ok(enKeys.includes(key), `${locale}:${key}`); + } + return; + } + for (const [locale, keys] of localeKeys) { + assert.deepEqual(keys, enKeys, `strict gate is on: ${locale} must be key-complete`); + } +}); + +test('mergeNamespace prefixes relative keys and rejects malformed namespaces', () => { + const merged = mergeNamespace({ NAMESPACE: 'demo', default: { 'a.b': 'x', plain: 'y' } }); + assert.deepEqual({ ...merged }, { 'demo.a.b': 'x', 'demo.plain': 'y' }); + assert.throws(() => mergeNamespace({}), /must export a lowercase NAMESPACE/); + assert.throws(() => mergeNamespace({ NAMESPACE: 'Nope' }), /must export a lowercase NAMESPACE/); + assert.throws(() => mergeNamespace({ NAMESPACE: '' }), /must export a lowercase NAMESPACE/); + assert.deepEqual({ ...mergeNamespace({ NAMESPACE: 'empty' }) }, {}); +}); + +test('registered namespaces produce the documented dot prefixes', () => { + const prefixes = new Set(enKeys.map((key) => key.split('.')[0])); + assert.deepEqual([...prefixes].sort(), ['cockpit', 'layers', 'setup', 'shell']); +}); diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs new file mode 100644 index 000000000..0b9ca8a23 --- /dev/null +++ b/src/i18n/i18n.test.mjs @@ -0,0 +1,457 @@ +// i18n core behavior: locale resolution precedence (incl. ?lang=), guarded +// storage, English fallback, interpolation, plural selection, document +// application on all four attributes, and hash-exact reload handling. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + CATALOG_LOCALES, + DEFAULT_LOCALE, + LOCALE_METADATA, + LOCALE_STORAGE_KEY, + applyDocumentLanguage, + availableLocales, + normalizeLocale, + resolveLocale, + resolveLocalePair, + writeStoredLocale, +} from './locale.js'; +import { + applyDocumentTranslations, + formatDate, + formatNumber, + formatRelativeTime, + getCatalog, + getLocale, + hasMessage, + persistLocaleAndReload, + resolveMessage, + selectPluralPattern, + setLocale, + t, +} from './index.js'; + +// Tests mutate module locale state; each dependent test re-pins it. +function pinLocale(locale) { + assert.equal(setLocale(locale), locale); +} + +test('normalizeLocale folds regional variants and rejects unsupported tags', () => { + assert.equal(normalizeLocale('es'), 'es'); + assert.equal(normalizeLocale('ES'), 'es'); + assert.equal(normalizeLocale('es-MX'), 'es'); + assert.equal(normalizeLocale('es_419'), 'es'); + assert.equal(normalizeLocale('en'), 'en'); + assert.equal(normalizeLocale('en-GB'), 'en'); + assert.equal(normalizeLocale('fr'), 'fr'); + assert.equal(normalizeLocale('FR'), 'fr'); + assert.equal(normalizeLocale('fr-CA'), 'fr'); + assert.equal(normalizeLocale('fr_FR'), 'fr'); + assert.equal(normalizeLocale('ru'), 'ru'); + assert.equal(normalizeLocale('RU'), 'ru'); + assert.equal(normalizeLocale('ru-RU'), 'ru'); + assert.equal(normalizeLocale('ru_KZ'), 'ru'); + assert.equal(normalizeLocale('uk'), 'uk'); + assert.equal(normalizeLocale('UK'), 'uk', 'uppercase country spelling still folds to the language tag'); + assert.equal(normalizeLocale('uk-UA'), 'uk'); + assert.equal(normalizeLocale('uk_UA'), 'uk'); + assert.equal(normalizeLocale('de'), null, 'no shipped de catalog'); + assert.equal(normalizeLocale('english'), null); + assert.equal(normalizeLocale(''), null); + assert.equal(normalizeLocale(null), null); + assert.equal(normalizeLocale(42), null); +}); + +test('resolution precedence: ?lang= is one-shot, pair-scoped, and never persisted', () => { + // English is the only shipped catalog in the foundation state, so the + // offered pair is en-only: an override naming any other locale — a + // planned-but-unshipped code ('es') or garbage ('de') — is ignored while + // the rest of the chain keeps looking. The override-beats-everything pin + // comes back with the first secondary-locale PR (docs/TRANSLATORS.md). + const storage = { getItem: () => 'en', setItem() { throw new Error('must not be written'); } }; + assert.equal(resolveLocale({ + location: { search: '?lang=es' }, + storage, + languages: ['en-US'], + }), 'en'); + // A garbage override is ignored — the chain keeps looking. + assert.equal(resolveLocale({ + location: { search: '?lang=de' }, + storage, + languages: ['en-US'], + }), 'en'); + // A locale code that normalizes but has no shipped catalog is equally + // ignored: fr cannot be offered before its locale PR lands. + assert.equal(resolveLocale({ + location: { search: '?lang=fr' }, + storage, + languages: ['en-US'], + }), 'en'); +}); + +test('resolution precedence: a stored preference is re-checked against the offered pair', () => { + // 'es' may have been stored under a pair that once offered it; while + // English is the only shipped catalog the stored value is out-of-pair and + // reads as absent, so the navigator list decides — the stored preference + // degrades gracefully instead of rendering English under an es . + assert.equal(resolveLocale({ + location: { search: '' }, + storage: { getItem: () => 'es' }, + languages: ['en-US', 'en'], + }), 'en'); +}); + +test('resolution precedence: navigator languages resolve before the English default', () => { + // Regional variants normalize to the primary tag and only then face the + // pair check: 'en-GB' resolves through the offered pair, unshipped codes + // ('fr-CA', 'es-MX') defer to the next step. + assert.equal(resolveLocale({ + location: { search: '' }, + storage: { getItem: () => 'xx' }, + languages: ['en-GB'], + }), 'en'); + assert.equal(resolveLocale({ + location: { search: '' }, + storage: { getItem: () => 'xx' }, + languages: ['fr-CA', 'es-MX'], + }), 'en', 'no shipped fr/es catalog: neither navigator entry resolves'); + assert.equal(resolveLocale({ location: { search: '' }, storage: null, languages: ['fr-CA'] }), 'en'); + assert.equal(resolveLocale({ location: null, storage: null, languages: null }), DEFAULT_LOCALE); +}); + +test('guarded storage: a throwing store (private mode / quota) never escapes', () => { + const hostile = { + getItem() { throw new Error('SecurityError'); }, + setItem() { throw new Error('SecurityError'); }, + }; + assert.equal(resolveLocale({ location: { search: '' }, storage: hostile, languages: ['es'] }), 'en', + 'an unshipped navigator locale defers to the en-only default'); + assert.equal(writeStoredLocale('es', hostile), false); + assert.equal(writeStoredLocale('es', { setItem() { throw new Error('quota'); } }), false); + assert.equal(writeStoredLocale('es', {}), false, 'no setItem function means no write'); + assert.equal(writeStoredLocale('es-AR', { setItem() {} }), true, 'stored value is normalized'); +}); + +test('applyDocumentLanguage reflects lang and dir from locale metadata', () => { + const makeDoc = () => ({ documentElement: { lang: '', dir: '' } }); + const enDoc = makeDoc(); + assert.equal(applyDocumentLanguage(enDoc, 'en'), true); + assert.deepEqual({ lang: enDoc.documentElement.lang, dir: enDoc.documentElement.dir }, + { lang: 'en', dir: LOCALE_METADATA.en.dir }); + assert.equal(LOCALE_METADATA.en.dir, 'ltr', 'English is LTR by metadata'); + const esDoc = makeDoc(); + assert.equal(applyDocumentLanguage(esDoc, 'es-MX'), true); + assert.deepEqual({ lang: esDoc.documentElement.lang, dir: esDoc.documentElement.dir }, + { lang: 'es', dir: LOCALE_METADATA.es.dir }); + assert.equal(LOCALE_METADATA.es.dir, 'ltr', 'Spanish is LTR by metadata'); + const frDoc = makeDoc(); + assert.equal(applyDocumentLanguage(frDoc, 'fr-CA'), true); + assert.deepEqual({ lang: frDoc.documentElement.lang, dir: frDoc.documentElement.dir }, + { lang: 'fr', dir: LOCALE_METADATA.fr.dir }); + assert.equal(LOCALE_METADATA.fr.dir, 'ltr', 'French is LTR by metadata'); + const ruDoc = makeDoc(); + assert.equal(applyDocumentLanguage(ruDoc, 'ru-RU'), true); + assert.deepEqual({ lang: ruDoc.documentElement.lang, dir: ruDoc.documentElement.dir }, + { lang: 'ru', dir: LOCALE_METADATA.ru.dir }); + assert.equal(LOCALE_METADATA.ru.dir, 'ltr', 'Russian is LTR by metadata'); + const ukDoc = makeDoc(); + assert.equal(applyDocumentLanguage(ukDoc, 'uk_UA'), true); + assert.deepEqual({ lang: ukDoc.documentElement.lang, dir: ukDoc.documentElement.dir }, + { lang: 'uk', dir: LOCALE_METADATA.uk.dir }); + assert.equal(LOCALE_METADATA.uk.dir, 'ltr', 'Ukrainian is LTR by metadata'); + assert.equal(applyDocumentLanguage(null, 'es'), false); + assert.equal(applyDocumentLanguage({ documentElement: null }, 'es'), false); + // An unsupported locale still yields valid metadata, never an empty dir. + const fallbackDoc = makeDoc(); + applyDocumentLanguage(fallbackDoc, 'de'); + assert.equal(fallbackDoc.documentElement.lang, DEFAULT_LOCALE); + assert.equal(fallbackDoc.documentElement.dir, 'ltr'); +}); + +test('t() resolves seed keys, interpolates named placeholders, and keeps unknown ones literal', () => { + pinLocale('en'); + assert.equal(t('shell.title.subtitle'), 'NO PLACE LEFT BEHIND'); + assert.equal( + t('shell.loading.status.tilesUnavailable', { detail: '403 from ion' }), + 'Google 3D Tiles unavailable (403 from ion). Loading the keyless globe...', + ); + assert.equal( + t('shell.loading.status.tilesUnavailable', {}), + 'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...', + 'an unprovided name stays visible instead of silently vanishing', + ); +}); + +test('t() selects one/other plural variants through Intl.PluralRules', () => { + pinLocale('en'); + assert.equal(t('layers.clear.toast.cleared', { count: 1 }), 'Cleared 1 data layer'); + assert.equal(t('layers.clear.toast.cleared', { count: 3 }), 'Cleared 3 data layers'); + assert.equal(t('layers.clear.toast.notCleared', { count: 1 }), + '1 data layer could not be cleared'); + // Non-en plural-category selection is pinned per locale in that locale's + // PR (es: one/other; ru/uk: one/few/many/other) — with real catalog values, + // not synthetic ones. The selection CONTRACT itself stays pinned below + // through the synthetic missing-category fallback under 'ru'. +}); + +test('a missing key returns the key itself and never warns outside dev builds', () => { + pinLocale('en'); + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + assert.equal(t('shell.definitely.not.here'), 'shell.definitely.not.here'); + assert.equal(hasMessage('shell.definitely.not.here'), false); + assert.equal(hasMessage('shell.title.subtitle'), true); + } finally { + console.warn = originalWarn; + } + // import.meta.env is absent under node:test, so the DEV gate keeps the + // console quiet here; the warn path itself only runs on the dev server. + assert.deepEqual(warnings, []); +}); + +test('a key missing in es resolves through the English fallback (pure lookup)', () => { + // The shipped es seed mirrors en key-for-key, so the fallback path is + // exercised through the pure resolver with synthetic catalogs — exactly the + // shape stage-3 leaves behind while es translation lags. + const catalogs = { + es: Object.freeze({}), + en: Object.freeze({ 'a.b': 'EN ONLY' }), + }; + assert.equal(resolveMessage(catalogs, 'es', 'a.b'), 'EN ONLY'); + assert.equal(resolveMessage(catalogs, 'fr', 'a.b'), 'EN ONLY', 'unsupported locale reads en'); + assert.equal(resolveMessage(catalogs, 'es', 'nope'), undefined); + assert.equal(resolveMessage(null, 'es', 'a.b'), undefined, 'no catalogs means no message'); +}); + +test('Intl formatters follow the active locale', () => { + pinLocale('en'); + // Five significant digits: Spanish (CLDR minimumGroupingDigits=2) only + // groups from five digits up, so a 4-digit value would hide the difference. + assert.equal(formatNumber(12345.6), '12,345.6'); + assert.equal(formatDate(new Date(Date.UTC(2026, 0, 15)), { year: 'numeric', month: 'long', day: 'numeric' }), + 'January 15, 2026'); + assert.equal(formatRelativeTime(-5, 'minute'), '5 minutes ago'); + pinLocale('es'); + assert.equal(formatNumber(12345.6), '12.345,6'); + assert.equal(formatDate(new Date(Date.UTC(2026, 0, 15)), { year: 'numeric', month: 'long', day: 'numeric' }), + '15 de enero de 2026'); + assert.equal(formatRelativeTime(-5, 'minute'), 'hace 5 minutos'); + // Formatter cache is keyed per locale: switching back must not leak es forms. + pinLocale('en'); + assert.equal(formatNumber(12345.6), '12,345.6'); +}); + +test('persistLocaleAndReload stores the choice, keeps the hash exact, strips ?lang', () => { + const writes = []; + const navigations = []; + const storage = { setItem(key, value) { writes.push([key, value]); } }; + const location = { + href: 'http://localhost:4173/?lang=es&welcome=1#cam=30.2672,-97.7431,alt=2500', + assign(url) { navigations.push(url); }, + }; + assert.equal(persistLocaleAndReload('es-MX', { location, storage }), true); + assert.deepEqual(writes, [[LOCALE_STORAGE_KEY, 'es']], 'normalized value under the versioned key'); + assert.equal(navigations.length, 1); + assert.equal(navigations[0], 'http://localhost:4173/?welcome=1#cam=30.2672,-97.7431,alt=2500'); + + // No ?lang present: the URL round-trips unchanged, hash included. + navigations.length = 0; + persistLocaleAndReload('en', { location: { href: 'http://localhost:4173/#scene=coast', assign: (u) => navigations.push(u) } }); + assert.deepEqual(navigations, ['http://localhost:4173/#scene=coast']); + + // A blocked store must not block the reload. + const blocked = { setItem() { throw new Error('SecurityError'); } }; + navigations.length = 0; + assert.equal(persistLocaleAndReload('es', { location: { href: 'http://x/?lang=es#a', assign: (u) => navigations.push(u) }, storage: blocked }), true); + assert.deepEqual(navigations, ['http://x/#a']); + + // Nothing navigable injected: no throw, no reload claim. + assert.equal(persistLocaleAndReload('es', { location: null }), false); + assert.equal(persistLocaleAndReload('es', { location: {} }), false); + + // Real-browser shape: a reloadable location MUST force the reload — assign() + // to a byte-identical URL never navigates, which stranded stored-locale + // switches (no ?lang present, hash unchanged). + const reloaded = []; + const assigned = []; + const replaced = []; + assert.equal(persistLocaleAndReload('es', { + location: { + href: 'http://localhost:4173/#scene=coast', + assign: (u) => assigned.push(u), + reload: () => reloaded.push(true), + }, + history: { replaceState: (_s, _t, u) => replaced.push(u) }, + }), true); + assert.deepEqual(replaced, ['http://localhost:4173/#scene=coast'], '?lang-free URL passes through replaceState'); + assert.equal(reloaded.length, 1, 'reload is forced'); + assert.equal(assigned.length, 0, 'assign is not used when reload exists'); +}); + +test('a plural entry lacking a category the active locale selects degrades to other (pure selection)', () => { + // Graceful degradation stays pinned after the ru/uk translation landed: + // full-parity catalogs can no longer carry a partial entry, so the + // runtime contract — entry[category] ?? entry.other in selectPluralPattern + // (src/i18n/index.js) — is exercised with a synthetic { one, other } twin + // under ru, which selects few/many for 2–4/5–20. This is the runtime half + // of the plural-superset gate in catalog.test.mjs. + const twin = Object.freeze({ one: 'ONE {count}', other: 'OTHER {count}' }); + pinLocale('ru'); + assert.equal(selectPluralPattern(twin, { count: 2 }, 'demo.key'), 'OTHER {count}', + "ru selects 'few' for 2 and the entry does not carry it: entry.other renders"); + assert.equal(selectPluralPattern(twin, { count: 5 }, 'demo.key'), 'OTHER {count}', + "ru selects 'many' for 5 and the entry does not carry it: entry.other renders"); + assert.equal(selectPluralPattern(twin, { count: 21 }, 'demo.key'), 'ONE {count}', + "21 selects 'one', which the entry carries directly"); + assert.equal(selectPluralPattern(twin, { count: 1.5 }, 'demo.key'), 'OTHER {count}', + 'fractional counts select other'); + assert.equal(selectPluralPattern(twin, {}, 'demo.key'), 'OTHER {count}', + 'a non-finite count degrades to entry.other ?? entry.one'); + pinLocale('en'); +}); + +class FakeElement { + constructor(attributes) { + this.attributes = new Map(Object.entries(attributes)); + this.textContent = ''; + } + getAttribute(name) { return this.attributes.has(name) ? this.attributes.get(name) : null; } + setAttribute(name, value) { this.attributes.set(name, String(value)); } +} + +function fakeDocumentFor(elements) { + return { + querySelectorAll(selector) { + const match = /^\[([a-z0-9-]+)\]$/.exec(selector); + if (!match) return []; + return elements.filter((element) => element.getAttribute(match[1]) !== null); + }, + }; +} + +test('applyDocumentTranslations writes all four attributes and skips unknown keys', () => { + pinLocale('en'); + const elements = [ + new FakeElement({ 'data-i18n': 'shell.title.subtitle' }), + new FakeElement({ 'data-i18n-title': 'shell.actions.clearLayers.title' }), + new FakeElement({ 'data-i18n-aria-label': 'shell.actions.share.ariaLabel' }), + new FakeElement({ 'data-i18n-placeholder': 'setup.keySetup.title' }), + new FakeElement({ 'data-i18n': 'shell.missing.key' }), + new FakeElement({ 'data-i18n': '' }), + ]; + const applied = applyDocumentTranslations(fakeDocumentFor(elements)); + assert.equal(applied, 4); + assert.equal(elements[0].textContent, 'NO PLACE LEFT BEHIND'); + assert.equal(elements[1].getAttribute('title'), 'Turn off all selected data layers'); + assert.equal(elements[2].getAttribute('aria-label'), 'Copy share link'); + assert.equal(elements[3].getAttribute('placeholder'), 'Power up the globe'); + // A key that resolves nowhere leaves the element untouched. + assert.equal(elements[4].textContent, ''); + assert.equal(elements[5].textContent, ''); + assert.equal(applyDocumentTranslations(null), 0); + assert.equal(applyDocumentTranslations({}), 0); +}); + +test('getCatalog exposes the merged, dot-prefixed registry for every shipped locale', () => { + assert.deepEqual([...CATALOG_LOCALES], ['en'], + 'the foundation ships English only; each locale PR appends its code here'); + for (const locale of CATALOG_LOCALES) { + const catalog = getCatalog(locale); + assert.ok(catalog, `catalog for ${locale}`); + assert.ok(Object.keys(catalog).length > 0); + for (const key of Object.keys(catalog)) { + assert.match(key, /^(shell|cockpit|layers|setup)\./, `${locale} key ${key}`); + } + } + // Codes without a shipped catalog stay null even though they normalize + // (locale-code hygiene): shipping is catalog-driven. An unknown locale + // stays null too. + assert.equal(getCatalog('es'), null); + assert.equal(getCatalog('fr'), null); + assert.equal(getCatalog('ru'), null); + assert.equal(getCatalog('uk'), null); + assert.equal(getCatalog('de'), null); + assert.equal(getLocale(), 'en', 'state survived the whole file'); +}); + +test('locale pair: built-in and configured pairs degrade to en-only while only English ships', () => { + // English is the only shipped catalog, so the offered set is ['en'] under + // any configuration — a pair naming anything else names an unshipped + // locale. The dedup-ordering pin (default, secondary, then en as the + // always-shipped fallback) returns with the first secondary-locale PR. + assert.deepEqual([...availableLocales()], ['en'], 'no config = built-in en-only pair'); + assert.deepEqual([...availableLocales({ defaultLocale: 'fr', secondaryLocale: 'en' })], ['en'], + 'fr has no shipped catalog: the pair degenerates'); + // Regional spellings still normalize before validation. + assert.deepEqual([...availableLocales({ defaultLocale: 'FR-fr', secondaryLocale: 'EN' })], ['en']); + // A blank field means "unset" (empty .env line) and takes its built-in default. + assert.deepEqual([...availableLocales({ defaultLocale: ' ' })], ['en']); + const pair = resolveLocalePair(); + assert.equal(pair.defaultLocale, 'en'); + assert.equal(pair.secondaryLocale, 'en'); + assert.ok(Object.isFrozen(pair) && Object.isFrozen(pair.availableLocales)); +}); + +test('locale pair: a configured pair naming an unshipped locale degrades to English resolution', () => { + const config = { defaultLocale: 'fr', secondaryLocale: 'en' }; + // fr normalizes but has no shipped catalog: the pair is unusable, the + // built-in en-only pair applies, and every resolution step yields English. + assert.equal(resolveLocale({ location: { search: '' }, storage: null, languages: null, config }), 'en'); + assert.equal(resolveLocale({ location: { search: '?lang=fr' }, storage: { getItem: () => 'fr' }, languages: null, config }), 'en'); + assert.equal(resolveLocale({ location: { search: '' }, storage: { getItem: () => 'fr' }, languages: ['fr-FR'], config }), 'en'); + assert.equal(resolveLocale({ location: { search: '?lang=en' }, storage: { getItem: () => 'en' }, languages: null, config }), 'en'); +}); + +test('locale pair: ?lang= cannot offer a locale without a shipped catalog', () => { + assert.equal( + resolveLocale({ location: { search: '?lang=fr' }, storage: { getItem: () => 'en' }, languages: null }), + 'en', + 'fr has no catalog: the override falls through to the stored preference', + ); + assert.equal( + resolveLocale({ + location: { search: '?lang=es' }, + storage: { getItem: () => 'en' }, + languages: null, + config: { defaultLocale: 'es', secondaryLocale: 'fr' }, + }), + 'en', + 'a configured pair cannot smuggle an unshipped locale into the offered set', + ); +}); + +test('locale pair: a stored preference outside the offered pair falls through', () => { + // A stored 'fr'/'es' reads as absent while those locales are unshipped; + // resolution defers to the navigator list and finally the built-in default. + assert.equal(resolveLocale({ location: { search: '' }, storage: { getItem: () => 'fr' }, languages: ['en-US'] }), 'en'); + assert.equal(resolveLocale({ location: { search: '' }, storage: { getItem: () => 'es' }, languages: null }), 'en', + '…down to the default'); +}); + +test('locale pair: invalid, unshipped, or degenerate values fall back to the built-in en-only pair (dev-warn only)', () => { + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const badPairs = [ + { defaultLocale: 'de', secondaryLocale: 'es' }, // unknown locale code + { defaultLocale: 'fr', secondaryLocale: 'fr' }, // unshipped + degenerate: same locale twice + { defaultLocale: 'en', secondaryLocale: 'en' }, // equals the built-in pair: silent, still en-only + { defaultLocale: 'english', secondaryLocale: 'ES' }, + { defaultLocale: 'en', secondaryLocale: 'es' }, // es is a known code without a shipped catalog + ]; + for (const bad of badPairs) { + const pair = resolveLocalePair(bad); + assert.equal(pair.defaultLocale, 'en', JSON.stringify(bad)); + assert.equal(pair.secondaryLocale, 'en', JSON.stringify(bad)); + assert.deepEqual([...pair.availableLocales], ['en'], JSON.stringify(bad)); + assert.equal(resolveLocale({ location: { search: '' }, storage: null, languages: null, config: bad }), 'en'); + } + } finally { + console.warn = originalWarn; + } + // import.meta.env is absent under node:test, so the DEV gate keeps the + // console quiet here; the warn fires only on the dev server. + assert.deepEqual(warnings, []); +}); diff --git a/src/i18n/index.js b/src/i18n/index.js new file mode 100644 index 000000000..3d38b3f93 --- /dev/null +++ b/src/i18n/index.js @@ -0,0 +1,348 @@ +// God's Eye View i18n core (phase 1): catalog registry, translation, and +// Intl-based formatting. English is the source/fallback catalog; a missing key +// in another locale falls back to English with a development-only warning. +// English is also the ONLY shipped catalog in this foundation change — each +// additional locale lands as a stacked follow-up PR following the recipe in +// docs/TRANSLATORS.md (catalog files + registration + CATALOG_LOCALES entry). +// Every shipped catalog is built UNCONDITIONALLY — the build cost is trivial +// and the registry stays declarative — but only locales in the configured pair +// are offered/accepted (see locale.js resolveLocalePair). +// +// APPEND-ONLY NAMESPACE REGISTRATION +// ───────────────────────────────────────────────────────────────────────────── +// A worker adding a namespace creates locales//.js (exporting +// NAMESPACE plus a default message map of namespace-relative keys), then +// appends ONE import line per locale and pushes the module into that locale's +// array below. Nothing else changes, so concurrent workers merge trivially. +// Namespace-relative keys are prefixed at registration time: shell.*, +// cockpit.*, layers.*, setup.*. +import { + DEFAULT_LOCALE, + normalizeLocale, + writeStoredLocale, +} from './locale.js'; + +import * as enShell from './locales/en/shell.js'; +import * as enCockpit from './locales/en/cockpit.js'; +import * as enLayers from './locales/en/layers.js'; +import * as enSetup from './locales/en/setup.js'; + +// Append new namespace modules here (one import + one entry per locale). +const EN_NAMESPACES = [enShell, enCockpit, enLayers, enSetup]; + +/** + * Prefix one namespace module's flat, namespace-relative keys with its + * declared namespace: { 'title.subtitle': … } + NAMESPACE 'shell' becomes + * 'shell.title.subtitle'. + * @param {{NAMESPACE: string, default?: object}} namespaceModule + * @returns {Record} Frozen prefixed message map. + */ +export function mergeNamespace(namespaceModule) { + const prefix = + typeof namespaceModule?.NAMESPACE === 'string' + ? namespaceModule.NAMESPACE.trim() + : ''; + if (!/^[a-z][a-z0-9-]*$/.test(prefix)) { + throw new Error( + `i18n namespace module must export a lowercase NAMESPACE, got ${JSON.stringify(prefix)}`, + ); + } + const merged = {}; + for (const [key, value] of Object.entries(namespaceModule.default || {})) { + merged[`${prefix}.${key}`] = value; + } + return Object.freeze(merged); +} + +/** Merge namespaces into one catalog, rejecting duplicate dot-prefixed keys. */ +function buildCatalog(namespaceModules) { + const catalog = {}; + for (const namespaceModule of namespaceModules) { + for (const [key, value] of Object.entries( + mergeNamespace(namespaceModule), + )) { + if (Object.hasOwn(catalog, key)) { + throw new Error(`i18n duplicate catalog key: ${key}`); + } + catalog[key] = value; + } + } + return Object.freeze(catalog); +} + +const CATALOGS = Object.freeze({ + [DEFAULT_LOCALE]: buildCatalog(EN_NAMESPACES), +}); + +/** + * The merged catalog for a locale (frozen; for coverage/parity tests and + * tooling — runtime lookups go through t()). + * @param {string} locale + * @returns {Record|null} + */ +export function getCatalog(locale) { + const normalized = normalizeLocale(locale); + return (normalized && CATALOGS[normalized]) || null; +} + +let currentLocale = DEFAULT_LOCALE; + +/** Active locale (any catalog locale; pair-scoped by resolveLocale at boot). */ +export function getLocale() { + return currentLocale; +} + +/** + * Switch the active locale. Pure state change: call applyDocumentLanguage / + * applyDocumentTranslations to reflect it in the DOM. + * @returns {string} The locale actually applied (normalized). + */ +export function setLocale(locale) { + const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; + if (normalized !== currentLocale) { + currentLocale = normalized; + formatterCache.clear(); + } + return currentLocale; +} + +/** Development-only missing-key warning. Off under node:test and prod builds. */ +function warnMissingKey(key, detail) { + // Vite replaces import.meta.env at build time; under node:test it is absent, + // so warnings never fire in CI — only on the dev server. + if (import.meta.env?.DEV !== true) return; + console.warn(`[i18n] missing key "${key}"${detail ? ` (${detail})` : ''}`); +} + +/** + * Pure lookup shared by t() and the parity tests: the requested locale first, + * then the English fallback catalog. + * @param {Record} catalogs Catalog map to read from. + * @param {string} locale Locale to resolve within that map. + * @param {string} key Dot-prefixed message key. + * @returns {string|object|undefined} Raw entry, or undefined when nowhere. + */ +export function resolveMessage(catalogs, locale, key) { + const source = catalogs || CATALOGS; + const normalized = normalizeLocale(locale); + if ( + normalized && + source[normalized] && + Object.hasOwn(source[normalized], key) + ) { + return source[normalized][key]; + } + const fallback = source[DEFAULT_LOCALE]; + return fallback && Object.hasOwn(fallback, key) ? fallback[key] : undefined; +} + +/** Whether the key resolves in the active locale or the English fallback. */ +export function hasMessage(key) { + return resolveMessage(CATALOGS, currentLocale, key) !== undefined; +} + +const pluralRulesCache = new Map(); + +function pluralRulesFor(locale) { + let rules = pluralRulesCache.get(locale); + if (!rules) { + rules = new Intl.PluralRules(locale); + pluralRulesCache.set(locale, rules); + } + return rules; +} + +/** Select a plural variant via Intl.PluralRules for the ACTIVE locale; + * an entry lacking the selected category degrades to entry.other. Exported + * for tests: full-parity catalogs can no longer stage a partial entry, so + * the fallback is pinned synthetically (i18n.test.mjs). */ +export function selectPluralPattern(entry, values, key) { + const count = values?.count; + if (typeof count !== 'number' || !Number.isFinite(count)) { + warnMissingKey(key, 'plural entry requires a finite {count} value'); + return entry.other ?? entry.one; + } + const category = pluralRulesFor(currentLocale).select(count); + return entry[category] ?? entry.other; +} + +const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g; + +/** Named {placeholder} interpolation. Unprovided names stay literal so a gap + * stays visible during development instead of silently vanishing. */ +function interpolate(pattern, values) { + if (!values || typeof pattern !== 'string') return pattern; + return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) => + Object.hasOwn(values, name) ? String(values[name]) : literal, + ); +} + +/** + * Translate one dot-prefixed key in the active locale. Missing keys in a + * non-English locale fall back to English; keys missing everywhere return the + * key itself. Plural entries ({ one, other }) require a numeric `count` value. + * @param {string} key e.g. 'layers.clear.toast.cleared' + * @param {object} [values] Named interpolation values, e.g. { count: 3, detail: '…' } + * @returns {string} + */ +export function t(key, values) { + const entry = resolveMessage(CATALOGS, currentLocale, key); + if (entry === undefined) { + warnMissingKey(key, `not in ${currentLocale} or en catalog`); + return key; + } + if ( + currentLocale !== DEFAULT_LOCALE && + !Object.hasOwn(CATALOGS[currentLocale] || {}, key) + ) { + warnMissingKey(key, `missing in ${currentLocale}, using en fallback`); + } + const pattern = + typeof entry === 'string' ? entry : selectPluralPattern(entry, values, key); + return interpolate(pattern, values); +} + +/* + * Intl formatters are memoized per (kind, locale, options): the constructors + * are expensive and stage-3 extraction will format numbers and dates on panel + * refresh paths. The cache is cleared whenever the active locale changes. + */ +const formatterCache = new Map(); + +function cachedFormatter(kind, Constructor, options) { + const cacheKey = `${kind}|${currentLocale}|${options ? JSON.stringify(options) : ''}`; + let formatter = formatterCache.get(cacheKey); + if (!formatter) { + formatter = new Constructor(currentLocale, options); + formatterCache.set(cacheKey, formatter); + } + return formatter; +} + +/** Locale-aware number formatting (Intl.NumberFormat). */ +export function formatNumber(value, options) { + return cachedFormatter('number', Intl.NumberFormat, options).format(value); +} + +/** Locale-aware date/time formatting (Intl.DateTimeFormat). */ +export function formatDate(value, options) { + return cachedFormatter('date', Intl.DateTimeFormat, options).format(value); +} + +/** + * Locale-aware relative time (Intl.RelativeTimeFormat). `value` is the signed + * distance in `unit`, e.g. formatRelativeTime(-5, 'minute') → + * '5 minutes ago' (en) / 'hace 5 minutos' (es). + * @param {number} value Signed distance in `unit`. + * @param {string} [unit] Intl unit ('second', 'minute', 'hour', 'day', …). + * @param {object} [options] Intl.RelativeTimeFormat options (numeric, style). + */ +export function formatRelativeTime(value, unit = 'second', options) { + return cachedFormatter('relative', Intl.RelativeTimeFormat, { + numeric: 'auto', + ...options, + }).format(value, unit); +} + +/** DOM write targets for applyDocumentTranslations: textContent plus the + * three presentation attributes vision.md requires to agree with visible + * labels. Never innerHTML — translation functions return text. */ +const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([ + Object.freeze({ + attribute: 'data-i18n', + apply(element, text) { + element.textContent = text; + }, + }), + Object.freeze({ + attribute: 'data-i18n-title', + apply(element, text) { + element.setAttribute('title', text); + }, + }), + Object.freeze({ + attribute: 'data-i18n-aria-label', + apply(element, text) { + element.setAttribute('aria-label', text); + }, + }), + Object.freeze({ + attribute: 'data-i18n-placeholder', + apply(element, text) { + element.setAttribute('placeholder', text); + }, + }), +]); + +/** + * Apply catalog text to every element carrying an i18n attribute in `doc`. + * Elements whose key resolves nowhere are left untouched (warned in dev). + * @param {Document} doc + * @returns {number} Count of element/attribute writes performed. + */ +export function applyDocumentTranslations(doc) { + if (typeof doc?.querySelectorAll !== 'function') return 0; + let applied = 0; + for (const target of DOCUMENT_TRANSLATION_TARGETS) { + for (const element of doc.querySelectorAll(`[${target.attribute}]`)) { + const key = element.getAttribute?.(target.attribute); + if (!key) continue; + if (!hasMessage(key)) { + warnMissingKey(key, `attribute ${target.attribute}`); + continue; + } + target.apply(element, t(key)); + applied += 1; + } + } + return applied; +} + +/** + * Persist a locale choice, then reload preserving window.location.hash exactly + * and stripping ?lang from the search string — the override is one-shot and + * must never graduate into the stored URL. Storage is best-effort: where + * storage is blocked (private mode) the preference simply will not survive. + * @param {string} locale Locale the user chose (normalized here). + * @param {object} [deps] + * @param {{href: string, assign: Function}} [deps.location] + * @param {object} [deps.storage] Injected localStorage-like store. + * @returns {boolean} true when a reload was triggered. + */ +export function persistLocaleAndReload( + locale, + { + location = globalThis.location, + history = globalThis.history, + storage, + } = {}, +) { + const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; + writeStoredLocale(normalized, storage); + if (!location || typeof location.assign !== 'function') return false; + const stripLang = () => { + try { + const url = new URL(location.href); + url.searchParams.delete('lang'); + return url.href; + } catch { + return location.href; + } + }; + // location.assign() to the byte-identical URL performs NO navigation in + // real browsers (the common case: no ?lang present, hash unchanged), so a + // stored locale change would never reload. Strip ?lang via replaceState + // (no extra history entry) and force a real reload instead. + const target = stripLang(); + try { + history?.replaceState?.(null, '', target); + } catch { + // replaceState is best-effort; the reload below still applies. + } + if (typeof location.reload === 'function') { + location.reload(); + } else { + location.assign(target); + } + return true; +} diff --git a/src/i18n/locale.js b/src/i18n/locale.js new file mode 100644 index 000000000..ea5968fea --- /dev/null +++ b/src/i18n/locale.js @@ -0,0 +1,314 @@ +// Locale identity for God's Eye View (i18n phase 1). +// +// English is the default-without-configuration, the ALWAYS-shipped fallback +// catalog, and the compatibility baseline (docs/TRANSLATORS.md). It is the +// only locale with a shipped catalog in this foundation change: additional +// locales land as stacked follow-up PRs, one per locale, each following the +// recipe in docs/TRANSLATORS.md. Which pair the app actually offers is +// configured at build/dev time through GEV_DEFAULT_LOCALE / +// GEV_SECONDARY_LOCALE (vite.config.js client defines) and resolved here — +// pair members are validated against the shipped catalogs, so while only +// English ships the pair degenerates to en-only (a single EN button). +// This module owns ONLY locale resolution and document language metadata — +// catalogs and translation helpers live in src/i18n/index.js. + +/** Durable preference key. Matches the repo convention gev::v1. */ +export const LOCALE_STORAGE_KEY = 'gev:locale:v1'; + +/** + * Locales with a shipped catalog. 'en' is the fallback catalog locale + * (DEFAULT_LOCALE); every entry here can be normalized and looked up, though + * only locales in the configured PAIR (see resolveLocalePair) are offered in + * the UI and accepted during resolution. A locale PR appends its code here + * alongside its catalogs (docs/TRANSLATORS.md) — nothing else flips shipping. + */ +export const CATALOG_LOCALES = Object.freeze(['en']); + +/** + * Locale codes normalizeLocale folds regional variants for. Kept ahead of + * shipping on purpose: normalization is locale-code hygiene ('es-MX' → 'es'), + * while SHIPPING is catalog-driven through CATALOG_LOCALES — so a locale PR + * edits only CATALOG_LOCALES plus its catalog files, and a code listed here + * without a catalog normalizes but is never offerable or resolvable. + */ +const NORMALIZABLE_LOCALES = Object.freeze(['en', 'es', 'fr', 'ru', 'uk']); + +/** Fallback catalog locale and no-config default: always English. */ +export const DEFAULT_LOCALE = 'en'; + +/** + * Built-in pair used when no env/config shapes one, or a configured one is + * unusable. English-only while English is the only shipped catalog: the + * selector degenerates to a single EN button until a secondary locale PR + * restores the built-in pair. + */ +const FALLBACK_PAIR = Object.freeze({ + defaultLocale: 'en', + secondaryLocale: 'en', +}); + +/** Document metadata per normalizable locale code. All of them are LTR. */ +export const LOCALE_METADATA = Object.freeze({ + en: Object.freeze({ dir: 'ltr' }), + es: Object.freeze({ dir: 'ltr' }), + fr: Object.freeze({ dir: 'ltr' }), + ru: Object.freeze({ dir: 'ltr' }), + uk: Object.freeze({ dir: 'ltr' }), +}); + +/** + * Map any candidate language tag to a known locale code. 'es'/'fr'/'ru'/'uk' + * and every regional variant ('es-MX', 'fr_CA', 'ru-KZ', 'uk_UA') resolve to the + * primary tag; anything unknown (or not a string) resolves to null so the + * caller's precedence chain can keep looking. Knowing a code is hygiene, not + * shipping: offering/resolving a locale additionally requires a catalog + * (CATALOG_LOCALES) — see resolveLocalePair. + * @param {*} candidate Raw locale tag (?lang= value, stored value, navigator entry). + * @returns {'en'|'es'|'fr'|'ru'|'uk'|null} + */ +export function normalizeLocale(candidate) { + if (typeof candidate !== 'string') return null; + const primary = candidate.trim().toLowerCase().split(/[-_]/)[0]; + return NORMALIZABLE_LOCALES.includes(primary) ? primary : null; +} + +/** + * Read the configured pair from the two client defines. Both spellings are + * replaced TEXTUALLY by Vite (vite.config.js `define`), so they must stay + * verbatim member expressions. Under node:test `import.meta.env` is absent + * and the first access throws; the catch turns that into "use the defaults". + * @returns {{defaultLocale?: string, secondaryLocale?: string}} + */ +function readEnvLocalePair() { + try { + return { + defaultLocale: import.meta.env.GEV_DEFAULT_LOCALE, + secondaryLocale: import.meta.env.GEV_SECONDARY_LOCALE, + }; + } catch { + return {}; + } +} + +/** A blank string means "unset" (an empty .env line), not an invalid value. */ +function orBuiltIn(value, builtIn) { + return typeof value === 'string' && value.trim() ? value : builtIn; +} + +/** Development-only diagnostics, same gate as warnMissingKey in index.js. */ +function warnInvalidPair(rawDefault, rawSecondary) { + // Vite replaces import.meta.env at build time; under node:test it is absent, + // so warnings never fire in CI — only on the dev server. + if (import.meta.env?.DEV !== true) return; + console.warn( + `[i18n] unusable locale pair ${JSON.stringify(String(rawDefault))}/` + + `${JSON.stringify(String(rawSecondary))} — offering English only`, + ); +} + +/** + * Resolve the offered locale pair. With no argument the two client defines are + * read (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE; blank means the built-in + * value); an explicit { defaultLocale, secondaryLocale } injects a pair for + * tests and embedders — injected fields never fall through to the env. + * + * SHIPPING IS CATALOG-DRIVEN: both members must name a locale in + * CATALOG_LOCALES. An unknown code, an unshipped code (a locale PR not yet + * merged), or a degenerate pair (the same locale twice) returns the built-in + * pair instead, and a dev-only console.warn explains why — except when the + * raw values already ARE the built-in pair, which is the normal unconfigured + * state and must stay silent. While English is the only shipped catalog the + * built-in pair is en-only, so the selector renders a single EN button. + * + * availableLocales is the deduped [default, secondary, 'en'] triple — English + * is always shippable because its catalog is the unconditional fallback. + * @param {{defaultLocale?: string, secondaryLocale?: string}} [config] + * @returns {{defaultLocale: string, secondaryLocale: string, availableLocales: readonly string[]}} Frozen. + */ +export function resolveLocalePair(config) { + const injected = + typeof config === 'object' && + config !== null && + (config.defaultLocale !== undefined || + config.secondaryLocale !== undefined); + const raw = injected ? config : readEnvLocalePair(); + const rawDefault = orBuiltIn(raw?.defaultLocale, FALLBACK_PAIR.defaultLocale); + const rawSecondary = orBuiltIn( + raw?.secondaryLocale, + FALLBACK_PAIR.secondaryLocale, + ); + const defaultLocale = normalizeLocale(rawDefault); + const secondaryLocale = normalizeLocale(rawSecondary); + const offerable = Boolean( + defaultLocale && + secondaryLocale && + CATALOG_LOCALES.includes(defaultLocale) && + CATALOG_LOCALES.includes(secondaryLocale), + ); + if (!offerable || defaultLocale === secondaryLocale) { + // The built-in pair itself is never a misconfiguration: only a raw value + // that had to be corrected warns on the dev server. + if ( + rawDefault !== FALLBACK_PAIR.defaultLocale || + rawSecondary !== FALLBACK_PAIR.secondaryLocale + ) { + warnInvalidPair(rawDefault, rawSecondary); + } + return pairOf(FALLBACK_PAIR.defaultLocale, FALLBACK_PAIR.secondaryLocale); + } + return pairOf(defaultLocale, secondaryLocale); +} + +/** Freeze the deduped pair + always-shipped English. */ +function pairOf(defaultLocale, secondaryLocale) { + const availableLocales = [ + ...new Set([defaultLocale, secondaryLocale, DEFAULT_LOCALE]), + ]; + return Object.freeze({ + defaultLocale, + secondaryLocale, + availableLocales: Object.freeze(availableLocales), + }); +} + +/** + * Locales the app offers and accepts, in selector order: the configured + * default, the secondary locale, then English if not already present. + * @param {{defaultLocale?: string, secondaryLocale?: string}} [config] Injectable pair (tests/embedders). + * @returns {readonly string[]} + */ +export function availableLocales(config) { + return resolveLocalePair(config).availableLocales; +} + +/** + * One-shot `?lang=` override, read from the SEARCH string only. + * + * The override is never persisted, and it must never leak into the URL hash or + * share links: it dies with the URL it arrived in (persistLocaleAndReload in + * src/i18n/index.js strips it when a choice is stored). An unsupported value — + * or a shipped locale outside the configured pair — is ignored rather than + * forced to English; the rest of the precedence chain still gets to speak. + * @param {{search?: string}|null} location + * @param {readonly string[]} availableLocales Resolved pair locales. + * @returns {string|null} + */ +function queryLocaleOverride(location, availableLocales) { + const search = location?.search; + if (!search) return null; + try { + return normalizeIfAvailable( + new URLSearchParams(search).get('lang'), + availableLocales, + ); + } catch { + return null; + } +} + +/** Normalize a candidate, keeping it only when the pair offers that locale. */ +function normalizeIfAvailable(candidate, availableLocales) { + const normalized = normalizeLocale(candidate); + return normalized && availableLocales.includes(normalized) + ? normalized + : null; +} + +/* + * STORAGE ACCESS IS LAZY AND GUARDED (same pattern as src/firstRunExperience.js): + * the `globalThis.localStorage` getter itself can throw SecurityError in Safari + * private mode or under enterprise policies, so it is only touched inside a + * try/catch at the moment of use — never in a default parameter. An injected + * `storage` (tests, embedders) short-circuits the global entirely; `undefined` + * means "use the global". + */ + +/** Read the stored preference, treating every failure as "nothing stored". + * A stored locale outside the current pair reads as absent. */ +function readStoredLocale(storage, availableLocales) { + try { + const store = storage !== undefined ? storage : globalThis.localStorage; + return normalizeIfAvailable( + store?.getItem?.(LOCALE_STORAGE_KEY), + availableLocales, + ); + } catch { + return null; + } +} + +/** + * Write the stored preference, best-effort. Never throws. The stored value is + * any known locale code (pair membership is re-checked at resolution time, so + * a choice made under one configuration — or for a locale whose catalog has + * not shipped yet — degrades gracefully to the pair's default). + * @returns {boolean} true only if the value actually landed. + */ +export function writeStoredLocale(locale, storage) { + const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; + try { + const store = storage !== undefined ? storage : globalThis.localStorage; + if (typeof store?.setItem !== 'function') return false; + store.setItem(LOCALE_STORAGE_KEY, normalized); + return true; + } catch { + // Privacy-restricted or quota-exhausted storage must never break the app. + return false; + } +} + +/** + * Resolve the active locale. Precedence (docs/TRANSLATORS.md): + * + * ?lang= → stored preference → navigator.languages → configured default + * + * Every step only accepts locales in the configured pair (which always + * includes English); each step that yields nothing (absent or out-of-pair) + * defers to the next. Without env configuration the built-in pair is en-only + * (English is the only shipped catalog), so every step resolves English — + * byte-identical to the pre-configuration resolution chain. + * @param {object} [input] + * @param {{search?: string, href?: string}|null} [input.location] Location to read ?lang= from. + * @param {object|null} [input.storage] Injected localStorage-like store; `undefined` uses the global. + * @param {readonly string[]|null} [input.languages] Browser language list. + * @param {{defaultLocale?: string, secondaryLocale?: string}} [input.config] Injectable pair (tests/embedders); omit to read the client defines. + * @returns {string} + */ +export function resolveLocale({ + location = globalThis.location, + storage, + languages = globalThis.navigator?.languages, + config, +} = {}) { + const { availableLocales: offered, defaultLocale } = + resolveLocalePair(config); + const override = queryLocaleOverride(location, offered); + if (override) return override; + const stored = readStoredLocale(storage, offered); + if (stored) return stored; + if (Array.isArray(languages)) { + for (const candidate of languages) { + const normalized = normalizeIfAvailable(candidate, offered); + if (normalized) return normalized; + } + } + return defaultLocale; +} + +/** + * Reflect the active locale on the document: `` and `` + * must always agree with the language actually being rendered. + * @param {Document} doc Document whose documentElement carries the metadata. + * @param {string} locale Locale to apply (normalized here). + * @returns {boolean} true when the metadata was written. + */ +export function applyDocumentLanguage(doc, locale = DEFAULT_LOCALE) { + const root = doc?.documentElement; + if (!root) return false; + const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; + root.lang = normalized; + root.dir = ( + LOCALE_METADATA[normalized] || LOCALE_METADATA[DEFAULT_LOCALE] + ).dir; + return true; +} diff --git a/src/i18n/locales/en/cockpit.js b/src/i18n/locales/en/cockpit.js new file mode 100644 index 000000000..91a551268 --- /dev/null +++ b/src/i18n/locales/en/cockpit.js @@ -0,0 +1,560 @@ +// English catalog — cockpit namespace (phase-1 seed). +// +// Owns the first-person cockpit HUD, its Contact summary, and the briefing +// carousel. Keys are namespace-relative; the registry prefixes them with +// `cockpit.`. +export const NAMESPACE = 'cockpit'; + +export default { + // index.html #cockpit-hud section aria-label + 'hud.sectionLabel': 'Aircraft cockpit view', + // index.html #map-view-switch button label + 'exit.label': 'EXIT COCKPIT', + // index.html .cockpit-readout-label + 'readout.groundSpeed': 'GROUND SPEED', + 'readout.altitude': 'ALTITUDE', + // index.html .cockpit-context-kicker / #cockpit-context-subject (initial) + 'context.kicker': 'CONTACT', + 'context.subjectWindow': 'CONTACTS · 250 KM', + // index.html #cockpit-brief-kicker / #cockpit-brief-subtitle (initial) + 'brief.kicker': 'LIVE SIGNALS', + 'brief.subtitle': 'OBSERVED / MAPPED PINGS', + // index.html #cockpit-vision-current small label + 'vision.current': 'CURRENT', + // index.html #cockpit-radio-station (initial) + 'radio.station.ready': 'READY', + + // ── Phase-2 static markup extraction (index.html), appended ────────────── + // The shell worker owns ALL index.html static markup in phase 2 + // (docs/TRANSLATORS.md cross-surface rule); these keys land here because + // src/ui.js and src/hud.js own the surfaces' runtime half in phase 3. + + // index.html #style-indicator / #clean-view-exit (visual-style chrome) + 'presets.activeStyleLabel': 'ACTIVE STYLE', + 'display.cleanViewExitTitle': 'Return UI controls', + 'display.cleanViewExitLabel': 'EXIT CLEAN VIEW', + + // index.html control panel — Visual Presets dock tray + 'presets.toggleAriaLabel': 'Expand Visual Presets', + // Key-only: the .panel-title node shares its element with the dock-label-icon + // span, so a data-i18n write would wipe the icon; phase 3 wires this via t(). + 'presets.title': 'VISUAL PRESETS', + 'presets.pinAriaLabel': 'Pin visual presets', + 'presets.pinTitle': 'Keep visual presets open', + 'presets.styleNormalTitle': 'Show the globe without a visual filter.', + 'presets.styleNormalLabel': 'Normal', + 'presets.styleCrtTitle': + 'Emulate a green phosphor CRT with scanlines and screen curvature.', + 'presets.styleCrtLabel': 'CRT', + 'presets.styleNvgTitle': + 'Simulate night-vision goggles with green intensification and a tube vignette.', + 'presets.styleNvgLabel': 'NVG', + 'presets.styleFlirTitle': + 'Simulate FLIR-style thermal contrast. Turn up Ironbow for color.', + 'presets.styleFlirLabel': 'FLIR', + 'presets.styleAnimeTitle': + 'Apply bright cel-shaded color and illustrated outlines.', + 'presets.styleAnimeLabel': 'Anime', + 'presets.styleNoirTitle': 'Apply high-contrast monochrome film-noir grading.', + 'presets.styleNoirLabel': 'Noir', + 'presets.styleSnowTitle': + 'Add a cold, snowy whiteout treatment to the scene.', + 'presets.styleSnowLabel': 'Snow', + // Key-only: the static span text is pinned verbatim by mapStackChips.test.mjs + // (id="map-source-label">MAP SOURCE<), so no attribute can be added without + // touching that test; phase 3 owns the swap. + 'presets.mapSourceLabel': 'MAP SOURCE', + // Key-only: the chip-row tag is pinned literally by mapStackChips.test.mjs. + 'presets.mapSourceChipsAriaLabel': 'Map source', + 'presets.miniStyleLabel': 'Style', + + // index.html #pp-toggles DISPLAY rail + // Key-only: .pp-header-label text is pinned verbatim by panelStackLayout.test.mjs. + 'display.title': 'DISPLAY', + 'display.collapseTitle': 'Collapse panel', + 'display.hudToggleTitle': 'Intelligence HUD (H)', + 'display.hudLayoutLabel': 'Layout', + 'display.hudLayoutAriaLabel': 'HUD layout', + 'display.hudLayoutTactical': 'Tactical', + 'display.hudLayoutOperator': 'Operator', + 'display.hudLayoutMinimal': 'Minimal', + 'display.detectionToggleTitle': 'Detection Overlay (D)', + 'display.detectionAriaLabel': 'Detection overlay', + 'display.detectionLabel': 'DETECT', + 'display.densityLabel': 'Density', + 'display.densityAriaLabel': 'Detection label density', + 'display.allocationLabel': 'Allocation', + 'display.allocationAriaLabel': 'Detection label allocation', + 'display.allocationElastic': 'Elastic', + 'display.allocationWeighted': 'Weighted', + 'display.fadeLabel': 'Fade', + 'display.fadeAriaLabel': 'Detection fade distance', + 'display.fadeTitle': + 'World-overlay fade distance outside the keyhole as a percentage of its radius', + 'display.outsideLabel': 'Outside', + 'display.outsideAriaLabel': 'Detection opacity outside the keyhole', + 'display.outsideTitle': + 'World-overlay label and card opacity beyond the fade distance', + 'display.parametersTitle': 'PARAMETERS', + 'display.parametersCollapseTitle': 'Collapse panel', + 'display.modelsToggleTitle': + '3D aircraft — flat icons zoomed out, 3D models up close', + 'display.modelsLabel': 'Models', + 'display.modelsCoverageAriaLabel': '3D model coverage', + 'display.modelsModeProximity': 'Proximity', + 'display.modelsModeAll': 'All', + 'display.scopeToggleTitle': 'Scope — the circular viewport mask', + 'display.scopeLabel': 'Scope', + 'display.featherLabel': 'Feather', + 'display.featherTitle': + 'Scope edge feather as a percentage of the keyhole radius', + 'display.celestialToggleTitle': 'Celestial ring — reveal the full globe', + 'display.celestialLabel': 'Celestial', + 'display.cleanViewToggleTitle': 'Hide UI chrome', + 'display.cleanViewLabel': 'Clean UI', + 'display.bloomToggleTitle': 'Bloom / Glow', + 'display.bloomLabel': 'Bloom', + 'display.sharpenToggleTitle': 'Sharpening', + 'display.sharpenLabel': 'Sharpen', + + // index.html location bar (dock locations tray; ui.js owns the runtime half) + // Key-only: .location-toolbar-label shares its element with dock-label-icon. + 'location.toolbarLabel': 'LOCATION', + 'location.collapseTitle': 'Collapse panel', + 'location.pinAriaLabel': 'Pin location tray', + 'location.pinTitle': 'Keep location tray open', + // Initial mini-status values; ui.js rewrites both once a place resolves. + 'location.miniCityInitial': '📍 Location: --', + 'location.miniPoiInitial': 'Landmark: --', + 'location.searchToggleTitle': 'Search any location', + 'location.searchPlaceholder': 'Search any location...', + + // index.html cockpit HUD statics + 'hud.level': 'LEVEL', + 'hud.routeDirectionAriaLabel': 'Estimated destination direction', + // Initial idle readout; ui.js rewrites it with a live bearing. + 'hud.routeDirectionIdle': 'DEST ---°', + 'hud.visorPlane': 'OPTICAL PLANE · 01', + 'hud.visorLock': 'VISOR LOCK · ACTIVE', + 'hud.firstPerson': 'FIRST PERSON', + // Initial meta line; ui.js rewrites it from live track state. + 'hud.aircraftMetaInitial': 'LIVE TRACK · COURSE ALIGNED', + 'hud.visionGroupAriaLabel': 'Cockpit vision style', + 'hud.compassAriaLabel': 'Current aircraft heading', + 'readout.rimGroundSpeed': 'GROUND SPEED · KTS', + 'readout.rimAltitude': 'ALTITUDE · FT', + + // index.html cockpit vision controls + 'vision.previousLabel': 'PREV', + 'vision.previousAriaLabel': 'Previous cockpit vision style', + 'vision.previousTitle': 'Previous vision style', + // The NORMAL token inside these two is the style name; phase 3 splits the + // dynamic token out when the runtime half is extracted. + 'vision.currentAriaLabel': + 'Current cockpit vision style: NORMAL. Activate for next style.', + 'vision.currentTitle': 'Current style: NORMAL — click for next', + 'vision.nextLabel': 'NEXT', + 'vision.nextAriaLabel': 'Next cockpit vision style', + 'vision.nextTitle': 'Next vision style', + + // index.html view switcher + 'exit.navAriaLabel': 'View switcher', + 'exit.resetAriaLabel': 'Reset cockpit to full globe view', + 'exit.resetTitle': 'Exit cockpit and return to full globe view', + 'exit.resetLabel': 'RESET', + 'exit.ariaLabel': 'Exit cockpit view', + 'exit.title': 'Exit cockpit view', + + // index.html cockpit route card + 'route.cardAriaLabel': 'Estimated flight plan', + 'route.kicker': 'ESTIMATED FLIGHT PLAN', + 'route.statusUnavailable': 'ROUTE DATA UNAVAILABLE', + 'route.fromLabel': 'FROM', + 'route.toLabel': 'TO', + 'route.unknownEndpoint': 'UNKNOWN', + + // index.html cockpit briefing carousel + // (brief.kicker 'LIVE SIGNALS' is seeded above but is KEY-ONLY: #cockpit-brief-kicker + // shares its element with the live-dot , so no data-i18n attribute can target it.) + 'brief.carouselAriaLabel': 'Cockpit briefing carousel', + 'brief.actionsAriaLabel': 'Cockpit briefing controls', + 'brief.previousAriaLabel': 'Previous briefing page', + 'brief.previousTitle': 'Previous briefing page', + 'brief.nextAriaLabel': 'Next briefing page', + 'brief.nextTitle': 'Next briefing page', + // Title is static prose; the visible label + aria-label are CYCLE ON/OFF + // states that belong to the phase-3 runtime extraction. + 'brief.autoTitle': + 'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.', + 'brief.collapseAriaLabel': 'Collapse cockpit briefing panel', + 'brief.collapseTitle': 'Collapse briefing panel', + 'brief.signalsAriaLabel': 'Live signals', + 'brief.newsAriaLabel': 'Latest regional news', + 'brief.localAriaLabel': 'Location-based information', + 'brief.newsAcquiring': 'ACQUIRING REGIONAL NEWS', + 'brief.localResolving': 'RESOLVING REGION', + 'brief.labelTemp': 'TEMP', + 'brief.labelWind': 'WIND', + 'brief.labelSky': 'SKY', + 'brief.labelPrecip': 'PRECIP', + 'brief.sourceNote': 'SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS', + 'brief.tabSignalsAriaLabel': 'Show Live Signals', + 'brief.tabNewsAriaLabel': 'Show Regional News', + 'brief.tabLocalAriaLabel': 'Show Local Info', + + // index.html cockpit context panel (right-rail Global Context included) + 'context.panelAriaLabel': 'Contact cockpit summary', + 'context.navAriaLabel': 'Contact navigation', + 'context.previousAriaLabel': + 'Previous — prior visited contact in the 250 km window', + 'context.previousTitle': + 'Previous — prior visited contact in the 250 km window', + 'context.nextAriaLabel': + 'Next — nearest unvisited contact in the 250 km window', + 'context.nextTitle': 'Next — nearest unvisited contact in the 250 km window', + 'context.toggleAriaLabel': 'Collapse Contact panel', + 'context.toggleTitle': 'Collapse contact panel', + 'context.qualifier': 'CONTEXT ONLY', + 'context.cohortsAriaLabel': 'Nearby cohort counts', + 'context.nearestLabel': 'NEAREST OBSERVED / MAPPED', + 'context.nearestEmpty': 'NO AVAILABLE EXAMPLE', + 'context.uncertaintyNote': 'AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR', + 'context.weatherEnableAriaLabel': 'Enable cockpit weather effects', + 'context.weatherEnableTitle': 'Enable cockpit weather effects', + 'context.panelTitle': 'CONTEXT', + 'context.collapseTitle': 'Expand panel', + 'context.modesAriaLabel': 'Context mode', + 'context.contactsTabAriaLabel': 'CONTACTS', + 'context.contactsTabTitle': + 'Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.', + 'context.contactsTabLabel': 'CONTACTS', + 'context.missionsTabLabel': 'SPACE MISSIONS', + 'context.standbyTitle': 'SELECT CONTEXT', + 'context.actionsAriaLabel': 'Contact Context actions', + 'context.cockpitEntryLabel': 'COCKPIT', + 'context.searchNearbyLabel': 'SEARCH NEARBY SITES', + 'context.tr3bAriaLabel': 'Reclassify tracked contact as TR-3B', + 'context.tr3bTitle': 'Reclassify as TR-3B', + 'context.awarenessOffTitle': 'CONTACTS CONTEXT OFF', + 'context.awarenessOffHint': + 'SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY', + 'context.rosterAriaLabel': 'Available Space Missions', + 'context.rosterTitle': 'AVAILABLE MISSIONS', + 'context.rosterHint': 'SELECT A MISSION TO INSPECT', + 'context.rosterLoading': 'LOADING 30-DAY MISSION INDEX', + 'context.radioToggleAriaLabel': 'Open compact Radio controls', + 'context.radioToggleTitle': 'Open compact Radio controls', + 'context.radioMiniGroupAriaLabel': 'Compact Radio controls', + 'context.radioMiniTitle': 'RADIO', + 'context.radioMiniReady': 'RADIO READY', + 'context.radioDetailsAriaLabel': 'Open detailed Radio controls', + 'context.radioDetailsTitle': 'Open detailed Radio controls', + 'context.radioCloseAriaLabel': 'Close compact Radio controls', + 'context.radioCloseTitle': 'Close compact Radio controls', + 'context.radioMiniEnable': 'ENABLE', + 'context.radioMiniPrevAriaLabel': 'Previous filtered radio station', + 'context.radioMiniPrevTitle': 'Previous station', + 'context.radioMiniPlayAriaLabel': 'Play selected radio station', + 'context.radioMiniPlayTitle': 'Play', + 'context.radioMiniNextAriaLabel': 'Next filtered radio station', + 'context.radioMiniNextTitle': 'Next station', + 'context.radioMiniVolumeLabel': 'VOLUME', + 'context.radioMiniVolumeAriaLabel': 'Compact Radio volume', + + // index.html cockpit utility controls + compact radio popover + 'utility.controlsAriaLabel': 'Cockpit display and Radio controls', + 'utility.displayLabel': 'DISPLAY', + 'utility.radioLabel': 'RADIO', + 'utility.displayToggleAriaLabel': 'Expand Cockpit display options', + 'utility.displayToggleTitle': 'Expand Cockpit display options', + 'utility.displayPanelAriaLabel': 'Cockpit display options', + 'utility.radioToggleAriaLabel': 'Expand Cockpit Radio controls', + 'utility.radioToggleTitle': 'Expand Cockpit Radio controls', + 'utility.radioPanelAriaLabel': 'Cockpit compact Radio controls', + 'radio.enable': 'ENABLE', + 'radio.prevAriaLabel': 'Previous filtered radio station', + 'radio.playAriaLabel': 'Play selected radio station', + 'radio.nextAriaLabel': 'Next filtered radio station', + 'radio.volumeLabel': 'VOLUME', + 'radio.volumeAriaLabel': 'Cockpit Radio volume', + + // ── Phase-3 runtime extraction (src/ui.js), appended ───────────────────── + // Values stay byte-identical to the previous English literals; existing + // tests pin many of them under the default locale. + + // _syncPanelCollapseButton(): composed panel collapse titles. {name} is the + // panel's own (already localized) .panel-title text. + 'panel.expandTitle': 'Expand {name}', + 'panel.collapseTitle': 'Collapse {name}', + // Fallback when a panel ships no .panel-title/.pp-header-label node. + 'panel.fallbackName': 'panel', + // Radio panel collapse state; the expand state reuses the layers.radio.* + // keys seeded on the static button in phase 2. + 'panel.radioCollapseTitle': 'Collapse Radio', + 'panel.radioCollapseAria': 'Collapse Radio section', + // _updateLocationMiniStatus(): runtime rewrite of the collapsed LOCATION + // readout's single-segment search fallback (initial-state keys + // location.miniCityInitial/miniPoiInitial were seeded in phase 2). + 'location.miniSearchedPlaceholder': 'Searched location', + + // ── Phase-3 runtime extraction batch 2: cockpit + Context renderers ─────── + + // syncWeatherToggle(): state-sibling keys for the runtime ON/OFF rewrite + // (context.weatherEnableAriaLabel was seeded on the static button). + 'context.weatherDisableAriaLabel': 'Disable cockpit weather effects', + 'context.weatherStateOn': 'ON', + 'context.weatherStateOff': 'OFF', + // syncTr3bToggle(): converted-state title sibling of context.tr3bTitle. + 'context.tr3bRestoreTitle': 'Restore real aircraft', + // setVisionMode(): the style token is dynamic, so the sentence is split out + // of the seeded vision.currentAriaLabel/currentTitle (whose values pin the + // NORMAL default) into {style} templates. + 'vision.styleNameNightVision': 'Night vision', + 'vision.styleNameThermal': 'Thermal', + 'vision.styleNameNoir': 'Noir', + 'vision.currentAriaTemplate': + 'Current cockpit vision style: {style}. Activate for next style.', + 'vision.currentTitleTemplate': 'Current style: {style} — click for next', + + // Cockpit signal stream (pushCockpitSignal / renderCockpitSignals). + 'signal.trackAcquired': 'TRACK ACQUIRED', + 'signal.trackDetail': '{label} · COURSE {heading}°', + 'signal.selectFlightAria': 'Select flight {title}', + 'signal.contextStandby': 'CONTEXT STANDBY', + 'signal.contextStandbyHint': 'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS', + 'signal.contactLostTitle': 'CONTACT LOST · {subject}', + 'signal.contactLostDetail': + 'SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN', + 'signal.classMilitary': 'MILITARY FLIGHT', + 'signal.classCommercial': 'COMMERCIAL FLIGHT', + 'signal.contactCurrent': '{aircraftClass} · CURRENT', + 'signal.contactRange': '{aircraftClass} · {distance}', + 'signal.distanceUnknown': 'DISTANCE UNKNOWN', + 'signal.inputsUnknown': { + one: '{count} INPUT UNKNOWN', + other: '{count} INPUTS UNKNOWN', + }, + 'signal.sourceStatusUnavailable': 'SOURCE STATUS UNAVAILABLE', + + // updateHud(): callsign fallback + the composed aircraft meta line (each + // feed state is its own key so no state can leak English later). + 'hud.fallbackCallsign': 'AIRCRAFT', + 'hud.metaClassMilitary': 'MILITARY', + 'hud.metaClassCommercial': 'COMMERCIAL', + 'hud.metaFeedAcquiringSurface': 'ACQUIRING SURFACE', + 'hud.metaFeedSurfaceFallback': 'SURFACE FALLBACK', + 'hud.metaFeedStale': 'STALE FEED', + 'hud.metaFeedLive': 'LIVE TRACK', + 'hud.aircraftMetaTemplate': '{aircraftClass} · {feedState} · COURSE ALIGNED', + + // updateRoute(): runtime state siblings of the seeded route keys. + 'route.statusArrowEstimated': 'ARROW · ESTIMATED DIRECTION', + 'route.directionLabel': 'DEST {bearing}', + + // updateContext(): uncertainty/nearest/bearing readout states. + 'context.uncertaintyContactLost': + 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR', + 'context.uncertaintyInputsUnknown': { + one: '{count} INPUT UNKNOWN · NOT AN ALL-CLEAR', + other: '{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR', + }, + 'context.uncertaintyInputsCurrent': + 'AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR', + 'context.nearestTemplate': '{cohort} · {contact}', + 'context.nearestUnavailableAria': '{cohort}, Unavailable', + 'context.bearingNone': 'BRG —', + 'context.bearingAhead': 'AHEAD', + 'context.bearingSide': '{side} {angle}', + 'context.sideLeft': 'L', + 'context.sideRight': 'R', + + // setContextCollapsed()/setSignalCollapsed(): expand-state siblings. + 'context.toggleExpandAriaLabel': 'Expand Contact panel', + 'context.toggleExpandTitle': 'Expand contact panel', + 'brief.expandAriaLabel': 'Expand cockpit briefing panel', + 'brief.expandTitle': 'Expand briefing panel', + + // setBriefAutoRotate(): cycle toggle states + the ON-state help text (the + // OFF-state help is the seeded brief.autoTitle). + 'brief.autoOn': 'CYCLE ON', + 'brief.autoOff': 'CYCLE OFF', + 'brief.autoTitleOn': + 'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.', + + // Briefing carousel pages (kicker/subtitle/source per page; the news/local + // source lines are provider attribution and stay English). + 'brief.kickerNews': 'REGIONAL NEWS', + 'brief.kickerLocal': 'LOCAL INFO', + 'brief.subtitleNews': 'LATEST LOCATION-MATCHED REPORTING', + 'brief.subtitleLocal': 'PLACE / CONDITIONS / POSITION', + + // Local position + regional brief status states. + 'brief.positionUnavailable': 'POSITION UNAVAILABLE', + 'brief.newsUnavailable': 'REGIONAL NEWS UNAVAILABLE', + 'brief.regionUnavailable': 'REGION UNAVAILABLE', + 'brief.newsEmpty': 'NO RECENT LOCATION MATCHES', + + // renderRegionalBrief(): article metadata, cloud readout, and age chips. + 'brief.metadataSourceFallback': 'SOURCE', + 'brief.articleMetaTemplate': '{domain} · {age}', + 'brief.newsSourceLine': '{source} · LOCATION QUERY', + 'brief.age.timeUnknown': 'TIME UNKNOWN', + 'brief.age.minutes': '{count}M AGO', + 'brief.age.hours': '{count}H AGO', + 'brief.age.days': '{count}D AGO', + 'brief.wind.dirUnknown': 'DIR UNKNOWN', + 'brief.cloudTemplate': 'CLOUD {pct}%', + 'brief.cloudUnknown': 'CLOUD UNKNOWN', + + // ── Phase-3 runtime extraction batch 3: toasts, loading helpers, map tray ─ + + // Panel-chrome + share/location toasts. + 'panel.layoutResetToast': + 'Panel layout updated — positions reset to new defaults', + 'share.toastCopied': 'Link copied!', + 'share.toastCopyFailed': 'Copy failed', + 'location.toastNotFound': 'Location not found', + 'location.toastSearchFailed': 'Search failed', + 'location.toastFlyToPoiFirst': 'Fly to a POI first', + 'actions.clearLayersBusyAria': 'Clearing selected data layers', + 'actions.clearLayersFailedToast': 'Selected data layers could not be cleared', + + // Global status chip notices (_handleShareTrackingRestoreStatus). + 'status.acquiring': 'ACQUIRING', + 'status.subjectFallback': 'entity', + 'status.sharedSubjectDetail': 'SHARED {subject}', + 'status.sharedFollowExpired': 'Shared {subject} follow expired', + 'status.sharedRestoreFailed': + 'Shared {subject} could not be restored — feed unavailable', + 'status.sharedUnavailable': 'Shared {subject} is unavailable', + + // Context mode user-facing action failures. + 'context.modeContext': 'Context', + 'context.modeSpaceMissions': 'Space Missions', + 'context.toastStartBlocked': + '{mode} could not start because another layer did not stop cleanly', + 'context.toastTransitionFailedContacts': + 'Contacts could not complete the requested transition; try again', + 'context.toastTransitionFailedMissions': + 'Space Missions could not complete the requested transition; try again', + 'context.toastInstallationsRefreshFailed': + 'Nearby installations could not be refreshed; try again', + 'context.toastRestoreFailed': + 'Context could not restore every layer; try again', + 'context.toastZoomToSearch': 'Zoom in to search mapped installations', + 'context.toastInstallationsRefreshed': 'Nearby installations refreshed', + 'context.toastLayerUnavailable': + 'That layer is unavailable in the current Context mode', + 'context.actionStart': 'start', + 'context.actionStop': 'stop', + 'context.toastLayerLifecycleFailed': '{layerId} could not {action} cleanly', + 'radio.toastLifecycleFailed': 'Radio could not {action} cleanly', + + // CCTV sync chip captions + calibration/availability toasts. + 'cctv.syncLoadingFrames': 'loading frames', + 'cctv.syncGridReady': 'camera grid ready', + 'cctv.toastCalibrationSaved': 'CCTV calibration saved', + 'cctv.toastCalibrationReset': 'CCTV calibration reset', + 'cctv.toastLayerUnavailable': 'CCTV layer unavailable', + + // _renderMapStackState(): status chip fallback when no stack label resolves + // (stack names themselves are keep-English provider/stack ids). + 'presets.mapStackFallback': 'MAP', + + // ── Phase-3 runtime extraction batch 4: awareness / CCTV / Radio panels ─── + // (radio.* and cctv.* state values that layers.js already seeds are reused + // cross-namespace; these are the runtime-only siblings and compositions.) + + // _applyRuntimeStaticHeaderText(): the key-only standby description span + // (both mode descriptions in one span split by a literal
in phase 2). + 'context.standbyContactsDesc': 'CONTACTS — nearest planes · vessels · sites', + 'context.standbyMissionsDesc': 'SPACE MISSIONS — launches & orbital assets', + // _syncContextRadioLauncherState(): close-state sibling of the seeded + // context.radioToggleAriaLabel ('Open compact Radio controls'). + 'context.radioToggleCloseAriaLabel': 'Close compact Radio controls', + 'context.toastMissionsCancelRestoreFailed': + 'Space Missions cancellation could not restore the previous layer state', + // Cockpit utility disclosures: collapse-state siblings of the seeded + // utility.*ToggleAriaLabel expand keys. + 'utility.displayToggleCollapseAriaLabel': 'Collapse Cockpit display options', + 'utility.radioToggleCollapseAriaLabel': 'Collapse Cockpit Radio controls', + + // Radio panel runtime states (state-sibling keys so no state can leak + // English later; lifecycle enum labels reuse layers.status.*). + 'radio.stateSync': 'SYNC', + 'radio.tunerCategoryBand': '{category} BAND', + 'radio.tunerNoStations': 'NO STATIONS', + 'radio.tunerStationAria': '{name}, station {index} of {total}', + 'radio.tunerNoStationAria': 'No station available', + 'radio.tunerOffAir': 'OFF AIR', + 'radio.tunerStationUnavailable': 'STATION UNAVAILABLE', + 'radio.actionPlay': 'Play', + 'radio.actionPause': 'Pause', + 'radio.actionResume': 'Resume', + 'radio.targetSelected': 'selected', + 'radio.targetNearest': 'nearest', + 'radio.playStateAria': '{action} {target} radio station', + 'radio.miniStateUncertain': 'RADIO STATE UNCERTAIN', + 'radio.miniSyncingDirectory': 'SYNCING DIRECTORY', + 'radio.stationSyncing': 'SYNCING', + 'radio.stationFallback': 'station', + 'radio.playbackReadyFallback': 'Ready', + + // CCTV panel runtime states (layers.cctv.* seeded keys reused where they + // match; these are the runtime-only compositions and flipped states). + 'cctv.coverageViewshedOn': 'VIEWSHED ON', + 'cctv.frameLoading': 'FRAME · LOADING', + 'cctv.frameUnavailable': 'FRAME · UNAVAILABLE', + 'cctv.calChipEdited': 'CAL · EDITED (UNSAVED)', + 'cctv.calChipTemplate': 'CAL · {badge}', + 'cctv.calBadgeCalibrated': 'CALIBRATED', + 'cctv.calBadgeCurated': 'CURATED', + 'cctv.calBadgeRawPrior': 'RAW PRIOR', + 'cctv.metaProjectionMonitor': 'MONITOR', + 'cctv.metaProjectionOff': 'OFF', + 'cctv.metaTemplate': + '{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}', + 'cctv.metaCamerasClick': { + one: '{count} camera loaded · click a camera to activate', + other: '{count} cameras loaded · click a camera to activate', + }, + 'cctv.metaCamerasEnable': { + one: '{count} camera loaded · enable CCTV to activate', + other: '{count} cameras loaded · enable CCTV to activate', + }, + 'cctv.summaryNoneAvailable': 'No summary available.', + + // ── Phase-3 runtime extraction batch 5: remainder + hud.js ──────────────── + + // _setCelestialRingEnabled(): unsupported-style title sibling of the seeded + // display.celestialToggleTitle. + 'display.celestialUnavailableTitle': + 'Celestial ring — available in Normal style', + // _updateDetectionButton(): density-profile labels (display.detectionLabel + // 'DETECT' was seeded on the static button) + composed aria. + 'display.detectionAriaTemplate': 'Detection overlay: {mode}', + 'display.detectionAriaOff': 'Detection overlay: off', + 'display.detectionLabelSparse': 'SPARSE', + 'display.detectionLabelBalanced': 'BALANCED', + 'display.detectionLabelDense': 'DENSE', + // Globe-reset buttons: idle/working aria states (cockpit variant sibling). + 'hud.resetGlobeAria': 'Reset to full globe view', + 'hud.resetGlobeCockpitAria': 'Reset cockpit to full globe view', + 'hud.resettingGlobeAria': 'Resetting to full globe view', + 'hud.resettingGlobeCockpitAria': 'Resetting cockpit to full globe view', + // _initOrbit(): indicator caption after the orbit glyph. + 'location.orbitLabel': 'ORBIT', + // Intel HUD (src/hud.js): summary caption, idle placeholder, REC indicator. + // Classification banners, terse instrument readout codes (MGRS/GSD/NIIRS/ + // ALT/COLL/ONA/BAND/BITS/LVL), and the AI summary line stay keep-English. + 'hud.summaryLabel': 'SUMMARY', + 'hud.summaryAwaiting': 'Awaiting telemetry...', + 'hud.recLabel': 'REC', + + // Upstream a11y additions (post-merge follow-up): slider/toggle accessible names. + 'display.featherAria': 'Scope edge feather', + 'display.bloomAria': 'Bloom intensity', + 'display.sharpenAria': 'Sharpen intensity', + 'location.searchAria': 'Search location by name or coordinates', + 'location.toggleAria': 'Expand LOCATION', +}; diff --git a/src/i18n/locales/en/layers.js b/src/i18n/locales/en/layers.js new file mode 100644 index 000000000..458a648f7 --- /dev/null +++ b/src/i18n/locales/en/layers.js @@ -0,0 +1,501 @@ +// English catalog — layers namespace (phase-1 seed). +// +// Owns data-layer presentation copy: display names, feed-state labels, and +// layer batch toasts. Machine-readable values (layer ids, status enum keys, +// provider names) are intentionally NOT here — see docs/TRANSLATORS.md +// for the keep-English boundary. Keys are namespace-relative; the registry +// prefixes them with `layers.`. +export const NAMESPACE = 'layers'; + +export default { + // src/data/manager.js FEED_STATE_LABELS value + 'status.unavailable': 'UNAVAILABLE', + // src/data/flights.js / src/data/earthquakes.js layer display names + 'name.liveFlights': 'Live Flights', + 'name.earthquakes': 'Earthquakes (24h)', + // src/ui.js clearSelectedLayers() result toasts (genuine plural copy) + 'clear.toast.noneSelected': 'No selected data layers', + 'clear.toast.cleared': { + one: 'Cleared {count} data layer', + other: 'Cleared {count} data layers', + }, + 'clear.toast.notCleared': { + one: '{count} data layer could not be cleared', + other: '{count} data layers could not be cleared', + }, + + // ── Phase-2 static markup extraction (index.html), appended ────────────── + // The shell worker owns ALL index.html static markup in phase 2 + // (docs/TRANSLATORS.md cross-surface rule); these keys cover the + // layers-worker surfaces (world overlay, CCTV panel, Radio panel) whose + // runtime presentation paths land here in phase 3. + + // index.html #world-overlay-actions region (src/overlays/worldOverlay.js) + 'overlay.regionAriaLabel': 'Visible map targets', + + // index.html CCTV panel chrome. Button texts marked as states (toggleOff, + // coverageOff, autoHopOff, projectionOn) are the shipped initial values; the + // phase-3 layer worker adds the flipped-state siblings. + 'cctv.panelTitle': 'CCTV', + 'cctv.collapseTitle': 'Collapse panel', + 'cctv.sourceUnknown': 'SOURCE · UNKNOWN', + 'cctv.metaIdle': 'Enable CCTV to load camera intersections', + 'cctv.toggleOff': 'CCTV OFF', + 'cctv.nearest': 'NEAREST', + 'cctv.prev': 'PREV', + 'cctv.cameraAriaLabel': 'CCTV camera', + 'cctv.next': 'NEXT', + 'cctv.focus': 'FOCUS', + 'cctv.coverageOff': 'COVERAGE OFF', + 'cctv.autoHopOff': 'AUTO HOP OFF', + 'cctv.projectionOn': 'PROJECTION ON', + 'cctv.calibrationLabel': 'CALIBRATION', + 'cctv.adjustLabel': 'ADJUST', + 'cctv.adjustTitle': + 'Drag the camera in the world: rings rotate, arrows move, handles set range/FOV', + 'cctv.calPoseAriaLabel': 'Camera pose — click a value to type', + 'cctv.calHeadingTitle': 'Heading (compass °) — click to type', + 'cctv.calPitchTitle': 'Pitch (° up/down) — click to type', + 'cctv.calFovTitle': 'Horizontal FOV (°) — click to type', + 'cctv.calRangeTitle': 'Range / monitor-plane distance (m) — click to type', + 'cctv.calHeightTitle': 'Mount height above ground (m) — click to type', + 'cctv.calNorthTitle': + 'North offset from catalog position (m) — click to type', + 'cctv.calEastTitle': 'East offset from catalog position (m) — click to type', + 'cctv.saveCal': 'SAVE CAL', + 'cctv.resetCal': 'RESET CAL', + 'cctv.summaryLabel': 'SCENE SUMMARY', + 'cctv.summaryIdle': + 'Enable CCTV to start camera-linked intelligence summaries.', + + // index.html Radio panel chrome (right rail #radio-panel) + 'radio.panelAriaLabel': 'Internet radio and local SDR', + 'radio.panelTitle': 'RADIO', + 'radio.expandTitle': 'Expand Radio', + 'radio.expandAriaLabel': 'Expand Radio section', + 'radio.enable': 'ENABLE', + 'radio.stationTagLabel': 'STATION TAG', + 'radio.filterAriaLabel': 'Filter stations by station tag', + 'radio.filterAll': 'All', + 'radio.noStation': 'NO STATION SELECTED', + 'radio.stationHint': 'Enable Radio, then choose a globe marker or use next.', + // Key-only: the #radio-tuner-band-label span text is pinned verbatim by + // radioMarkup.test.mjs, so the attribute must be wired by a later phase. + 'radio.bandLabel': 'DIRECTORY BAND', + 'radio.dragToTune': 'DRAG TO TUNE', + 'radio.tunerIdle': 'ALL · DRAG THE NEEDLE', + 'radio.snapsNote': 'SNAPS TO AVAILABLE STATIONS', + 'radio.transportAriaLabel': 'Radio playback', + 'radio.prev': 'PREV', + 'radio.prevAriaLabel': 'Previous filtered station', + 'radio.play': 'PLAY', + 'radio.playAriaLabel': 'Play selected station', + 'radio.next': 'NEXT', + 'radio.nextAriaLabel': 'Next filtered station', + 'radio.stop': 'STOP', + 'radio.stopAriaLabel': 'Stop radio playback', + 'radio.volumeLabel': 'VOLUME', + 'radio.volumeAriaLabel': 'Radio volume', + // Initial idle announcement; the runtime states land with the layers worker. + 'radio.playbackOff': 'Radio off', + 'radio.stationSite': 'STATION SITE', + 'radio.privacyNote': + 'Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.', + + // ── Phase-3 runtime extraction (src/data/*), appended ──────────────────── + // Layer display names are translated only at the presentation boundary + // (manager.js _renderToggles/_syncToggleButton map layer.id → key here). + // layer.id values, FEED_STATE_LABELS enum KEYS, and lifecycle state keys + // stay English — see docs/TRANSLATORS.md keep-English boundary. + + // src/data/manager.js FEED_STATE_LABELS values + toggle-button states. + 'status.on': 'ON', + 'status.loading': 'LOADING', + 'status.degraded': 'DEGRADED', + 'status.stale': 'STALE', + 'status.fallback': 'FALLBACK', + 'status.off': 'OFF', + 'status.partial': 'PARTIAL', + 'status.uncertain': 'UNCERTAIN', + 'status.enabling': 'ENABLING', + 'status.disabling': 'DISABLING', + + // Layer display names (toggle panel rows + toggle-button aria-label). + 'name.militaryFlights': 'Military Flights', + 'name.satellites': 'Satellites', + 'name.rocketLaunches': 'Space Missions (30d)', + 'name.traffic': 'Street Traffic', + 'name.cctv': 'CCTV', + 'name.radio': 'Radio', + 'name.bikeshare': 'Bikeshare', + 'name.aisVessels': 'Live AIS Vessels', + 'name.installations': 'Mapped Installations', + 'name.globalContext': 'Global Context', + 'name.datacenters': 'Datacenters', + 'name.dams': 'Dams', + 'name.submarineCables': 'Submarine Cables', + 'name.fires': 'FIRMS Active Fires', + + // src/data/manager.js data-panel row meta line (_buildMetaText) + time ago. + 'meta.justNow': 'just now', + 'meta.secondsAgo': '{count}s ago', + 'meta.minutesAgo': '{count}m ago', + 'meta.hoursAgo': '{count}h ago', + 'meta.never': 'never', + 'meta.loading': 'loading...', + 'meta.uncertainLifecycle': + 'UNCERTAIN · {source} · lifecycle state requires reconciliation', + 'meta.transitioning': '{state} · {source}', + 'meta.stateSourceRetry': '{state} · {source} · {detail} · retry {seconds}s', + 'meta.stateSourceDetail': '{state} · {source} · {detail}', + 'meta.stateSourceAgo': '{state} · {source} · {ago}', + 'meta.stateSourceAgoRetry': + '{state} · {source} · {ago} · retrying in {seconds}s', + 'meta.sourceLoading': '{source} · {loadingLabel}', + 'meta.sourceAgo': '{source} · {ago}', + 'meta.partialCounts': '{accepted} of {raw} records accepted', + 'meta.partialIncomplete': 'incomplete snapshot', + 'meta.partialDetail': '{state} · {source} · {detail} · {ago}', + 'meta.toggleAriaLabel': '{name}: {state}', + + // src/data/militaryFlights.js tracked readout fallback captions. + 'readout.typeUnknown': 'Type unknown', + 'readout.regUnknown': 'Reg unknown', + 'readout.operatorUnknown': 'Operator unknown', + 'readout.altUnknown': 'Alt unknown', + + // src/data/vesselLabels.js AIS type names (display only — the styling + // matcher keeps the English tokens as machine values). + 'vessel.type.fishing': 'FISHING', + 'vessel.type.towing': 'TOWING', + 'vessel.type.dredger': 'DREDGER', + 'vessel.type.diveOps': 'DIVE OPS', + 'vessel.type.military': 'MILITARY', + 'vessel.type.sailing': 'SAILING', + 'vessel.type.pleasure': 'PLEASURE', + 'vessel.type.pilot': 'PILOT', + 'vessel.type.sar': 'SAR', + 'vessel.type.tug': 'TUG', + 'vessel.type.portTender': 'PORT TENDER', + 'vessel.type.antiPollution': 'ANTI-POLLUTION', + 'vessel.type.lawEnforce': 'LAW ENFORCE', + 'vessel.type.medical': 'MEDICAL', + 'vessel.type.highSpeed': 'HIGH-SPEED', + 'vessel.type.passenger': 'PASSENGER', + 'vessel.type.cargo': 'CARGO', + 'vessel.type.tanker': 'TANKER', + 'vessel.type.other': 'OTHER', + + // src/data/aisLiveVessels.js vessel cards + selected-vessel HUD. + 'vessel.fallback': 'VESSEL', + 'vessel.posLive': 'POS: LIVE', + 'vessel.posAt': 'POS: {time}Z', + 'vessel.hudIdle': 'AIS: --', + 'vessel.hudName': 'AIS: {name}', + 'vessel.hudTypeLine': '{type} SPD: {speed} HDG: {heading}', + 'vessel.awaitingFirstPosition': 'awaiting first AIS position…', + + // index.html CCTV panel flipped-state siblings (runtime writers live in + // src/ui.js, owned by core-ui — these keys are the layers-surface half of + // the cross-surface rule in docs/TRANSLATORS.md). + 'cctv.toggleOn': 'CCTV ON', + 'cctv.coverageOn': 'COVERAGE ON', + 'cctv.autoHopOn': 'AUTO HOP ON', + 'cctv.projectionOff': 'PROJECTION OFF', + 'cctv.adjustOn': 'ADJUST ON', + + // src/data/cctv.js buildSummaryText() — the SCENE SUMMARY line. + 'cctv.summary.standingBy': + '{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE', + 'cctv.summary.empty': 'No cameras available in catalog.', + 'cctv.summary.city': '{city} CCTV', + 'cctv.summary.hdg': 'HDG {value}°', + 'cctv.summary.fov': 'FOV {value}°', + 'cctv.summary.coverage': 'COVERAGE {value}km²', + 'cctv.summary.overlap': 'OVERLAP {count} cams', + 'cctv.summary.isolated': 'ISOLATED VIEW', + 'cctv.summary.projMonitor': 'PROJ MONITOR', + 'cctv.summary.projOff': 'PROJ OFF', + 'cctv.summary.viewshed': 'VIEWSHED', + 'cctv.summary.cal': 'CAL {value}', + 'cctv.summary.src': 'SRC {value}', + 'cctv.summary.context': '{value} CONTEXT', + + // src/data/radio.js station-tag categories, genre labels, cluster badges. + 'radio.category.news': 'News', + 'radio.category.talk': 'Talk', + 'radio.category.weather': 'Weather / Emergency', + 'radio.category.publicSafety': 'Public Safety', + 'radio.category.aviationMarine': 'Aviation / Marine', + 'radio.category.trafficTransit': 'Traffic / Transit', + 'radio.category.music': 'Music', + 'radio.category.other': 'Other', + 'radio.genre.alternative': 'Alternative', + 'radio.genre.ambient': 'Ambient', + 'radio.genre.blues': 'Blues', + 'radio.genre.classical': 'Classical', + 'radio.genre.country': 'Country', + 'radio.genre.dance': 'Dance', + 'radio.genre.electronic': 'Electronic', + 'radio.genre.folk': 'Folk', + 'radio.genre.funk': 'Funk', + 'radio.genre.hipHop': 'Hip-Hop', + 'radio.genre.house': 'House', + 'radio.genre.indie': 'Indie', + 'radio.genre.jazz': 'Jazz', + 'radio.genre.latin': 'Latin', + 'radio.genre.metal': 'Metal', + 'radio.genre.oldies': 'Oldies', + 'radio.genre.pop': 'Pop', + 'radio.genre.punk': 'Punk', + 'radio.genre.rAndB': 'R&B', + 'radio.genre.reggae': 'Reggae', + 'radio.genre.rock': 'Rock', + 'radio.genre.soul': 'Soul', + 'radio.genre.techno': 'Techno', + 'radio.genre.trance': 'Trance', + 'radio.genre.world': 'World', + 'radio.cluster.news': 'NEWS', + 'radio.cluster.talk': 'TALK', + 'radio.cluster.weather': 'WEATHER', + 'radio.cluster.publicSafety': 'SAFETY', + 'radio.cluster.aviationMarine': 'AIR / SEA', + 'radio.cluster.trafficTransit': 'TRANSIT', + 'radio.cluster.music': 'MUSIC', + 'radio.cluster.other': 'OTHER', + + // Radio right-rail panel runtime states (seeded here per the ownership + // contract; src/ui.js wires the writes — core-ui). English values mirror + // the literals ui.js writes today so wiring is byte-identical under 'en'. + 'radio.noStationAvailable': 'NO STATION AVAILABLE', + 'radio.disable': 'DISABLE', + 'radio.reconcile': 'RECONCILE', + 'radio.enableAria': 'Enable Radio', + 'radio.disableAria': 'Disable Radio', + 'radio.reconcileAria': 'Reconcile Radio — lifecycle uncertain', + 'radio.meta.loading': 'Loading station directory…', + 'radio.meta.metadataOnly': 'Directory metadata only', + 'radio.meta.chooseHint': 'Choose a globe marker or use next.', + 'radio.tags': 'TAGS · {tags}', + 'radio.state.ready': 'Ready — playback starts only from your action', + 'radio.state.loading': 'Connecting directly to broadcaster…', + 'radio.state.buffering': 'Buffering broadcaster stream…', + 'radio.state.playing': 'Playing {station}', + 'radio.state.paused': 'Paused {station}', + 'radio.state.error': 'Broadcaster stream unavailable', + 'radio.state.degradedDirectory': ' · degraded directory', + 'radio.state.staleDirectory': ' · stale directory', + 'radio.state.staleDegradedDirectory': ' · stale/degraded directory', + 'radio.state.outsideFilter': ' · outside current filter', + 'radio.state.voiceMuted': ' · muted during voice interaction', + 'radio.state.voiceRestoring': ' · restoring volume after voice', + 'radio.state.staticNoAudio': ' · static indicates no broadcaster audio', + 'radio.state.tuningStatic': ' · tuning static until broadcaster starts', + 'radio.state.stationUnavailable': + 'Station unavailable after directory refresh — choose another channel', + 'radio.state.enabling': 'Radio is enabling…', + 'radio.state.disabling': 'Radio is disabling…', + 'radio.state.uncertain': + 'Radio lifecycle is uncertain — use Enable or Disable to reconcile', + + // src/data/rocketLaunches.js space-mission surface (runtime-built panel, + // roster, replay overlay, and map cards). + 'missions.launchSite': 'LAUNCH SITE', + 'missions.launchSiteValue': 'LAUNCH SITE · {site}', + 'missions.panel.selectedHeader': 'SELECTED SPACE MISSION', + 'missions.panel.showAllTitle': 'Show all missions', + 'missions.panel.deselectAria': 'Deselect mission', + 'missions.panel.mission': 'MISSION', + 'missions.panel.status': 'STATUS · ', + 'missions.panel.launchSite': 'LAUNCH SITE · ', + 'missions.panel.launchTime': 'LAUNCH TIME · ', + 'missions.panel.orbit': 'ORBIT · ', + 'missions.panel.ascentPath': 'ASCENT PATH · ', + 'missions.panel.distance': 'CURRENT DISTANCE FROM EARTH · ', + 'missions.panel.speed': 'SATELLITE SPEED · ', + 'missions.panel.payload': 'PAYLOAD', + 'missions.panel.colName': 'NAME', + 'missions.panel.colType': 'TYPE', + 'missions.panel.colDestination': 'DESTINATION', + 'missions.panel.stagesSection': 'STAGE / RE-ENTRY / RECOVERY', + 'missions.panel.colStage': 'STAGE', + 'missions.panel.colStatus': 'STATUS', + 'missions.panel.colFinalPosition': 'FINAL POSITION', + 'missions.panel.focus': 'FOCUS', + 'missions.panel.prev': 'PREV', + 'missions.panel.next': 'NEXT', + 'missions.panel.prevTitle': 'Previous mission', + 'missions.panel.nextTitle': 'Next mission', + 'missions.panel.showAll': 'SHOW ALL / DESELECT', + 'missions.roster.empty': 'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW', + 'missions.roster.count': '{count} / 30D', + 'missions.roster.dateUnavailable': 'DATE UNAVAILABLE', + 'missions.roster.unspecifiedOperator': 'UNSPECIFIED OPERATOR', + 'missions.roster.selectAria': 'Select {mission}', + 'missions.rows.additionalPayloads': '+{count} additional payload records', + 'missions.rows.classified': 'PAYLOAD DATA UNAVAILABLE', + 'missions.rows.noStageData': 'NO STAGE RE-ENTRY / RECOVERY DATA', + 'missions.rows.unspecified': 'UNSPECIFIED', + 'missions.rows.unavailable': 'UNAVAILABLE', + 'missions.rows.flightNumber': 'FLIGHT {count}', + 'missions.rows.reused': 'REUSED', + 'missions.rows.positionUnavailable': 'POSITION UNAVAILABLE', + 'missions.rows.kmDownrange': '{value} KM DOWNRANGE', + 'missions.rows.plannedPrefix': 'PLANNED · ', + 'missions.rows.suppliedTrajectory': 'SUPPLIED TRAJECTORY POINTS', + 'missions.rows.reconstructedEstimate': 'RECONSTRUCTED ESTIMATE', + 'missions.telemetry.km': '{value} KM', + 'missions.telemetry.kmPerSecond': '{value} KM/S', + 'missions.replay.start': 'REPLAY ASCENT', + 'missions.replay.startTitle': + 'Replay the estimated ascent with a following camera', + 'missions.replay.pause': 'Pause replay', + 'missions.replay.resume': 'Resume replay', + 'missions.replay.cancel': 'Cancel replay', + 'missions.replay.speedLabel': 'REPLAY SPEED', + 'missions.replay.speedAria': 'Replay speed multiplier', + 'missions.replay.countdown': 'T−{time} · {mission}', + 'missions.replay.standbyDetail': 'LAUNCH STANDBY', + 'missions.replay.liftoff': 'LIFTOFF · {mission}', + 'missions.replay.ascentReplay': 'ASCENT REPLAY · {mission}', + 'missions.replay.ascentEstimate': 'ASCENT ESTIMATE · {mission}', + 'missions.replay.recovery': 'STAGE RE-ENTRY / RECOVERY · {mission}', + 'missions.replay.orbit': 'ORBIT REPLAY · {mission}', + 'missions.replay.pausedPrefix': 'PAUSED · {title}', + 'missions.replay.phaseCountdown': 'T minus {seconds}', + 'missions.replay.phasePreparing': 'Preparing launch site', + 'missions.replay.phaseLiftoff': 'Liftoff', + 'missions.replay.phaseAscent': 'Ascent replay', + 'missions.replay.phaseOrbit': 'Orbit replay', + 'missions.replay.pausedSuffix': ', paused', + + // src/data/satellites.js dense-shell chip + src/data/satelliteClass.js + // class labels and legend blurbs. + 'satellites.denseChip': 'DENSE', + 'satellites.denseChipLoading': 'DENSE ···', + 'satellites.denseChipFailed': 'DENSE ✕', + 'satellites.denseTitleAdd': + 'Add the full Starlink broadband shell (thousands of extra points)', + 'satellites.denseTitleLoading': 'Loading the Starlink shell…', + 'satellites.denseTitleFailed': 'Starlink {detail} — click to retry', + 'satellites.loadFailed': 'load failed', + 'satellites.denseTitleActive': + 'Showing the full Starlink shell — click for the core catalog only', + 'satellites.class.station': 'STATION', + 'satellites.class.stationBlurb': + 'Crewed stations and their visiting vehicles', + 'satellites.class.nav': 'NAV', + 'satellites.class.navBlurb': 'GNSS navigation — GPS, GLONASS, Galileo', + 'satellites.class.geo': 'GEO', + 'satellites.class.geoBlurb': + 'Geostationary belt — comms and weather, fixed over the equator', + 'satellites.class.visual': 'VISUAL', + 'satellites.class.visualBlurb': + 'Brightest naked-eye objects — CelesTrak visual group', + 'satellites.class.comms': 'COMMS', + 'satellites.class.commsBlurb': + 'Broadband constellation shell — shown only in DENSE mode', + + // src/data/firmsHeatmap.js stats labels + fire/cell cards. + 'meta.refreshing': 'refreshing...', + 'firms.keyRequired': 'KEY REQUIRED', + 'firms.staleCached': 'STALE · cached {age}', + 'firms.liveUpdated': 'LIVE · updated {age}', + 'firms.underMinuteAgo': '<1m ago', + 'firms.card.fire': 'FIRE · {frp} MW', + 'firms.card.ambient': '▲ {frp} MW', + 'firms.card.fireNoun': 'FIRE', + 'firms.card.firesNoun': 'FIRES', + 'firms.card.confSuffix': '{value} conf', + 'firms.card.ageSuffix': '{value} ago', + 'firms.card.maxFrp': 'max {value} MW', + 'firms.card.newAge': 'new {value}', + 'firms.card.night': 'NIGHT', + 'firms.card.sensorUnavailable': 'sensor n/a', + 'firms.card.focusAria': 'Focus fire detection {title}, {details}', + + // src/data/traffic.js feed presentation (manager meta loadingLabel values). + 'traffic.loadingSyncing': 'syncing LIVE traffic flow', + 'traffic.liveCoverage': 'LIVE · TomTom flow · {percent}% cov', + 'traffic.simUnavailable': 'SIMULATED — traffic service unreachable', + 'traffic.simKeyless': 'SIMULATED — add TomTom key for live', + + // src/data/bikeshare.js loading labels + selected-station card. + 'bike.loadingSyncing': 'syncing {count} city feeds...', + 'bike.loadingScanning': 'scanning nearby systems...', + 'bike.card.capacity': '🚲 {bikes} avail · {docks} docks · {capacity} cap', + 'bike.warning.notInstalled': '⚠️ Not installed', + 'bike.warning.notRenting': '⚠️ Not renting', + 'bike.warning.notReturning': '⚠️ Not returning', + 'bike.stationFallback': 'Station', + 'bike.stationWithId': 'Station {id}', + + // src/data/militaryInstallations.js + installationFeedback.js. + 'installations.fallbackTitle': 'MAPPED INSTALLATION', + 'installations.loading': 'loading mapped installation context', + 'installations.feedback.reason.rateLimited': 'Overpass rate-limited', + 'installations.feedback.reason.timeout': 'Overpass timed out', + 'installations.feedback.reason.queryFailed': + 'Overpass could not complete the query', + 'installations.feedback.reason.unavailable': + 'Overpass temporarily unavailable', + 'installations.feedback.retrying': 'Retrying mapped sites…', + 'installations.feedback.fetching': 'Fetching mapped sites…', + 'installations.feedback.retryIn': '{reason} — retrying in {seconds}s', + 'installations.feedback.retryPending': '{reason} — retry pending', + 'installations.feedback.zoomIn': 'Zoom in to search mapped installations', + 'installations.feedback.cached': 'Showing cached mapped sites', + 'installations.feedback.notLoaded': 'Mapped sites not loaded', + 'installations.feedback.loaded': 'Mapped sites loaded', + + // src/data/militaryAwareness.js panel + militaryAwarenessEngine.js reasons. + 'awareness.standbyReady': 'CONTEXT READY', + 'awareness.standbyOff': 'GLOBAL CONTEXT OFF', + 'awareness.standbySelect': 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION', + 'awareness.standbyEnable': 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY', + 'awareness.controlsAria': 'Global Context navigation', + 'awareness.previous': 'PREVIOUS', + 'awareness.focus': 'FOCUS', + 'awareness.next': 'NEXT', + 'awareness.previousTitle': + 'Previous — prior visited contact in the 250 km window', + 'awareness.nextTitle': + 'Next — nearest unvisited contact in the 250 km window', + 'awareness.note': + 'Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.', + 'awareness.unavailableAria': 'Unavailable', + 'awareness.focusAria': 'Focus {label}', + 'awareness.cohort.flights': 'Flights', + 'awareness.cohort.military': 'Military flights', + 'awareness.cohort.vessels': 'AIS vessels', + 'awareness.cohort.installations': 'Mapped installations', + 'awareness.coverage.viewport': 'CURRENT VIEWPORT ONLY', + 'awareness.hdg': 'HDG {value}°', + 'awareness.brg': 'BRG {value}°', + 'awareness.brgUnknown': 'BRG —', + 'awareness.crs': 'CRS {value}°', + 'awareness.reason.feedUnavailable': 'feed unavailable', + 'awareness.reason.feedStale': 'feed stale', + 'awareness.reason.nearby': 'observed or mapped nearby context', + 'awareness.reason.none': 'no observed or mapped objects in current feeds', + + // src/data/regionalBrief.js WMO weather-code labels (cockpit copy). + 'weather.conditionsUnknown': 'CONDITIONS UNKNOWN', + 'weather.clear': 'CLEAR', + 'weather.partlyCloudy': 'PARTLY CLOUDY', + 'weather.overcast': 'OVERCAST', + 'weather.fog': 'FOG', + 'weather.drizzle': 'DRIZZLE', + 'weather.rain': 'RAIN', + 'weather.snow': 'SNOW', + 'weather.rainShowers': 'RAIN SHOWERS', + 'weather.snowShowers': 'SNOW SHOWERS', + 'weather.thunderstorm': 'THUNDERSTORM', + 'weather.mixed': 'MIXED CONDITIONS', + + 'missions.roster.keyboardHint': 'TAB PREVIEWS · ENTER / SPACE SELECTS', + + 'vessel.awaitingPositions': 'awaiting usable AIS positions…', + 'vessel.awaitingFirstMessage': 'awaiting first AIS message…', + 'missions.estDownrange': 'EST. DOWNRANGE', +}; diff --git a/src/i18n/locales/en/setup.js b/src/i18n/locales/en/setup.js new file mode 100644 index 000000000..1c52248dc --- /dev/null +++ b/src/i18n/locales/en/setup.js @@ -0,0 +1,211 @@ +// English catalog — setup namespace (phase-1 seed). +// +// Owns first-launch onboarding, in-app key setup (Provider Settings), scene +// recipes, and voice-control chrome. Keys are namespace-relative; the registry +// prefixes them with `setup.`. +export const NAMESPACE = 'setup'; + +export default { + // index.html #first-run-launcher header/title/choices + 'firstRun.kicker': 'MISSION CONTROL · FIRST LAUNCH', + 'firstRun.title': 'Choose your first view', + 'firstRun.choice.contacts': 'LIVE CONTACTS', + 'firstRun.suppress': "Don't show this again", + // index.html #key-setup-chip / #key-setup dialog + 'keySetup.chip': 'POWER UP', + 'keySetup.kicker': 'GROUND STATION · PROVIDER SETTINGS', + 'keySetup.title': 'Power up the globe', + 'keySetup.apply': 'SAVE KEYS', + // src/keySetup.js submitUpdates() status announcement + 'keySetup.status.saving': 'Saving…', + + // ── Phase-2 static markup extraction (index.html), appended ────────────── + // The shell worker owns ALL index.html static markup in phase 2 + // (docs/TRANSLATORS.md cross-surface rule); these keys cover the + // support-worker surfaces (first-run, key setup, scene director) whose + // runtime paths land here in phase 3. + + // index.html #first-run-launcher + // Owner-authored persuasive line, pinned verbatim by firstRunExperience.test.mjs + // (unspaced em dash included) — keep the exact string when translating. + 'firstRun.description': + 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.', + 'firstRun.choice.contactsSub': 'Aircraft, vessels and nearby intelligence', + 'firstRun.choice.spaceMissions': 'SPACE MISSIONS', + 'firstRun.choice.spaceMissionsSub': + 'Launches, spacecraft and orbital context', + 'firstRun.choice.explore': 'EXPLORE MANUALLY', + 'firstRun.choice.exploreSub': 'Begin with a clean globe', + 'firstRun.dismissHint': 'ESC to dismiss', + // Initial status tip; firstRunExperience.js swaps it for progress/errors. + 'firstRun.note': + 'Tip: the GEV MIC button in the dock lets you talk to the map.', + // NOT seeded: the environmental tile's / are pinned verbatim + // by firstRunExperience.test.mjs (and the title is painted from + // ENVIRONMENTAL_LABEL_CHOICE at init) — the support worker wires them in + // phase 3 together with the test update. + + // index.html #key-setup + 'keySetup.closeAriaLabel': 'Close key setup', + 'keySetup.description': + "The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.", + 'keySetup.hint': 'ESC to close', + 'keySetup.note': + 'The Google Maps key buys the photorealistic planet — everything else stacks on top.', + + // index.html scene director panel chrome (src/scenes/director.js surface) + 'scenes.panelTitle': 'SCENES', + 'scenes.collapseTitle': 'Collapse panel', + 'scenes.recipeAriaLabel': 'Scene recipe', + 'scenes.new': 'NEW', + 'scenes.delete': 'DEL', + 'scenes.capture': 'CAPTURE SHOT', + 'scenes.updateShot': 'UPDATE SHOT', + 'scenes.start': 'START', + 'scenes.stop': 'STOP', + 'scenes.next': 'NEXT', + 'scenes.exportPresets': 'EXPORT PRESETS', + 'scenes.import': 'IMPORT', + 'scenes.runLog': 'RUN LOG', + 'scenes.statusReady': 'Ready', + + // ── Phase-3 runtime extraction (support surfaces), appended ─────────────── + + // src/firstRunExperience.js — mission busy lines and status/error copy. + // {detail} is the failed layer-id list (machine values, kept raw). + 'firstRun.busy.contacts': 'Starting live contacts…', + 'firstRun.busy.spaceMissions': 'Opening space missions…', + 'firstRun.busy.environmental': 'Scanning active events…', + 'firstRun.busy.working': 'Working…', + 'firstRun.status.failed': + 'Could not open that mission{detail}. Retry or explore manually.', + 'firstRun.status.storageBlocked': + 'This browser is blocking storage, so that could not be saved.', + // The environmental tile is painted at init from ENVIRONMENTAL_LABEL_CHOICE; + // the subcopy names BOTH feeds (pinned verbatim by firstRunExperience.test.mjs). + 'firstRun.choice.environmentalSub': + 'Live earthquakes and active fires, from USGS and NASA', + 'firstRun.environmentalTitle.environmental': 'ENVIRONMENTAL', + 'firstRun.environmentalTitle.earthWatch': 'EARTH WATCH', + 'firstRun.environmentalTitle.activeEvents': 'ACTIVE EVENTS', + + // src/keySetup.js — chip counter, status line, and remove confirm. + 'keySetup.chipWaiting': { + one: 'POWER UP · {count} KEY WAITING', + other: 'POWER UP · {count} KEYS WAITING', + }, + 'keySetup.chipReady': 'POWERED UP', + 'keySetup.status.saveFailed': 'Save failed ({status}).', + 'keySetup.status.saveFailedDetail': 'Save failed: {detail}', + 'keySetup.status.pasteFirst': 'Paste at least one key first.', + 'keySetup.status.saved': + 'Saved to {store}. Restarting — this page reloads itself.', + 'keySetup.status.removed': + 'Removed from {store}. Restarting — this page reloads itself.', + 'keySetup.store.pinokio': 'your app configuration', + 'keySetup.store.env': 'your local .env', + 'keySetup.confirm.remove': 'Remove this key from your saved configuration?', + + // src/mapStackChips.js — unavailable-chip tooltip and aria templates. + 'mapStack.fallbackName': 'This map stack', + 'mapStack.unavailableReason': '{label} is unavailable', + 'mapStack.unavailableAriaLabel': '{label} unavailable: {hint}', + + // src/scenes/director.js — built-in recipe DISPLAY names only. Recipe ids + // and the URL 'scene' param stay English; stored/renamed project titles are + // user data and render verbatim. + 'scenes.recipe.flightsRadar': 'Global Flights Radar', + 'scenes.recipe.orbitalWatch': 'Orbital Watch', + 'scenes.recipe.thermalThreats': 'Thermal Threat Board', + 'scenes.recipe.cityOverload': 'City Overload', + 'scenes.recipe.omnisciencePullback': 'Omniscience Pullback', + + // src/voice/gevRealtime.js — mic chrome and connection/execution STATUS + // text. Status enum keys (idle/connecting/…) are machine values; tool + // names/schemas and model-facing results stay English (keep-English + // boundary). Tier badges STD/MINI and the MIC/ON-OFF mic label stay machine + // identifiers this phase. + 'voice.status.idle': 'OFF', + 'voice.status.connecting': 'CONNECTING', + 'voice.status.listening': 'LISTENING', + 'voice.status.executing': 'EXECUTING', + 'voice.status.error': 'ERROR', + 'voice.status.sessionCostCap': 'Session ended — cost cap {cost}', + 'voice.detail.standby': 'VOICE STANDBY', + 'voice.detail.active': 'VOICE ACTIVE', + 'voice.detail.unavailable': 'VOICE UNAVAILABLE', + 'voice.detail.microphoneUnavailable': 'WebRTC microphone support unavailable', + 'voice.detail.requestingMicrophone': 'Requesting microphone', + 'voice.detail.holdSpaceTalk': 'Hold Space to talk', + 'voice.detail.releaseSpaceSend': 'Release Space to send', + 'voice.detail.askOrCommand': 'Ask or command', + 'voice.detail.voiceOff': 'Voice off', + 'voice.detail.runningCommand': 'Running command', + 'voice.detail.radioDidNotStart': 'Radio did not start', + 'voice.hint.default': + 'Hold Space to speak · tap Space to activate focused controls', + 'voice.error.sessionStart': 'Voice session could not be started.', + 'voice.error.trayTitle': 'VOICE SYSTEM ERROR', + 'voice.error.dismiss': 'DISMISS', + 'voice.error.hint': + 'Check microphone permission and network access, then try again.', + 'voice.kicker.agent': 'AI AGENT', + 'voice.kicker.control': 'VOICE CONTROL', + 'voice.tier.appliesNextSession': '{tier} applies next session', + 'voice.tier.buttonTitle': 'Voice model tier — applies next session', + 'voice.cost.buttonTitle': 'Estimated session cost', + 'voice.button.ariaLabel': + 'Voice control — activate to toggle voice; hold Space to speak', + + // ── Stage-4 repair pass: src/scenes/director.js status corpus, appended ─── + // Status/confirm lines and the default shot title. {scene}/{shot} carry + // stored project titles (user data, rendered verbatim); {mode} is the raw + // context-mode identifier (machine value, keep-English boundary). + 'scenes.status.captureCameraNotReady': + 'Cannot capture shot: camera not ready', + 'scenes.status.shotTitleDefault': 'Shot {n}', + 'scenes.status.captured': 'Captured: {scene} / {shot}', + 'scenes.status.selectShotFirst': 'Select a shot first', + 'scenes.status.updated': 'Updated: {scene} / {shot}', + 'scenes.status.deleteShotConfirm': 'Delete shot "{shot}"?', + 'scenes.status.loaded': 'Loaded: {scene} / {shot}', + 'scenes.status.cameraUnavailable': 'Camera unavailable — exit cockpit first', + 'scenes.status.noShotsToRun': 'No shots to run', + 'scenes.status.runningShot': 'Running {index}/{total}: {scene} / {shot}', + 'scenes.status.runComplete': 'Scene run complete', + 'scenes.status.runError': 'Error: {message}', + 'scenes.status.contextExitFailed': + 'Could not exit {mode} — scene layers may be refused', + + // ── Stage-4 repair pass: keySetup dev-surface copy, appended ────────────── + // src/keySetup.js buildRow() remove chrome (dev-server-only surface). + 'keySetup.row.remove': 'REMOVE', + 'keySetup.row.removeTitle': "Remove {title} from this app's saved keys", + // src/keySetupCore.mjs keySetupRequirement(): availability sentence naming + // the registry env vars (machine values stay raw). + 'keySetup.requirement': 'Needs {envVars} — add it in Provider Settings', + // src/keySetupCore.mjs KEY_SETUP_KEYS `unlocks` copy, keyed by registry id; + // en values must stay byte-identical to the registry strings. + 'keySetup.unlocks.google-maps': 'The photorealistic 3D planet + place search', + 'keySetup.unlocks.google-maps-server': + 'Places context + Street View fallback; optional separate key', + 'keySetup.unlocks.openai': 'Voice control — talk to the planet', + 'keySetup.unlocks.aisstream': 'Live ships, worldwide', + 'keySetup.unlocks.firms': 'Live active-fire detections', + 'keySetup.unlocks.tomtom': 'Real live traffic (keyless runs a simulation)', + 'keySetup.unlocks.cesium-ion': 'Bing imagery map stacks + world terrain', + 'keySetup.unlocks.opensky': + 'More flight-polling credits (anonymous works without)', + 'keySetup.unlocks.launch-library': 'Higher space-missions request allowance', + + 'scenes.status.actionFailed': 'Scene action failed', + + 'scenes.status.projectExported': 'Project exported', + + 'voice.tierNextSession': + 'Next session: {pendingId} — this session stays on {modelId}', + 'voice.tierSwitchHint': + 'Voice model: {pendingId} — click to switch to {target}; applies next session', + 'voice.costTooltip': + 'Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.', +}; diff --git a/src/i18n/locales/en/shell.js b/src/i18n/locales/en/shell.js new file mode 100644 index 000000000..838fa0bd5 --- /dev/null +++ b/src/i18n/locales/en/shell.js @@ -0,0 +1,82 @@ +// English catalog — shell namespace (phase-1 seed). +// +// Owns the global application chrome: title bar, loader status, top-center +// actions, panel titles, and the shared global status surface. Keys are +// namespace-relative; the registry in src/i18n/index.js prefixes them with +// `shell.`. Values are plain strings or { one, other } plural variant objects +// with named {placeholder} interpolation. +export const NAMESPACE = 'shell'; + +export default { + // index.html #title-bar .subtitle + 'title.subtitle': 'NO PLACE LEFT BEHIND', + // index.html .loader-status (initial paint) + 'loading.initialStatus': 'Initializing photorealistic world...', + // src/main.js init() loaderStatus writes + 'loading.status.configuring': 'Configuring viewer...', + 'loading.status.tilesUnavailable': + 'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...', + 'loading.status.flying': 'Flying to Austin, TX...', + 'loading.status.restoring': 'Restoring shared view...', + // index.html #global-loading-label (initial) and + // src/loadingFeedback.js presentLoadingFeedback() labels + 'status.loadingLiveData': 'LOADING LIVE DATA', + 'status.loadComplete': 'LOAD COMPLETE', + 'status.loadFailed': 'LOAD FAILED', + 'status.loadCancelled': 'LOAD CANCELLED', + // index.html #traffic-sync-label (initial) and + // src/loadingFeedback.js reduceTrafficSyncFeedback() neutral fallback + 'status.trafficSyncing': 'syncing road network', + 'status.retryingIn': 'retrying in {seconds}s', + 'status.retryPending': 'retry pending', + // index.html #top-center-actions buttons + 'actions.clearLayers.ariaLabel': 'Clear selected data layers', + 'actions.clearLayers.title': 'Turn off all selected data layers', + 'actions.share.ariaLabel': 'Copy share link', + 'actions.resetView.ariaLabel': 'Reset to full globe view', + // index.html #data-toggles panel header + 'panels.dataLayers': 'DATA LAYERS', + // index.html .panel-collapse-btn on #data-panel + 'panels.collapseTitle': 'Collapse panel', + + // ── Phase-2 static markup extraction (index.html), appended ────────────── + // index.html #command-dock nav region + 'dock.ariaLabel': 'Navigation, voice, and visual preset controls', + // index.html #top-center-actions nav + 'actions.navAriaLabel': 'Globe actions', + 'actions.share.title': 'Copy share link', + 'actions.resetView.title': 'Reset camera and return to full globe view', + // index.html #cctv-sync-label (initial); src/ui.js setSplitFlapText() fallback + 'status.framesLoading': 'loading frames', + + // Locale selector (index.html #control-panel tray): the group container is + // static; its buttons are runtime-rendered by ui.js _initLocaleSelector() + // from the configured locale pair (keys at the end of this file). + 'locale.groupAriaLabel': 'Language', + + // ── Phase-3 runtime extraction (support worker), appended ───────────────── + // src/main.js init() loaderStatus write sites without a phase-1/2 key. + 'loading.status.tilesGoogle': 'Loading Google 3D Tiles...', + 'loading.status.tilesKeyless': 'Loading the keyless globe...', + 'loading.status.systems': 'Initializing systems...', + // src/loadingFeedback.js presentLoadingFeedback() labels beyond the phase-1 + // seeds. 'OpenStreetMap · Overpass' detail strings are provider names and + // stay verbatim at the call site. + 'status.liveDataOff': 'LIVE DATA OFF', + 'status.mappedSitesLoaded': 'MAPPED SITES LOADED', + 'status.retryingMappedSites': 'RETRYING MAPPED SITES', + 'status.fetchingMappedSites': 'FETCHING MAPPED SITES', + 'status.turningOffLiveData': 'TURNING OFF LIVE DATA', + 'status.refreshingLiveData': 'REFRESHING LIVE DATA', + + // ── Configurable locale pair (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE, + // vite.config.js client defines), appended ──────────────────────────── + // ui.js _initLocaleSelector() renders one runtime button per offered + // locale and reads shell.locale..ariaLabel for its aria-label. + // Only SHIPPED locales get a key: each locale PR appends its label to every + // shipped catalog (docs/TRANSLATORS.md), so an unshipped locale can never + // render a selector button. + 'locale.en.ariaLabel': 'Switch to English', + + 'credits.closeAria': 'Close data attribution', +}; diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 497035a40..d4018c0f6 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -11,6 +11,8 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop plugins: [plugin], googleApiKey: 'browser-fixture', cesiumToken: 'ion-fixture', + defaultLocale: 'fr', + secondaryLocale: 'en', }); assert.equal(config.plugins[2], plugin); assert.equal(config.server.host, 'localhost'); @@ -30,6 +32,9 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop assert.deepEqual(config.define, { 'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"', 'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"', + // Locale pair (i18n): threaded like the other explicit browser inputs. + 'import.meta.env.GEV_DEFAULT_LOCALE': '"fr"', + 'import.meta.env.GEV_SECONDARY_LOCALE': '"en"', }); assert.equal( createBrowserViteConfig({ host: '0.0.0.0', port: '4800' }).server