mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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, <html lang>/<html dir> 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.
This commit is contained in:
1 parent
e7707d9a0f
commit
bde1e430ef
12 files changed
+2702
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.<category> ?? 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']);
|
||||
});
|
||||
@@ -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 <html lang>.
|
||||
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, []);
|
||||
});
|
||||
@@ -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/<locale>/<ns>.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<string, string|object>} 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<string, string|object>|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<string, object>} 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;
|
||||
}
|
||||
@@ -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:<name>: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=<locale> → 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: `<html lang>` and `<html dir>`
|
||||
* 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;
|
||||
}
|
||||
@@ -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 <i>, 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 <br> 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',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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 <strong>/<small> 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}.',
|
||||
};
|
||||
@@ -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.<code>.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',
|
||||
};
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user