i18n: fix the LGTM review findings and tidy the branch texts

- i18n scanner: lastError sink, no false sinks on defaults and ternaries
- shared src/i18n/marker.js for modules that only mark strings
- wind field messages move to wind/fields.js; CITY_POIS imports from cityPois.js
- drop review IDs, change history and stale references from comments and docs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-16 21:00:28 +04:00
1 parent 0ed05568f7
commit 20f4cf3763
178 files changed
+918 -948

No files matched your search

+5 -6
View File
@@ -168,12 +168,11 @@
`t()`, `tn()` (plurals) and `msg()` in code and `data-i18n*` in markup, keyed `t()`, `tn()` (plurals) and `msg()` in code and `data-i18n*` in markup, keyed
by the English text itself; each language is one flat JSON catalog by the English text itself; each language is one flat JSON catalog
(`src/i18n/locales/ru.json`). The language comes from `?lang=`, the language (`src/i18n/locales/ru.json`). The language comes from `?lang=`, the language
switch in the Visual Presets dock, then the browser language; English stays picker (the globe button in the bottom-right corner), then the browser
the default and the fallback. `npm run i18n`, also run by `npm test`, fails on language; English stays the default and the fallback. `npm run i18n`, also
missing, unused or broken translations. Built on the i18n foundation by run by `npm test`, fails on missing, unused or broken translations. Built on
@meirm (#601), with the Russian catalog started from #312. It also fails on UI text that is the i18n foundation by @meirm (#601), with the Russian catalog started
not marked at all, so the whole interface, including surfaces added after from #312. It also fails on UI text that is not marked at all.
the original extraction, is translated (1,679 messages).
- Street Level counts unique tiles before ranking so overlapping dateline halves - Street Level counts unique tiles before ranking so overlapping dateline halves
in polar views do not waste slots in the 16-tile overview budget. in polar views do not waste slots in the 16-tile overview budget.
+7 -8
View File
@@ -3241,14 +3241,13 @@ Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives
value). English has no catalog; every other locale is value). English has no catalog; every other locale is
`src/i18n/locales/<code>.json`, and a missing entry falls back to English. `src/i18n/locales/<code>.json`, and a missing entry falls back to English.
- **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) → - **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) →
`navigator.languages` → English. `?lang=` is not stored: it applies while it `navigator.languages` → English. `?lang=` is not stored, and share links drop
stays in the address, and the copied share link drops it, so a recipient keeps it, so a recipient keeps their own language. The globe picker in the
their own language. The language picker in the bottom-right corner (a globe bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops
button beside POWER UP, `src/ui/localePicker.js`) stores the choice, removes `?lang=` (keeps it only where storage is blocked) and reloads, `?lang=` (keeps it only when storage is blocked) and reloads. It asks first
so nothing re-renders live; it asks first when the reload would lose work when the reload would lose work registered with `registerUnsavedWork`:
(unsaved CCTV calibration, the open cockpit, Draw marks on the board, unsaved keys or CCTV calibration, Draw marks, imported map files, the open
imported map files, an active voice session — each registered through cockpit, a voice session.
`registerUnsavedWork`).
- **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`) - **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`)
extracts every message and fails on missing, empty, unused, extracts every message and fails on missing, empty, unused,
placeholder-mismatched, still-English (plural forms included) or placeholder-mismatched, still-English (plural forms included) or
+8 -5
View File
@@ -23,6 +23,8 @@ const FEED_STATE_LABELS = { loading: msg('LOADING'), stale: msg('STALE') };
label.textContent = t(FEED_STATE_LABELS[state]); label.textContent = t(FEED_STATE_LABELS[state]);
``` ```
A module the server, tools or other portable code also imports must not load the catalogs: it imports `msg` from `src/i18n/marker.js`, which only marks the text, and the UI translates it with `t()` where it shows it. (Older modules keep a local `const msg = (text) => text`, which works the same.)
`msg()` exists for two reasons. `npm run i18n` finds messages only as string literals inside `t(…)`, `tn(…)` and `msg(…)`, so `t(FEED_STATE_LABELS[state])` alone tells it nothing. And a `t()` call at module load runs before the page has picked its language, so it would freeze the English. `msg()` exists for two reasons. `npm run i18n` finds messages only as string literals inside `t(…)`, `tn(…)` and `msg(…)`, so `t(FEED_STATE_LABELS[state])` alone tells it nothing. And a `t()` call at module load runs before the page has picked its language, so it would freeze the English.
## Markup ## Markup
@@ -42,7 +44,7 @@ label.textContent = t(FEED_STATE_LABELS[state]);
- **Use `tn()` for counts**, not `count === 1 ? … : …`. Russian has three forms. - **Use `tn()` for counts**, not `count === 1 ? … : …`. Russian has three forms.
- **Translate the label, not the value.** Layer ids, enums, share-link parameters, API fields, voice tool schemas and feed data (callsigns, vessel and place names) stay as they are; translate only the text shown for them. - **Translate the label, not the value.** Layer ids, enums, share-link parameters, API fields, voice tool schemas and feed data (callsigns, vessel and place names) stay as they are; translate only the text shown for them.
- **No `t()` at module top level.** Keep the English in `msg()` and call `t()` when rendering. - **No `t()` at module top level.** Keep the English in `msg()` and call `t()` when rendering.
- **Numbers and dates** go through `formatNumber`, `formatDate` and `formatRelativeTime` from `src/i18n/index.js`, which follow the active language. - **Numbers and dates** go through `formatNumber`, `formatDate`, `formatRelativeTime` and `formatUtcDayMonth` from `src/i18n/index.js`, which follow the active language.
- **Identical English means identical translation,** unless you give it a context: `t('CLEAR', { context: 'action' })` (a button) is its own catalog entry, `action|CLEAR`, while `t('CLEAR')` (the sky) stays separate. English output is the same. - **Identical English means identical translation,** unless you give it a context: `t('CLEAR', { context: 'action' })` (a button) is its own catalog entry, `action|CLEAR`, while `t('CLEAR')` (the sky) stays separate. English output is the same.
- **Text that should stay English** (a brand, a code, a unit symbol) is simply not wrapped. - **Text that should stay English** (a brand, a code, a unit symbol) is simply not wrapped.
@@ -54,18 +56,18 @@ npm run i18n -- --missing ru # the missing ru entries as JSON
npm run i18n -- --prune # drop entries whose English no longer exists npm run i18n -- --prune # drop entries whose English no longer exists
``` ```
Changing the English changes the key, so the old translation goes unused and the new text shows as missing. Add the translation to `src/i18n/locales/ru.json` (or ask a translator), prune, and run `npm test`, which runs the same check and fails until every language is complete. Changing the English changes the key: the old translation goes unused and the new text shows as missing. Add the translation to every `src/i18n/locales/<code>.json` (or ask a translator), prune, and run `npm test`; it fails until every language is complete.
To see a language, open the app with `?lang=ru`, or use the language picker (the globe button in the bottom-right corner). To see a language, open the app with `?lang=ru`, or use the language picker (the globe button in the bottom-right corner).
An empty translation is reported, and so is one that is exactly the English ("Bloom" → "Bloom"), including any plural form left as the English: it is reported as still English. When it is right on purpose, such as a code (HDG, CRT), a product name or a word spelled the same in that language, list it under the language in `src/i18n/same-as-english.json`. An empty translation is reported, and so is one left as the English, plural forms included. When English is right on purpose (a code such as CRT, a product name), list it under the language in `src/i18n/same-as-english.json`.
## Text the check reports as unmarked ## Text the check reports as unmarked
`npm run i18n` also lists UI text that is not marked at all, and fails while there is any; `npm test` runs the same check: `npm run i18n` and `npm test` fail on UI text that is not marked:
- **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`. - **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`.
- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `innerText`, `innerHTML`, `title`, `placeholder`; passed to `setAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …)`, `createTextNode`, `insertAdjacentHTML`, `showToast` (also `showToast?.(…)` and `this._showToast(…)`), `toast`, `setHint`, `announce`, `say`; assigned to a layer's `error`, `_error`, `loadingLabel`, `statusMessage` or `status` (`this._error = …`, `stats.loadingLabel = …`; a `const`/`let` local is not checked); or kept in `label:`, `title:`, `tooltip:`, `placeholder:`, `hint:`, `ariaLabel:`, `accessibilityLabel:`, `caption:`, `subtitle:`, `kicker:`, `emptyText:`, `blurb:`, `countLabel:`, `status:`, `loadingLabel:`, `statusMessage:` properties. Lower-case codes without spaces, such as `status: 'auth-failed'`, are not reported. - **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `title` and the like; passed to `setAttribute('title' | 'aria-label' | …)`, `createTextNode`, `showToast` and other notice helpers; assigned to a layer's `error`, `lastError`, `loadingLabel`, `statusMessage` or `status` (a plain `error = …` too); or kept in `label:`, `title:`, `status:` and similar properties. Not reported: declarations (`let status = …`), default values, ternary branches and lower-case codes (`'auth-failed'`). The full list is `JS_SINKS` in `scripts/i18n.mjs`.
Fix it by marking the text. When the text must stay as it is, say so where it lives: Fix it by marking the text. When the text must stay as it is, say so where it lives:
@@ -82,6 +84,7 @@ Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowerca
| File | What it holds | | File | What it holds |
| ------------------------------- | ------------------------------------------------------------------------------ | | ------------------------------- | ------------------------------------------------------------------------------ |
| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language | | `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language |
| `src/i18n/marker.js` | `msg` alone, for modules the server or tools import (no catalogs) |
| `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot | | `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot |
| `src/i18n/locales/<code>.json` | one catalog per language, keyed by English text | | `src/i18n/locales/<code>.json` | one catalog per language, keyed by English text |
| `src/i18n/same-as-english.json` | per language, the entries that stay as in English on purpose | | `src/i18n/same-as-english.json` | per language, the entries that stay as in English on purpose |
+1 -1
View File
@@ -32,7 +32,7 @@ npm run i18n -- --missing ru # missing ru entries as JSON, ready to transla
npm run i18n -- --prune # drop entries the code no longer uses npm run i18n -- --prune # drop entries the code no longer uses
``` ```
`npm test` runs the same check (`src/i18n/catalog.test.mjs`). It fails when a translation is missing or empty, when an entry is no longer used, when placeholders differ from the English, when a plural entry lacks a form, or when a translation, or any form of a plural entry, is still the English text. The `--missing` template is not a translation: its strings are empty and its plural forms are the English, so the check fails until every one is translated. If an entry is right as it is in English (a code such as HDG, a product name, a word spelled the same in your language), add it under your language code in `src/i18n/same-as-english.json`. `npm test` runs the same check. It fails on a missing, empty or unused entry, on placeholders that differ from the English, on a missing plural form, and on text or plural forms still in English. The `--missing` template fails until every entry is translated. If an entry is right in English (a code such as CRT, a product name), add it under your language code in `src/i18n/same-as-english.json`.
Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune. Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune.
+28 -10
View File
@@ -4,9 +4,8 @@
// npm run i18n -- --prune also drop entries the code no longer uses // npm run i18n -- --prune also drop entries the code no longer uses
// npm run i18n -- --missing ru print what ru lacks, as JSON to translate // npm run i18n -- --missing ru print what ru lacks, as JSON to translate
// //
// A translation identical to the English is reported too, unless it is // A translation left as the English fails unless src/i18n/same-as-english.json
// listed for that locale in src/i18n/same-as-english.json (codes, product // lists it.
// names, words spelled the same in both languages).
// //
// Messages are the English literals passed to t(), tn() and msg() in src/, // Messages are the English literals passed to t(), tn() and msg() in src/,
// plus the markup opted in with data-i18n* in src/ui/templates and index.html. // plus the markup opted in with data-i18n* in src/ui/templates and index.html.
@@ -220,12 +219,30 @@ const JS_SINKS = [
// Also showToast?.(…) and this._showToast(…). // Also showToast?.(…) and this._showToast(…).
/\b_?(?:showToast|toast|setHint|announce|say)(?:\?\.)?\(\s*/g, /\b_?(?:showToast|toast|setHint|announce|say)(?:\?\.)?\(\s*/g,
// `status` values are often codes ('ok', 'auth-failed'); visibleText drops // `status` values are often codes ('ok', 'auth-failed'); visibleText drops
// those, so only readable text is reported. // those, so only readable text is reported. A name right after `?` is a
/(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|accessibilityLabel|caption|subtitle|kicker|emptyText|blurb|countLabel|status|loadingLabel|statusMessage)\s*:\s*/gm, // ternary branch (`ok ? status : 'Fallback'`), not a property.
// Layer status set by assignment: this._error = …, stats.loadingLabel = …. /(?:^|[{,\s])(?<!\?\s*)(?:label|title|tooltip|placeholder|hint|ariaLabel|accessibilityLabel|caption|subtitle|kicker|emptyText|blurb|countLabel|status|loadingLabel|statusMessage)\s*:\s*/gm,
// A declared local (`const error = …`) is scratch, not a status field.
/(?<![\w$])(?<!\b(?:const|let|var)\s+)_?(?:error|loadingLabel|statusMessage|status)\s*(?:\?\?|\|\|)?=(?![=>])\s*/g,
]; ];
// Layer status set by assignment: this._error = …, stats.lastError = …,
// error = … (a layer factory keeps its status in `let error`). Not checked:
// the declaration itself (`const error = …`, `let status = …`) and a default
// value in a parameter list or destructuring (see defaultValue).
const ASSIGNMENT_SINK =
/(?<![\w$])(?<!\b(?:const|let|var)\s+)_?(?:error|lastError|loadingLabel|statusMessage|status)\s*(?:\?\?|\|\|)?=(?![=>])\s*/g;
JS_SINKS.push(ASSIGNMENT_SINK);
/**
* Whether `name = value` at `nameStart` is a default (`(status = 'idle') =>`,
* `const { error = '…' } = options`): it follows `(`, `[`, `{` or `,` and its
* value ends at `,` or a closing bracket. An assignment statement ends at `;`.
*/
function defaultValue(source, nameStart, valueStart) {
const before = source.slice(0, nameStart).trimEnd().at(-1);
if (!before || !'([{,'.includes(before)) return false;
const end = valueStart + expressionAt(source, valueStart).length;
const after = source.slice(end).trimStart()[0];
return Boolean(after) && ',)]}'.includes(after);
}
const MARKERS = /\b(?:t|tn|msg)\(/y; const MARKERS = /\b(?:t|tn|msg)\(/y;
/** The expression starting at `start`, up to its end at bracket depth 0. */ /** The expression starting at `start`, up to its end at bracket depth 0. */
@@ -364,6 +381,8 @@ export function unmarkedScript(file, original) {
const start = match.index + match[0].length; const start = match.index + match[0].length;
const line = lineAt(source, start); const line = lineAt(source, start);
if (ignored(line)) continue; if (ignored(line)) continue;
if (sink === ASSIGNMENT_SINK && defaultValue(source, match.index, start))
continue;
const html = /HTML/.test(match[0]); const html = /HTML/.test(match[0]);
for (const literal of valueLiterals(expressionAt(source, start))) { for (const literal of valueLiterals(expressionAt(source, start))) {
const text = visibleText(literal, html); const text = visibleText(literal, html);
@@ -377,8 +396,7 @@ export function unmarkedScript(file, original) {
/** /**
* UI text that is not marked for translation: markup without data-i18n* and * UI text that is not marked for translation: markup without data-i18n* and
* string literals that reach the page without t(), tn() or msg(). * string literals that reach the page without t(), tn() or msg().
* Opt out with translate="no" in markup or an `// i18n-ignore` comment on * Opt-outs: see unmarkedMarkup; in code, `// i18n-ignore` on or above the line.
* (or just above) the line in code.
* @returns {{where: string, text: string}[]} * @returns {{where: string, text: string}[]}
*/ */
export function findUnmarked() { export function findUnmarked() {
+70 -13
View File
@@ -53,7 +53,8 @@
"src/data/adsbLolFallback.js", "src/data/adsbLolFallback.js",
"src/data/aisStreamAdapter.js", "src/data/aisStreamAdapter.js",
"src/data/aisWatchdog.js", "src/data/aisWatchdog.js",
"src/sources/httpBody.js" "src/sources/httpBody.js",
"src/i18n/marker.js"
], ],
"external": ["ws"] "external": ["ws"]
}, },
@@ -79,6 +80,7 @@
"src/localRequestGate.mjs", "src/localRequestGate.mjs",
"src/data/routeSteps.js", "src/data/routeSteps.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/rateLimit.js" "src/sources/rateLimit.js"
], ],
"external": [] "external": []
@@ -97,7 +99,8 @@
"server/providers/space/launch-library.js", "server/providers/space/launch-library.js",
"server/providers/common/http.js", "server/providers/common/http.js",
"src/data/spaceProviderRequests.js", "src/data/spaceProviderRequests.js",
"src/sources/httpBody.js" "src/sources/httpBody.js",
"src/i18n/marker.js"
], ],
"external": [] "external": []
}, },
@@ -127,6 +130,7 @@
"server/providers/traffic.js", "server/providers/traffic.js",
"server/providers/common/http.js", "server/providers/common/http.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/data/tomtomTiles.js" "src/data/tomtomTiles.js"
], ],
"external": [] "external": []
@@ -148,6 +152,7 @@
"server/providers/firms.js", "server/providers/firms.js",
"server/providers/common/http.js", "server/providers/common/http.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/data/firmsCsv.js" "src/data/firmsCsv.js"
], ],
"external": [] "external": []
@@ -161,7 +166,8 @@
"server/providers/common/rate-limit.js", "server/providers/common/rate-limit.js",
"src/sources/rateLimit.js", "src/sources/rateLimit.js",
"src/data/gbfsSource.js", "src/data/gbfsSource.js",
"src/sources/httpBody.js" "src/sources/httpBody.js",
"src/i18n/marker.js"
], ],
"external": [] "external": []
}, },
@@ -192,7 +198,8 @@
"scripts/google-server-key.mjs", "scripts/google-server-key.mjs",
"src/data/cctvFootprint.js", "src/data/cctvFootprint.js",
"src/sources/cctvTypes.js", "src/sources/cctvTypes.js",
"src/sources/httpBody.js" "src/sources/httpBody.js",
"src/i18n/marker.js"
], ],
"external": [] "external": []
}, },
@@ -210,7 +217,8 @@
"server/providers/common/rate-limit.js", "server/providers/common/rate-limit.js",
"src/sources/rateLimit.js", "src/sources/rateLimit.js",
"server/providers/common/http.js", "server/providers/common/http.js",
"src/sources/httpBody.js" "src/sources/httpBody.js",
"src/i18n/marker.js"
], ],
"external": ["pbf"] "external": ["pbf"]
}, },
@@ -226,7 +234,8 @@
"src/data/radioCountry.js", "src/data/radioCountry.js",
"server/providers/common/http.js", "server/providers/common/http.js",
"src/sources/radioBrowser.js", "src/sources/radioBrowser.js",
"src/sources/httpBody.js" "src/sources/httpBody.js",
"src/i18n/marker.js"
], ],
"external": [] "external": []
}, },
@@ -248,6 +257,7 @@
"src/data/placeProviderPayloads.js", "src/data/placeProviderPayloads.js",
"src/data/routeSteps.js", "src/data/routeSteps.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/overpass.js", "src/sources/overpass.js",
"src/sources/rateLimit.js" "src/sources/rateLimit.js"
], ],
@@ -268,6 +278,7 @@
"server/providers/overpass/geometry.js", "server/providers/overpass/geometry.js",
"server/providers/overpass/transport.js", "server/providers/overpass/transport.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/rateLimit.js" "src/sources/rateLimit.js"
], ],
"external": [] "external": []
@@ -290,6 +301,7 @@
"src/data/regionalModel.js", "src/data/regionalModel.js",
"src/nominatimGeocode.js", "src/nominatimGeocode.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/rateLimit.js", "src/sources/rateLimit.js",
"src/data/local_data/natural_earth/marine.json", "src/data/local_data/natural_earth/marine.json",
"src/data/local_data/natural_earth/regions.json", "src/data/local_data/natural_earth/regions.json",
@@ -303,6 +315,7 @@
"runtime": "node", "runtime": "node",
"exports": ["./server/providers/openai"], "exports": ["./server/providers/openai"],
"modules": [ "modules": [
"src/i18n/marker.js",
"src/hudLayoutPolicy.js", "src/hudLayoutPolicy.js",
"server/providers/common/rate-limit.js", "server/providers/common/rate-limit.js",
"server/providers/common/request.js", "server/providers/common/request.js",
@@ -335,6 +348,7 @@
"runtime": "node", "runtime": "node",
"exports": ["./server/standalone/key-setup"], "exports": ["./server/standalone/key-setup"],
"modules": [ "modules": [
"src/i18n/marker.js",
"scripts/pinokio-environment.mjs", "scripts/pinokio-environment.mjs",
"server/providers/common/source-root.js", "server/providers/common/source-root.js",
"server/standalone/key-setup.js", "server/standalone/key-setup.js",
@@ -367,6 +381,7 @@
"src/data/local_data/natural_earth/regions.json", "src/data/local_data/natural_earth/regions.json",
"src/data/local_data/natural_earth/marine.json", "src/data/local_data/natural_earth/marine.json",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/nominatim.js" "src/sources/nominatim.js"
], ],
"external": [] "external": []
@@ -435,6 +450,7 @@
"map-source-controls": { "map-source-controls": {
"exports": ["./ui/maps"], "exports": ["./ui/maps"],
"modules": [ "modules": [
"src/i18n/marker.js",
"src/ui/mapSource.js", "src/ui/mapSource.js",
"src/ui/mapSourceControls.js", "src/ui/mapSourceControls.js",
"src/mapStackChips.js", "src/mapStackChips.js",
@@ -450,6 +466,7 @@
"layer-panel": { "layer-panel": {
"exports": ["./ui/layers", "./ui/layers/feedback"], "exports": ["./ui/layers", "./ui/layers/feedback"],
"modules": [ "modules": [
"src/i18n/marker.js",
"src/ui/layers.js", "src/ui/layers.js",
"src/ui/layerPanel.js", "src/ui/layerPanel.js",
"src/ui/layerNames.js", "src/ui/layerNames.js",
@@ -528,6 +545,7 @@
"context-controls": { "context-controls": {
"exports": ["./ui/context", "./ui/context/policy"], "exports": ["./ui/context", "./ui/context/policy"],
"modules": [ "modules": [
"src/i18n/marker.js",
"src/ui/context.js", "src/ui/context.js",
"src/ui/contextActions.js", "src/ui/contextActions.js",
"src/ui/contextBindings.js", "src/ui/contextBindings.js",
@@ -598,6 +616,7 @@
"ui-shell": { "ui-shell": {
"exports": ["./ui/shell"], "exports": ["./ui/shell"],
"modules": [ "modules": [
"src/i18n/marker.js",
"src/ui/placeNames.js", "src/ui/placeNames.js",
"src/hudLayouts.js", "src/hudLayouts.js",
"src/hudLayoutPolicy.js", "src/hudLayoutPolicy.js",
@@ -794,6 +813,7 @@
"src/director/interactions/session.js", "src/director/interactions/session.js",
"src/director/sharing/bundle.js", "src/director/sharing/bundle.js",
"src/director/sharing/preview.js", "src/director/sharing/preview.js",
"src/i18n/marker.js",
"src/director/sharing/lifetime.js", "src/director/sharing/lifetime.js",
"src/director/authoring.js" "src/director/authoring.js"
], ],
@@ -844,6 +864,7 @@
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/director/sharing/bundle.js", "src/director/sharing/bundle.js",
"src/director/sharing/preview.js", "src/director/sharing/preview.js",
"src/i18n/marker.js",
"src/director/sharing/lifetime.js", "src/director/sharing/lifetime.js",
"src/director/authoring.js", "src/director/authoring.js",
"src/scenes/sharing.js", "src/scenes/sharing.js",
@@ -860,6 +881,7 @@
"live-observations": { "live-observations": {
"exports": ["./sources/live"], "exports": ["./sources/live"],
"modules": [ "modules": [
"src/i18n/marker.js",
"src/data/aircraftEmergency.js", "src/data/aircraftEmergency.js",
"src/sources/live/aircraft.js", "src/sources/live/aircraft.js",
"src/sources/live/contract.js", "src/sources/live/contract.js",
@@ -872,6 +894,7 @@
"civil-flights": { "civil-flights": {
"exports": ["./layers/flights"], "exports": ["./layers/flights"],
"modules": [ "modules": [
"src/i18n/marker.js",
"src/cyberSonar.js", "src/cyberSonar.js",
"src/data/aircraftClass.js", "src/data/aircraftClass.js",
"src/data/aircraftEmergency.js", "src/data/aircraftEmergency.js",
@@ -921,6 +944,7 @@
"military-flights": { "military-flights": {
"exports": ["./layers/military"], "exports": ["./layers/military"],
"modules": [ "modules": [
"src/i18n/marker.js",
"src/cyberSonar.js", "src/cyberSonar.js",
"src/data/aircraftClass.js", "src/data/aircraftClass.js",
"src/data/aircraftEmergency.js", "src/data/aircraftEmergency.js",
@@ -965,6 +989,7 @@
"aircraft-mechanics": { "aircraft-mechanics": {
"exports": ["./aircraft"], "exports": ["./aircraft"],
"modules": [ "modules": [
"src/i18n/marker.js",
"src/data/aircraftClass.js", "src/data/aircraftClass.js",
"src/data/aircraftIcons.js", "src/data/aircraftIcons.js",
"src/data/aircraftMeta.js", "src/data/aircraftMeta.js",
@@ -1032,6 +1057,7 @@
"recent-imagery": { "recent-imagery": {
"exports": ["./layers/recent-imagery"], "exports": ["./layers/recent-imagery"],
"modules": [ "modules": [
"src/i18n/marker.js",
"src/layers/recentImagery/index.js", "src/layers/recentImagery/index.js",
"src/layers/recentImagery/source.js", "src/layers/recentImagery/source.js",
"src/layers/recentImagery/model.js", "src/layers/recentImagery/model.js",
@@ -1159,6 +1185,7 @@
"src/data/tomtomTiles.js", "src/data/tomtomTiles.js",
"src/sources/capability.js", "src/sources/capability.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/openFreeMap.js", "src/sources/openFreeMap.js",
"src/sources/militaryTileGeometry.js", "src/sources/militaryTileGeometry.js",
"src/sources/vectorTiles.js", "src/sources/vectorTiles.js",
@@ -1181,6 +1208,7 @@
"awareness-layer": { "awareness-layer": {
"exports": ["./layers/awareness"], "exports": ["./layers/awareness"],
"modules": [ "modules": [
"src/i18n/marker.js",
"src/cyberSonar.js", "src/cyberSonar.js",
"src/cockpitMath.js", "src/cockpitMath.js",
"src/data/iconOrientation.js", "src/data/iconOrientation.js",
@@ -1244,6 +1272,7 @@
"src/sources/overpassRoads.js", "src/sources/overpassRoads.js",
"src/sources/capability.js", "src/sources/capability.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/openFreeMap.js", "src/sources/openFreeMap.js",
"src/data/installationNames.js", "src/data/installationNames.js",
"src/sources/militaryTileGeometry.js", "src/sources/militaryTileGeometry.js",
@@ -1411,6 +1440,7 @@
"src/layers/alpr/tileRecords.js", "src/layers/alpr/tileRecords.js",
"src/sources/capability.js", "src/sources/capability.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/vectorTiles.js", "src/sources/vectorTiles.js",
"src/sources/phaseTiming.js", "src/sources/phaseTiming.js",
"src/layers/alpr/floors.js", "src/layers/alpr/floors.js",
@@ -1513,6 +1543,7 @@
"./maps/defaults" "./maps/defaults"
], ],
"modules": [ "modules": [
"src/i18n/marker.js",
"src/maps/availability.js", "src/maps/availability.js",
"src/maps/catalog.js", "src/maps/catalog.js",
"src/maps/controller.js", "src/maps/controller.js",
@@ -2237,6 +2268,7 @@
"src/search/nominatim.js", "src/search/nominatim.js",
"src/nominatimGeocode.js", "src/nominatimGeocode.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/nominatim.js", "src/sources/nominatim.js",
"src/app/layers/transit.js", "src/app/layers/transit.js",
"src/data/transit.js", "src/data/transit.js",
@@ -2863,6 +2895,7 @@
"src/nominatimGeocode.js", "src/nominatimGeocode.js",
"src/data/regionalModel.js", "src/data/regionalModel.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/nominatim.js", "src/sources/nominatim.js",
"src/app/layers/transit.js", "src/app/layers/transit.js",
"src/data/transitFeeds.js", "src/data/transitFeeds.js",
@@ -3012,9 +3045,7 @@
"src/layers/airQuality/model.js", "src/layers/airQuality/model.js",
"src/layers/airQuality/source.js", "src/layers/airQuality/source.js",
"src/ui/statusText.js", "src/ui/statusText.js",
"src/ui/layerNames.js", "src/ui/layerNames.js"
"src/layers/wind/source.js",
"src/sources/windBody.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -3074,6 +3105,7 @@
"src/nominatimGeocode.js", "src/nominatimGeocode.js",
"src/data/regionalModel.js", "src/data/regionalModel.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/nominatim.js", "src/sources/nominatim.js",
"src/sources/featureGeometry.js", "src/sources/featureGeometry.js",
"src/sources/overpassFeaturesRecords.js", "src/sources/overpassFeaturesRecords.js",
@@ -3114,6 +3146,7 @@
"ui-share-restoration": { "ui-share-restoration": {
"exports": ["./ui/share-restoration"], "exports": ["./ui/share-restoration"],
"modules": [ "modules": [
"src/i18n/marker.js",
"src/data/installationFeedback.js", "src/data/installationFeedback.js",
"src/data/idSwitches.js", "src/data/idSwitches.js",
"src/data/layerState.js", "src/data/layerState.js",
@@ -3189,6 +3222,7 @@
"civil-flight-records": { "civil-flight-records": {
"exports": ["./layers/flights/records"], "exports": ["./layers/flights/records"],
"modules": [ "modules": [
"src/i18n/marker.js",
"src/layers/flights/records.js", "src/layers/flights/records.js",
"src/layers/flights/recordPolicy.js", "src/layers/flights/recordPolicy.js",
"src/data/aircraftMeta.js", "src/data/aircraftMeta.js",
@@ -3209,6 +3243,7 @@
"military-flight-records": { "military-flight-records": {
"exports": ["./layers/military/records"], "exports": ["./layers/military/records"],
"modules": [ "modules": [
"src/i18n/marker.js",
"src/layers/military/records.js", "src/layers/military/records.js",
"src/layers/military/recordPolicy.js", "src/layers/military/recordPolicy.js",
"src/data/aircraftMeta.js", "src/data/aircraftMeta.js",
@@ -3260,6 +3295,7 @@
"directions-layer": { "directions-layer": {
"exports": ["./layers/directions"], "exports": ["./layers/directions"],
"modules": [ "modules": [
"src/i18n/marker.js",
"src/layers/directions/index.js", "src/layers/directions/index.js",
"src/data/pointerOwnerNames.js", "src/data/pointerOwnerNames.js",
"src/layers/directions/source.js", "src/layers/directions/source.js",
@@ -3335,6 +3371,7 @@
"src/data/tomtomTiles.js", "src/data/tomtomTiles.js",
"src/layers/alpr/tileRecords.js", "src/layers/alpr/tileRecords.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/vectorTiles.js", "src/sources/vectorTiles.js",
"src/sources/phaseTiming.js" "src/sources/phaseTiming.js"
], ],
@@ -3372,6 +3409,7 @@
"src/data/tomtomTiles.js", "src/data/tomtomTiles.js",
"src/sources/capability.js", "src/sources/capability.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/openFreeMap.js", "src/sources/openFreeMap.js",
"src/sources/militaryTileGeometry.js", "src/sources/militaryTileGeometry.js",
"src/sources/vectorTiles.js", "src/sources/vectorTiles.js",
@@ -3409,6 +3447,7 @@
"src/layers/traffic/source.js", "src/layers/traffic/source.js",
"src/sources/overpassRoads.js", "src/sources/overpassRoads.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/openFreeMap.js", "src/sources/openFreeMap.js",
"src/data/installationNames.js", "src/data/installationNames.js",
"src/sources/militaryTileGeometry.js", "src/sources/militaryTileGeometry.js",
@@ -3426,6 +3465,7 @@
"src/layers/earthquakes/source.js", "src/layers/earthquakes/source.js",
"src/layers/earthquakes/records.js", "src/layers/earthquakes/records.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/layers/perimeters/source.js", "src/layers/perimeters/source.js",
"src/layers/perimeters/records.js", "src/layers/perimeters/records.js",
"src/layers/submarineCables/bundledSource.js", "src/layers/submarineCables/bundledSource.js",
@@ -3444,13 +3484,14 @@
"src/search/geospatial.js", "src/search/geospatial.js",
"src/data/regionalModel.js", "src/data/regionalModel.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/nominatim.js" "src/sources/nominatim.js"
], ],
"external": [] "external": []
}, },
"source-http-body": { "source-http-body": {
"exports": ["./sources/http-body"], "exports": ["./sources/http-body"],
"modules": ["src/sources/httpBody.js"], "modules": ["src/sources/httpBody.js", "src/i18n/marker.js"],
"external": [] "external": []
}, },
"overpass-lexer": { "overpass-lexer": {
@@ -3465,7 +3506,8 @@
"src/nominatimGeocode.js", "src/nominatimGeocode.js",
"src/search/geospatial.js", "src/search/geospatial.js",
"src/data/regionalModel.js", "src/data/regionalModel.js",
"src/sources/httpBody.js" "src/sources/httpBody.js",
"src/i18n/marker.js"
], ],
"external": [] "external": []
}, },
@@ -3481,6 +3523,7 @@
"src/data/transitFeeds.js", "src/data/transitFeeds.js",
"src/data/transitProxy.js", "src/data/transitProxy.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/transitHistory.js", "src/sources/transitHistory.js",
"src/sources/transitService.js", "src/sources/transitService.js",
"src/sources/transitHistoryStore.js", "src/sources/transitHistoryStore.js",
@@ -3620,6 +3663,7 @@
"src/data/transitProxy.js", "src/data/transitProxy.js",
"src/data/gtfsRealtime.js", "src/data/gtfsRealtime.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/transitHistory.js", "src/sources/transitHistory.js",
"src/sources/transitService.js", "src/sources/transitService.js",
"src/sources/transitHistoryStore.js", "src/sources/transitHistoryStore.js",
@@ -3643,6 +3687,7 @@
"modules": [ "modules": [
"src/layers/perimeters/index.js", "src/layers/perimeters/index.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/layers/perimeters/source.js", "src/layers/perimeters/source.js",
"src/layers/perimeters/records.js", "src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js", "src/layers/perimeters/cards.js",
@@ -3659,6 +3704,7 @@
"modules": [ "modules": [
"src/layers/gnss/index.js", "src/layers/gnss/index.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/layers/gnss/source.js", "src/layers/gnss/source.js",
"src/layers/gnss/records.js", "src/layers/gnss/records.js",
"src/i18n/index.js", "src/i18n/index.js",
@@ -3672,7 +3718,11 @@
}, },
"gnss-source": { "gnss-source": {
"exports": ["./layers/gnss/source"], "exports": ["./layers/gnss/source"],
"modules": ["src/sources/httpBody.js", "src/layers/gnss/source.js"], "modules": [
"src/sources/httpBody.js",
"src/i18n/marker.js",
"src/layers/gnss/source.js"
],
"external": [] "external": []
}, },
"perimeters-source": { "perimeters-source": {
@@ -3680,6 +3730,7 @@
"modules": [ "modules": [
"src/layers/perimeters/records.js", "src/layers/perimeters/records.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/layers/perimeters/source.js" "src/layers/perimeters/source.js"
], ],
"external": [] "external": []
@@ -3713,6 +3764,7 @@
"src/layers/wind/inspection.js", "src/layers/wind/inspection.js",
"src/layers/wind/model.js", "src/layers/wind/model.js",
"src/layers/wind/fields.js", "src/layers/wind/fields.js",
"src/i18n/marker.js",
"src/tools/queries/imagery.js", "src/tools/queries/imagery.js",
"src/tools/queries/cables.js", "src/tools/queries/cables.js",
"src/tools/queries/surveillance.js", "src/tools/queries/surveillance.js",
@@ -3766,6 +3818,7 @@
"src/layers/cctv/sourcePolicy.js", "src/layers/cctv/sourcePolicy.js",
"src/layers/cyclones/source.js", "src/layers/cyclones/source.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/layers/earthquakes/source.js", "src/layers/earthquakes/source.js",
"src/layers/earthquakes/records.js", "src/layers/earthquakes/records.js",
"src/layers/firms/source.js", "src/layers/firms/source.js",
@@ -3812,6 +3865,8 @@
"src/layers/weather/source.js", "src/layers/weather/source.js",
"src/layers/wind/source.js", "src/layers/wind/source.js",
"src/sources/windBody.js", "src/sources/windBody.js",
"src/layers/wind/fields.js",
"src/layers/wind/model.js",
"src/layers/recentImagery/catalog.js", "src/layers/recentImagery/catalog.js",
"src/layers/recentImagery/model.js", "src/layers/recentImagery/model.js",
"src/layers/submarineCables/bundledSource.js", "src/layers/submarineCables/bundledSource.js",
@@ -3841,6 +3896,7 @@
"src/layers/airQuality/model.js", "src/layers/airQuality/model.js",
"src/layers/airQuality/source.js", "src/layers/airQuality/source.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
@@ -3855,7 +3911,8 @@
"modules": [ "modules": [
"src/layers/airQuality/model.js", "src/layers/airQuality/model.js",
"src/layers/airQuality/source.js", "src/layers/airQuality/source.js",
"src/sources/httpBody.js" "src/sources/httpBody.js",
"src/i18n/marker.js"
], ],
"external": [] "external": []
} }
+4 -6
View File
@@ -3,12 +3,10 @@
* qa-scene-dialog-file-drop — the SHARE SCENE dialog's file control accepts a * qa-scene-dialog-file-drop — the SHARE SCENE dialog's file control accepts a
* file dropped from the desktop, proved in a real browser. * file dropped from the desktop, proved in a real browser.
* *
* The dialog replaces the browser-drawn "Choose files / No file chosen" line * The dialog draws a translated row over the native file input
* with a translated one (src/ui/sceneSharing.js, fileChooser). The native * (src/ui/sceneSharing.js, fileChooser). The input must still cover the row,
* input must stay the whole control underneath it: a shrunken 1 px input * or drops miss it. Drops a file on the row's centre via CDP and expects
* accepts drops only on its own pixel, so dragging a data-pack file onto the * `input.files` to hold it.
* visible row selected nothing. This harness drops a file onto the centre of
* the visible row through CDP and expects `input.files` to hold it.
* *
* Usage: * Usage:
* node scripts/qa-scene-dialog-file-drop.mjs * node scripts/qa-scene-dialog-file-drop.mjs
+1 -2
View File
@@ -134,8 +134,7 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
}; };
// The gate itself is pure and unit-tested (admitKeySetupRequest in // The gate itself is pure and unit-tested (admitKeySetupRequest in
// src/keySetupCore.mjs) — this just feeds it the request. // src/keySetupCore.mjs) — this just feeds it the request.
// A refusal as the panel reads it: `code` (and `params`) pick its localized // `code`/`params` pick the panel's sentence (src/keySetup.js); `error` is for logs.
// sentence in src/keySetup.js; `error` is the English for logs and scripts.
const refusal = ({ error, code, params }) => ({ error, code, params }); const refusal = ({ error, code, params }) => ({ error, code, params });
const admit = (req) => const admit = (req) =>
admitKeySetupRequest({ admitKeySetupRequest({
+9 -9
View File
@@ -1137,14 +1137,14 @@ export function createAnnotationEngine({
{ {
type: 'highlight', type: 'highlight',
target: 'Palace of Fine Arts, San Francisco', target: 'Palace of Fine Arts, San Francisco',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated // i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'Palace of Fine Arts', label: 'Palace of Fine Arts',
color: 'amber', color: 'amber',
}, },
{ {
type: 'area', type: 'area',
target: 'Presidio of San Francisco', target: 'Presidio of San Francisco',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated // i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'The Presidio (former Army base)', label: 'The Presidio (former Army base)',
color: 'green', color: 'green',
footprint: true, footprint: true,
@@ -1152,7 +1152,7 @@ export function createAnnotationEngine({
{ {
type: 'pin', type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco', target: 'Letterman Digital Arts Center, San Francisco',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated // i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'ILM / Lucasfilm', label: 'ILM / Lucasfilm',
color: 'cyan', color: 'cyan',
}, },
@@ -1160,7 +1160,7 @@ export function createAnnotationEngine({
type: 'arrow', type: 'arrow',
target: 'Palace of Fine Arts, San Francisco', target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina District, San Francisco', toTarget: 'Marina District, San Francisco',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated // i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'next to the Marina', label: 'next to the Marina',
}, },
], ],
@@ -1190,7 +1190,7 @@ export function createAnnotationEngine({
{ {
type: 'highlight', type: 'highlight',
target: 'Palace of Fine Arts, San Francisco', target: 'Palace of Fine Arts, San Francisco',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated // i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'Palace of Fine Arts', label: 'Palace of Fine Arts',
color: 'amber', color: 'amber',
}, },
@@ -1205,7 +1205,7 @@ export function createAnnotationEngine({
type: 'arrow', type: 'arrow',
target: 'Palace of Fine Arts, San Francisco', target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina Green, San Francisco', toTarget: 'Marina Green, San Francisco',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated // i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'next to the Marina', label: 'next to the Marina',
color: 'cyan', color: 'cyan',
}, },
@@ -1229,7 +1229,7 @@ export function createAnnotationEngine({
{ {
type: 'area', type: 'area',
target: 'Presidio of San Francisco', target: 'Presidio of San Francisco',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated // i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'The Presidio — a former Army base', label: 'The Presidio — a former Army base',
color: 'green', color: 'green',
footprint: true, footprint: true,
@@ -1244,7 +1244,7 @@ export function createAnnotationEngine({
{ {
type: 'pin', type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco', target: 'Letterman Digital Arts Center, San Francisco',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated // i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'ILM / Lucasfilm', label: 'ILM / Lucasfilm',
color: 'red', color: 'red',
}, },
@@ -1258,7 +1258,7 @@ export function createAnnotationEngine({
{ {
type: 'route', type: 'route',
color: 'amber', color: 'amber',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated // i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'Crissy Field shoreline', label: 'Crissy Field shoreline',
points: [ points: [
{ target: 'Palace of Fine Arts, San Francisco' }, { target: 'Palace of Fine Arts, San Francisco' },
+2 -3
View File
@@ -241,8 +241,7 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
/* ── the tests ─────────────────────────────────────────────────────────── */ /* ── the tests ─────────────────────────────────────────────────────────── */
// K-27. First in the file: the tools other tests leave behind would answer the // First in the file: tools left by later tests would answer the same registry.
// same registry.
test('a lasting mark or a half-drawn shape makes the language switch ask first', async () => { test('a lasting mark or a half-drawn shape makes the language switch ask first', async () => {
const h = harness(); const h = harness();
try { try {
@@ -288,7 +287,7 @@ test('Draw refuses to start when something else holds the pointer', async () =>
h.element('draw-toggle').emit('click'); h.element('draw-toggle').emit('click');
assert.equal(h.tool.active, false, 'it must not half-start'); assert.equal(h.tool.active, false, 'it must not half-start');
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim'); assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
// Named by its tool, not by the internal owner id (E3/E12). // Named by its tool, not by the internal owner id.
assert.equal( assert.equal(
h.element('draw-hint').textContent, h.element('draw-hint').textContent,
'Directions is using the pointer — close it first.', 'Directions is using the pointer — close it first.',
+5 -7
View File
@@ -541,7 +541,7 @@ export function panelRuntime(config) {
const notice = document.createElement('div'); const notice = document.createElement('div');
notice.id = 'status'; notice.id = 'status';
notice.textContent = notice.textContent =
// i18n-ignore: panel runtime runs as source text in the host page, with no i18n module or app locale // i18n-ignore: host-panel script, no app catalogs
"This panel lost its 3D graphics, likely because other God's Eye " + "This panel lost its 3D graphics, likely because other God's Eye " +
"View panels in this conversation hold them. Use Open in God's " + "View panels in this conversation hold them. Use Open in God's " +
'Eye View above, or show it in a new conversation.'; 'Eye View above, or show it in a new conversation.';
@@ -569,20 +569,18 @@ export function panelRuntime(config) {
queued = view; queued = view;
if (started) return; if (started) return;
started = true; started = true;
// i18n-ignore: this script runs as source text in the host panel, without the app catalogs // i18n-ignore: host-panel script, no app catalogs
say("Loading God's Eye View…"); say("Loading God's Eye View…");
startApp(url, view).catch((error) => startApp(url, view).catch((error) =>
// i18n-ignore: this script runs as source text in the host panel, without the app catalogs // i18n-ignore: host-panel script, no app catalogs
say(`God's Eye View could not load here: ${error?.message || error}.`), say(`God's Eye View could not load here: ${error?.message || error}.`),
); );
setTimeout(() => { setTimeout(() => {
// i18n-ignore: this script runs as source text in the host panel
if (!ready) if (!ready)
say( say(
// i18n-ignore: see above // i18n-ignore: see above
"God's Eye View did not load here" + "God's Eye View did not load here" +
(blocked ? ` (${blocked})` : '') + (blocked ? ` (${blocked})` : '') +
// i18n-ignore: see above
". Use Open in God's Eye View above.", ". Use Open in God's Eye View above.",
); );
}, config.loadTimeoutMs); }, config.loadTimeoutMs);
@@ -622,7 +620,7 @@ export function panelRuntime(config) {
if (context?.displayMode) displayMode = context.displayMode; if (context?.displayMode) displayMode = context.displayMode;
if (context?.availableDisplayModes) if (context?.availableDisplayModes)
expand.hidden = !context.availableDisplayModes.includes('fullscreen'); expand.hidden = !context.availableDisplayModes.includes('fullscreen');
// i18n-ignore: panel runtime runs as source text in the host page, with no i18n module or app locale // i18n-ignore: host-panel script, no app catalogs
expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand'; expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand';
}; };
expand.addEventListener('click', () => { expand.addEventListener('click', () => {
@@ -663,7 +661,7 @@ export function panelRuntime(config) {
}, },
(error) => (error) =>
say( say(
// i18n-ignore: this script runs as source text in the host panel, without the app catalogs // i18n-ignore: host-panel script, no app catalogs
"This client did not accept the God's Eye View panel" + "This client did not accept the God's Eye View panel" +
(error?.message ? `: ${error.message}` : '.'), (error?.message ? `: ${error.message}` : '.'),
), ),
+2 -2
View File
@@ -3,7 +3,7 @@ import * as credits from '../../data/dataCredits.js';
import * as sprites from '../../data/spriteOrder.js'; import * as sprites from '../../data/spriteOrder.js';
import * as activation from '../../cctvFocusRequest.js'; import * as activation from '../../cctvFocusRequest.js';
import * as overlays from '../../overlays/worldOverlay.js'; import * as overlays from '../../overlays/worldOverlay.js';
import * as locations from '../../locations.js'; import { CITY_POIS } from '../../cityPois.js';
import * as picking from '../../data/pickRegistry.js'; import * as picking from '../../data/pickRegistry.js';
import * as focus from '../../data/focusDeemphasis.js'; import * as focus from '../../data/focusDeemphasis.js';
import * as render from '../../renderGovernor.js'; import * as render from '../../renderGovernor.js';
@@ -18,7 +18,7 @@ export function createApplicationCctv({ surface, source }) {
sprites, sprites,
activation, activation,
overlays, overlays,
locations, locations: { CITY_POIS },
picking, picking,
terrain, terrain,
ground, ground,
-1
View File
@@ -2,7 +2,6 @@
* @module cityPois * @module cityPois
* @description The preset cities and their landmarks — plain data with no * @description The preset cities and their landmarks — plain data with no
* Cesium, so modules that only need the names (voice cards) can import it. * Cesium, so modules that only need the names (voice cards) can import it.
* `locations.js` re-exports it.
*/ */
/** /**
+2 -1
View File
@@ -8,7 +8,8 @@
// say nothing about whether a destination is framed well. // say nothing about whether a destination is framed well.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { CITY_POIS, LOCATIONS } from './locations.js'; import { CITY_POIS } from './cityPois.js';
import { LOCATIONS } from './locations.js';
const DESTINATIONS = Object.entries(CITY_POIS); const DESTINATIONS = Object.entries(CITY_POIS);
const REQUIRED_POI_KEYS = ['name', 'lat', 'lon', 'alt', 'pitch', 'heading']; const REQUIRED_POI_KEYS = ['name', 'lat', 'lon', 'alt', 'pitch', 'heading'];
+1 -4
View File
@@ -119,10 +119,7 @@ export function formatCockpitEmergency(emergency, format = ENGLISH_EMERGENCY) {
}); });
} }
/** /** The English Context scope line; see formatCockpitContextScope. */
* The English Context scope line. The cockpit passes its own formatter so the
* page shows it in the active language; this module stays i18n-free.
*/
const ENGLISH_CONTEXT_SCOPE = Object.freeze({ const ENGLISH_CONTEXT_SCOPE = Object.freeze({
window: ({ label, radiusKm }) => window: ({ label, radiusKm }) =>
`${label} · ${radiusKm ?? '—'} KM AIR/SEA WINDOW`, `${label} · ${radiusKm ?? '—'} KM AIR/SEA WINDOW`,
+4 -6
View File
@@ -1,3 +1,5 @@
import { msg } from './i18n/marker.js';
const CONTEXT_DEPENDENCIES = Object.freeze({ const CONTEXT_DEPENDENCIES = Object.freeze({
flights: new Set([ flights: new Set([
'military-awareness', 'military-awareness',
@@ -9,10 +11,6 @@ const CONTEXT_DEPENDENCIES = Object.freeze({
'space-missions': new Set(['rocket-launches', 'satellites']), 'space-missions': new Set(['rocket-launches', 'satellites']),
}); });
const CONTEXT_COMPANIONS = new Set(['radio']); const CONTEXT_COMPANIONS = new Set(['radio']);
// This module is shared with voice actions and scene policy, so the refusal
// reason stays English data; the Context shell translates it with t() at the
// toast (src/ui/contextLayerChanges.js).
const msg = (text) => text;
/** The Space Missions refusal, with the blocked layer's name as {layer}. */ /** The Space Missions refusal, with the blocked layer's name as {layer}. */
export const SPACE_MISSIONS_LAYER_BLOCK_REASON = msg( export const SPACE_MISSIONS_LAYER_BLOCK_REASON = msg(
'Space Missions isolates replay data. Exit the mode to enable {layer}.', 'Space Missions isolates replay data. Exit the mode to enable {layer}.',
@@ -350,8 +348,8 @@ export function contextLayerEnableBlockReason({
} }
/** /**
* Split a Space Missions refusal back into its template and layer name so * The layer name from a Space Missions refusal, so the UI can rebuild the
* the presentation can translate the sentence and localize the name. * sentence in its language.
* @param {string} reason * @param {string} reason
* @returns {{layer: string}|null} The blocked layer's name, or null when the * @returns {{layer: string}|null} The blocked layer's name, or null when the
* reason is not a Space Missions refusal. * reason is not a Space Missions refusal.
+2 -4
View File
@@ -7,6 +7,8 @@
* integer category (states index 17) and ADS-B emitter strings ("A1".."B1"). * integer category (states index 17) and ADS-B emitter strings ("A1".."B1").
*/ */
import { msg } from '../i18n/marker.js';
const HELI = new Set([ const HELI = new Set([
'EC20', 'EC20',
'EC25', 'EC25',
@@ -438,10 +440,6 @@ export function classifyAircraft({ typeCode, category } = {}) {
return 'airliner'; return 'airliner';
} }
// Portable flight records import this module, so it has no i18n: msg() only
// marks the labels, and the card translates them where it shows them.
const msg = (text) => text;
/** Readable name of each class, for cards and readouts (English data). */ /** Readable name of each class, for cards and readouts (English data). */
export const AIRCRAFT_CLASS_LABELS = Object.freeze({ export const AIRCRAFT_CLASS_LABELS = Object.freeze({
light: msg('Light aircraft'), light: msg('Light aircraft'),
+2 -3
View File
@@ -13,12 +13,11 @@
* broadcast, which does not confirm an incident or its resolution. * broadcast, which does not confirm an incident or its resolution.
*/ */
import { msg } from '../i18n/marker.js';
/** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */ /** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */
export const EMERGENCY_TINT_CSS = '#FF4444'; export const EMERGENCY_TINT_CSS = '#FF4444';
// Marks catalog text without importing i18n: tools and the server load this module.
const msg = (text) => text;
const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' }; const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' };
const KINDS = { const KINDS = {
+4 -4
View File
@@ -249,7 +249,7 @@ export function createAisWatchdog(options = {}) {
if (!env.hasKey) { if (!env.hasKey) {
const actions = terminateOwned('key-absent'); const actions = terminateOwned('key-absent');
status = 'missing-key'; status = 'missing-key';
// i18n-ignore: server detail; the chip shows a translated reason (vessels/queries.js) // i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
error = 'AISSTREAM_API_KEY is not set'; error = 'AISSTREAM_API_KEY is not set';
reconnectAttempt = 0; reconnectAttempt = 0;
keyFingerprint = null; keyFingerprint = null;
@@ -258,7 +258,7 @@ export function createAisWatchdog(options = {}) {
if (env.hasTransport === false) { if (env.hasTransport === false) {
const actions = terminateOwned('no-transport'); const actions = terminateOwned('no-transport');
status = 'unsupported'; status = 'unsupported';
// i18n-ignore: server detail; the chip shows a translated reason (vessels/queries.js) // i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
error = 'Node WebSocket transport is unavailable'; error = 'Node WebSocket transport is unavailable';
return actions; return actions;
} }
@@ -331,7 +331,7 @@ export function createAisWatchdog(options = {}) {
if (!silenceWatchArmed) return []; if (!silenceWatchArmed) return [];
const silentFor = monoNow - silenceSinceMono; const silentFor = monoNow - silenceSinceMono;
if (silentFor >= recycleAfterMs) { if (silentFor >= recycleAfterMs) {
// i18n-ignore: server detail; the chip shows a translated reason (vessels/queries.js) // i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
error = `AISStream delivered no data for ${Math.round(silentFor / 1000)}s`; error = `AISStream delivered no data for ${Math.round(silentFor / 1000)}s`;
const actions = terminateOwned('silent'); const actions = terminateOwned('silent');
scheduleRetry('transport'); scheduleRetry('transport');
@@ -404,7 +404,7 @@ export function createAisWatchdog(options = {}) {
function onClose(eventGeneration) { function onClose(eventGeneration) {
if (!ownsGeneration(eventGeneration)) return []; if (!ownsGeneration(eventGeneration)) return [];
release(); release();
// i18n-ignore: server detail; the chip shows a translated reason (vessels/queries.js) // i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
if (!error) error = 'AISStream websocket closed'; if (!error) error = 'AISStream websocket closed';
scheduleRetry('transport'); scheduleRetry('transport');
return []; return [];
+2 -4
View File
@@ -40,10 +40,8 @@ const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
facebook: Object.freeze(['https://www.facebook.com']), facebook: Object.freeze(['https://www.facebook.com']),
}); });
// Authored English in public/events/bhote-koshi-2026/event.json. The pack is // Known English from public/events/bhote-koshi-2026/event.json, translated at
// data and stays English; these known strings translate at display. Anything // display (whole or by " · " parts). Anything else shows as authored.
// else (a newer pack, a credit, a source's own title) shows as authored. A
// string that is not known whole translates by its " · " parts.
const EVENT_PACK_TEXT = new Set([ const EVENT_PACK_TEXT = new Set([
// Field reports: our labels (credits after " · " stay) and their statuses. // Field reports: our labels (credits after " · " stay) and their statuses.
msg('Rasuwagadhi witness video'), msg('Rasuwagadhi witness video'),
+3 -5
View File
@@ -826,11 +826,9 @@ export function updateEvidenceFrame(
const EVIDENCE_SOURCE_MIN_FONT_SCALE = 0.8; const EVIDENCE_SOURCE_MIN_FONT_SCALE = 0.8;
/** /**
* Set the font for the «{publisher} / OPEN» line and return the line so it * Set the font for the «{publisher} / OPEN» line and return a line that fits
* fits `maxWidth`: a translation («ИСТОЧНИК YOUTUBE / ОТКРЫТЬ») is longer than * `maxWidth`. Translations run longer: first shrink the font (to 80%), then
* the English and the portrait card cut its action word. A line that fits is * shorten the publisher; «/ OPEN» always stays whole.
* drawn exactly as before; one that does not first shrinks its font (to 80%),
* then shortens the publisher — the «/ OPEN» action always stays whole.
* @param {CanvasRenderingContext2D} ctx * @param {CanvasRenderingContext2D} ctx
* @param {string} publisher Display publisher, already upper-cased. * @param {string} publisher Display publisher, already upper-cased.
* @param {string} font CSS font with a px size. * @param {string} font CSS font with a px size.
+3 -4
View File
@@ -1,7 +1,6 @@
// Proposal 5 / E4: the evidence frame's «{publisher} / OPEN» line is drawn on // The «{publisher} / OPEN» line is drawn on a canvas, so CSS cannot wrap it:
// a canvas, so CSS cannot widen it; on the portrait card the Russian // a long translation shrinks to fit the portrait card, action word whole.
// «ИСТОЧНИК YOUTUBE / ОТКРЫТЬ» lost its last letter. The line is measured and // English is drawn at full size.
// shrunk to fit, keeping the action word whole; English draws as before.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import test from 'node:test'; import test from 'node:test';
@@ -2,8 +2,8 @@ import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import test from 'node:test'; import test from 'node:test';
// K-30: the publisher on the evidence frame ("YouTube source") is pack text // The publisher on the evidence frame ("YouTube source") is pack text shown
// and is shown in the UI language; English stays "YOUTUBE SOURCE / OPEN". // in the UI language; English stays "YOUTUBE SOURCE / OPEN".
import { setLocale } from '../i18n/index.js'; import { setLocale } from '../i18n/index.js';
import { createEvidenceFrame } from './bhoteKoshiEvent.js'; import { createEvidenceFrame } from './bhoteKoshiEvent.js';
+2 -3
View File
@@ -52,9 +52,8 @@ const TRIGGER_ORBIT_DURATION_SEC = 2.8;
const TRIGGER_SOURCE_URL = const TRIGGER_SOURCE_URL =
'https://earthquake.usgs.gov/earthquakes/eventpage/us7000tbwb/executive'; 'https://earthquake.usgs.gov/earthquakes/eventpage/us7000tbwb/executive';
// Overview pin titles that are more than a place name translate at display, // Descriptive pin titles translate at display (same entries as the event
// through the same entries the event panel uses. The data stays English and // panel); place names stay Latin, anything else shows as authored.
// place names stay Latin; everything else shows as authored.
const PLACE_TITLE_TEXT = new Map( const PLACE_TITLE_TEXT = new Map(
[ [
msg('Collapse viewpoint'), msg('Collapse viewpoint'),
+1 -3
View File
@@ -2,9 +2,7 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { createImportImageBudget } from './geoImportImages.js'; import { createImportImageBudget } from './geoImportImages.js';
import { createImportedPins } from './geoImportPins.js'; import { createImportedPins } from './geoImportPins.js';
import { msg } from '../i18n/marker.js';
// Marks catalog text without importing i18n; the UI translates the message.
const msg = (text) => text;
/** Largest file accepted, in bytes. */ /** Largest file accepted, in bytes. */
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024; export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
+2 -4
View File
@@ -1,8 +1,6 @@
// Portable node/browser module (the HUD summary in loadingFeedback.js reads import { msg } from '../i18n/marker.js';
// it too), so it marks its copy with a local msg() instead of importing i18n;
// installations/controls.js translates describeInstallationFeedback().
const msg = (text) => text;
// layers/installations/controls.js translates describeInstallationFeedback().
const REASONS = Object.freeze({ const REASONS = Object.freeze({
rate_limited: msg('Overpass rate-limited'), rate_limited: msg('Overpass rate-limited'),
timeout: msg('Overpass timed out'), timeout: msg('Overpass timed out'),
+5 -9
View File
@@ -1,14 +1,10 @@
import { msg } from '../i18n/marker.js';
/** /**
* The English names the data gives a military installation that has no * English names for installations without a mapped name. Data keeps them
* mapped name. They stay English in data — the server provider, the AI tools * English (server, AI tools, voice context); classLabel.js and
* and the voice context read the records — and the display translates them * militaryAwarenessEngine.js translate them.
* (installationTitle in src/layers/installations/classLabel.js and
* formatAwarenessLabel in src/data/militaryAwarenessEngine.js).
*
* The server, tools and source modules load this file, so it marks the names
* with a local msg() instead of importing i18n.
*/ */
const msg = (text) => text;
/** Name of an unnamed feature, by installation class. */ /** Name of an unnamed feature, by installation class. */
export const UNNAMED_INSTALLATION_LABEL_BY_CLASS = Object.freeze({ export const UNNAMED_INSTALLATION_LABEL_BY_CLASS = Object.freeze({
+2 -2
View File
@@ -1,5 +1,5 @@
// Marks restore labels for i18n extraction; shareRestoration translates them. // Restore labels are marked with msg(); ui/shareRestoration.js translates them.
// Kept local so this module stays free of the i18n import. // Local, not i18n/marker.js: the token-ledger tests run this file in isolation.
const msg = (text) => text; const msg = (text) => text;
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js'; import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' }; import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
-7
View File
@@ -32,13 +32,6 @@ const CLASS_BY_MILITARY_TAG = {
const LABEL_BY_CLASS = UNNAMED_INSTALLATION_LABEL_BY_CLASS; const LABEL_BY_CLASS = UNNAMED_INSTALLATION_LABEL_BY_CLASS;
const UNNAMED_FALLBACK = UNNAMED_INSTALLATION_FALLBACK; const UNNAMED_FALLBACK = UNNAMED_INSTALLATION_FALLBACK;
/** Every name humanizeInstallationClass gives a record of a class
* CLASS_BY_MILITARY_TAG can produce, for the display to translate. */
export const UNNAMED_INSTALLATION_NAMES = Object.freeze([
...Object.values(LABEL_BY_CLASS),
UNNAMED_FALLBACK,
]);
const MAX_FOOTPRINT_POINTS = 400; const MAX_FOOTPRINT_POINTS = 400;
/** /**
+5 -11
View File
@@ -1,15 +1,9 @@
/** import { msg } from '../i18n/marker.js';
* The tool name to show for a pointer owner.
*
* Pointer-owner ids (src/data/inputOwnership.js) are stable internal
* identifiers — 'draw', 'recent-imagery-box' — and never belong on screen.
* This maps each to the English name of the tool, which the caller translates
* with translateKnown() where it is shown. Pure and stateless, so any package
* can carry it without a second copy of the pointer claim.
*/
// English marker for the i18n extractor; this module imports no i18n. /**
const msg = (text) => text; * Tool name for a pointer owner. Owner ids (inputOwnership.js) never go on
* screen; callers translate the English name with translateKnown().
*/
const POINTER_OWNER_NAMES = Object.freeze({ const POINTER_OWNER_NAMES = Object.freeze({
draw: msg('Draw'), draw: msg('Draw'),
+6 -18
View File
@@ -20,53 +20,43 @@
import { t, msg } from '../i18n/index.js'; import { t, msg } from '../i18n/index.js';
/** /**
* Class registry. `label` is the card/legend token, `color` the point color, * Class registry: the point color of each class. The card and legend text is
* `blurb` the plain-language gloss for the legend tooltip. * in CLASS_DISPLAY_KEYS.
* @type {Readonly<Record<string, { label: string, color: string, blurb: string }>>} * @type {Readonly<Record<string, { color: string }>>}
*/ */
export const SATELLITE_CLASSES = Object.freeze({ export const SATELLITE_CLASSES = Object.freeze({
station: Object.freeze({ station: Object.freeze({
label: 'STATION', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
// Warm white — "crewed", and the brightest class in the sky. Deliberately // Warm white — "crewed", and the brightest class in the sky. Deliberately
// NOT amber: 40-48deg amber is the app's known-military convention // NOT amber: 40-48deg amber is the app's known-military convention
// (flights.js MIL_TINT / militaryFlights.js MIL_ICON_COLOR) and must not // (flights.js MIL_TINT / militaryFlights.js MIL_ICON_COLOR) and must not
// leak into another domain. Slightly warm so it also separates from the // leak into another domain. Slightly warm so it also separates from the
// pure white the civilian air fleet draws in. // pure white the civilian air fleet draws in.
color: '#fff6e5', color: '#fff6e5',
blurb: 'Crewed stations and their visiting vehicles', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
}), }),
nav: Object.freeze({ nav: Object.freeze({
label: 'NAV', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
// Cyan — the strongest accent goes to the class users actually name // Cyan — the strongest accent goes to the class users actually name
// ("how many GPS satellites are up?"). Takes over the slot the catch-all // ("how many GPS satellites are up?"). Takes over the slot the catch-all
// VISUAL group used to occupy, so this adds no new hue to the app. // VISUAL group used to occupy, so this adds no new hue to the app.
color: '#4fd8ff', color: '#4fd8ff',
blurb: 'GNSS navigation — GPS, GLONASS, Galileo', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
}), }),
geo: Object.freeze({ geo: Object.freeze({
label: 'GEO', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
// Violet, unchanged. Already the app's "space" semantic (the detection // Violet, unchanged. Already the app's "space" semantic (the detection
// overlay paints the SAT tier #bda4ff), and the geostationary belt draws // overlay paints the SAT tier #bda4ff), and the geostationary belt draws
// as one clean equatorial ring at globe scale. // as one clean equatorial ring at globe scale.
color: '#c89bff', color: '#c89bff',
blurb: 'Geostationary belt — comms and weather, fixed over the equator', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
}), }),
visual: Object.freeze({ visual: Object.freeze({
label: 'VISUAL', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
// Muted blue-gray. This is the catch-all bucket, so it gives up the bright // Muted blue-gray. This is the catch-all bucket, so it gives up the bright
// cyan it used to hold and recedes behind the three classes that mean // cyan it used to hold and recedes behind the three classes that mean
// something. Still well clear of the dense shell by luminance. // something. Still well clear of the dense shell by luminance.
color: '#9fb3c4', color: '#9fb3c4',
blurb: 'Brightest naked-eye objects — CelesTrak visual group', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
}), }),
comms: Object.freeze({ comms: Object.freeze({
label: 'COMMS', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
// Dim slate — thousands of these appear in DENSE mode. They must read as // Dim slate — thousands of these appear in DENSE mode. They must read as
// texture behind the core catalog, never compete with it. Rec.601 luma // texture behind the core catalog, never compete with it. Rec.601 luma
// ~0.40 against VISUAL's ~0.69 keeps the two separable even when the // ~0.40 against VISUAL's ~0.69 keeps the two separable even when the
// NVG/FLIR shaders collapse the scene to a single channel. // NVG/FLIR shaders collapse the scene to a single channel.
color: '#54697f', color: '#54697f',
blurb: 'Broadband constellation shell — shown only in DENSE mode', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
}), }),
}); });
@@ -79,9 +69,7 @@ export const SATELLITE_CLASS_ORDER = Object.freeze([
'comms', 'comms',
]); ]);
// Display keys for the class table above: `label`/`blurb` stay the English // What the card and legend show for each class.
// source-of-truth tokens (tests read them), while the card and legend accessors
// translate through these keys at the presentation boundary.
const CLASS_DISPLAY_KEYS = Object.freeze({ const CLASS_DISPLAY_KEYS = Object.freeze({
station: Object.freeze({ station: Object.freeze({
label: msg('STATION'), label: msg('STATION'),
@@ -164,7 +152,8 @@ export function satelliteClassColor(group) {
* The ISS is always a STATION regardless of which group ingested it — see * The ISS is always a STATION regardless of which group ingested it — see
* satelliteClassOf, which owns that rule for every consumer. * satelliteClassOf, which owns that rule for every consumer.
* @param {string|undefined|null} group CelesTrak group tag. * @param {string|undefined|null} group CelesTrak group tag.
* @param {{ isIss?: boolean }} [options] Whether this is the ISS. * @param {{ isIss?: boolean, localize?: boolean }} [options] isIss: the ISS;
* localize: false keeps the English label (data for the AI tools).
* @returns {string} Display label. * @returns {string} Display label.
*/ */
export function satelliteClassLabel( export function satelliteClassLabel(
@@ -172,7 +161,6 @@ export function satelliteClassLabel(
{ isIss = false, localize = true } = {}, { isIss = false, localize = true } = {},
) { ) {
const { klass, subtype } = satelliteClassOf(group, { isIss }); const { klass, subtype } = satelliteClassOf(group, { isIss });
// localize: false keeps the English label for data the AI tools query.
const label = CLASS_DISPLAY_KEYS[klass].label; const label = CLASS_DISPLAY_KEYS[klass].label;
const base = localize ? t(label) : label; const base = localize ? t(label) : label;
return subtype ? `${base} · ${subtype}` : base; return subtype ? `${base} · ${subtype}` : base;
+2 -4
View File
@@ -48,9 +48,8 @@ function run(locale) {
return details; return details;
} }
test('K-32 es: tracked ISS card', () => { test('es: tracked ISS card is localized', () => {
const details = run('es'); const details = run('es');
console.log('es details:', JSON.stringify(details));
assert.equal(details[0], 'ESTACIÓN · ISS'); assert.equal(details[0], 'ESTACIÓN · ISS');
assert.doesNotMatch( assert.doesNotMatch(
details[2], details[2],
@@ -59,9 +58,8 @@ test('K-32 es: tracked ISS card', () => {
); );
}); });
test('K-32 ru: tracked ISS card', () => { test('ru: tracked ISS card is localized', () => {
const details = run('ru'); const details = run('ru');
console.log('ru details:', JSON.stringify(details));
assert.equal(details[0], 'СТАНЦИЯ · ISS'); assert.equal(details[0], 'СТАНЦИЯ · ISS');
assert.match(details[1], /км/, 'ru glossary: km → км'); assert.match(details[1], /км/, 'ru glossary: km → км');
assert.doesNotMatch( assert.doesNotMatch(
+2 -3
View File
@@ -68,9 +68,8 @@ const NUMERIC_TYPE_FAMILIES = {
9: 'OTHER', 9: 'OTHER',
}; };
// Display keys for the normalized English type tokens above. The tokens stay // Card text for the type tokens above. The tokens stay English: TYPE_STYLES
// machine values — TYPE_STYLES matches on them and tests pin them — so card // and tests match on them.
// text translates only through this map at the presentation boundary.
const TYPE_DISPLAY_KEYS = Object.freeze({ const TYPE_DISPLAY_KEYS = Object.freeze({
FISHING: msg('FISHING'), FISHING: msg('FISHING'),
TOWING: msg('TOWING'), TOWING: msg('TOWING'),
+1 -1
View File
@@ -1,4 +1,4 @@
// K-12: a translated vessel type is never cut mid-word on the card. // A translated vessel type is never cut mid-word on the card.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
+2 -3
View File
@@ -1,8 +1,7 @@
import { msg } from '../../i18n/marker.js';
import { BUNDLE_SOURCE } from './bundle.js'; import { BUNDLE_SOURCE } from './bundle.js';
// Pack statuses stay English in the report; the import dialog shows them // Statuses stay English; scenes/sharing.js translates them for the dialog.
// through t() (scenes/sharing.js).
const msg = (text) => text;
/** Describe an import without fetching assets, applying state or exposing source configuration. */ /** Describe an import without fetching assets, applying state or exposing source configuration. */
export function describeSceneShare( export function describeSceneShare(
+2 -7
View File
@@ -36,8 +36,6 @@ export const FIRST_RUN_SESSION_KEY = 'gev:first-run-mission-session:v1';
*/ */
export const ENVIRONMENTAL_LABEL_CHOICE = 'ENVIRONMENTAL'; export const ENVIRONMENTAL_LABEL_CHOICE = 'ENVIRONMENTAL';
// Catalog keys per label choice. Resolved through t() at CALL time (init),
// never at module load — the locale is not resolved until main.js's body runs.
const ENVIRONMENTAL_LABEL_KEYS = Object.freeze({ const ENVIRONMENTAL_LABEL_KEYS = Object.freeze({
ENVIRONMENTAL: msg('ENVIRONMENTAL'), ENVIRONMENTAL: msg('ENVIRONMENTAL'),
EARTH_WATCH: msg('EARTH WATCH'), EARTH_WATCH: msg('EARTH WATCH'),
@@ -361,11 +359,8 @@ export function initFirstRunExperience({
} }
// The tile name is owner-switchable from one constant, so paint it from the // The tile name is owner-switchable from one constant, so paint it from the
// module rather than trusting the markup to have been edited to match. The // module rather than trusting the markup to match. The description and the
// persuasive description line and the tile's feed subcopy are the two // tile subcopy are data-i18n-runtime in welcome.html and translated here.
// phase-2 exceptions whose static sites are pinned verbatim by
// firstRunExperience.test.mjs — the runtime write is their only legal
// localization point (docs/TRANSLATORS.md, runtime-only static sites).
const environmentalTitle = root.querySelector( const environmentalTitle = root.querySelector(
'[data-first-run-environmental-title]', '[data-first-run-environmental-title]',
); );
+11 -12
View File
@@ -16,7 +16,7 @@ import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { forward as toMGRS } from 'mgrs'; import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './locations.js'; import { CITY_POIS } from './cityPois.js';
import { composeLocalityTag } from './hudLocality.js'; import { composeLocalityTag } from './hudLocality.js';
import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js'; import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
import { import {
@@ -64,9 +64,8 @@ const HUD_COLORS = {
}; };
/** /**
* Mode names shown for each style. Same English as STYLE_STATUS_LABELS in * Mode names per style: the same English as STYLE_STATUS_LABELS
* ui/visualPresets.js, so the top-left mode and the dock's active style * (ui/visualPresets.js), so both translate alike.
* translate alike.
*/ */
const MODE_LABELS = Object.freeze({ const MODE_LABELS = Object.freeze({
normal: msg('NORMAL'), normal: msg('NORMAL'),
@@ -262,7 +261,7 @@ export class IntelHUD {
this._el = document.getElementById('intel-hud'); this._el = document.getElementById('intel-hud');
if (!this._el) return; if (!this._el) return;
// i18n-ignore: sensor readout codes (MGRS, GSD, NIIRS, ONA, BAND…) and a classification banner; translated parts use t() // i18n-ignore: sensor codes and a classification banner; the rest uses t()
this._el.innerHTML = ` this._el.innerHTML = `
<div class="hud-sonar" aria-hidden="true"></div> <div class="hud-sonar" aria-hidden="true"></div>
@@ -433,11 +432,11 @@ export class IntelHUD {
const formatted = this._formatMGRS(mgrsStr); const formatted = this._formatMGRS(mgrsStr);
mgrsLabel = formatted; mgrsLabel = formatted;
const el = document.getElementById('hud-mgrs'); const el = document.getElementById('hud-mgrs');
// i18n-ignore: sensor readout codes, same in every language // i18n-ignore: sensor codes
if (el) el.textContent = `MGRS: ${formatted}`; if (el) el.textContent = `MGRS: ${formatted}`;
} catch { } catch {
const el = document.getElementById('hud-mgrs'); const el = document.getElementById('hud-mgrs');
// i18n-ignore: sensor readout codes, same in every language // i18n-ignore: sensor codes
if (el) el.textContent = 'MGRS: ---'; if (el) el.textContent = 'MGRS: ---';
} }
@@ -446,7 +445,7 @@ export class IntelHUD {
if (llEl) llEl.textContent = `${latDMS} ${lonDMS}`; if (llEl) llEl.textContent = `${latDMS} ${lonDMS}`;
const bottomEl = document.getElementById('hud-bottom-line'); const bottomEl = document.getElementById('hud-bottom-line');
if (bottomEl) { if (bottomEl) {
// i18n-ignore: sensor readout codes, same in every language // i18n-ignore: sensor codes
bottomEl.textContent = `MGRS: ${mgrsLabel} LAT: ${latDMS} LON: ${lonDMS}`; bottomEl.textContent = `MGRS: ${mgrsLabel} LAT: ${latDMS} LON: ${lonDMS}`;
} }
@@ -462,7 +461,7 @@ export class IntelHUD {
); );
const gsdEl = document.getElementById('hud-gsd'); const gsdEl = document.getElementById('hud-gsd');
if (gsdEl) if (gsdEl)
// i18n-ignore: sensor readout codes, same in every language // i18n-ignore: sensor codes
gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`; gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
// Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw // Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
@@ -475,7 +474,7 @@ export class IntelHUD {
const altMslM = ellipsoidalToMslDisplayM(altM, geoidN); const altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
const sunEl = this._estimateSunElevation(latDeg, lonDeg); const sunEl = this._estimateSunElevation(latDeg, lonDeg);
if (altEl) if (altEl)
// i18n-ignore: sensor readout codes, same in every language // i18n-ignore: sensor codes
altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`; altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
// Collection timestamp // Collection timestamp
@@ -485,7 +484,7 @@ export class IntelHUD {
const h = String(now.getUTCHours()).padStart(2, '0'); const h = String(now.getUTCHours()).padStart(2, '0');
const m = String(now.getUTCMinutes()).padStart(2, '0'); const m = String(now.getUTCMinutes()).padStart(2, '0');
const s = String(now.getUTCSeconds()).padStart(2, '0'); const s = String(now.getUTCSeconds()).padStart(2, '0');
// i18n-ignore: sensor readout codes, same in every language // i18n-ignore: sensor codes
collEl.textContent = `COLL: ${h}:${m}:${s}Z`; collEl.textContent = `COLL: ${h}:${m}:${s}Z`;
} }
@@ -494,7 +493,7 @@ export class IntelHUD {
const pitchDeg = Cesium.Math.toDegrees(camera.pitch); const pitchDeg = Cesium.Math.toDegrees(camera.pitch);
const ona = Math.max(0, 90 + pitchDeg); const ona = Math.max(0, 90 + pitchDeg);
const onaEl = document.getElementById('hud-ona'); const onaEl = document.getElementById('hud-ona');
// i18n-ignore: sensor readout codes, same in every language // i18n-ignore: sensor codes
if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`; if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`;
// `altM` stays the raw ellipsoidal camera height the sensor model reads // `altM` stays the raw ellipsoidal camera height the sensor model reads
+3 -7
View File
@@ -23,10 +23,7 @@
*/ */
export const NEAR_POI_MAX_KM = 150; export const NEAR_POI_MAX_KM = 150;
/** /** The English locality line; the HUD passes hudLocalityFormat.js instead. */
* The English locality line. The HUD passes its own formatter so the page
* shows it in the active language; this default stays i18n-free.
*/
const ENGLISH_LOCALITY = Object.freeze({ const ENGLISH_LOCALITY = Object.freeze({
near: ({ poi, city, distKm }) => near: ({ poi, city, distKm }) =>
`NEAR ${poi.toUpperCase()} (${city.toUpperCase()}) ${Math.round(distKm)}KM`, `NEAR ${poi.toUpperCase()} (${city.toUpperCase()}) ${Math.round(distKm)}KM`,
@@ -54,9 +51,8 @@ function coordinateTag(value, positive, negative) {
* @param {number} latDeg Camera latitude in decimal degrees. * @param {number} latDeg Camera latitude in decimal degrees.
* @param {number} lonDeg Camera longitude in decimal degrees. * @param {number} lonDeg Camera longitude in decimal degrees.
* @param {{near: Function, sector: Function}} [format] Builds the line from * @param {{near: Function, sector: Function}} [format] Builds the line from
* `{ poi, city, distKm }` or `{ latDeg, lonDeg }`. The names arrive as * `{ poi, city, distKm }` or `{ latDeg, lonDeg }`; names arrive as
* catalogued (`Tokyo`), so a formatter can translate them first and * catalogued (`Tokyo`), not upper-cased.
* upper-case the result in the page's language.
* @returns {string} `NEAR <POI> (<CITY>) <N>KM` within the bound (inclusive), * @returns {string} `NEAR <POI> (<CITY>) <N>KM` within the bound (inclusive),
* otherwise `SECTOR <lat> <lon>`. * otherwise `SECTOR <lat> <lon>`.
*/ */
-2
View File
@@ -124,8 +124,6 @@ test('a missing or malformed nearest POI never crashes the summary', () => {
assert.match(composeLocalityTag({ ...LINCOLN }, 10, 10), /^SECTOR /); assert.match(composeLocalityTag({ ...LINCOLN }, 10, 10), /^SECTOR /);
}); });
// The names reach a formatter as catalogued, so the HUD can translate the
// city before upper-casing it: the place-name table knows `Tokyo`, not `TOKYO`.
test('a formatter builds the line from the same parts', () => { test('a formatter builds the line from the same parts', () => {
const format = { const format = {
near: ({ poi, city, distKm }) => `near:${poi}|${city}|${distKm}`, near: ({ poi, city, distKm }) => `near:${poi}|${city}|${distKm}`,
+67
View File
@@ -211,3 +211,70 @@ test('unmarkedScript sees human-readable status, loadingLabel and statusMessage
assert.deepEqual(texts("return { status: 'auth-failed' };"), []); assert.deepEqual(texts("return { status: 'auth-failed' };"), []);
assert.deepEqual(texts("return { status: msg('Source unavailable') };"), []); assert.deepEqual(texts("return { status: msg('Source unavailable') };"), []);
}); });
// The Layers panel shows `stats.error || stats.lastError`, so a
// lastError is status text like error.
test('unmarkedScript sees status text assigned to lastError', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(
texts(
"state.coverage.lastError = error?.message || 'Coverage tile failed';",
),
['Coverage tile failed'],
);
assert.deepEqual(texts("layerState._lastError = 'CelesTrak unreachable';"), [
'CelesTrak unreachable',
]);
assert.deepEqual(
texts(
"state.coverage.lastError = error?.message || msg('Coverage tile failed');",
),
[],
);
assert.deepEqual(texts('this._lastError = message;'), []);
});
test('unmarkedScript skips default values and ternary branches, not later assignments', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
// A default value in a parameter list or destructuring is a local's.
assert.deepEqual(texts("function f(error = 'Feed went away') {}"), []);
assert.deepEqual(texts("const f = (status = 'Not ready', x) => x;"), []);
assert.deepEqual(
texts("function f(\n status = 'Not ready',\n x,\n) {}"),
[],
);
assert.deepEqual(texts("const { status = 'Ready now' } = options;"), []);
assert.deepEqual(
texts("const {\n error = 'No data yet',\n} = options;"),
[],
);
assert.deepEqual(
texts("for (const [id, status = 'Waiting'] of rows) {}"),
[],
);
// A name right after `?` is a ternary branch, not a `status:` property.
assert.deepEqual(texts("const s = ok ? status : 'Source unavailable';"), []);
assert.deepEqual(texts("const s = ok\n ? title\n : 'Untitled view';"), []);
// …while the property itself still is.
assert.deepEqual(texts("return { title: x ? title : 'Untitled view' };"), [
'Untitled view',
]);
// A later assignment is checked even when the name is a declared local:
// a layer keeps its status in `let error` (wind, AIS watchdog) and builds
// `loadingLabel` in a `let` before returning it (FIRMS).
assert.deepEqual(
texts(
"function f() {\n let error = null;\n if (x) error = 'Feed closed';\n}",
),
['Feed closed'],
);
assert.deepEqual(
texts(
"let loadingLabel = '';\nif (x) {\n loadingLabel = 'KEY REQUIRED';\n}",
),
['KEY REQUIRED'],
);
assert.deepEqual(texts("let a = 1,\n error = 'Feed closed';"), [
'Feed closed',
]);
});
+1 -1
View File
@@ -1,4 +1,4 @@
// K-21: an empty or blank translation must not count as done. // An empty or blank translation must not count as done.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
+1 -1
View File
@@ -179,7 +179,7 @@ const UTC_DAY_MONTH = Object.freeze({
/** /**
* The UTC day and month of a time, for "MM-DD HH:MM UTC" readouts: "10-13" in * The UTC day and month of a time, for "MM-DD HH:MM UTC" readouts: "10-13" in
* English (as before), "13.10" in Russian, "13/10" in Spanish, never read as a * English, "13.10" in Russian, "13/10" in Spanish, never read as a
* range. Takes an ISO string (its date part is used as written) or a time. * range. Takes an ISO string (its date part is used as written) or a time.
* @param {string|number|Date} value * @param {string|number|Date} value
* @returns {string} '' when the value is not a time. * @returns {string} '' when the value is not a time.
+2 -4
View File
@@ -1,7 +1,5 @@
// K-194: the KartaView provider's failure and state texts are English data // KartaView texts are English data (msg() in providers/kartaview/) shown
// (msg() in kartaview/{policy,source,viewer,index}.js) that the Street Level // through translateKnown, so each needs an entry in every catalog.
// panel and the chip tooltip show through translateKnown, so every one needs
// an entry in each language's catalog.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { setLocale, translateKnown } from './index.js'; import { setLocale, translateKnown } from './index.js';
+5 -7
View File
@@ -3,10 +3,8 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { setLocale, translateKnown } from './index.js'; import { setLocale, translateKnown } from './index.js';
// Status and error texts the layers write straight into their state // Layers keep these status texts as English data, and the panels show them
// (loadingLabel / _error / street.error) as English data; the layer panel and // through translateKnown, so each needs an entry in every catalog.
// the Street Level panel show them through translateKnown, so each needs a
// catalog entry in every language (K-36).
const SITES = [ const SITES = [
['../layers/streetLevel/index.js', 'loading coverage...'], ['../layers/streetLevel/index.js', 'loading coverage...'],
['../layers/firms/ingestion.js', 'live feed unavailable'], ['../layers/firms/ingestion.js', 'live feed unavailable'],
@@ -33,8 +31,8 @@ const SITES = [
['../layers/wind/source.js', 'Wind request timed out'], ['../layers/wind/source.js', 'Wind request timed out'],
['../layers/wind/source.js', 'Malformed wind manifest'], ['../layers/wind/source.js', 'Malformed wind manifest'],
['../layers/wind/source.js', 'Malformed wind grid'], ['../layers/wind/source.js', 'Malformed wind grid'],
['../layers/wind/source.js', 'Temperature field unavailable'], ['../layers/wind/fields.js', 'Temperature field unavailable'],
['../layers/wind/source.js', 'Mean sea level pressure field unavailable'], ['../layers/wind/fields.js', 'Mean sea level pressure field unavailable'],
]; ];
// A fallback that reaches the panel must be marked, or `npm run i18n` cannot // A fallback that reaches the panel must be marked, or `npm run i18n` cannot
@@ -55,7 +53,7 @@ for (const locale of ['es', 'ru']) {
for (const [file, text] of SITES) { for (const [file, text] of SITES) {
test(`${locale}: "${text}" from ${file} is translated`, () => { test(`${locale}: "${text}" from ${file} is translated`, () => {
const source = readFileSync(new URL(file, import.meta.url), 'utf8'); const source = readFileSync(new URL(file, import.meta.url), 'utf8');
assert.ok(source.includes(`'${text}'`), `literal still in ${file}`); assert.ok(source.includes(`'${text}'`), `literal not found in ${file}`);
setLocale(locale); setLocale(locale);
try { try {
assert.notEqual(translateKnown(text), text); assert.notEqual(translateKnown(text), text);
+1 -5
View File
@@ -7,11 +7,7 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
/** Source language and fallback. */ /** Source language and fallback. */
export const DEFAULT_LOCALE = 'en'; export const DEFAULT_LOCALE = 'en';
/** /** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
* Shipped locales. The picker (src/ui/localePicker.js) lists the active one
* first and the rest by LOCALE_NAMES. Adding one: locales/<code>.json, an
* import in index.js, and an entry here (see docs/TRANSLATORS.md).
*/
export const LOCALES = Object.freeze(['en', 'es', 'ru']); export const LOCALES = Object.freeze(['en', 'es', 'ru']);
/** Each locale named in its own language, for the selector. */ /** Each locale named in its own language, for the selector. */
+3 -1
View File
@@ -2883,5 +2883,7 @@
"Included in bundle": "Incluido en el paquete", "Included in bundle": "Incluido en el paquete",
"Missing bundle file — reimport its bundle": "Falta el archivo en el paquete — vuelve a importar su paquete", "Missing bundle file — reimport its bundle": "Falta el archivo en el paquete — vuelve a importar su paquete",
"Source configured; file checked when loaded": "Fuente configurada; el archivo se comprobará al cargarlo", "Source configured; file checked when loaded": "Fuente configurada; el archivo se comprobará al cargarlo",
"Source unavailable": "Fuente no disponible" "Source unavailable": "Fuente no disponible",
"Coverage tile failed": "No se pudo cargar la tesela de cobertura",
"Unknown": "Desconocido"
} }
+3 -1
View File
@@ -2950,5 +2950,7 @@
"Included in bundle": "Входит в архив", "Included in bundle": "Входит в архив",
"Missing bundle file — reimport its bundle": "Файла нет в архиве — импортируйте этот архив снова", "Missing bundle file — reimport its bundle": "Файла нет в архиве — импортируйте этот архив снова",
"Source configured; file checked when loaded": "Источник настроен; файл будет проверен при загрузке", "Source configured; file checked when loaded": "Источник настроен; файл будет проверен при загрузке",
"Source unavailable": "Источник недоступен" "Source unavailable": "Источник недоступен",
"Coverage tile failed": "Не удалось загрузить тайл покрытия",
"Unknown": "Неизвестно"
} }
+7
View File
@@ -0,0 +1,7 @@
// msg() for modules the server, tools or other portable code also import:
// marks English that stays in data for `npm run i18n`, without loading the
// catalogs (src/i18n/index.js). The UI shows it later through t().
// Browser-only modules import msg from src/i18n/index.js instead.
/** Mark English text that is stored in data and translated later by t(). */
export const msg = (text) => text;
+3 -6
View File
@@ -6,9 +6,7 @@ import {
missingTemplate, missingTemplate,
} from '../../scripts/i18n.mjs'; } from '../../scripts/i18n.mjs';
// A plural entry whose every form is still the English text must be reported // A plural entry with forms left in English is reported like a string entry.
// the same way a string entry is ("is still English"). Today the plural
// branch of checkCatalog never compares forms with the English.
const fixture = new Map([ const fixture = new Map([
['{count} cameras', { plural: true, one: '{count} camera' }], ['{count} cameras', { plural: true, one: '{count} camera' }],
['Save keys', { plural: false }], ['Save keys', { plural: false }],
@@ -64,9 +62,8 @@ for (const [locale, entry] of [
}); });
} }
// `npm run i18n -- --missing <code>` prints a template; pasted without edits, // The `--missing` template, pasted without edits, must fail entry by entry,
// none of its entries may pass as a translation (K-21, K-52), and a plural // and its plural entries carry every form the language needs.
// entry carries every form the language needs (K-97).
const messages = extractMessages(); const messages = extractMessages();
for (const locale of ['de', 'ja', 'es', 'ru']) { for (const locale of ['de', 'ja', 'es', 'ru']) {
test(`${locale}: an untouched --missing template fails the check entry by entry`, () => { test(`${locale}: an untouched --missing template fails the check entry by entry`, () => {
+2 -2
View File
@@ -1,5 +1,5 @@
// K-23: scripts/i18n.mjs must skip index entries that are gone from the // scripts/i18n.mjs must skip index entries gone from the working tree
// working tree (plain `mv`/`rm` without `git mv`/`git rm`). // (plain `mv`/`rm` without `git mv`/`git rm`).
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
+6 -11
View File
@@ -23,9 +23,7 @@ import {
import { t, msg, tn, registerUnsavedWork } from './i18n/index.js'; import { t, msg, tn, registerUnsavedWork } from './i18n/index.js';
/** /**
* Chip label — pure, exported for tests. The exact English values are pinned * Chip label — pure, exported for tests (keySetup.test.mjs pins the English).
* by keySetup.test.mjs; t() resolves them from the catalog, so the default
* locale renders byte-identical text and 'es' localizes at the same site.
* @param {{total?: number, setCount?: number}|null} status * @param {{total?: number, setCount?: number}|null} status
* @returns {string} * @returns {string}
*/ */
@@ -55,10 +53,9 @@ export function collectKeyUpdates(fields) {
} }
/** /**
* Why /api/setup/keys refused a save, by the `code` the server sends with its * Why /api/setup/keys refused a save, by the `code` the server sends beside
* English `error` (src/keySetupCore.mjs, server/standalone/key-setup.js). The * its English `error` (src/keySetupCore.mjs, server/standalone/key-setup.js).
* English here is the server's own sentence, so the default locale reads the * The English matches the server's text.
* same as before.
*/ */
const SAVE_REFUSALS = Object.freeze({ const SAVE_REFUSALS = Object.freeze({
proxied: msg('Provider Settings does not answer proxied requests'), proxied: msg('Provider Settings does not answer proxied requests'),
@@ -141,10 +138,8 @@ function sleepWithSignal(ms, signal) {
} }
/** /**
* An OAuth check error. A server-sent reason is kept as-is and later wrapped * An OAuth check error. A server reason is later wrapped in «OAuth check
* in «OAuth check failed: …»; when there is none, the error carries our own * failed: …»; our own translated fallback is flagged to show unwrapped.
* already-translated sentence and is flagged so the toast shows it unwrapped
* (the wrapper would only repeat it).
* @param {unknown} serverError * @param {unknown} serverError
* @param {string} fallback Already-translated fallback sentence. * @param {string} fallback Already-translated fallback sentence.
* @returns {Error} * @returns {Error}
+1
View File
@@ -174,6 +174,7 @@ test('aborting pending setup removes its surface and ignores a late response', a
resolveResponse({ ok: true, json: async () => ({ keys: [] }) }); resolveResponse({ ok: true, json: async () => ({ keys: [] }) });
assert.equal(await pending, null); assert.equal(await pending, null);
}); });
test('a pasted, unsaved key makes the language switch ask first until teardown', async () => { test('a pasted, unsaved key makes the language switch ask first until teardown', async () => {
const node = () => ({ const node = () => ({
dataset: {}, dataset: {},
+8 -11
View File
@@ -1,8 +1,9 @@
/** /**
* Key setup ("POWER UP") — the pure core. * Key setup ("POWER UP") — the pure core.
* *
* One registry and pure functions; its only import is the proxy-header check * One registry and pure functions; it imports only the proxy-header check
* shared with the other local gates (localRequestGate.mjs). The dev server's * shared with the other local gates (localRequestGate.mjs) and the msg()
* marker (src/i18n/marker.js). The dev server's
* /api/setup endpoints (vite.config.js) and the in-app panel (keySetup.js) * /api/setup endpoints (vite.config.js) and the in-app panel (keySetup.js)
* are both thin shells over this module, so what a key is called, what it * are both thin shells over this module, so what a key is called, what it
* unlocks, and how a .env line is written each live in exactly one place. * unlocks, and how a .env line is written each live in exactly one place.
@@ -12,6 +13,7 @@
* behavior below unit-testable. * behavior below unit-testable.
*/ */
import { msg } from './i18n/marker.js';
import { hasProxySignals } from './localRequestGate.mjs'; import { hasProxySignals } from './localRequestGate.mjs';
/** Longest accepted key/token value. Real provider keys are all far shorter. */ /** Longest accepted key/token value. Real provider keys are all far shorter. */
@@ -32,10 +34,6 @@ export const KEY_SETUP_APPEND_HEADER =
* bundle by design (restrict them at the provider, per SECURITY.md). * bundle by design (restrict them at the provider, per SECURITY.md).
* `hidden` keeps advanced configuration out of the panel and missing-key count. * `hidden` keeps advanced configuration out of the panel and missing-key count.
*/ */
// Marks `unlocks` copy for i18n extraction; Provider Settings translates it
// with t(). Kept local so the server can import this module without i18n.
const msg = (text) => text;
export const KEY_SETUP_KEYS = Object.freeze([ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({ Object.freeze({
id: 'google-maps', id: 'google-maps',
@@ -397,13 +395,11 @@ export function knownKeySetupEnvVars() {
return names; return names;
} }
/** Tooltip guidance for a control gated by one registry entry. */
/** /**
* Env-var requirements for a registry entry, as machine values for * Env-var names a registry entry needs, joined with ' + ', for callers that
* presentation layers that localize the guidance sentence themselves * build the guidance sentence in the UI language (src/ui/keyGuidance.js).
* (this module also runs in node-side key-setup builds, which stay English).
* @param {string} id Registry entry id. * @param {string} id Registry entry id.
* @returns {string|null} Joined env-var names, or null when nothing is required. * @returns {string|null} null for an unknown or hidden entry.
*/ */
export function keySetupRequirementEnvVars(id) { export function keySetupRequirementEnvVars(id) {
const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id); const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id);
@@ -411,6 +407,7 @@ export function keySetupRequirementEnvVars(id) {
return entry.envVars.join(' + '); return entry.envVars.join(' + ');
} }
/** English tooltip guidance for a control gated by one registry entry. */
export function keySetupRequirement(id) { export function keySetupRequirement(id) {
const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id); const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id);
if (!entry || entry.hidden) return ''; if (!entry || entry.hidden) return '';
+3 -5
View File
@@ -1,7 +1,5 @@
// K-25: a refused key save reads in the panel's language. The server sends a // A refused key save reads in the panel's language: the server sends `code`
// `code` (and `params`) beside its English `error`; the panel builds the // and `params` beside its English `error`, the panel builds the sentence.
// sentence through t(), so English stays the server's own text and es/ru get
// the catalog's.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
@@ -94,7 +92,7 @@ test('in English the panel shows exactly the server sentence', () => {
assert.equal(saveRefusalText(refusal, 400), refusal.error); assert.equal(saveRefusalText(refusal, 400), refusal.error);
}); });
test('an unknown code or a bare error falls back as before', () => { test('an unknown code or a bare error falls back to the server text or the status', () => {
setLocale('en'); setLocale('en');
assert.equal( assert.equal(
saveRefusalText({ error: 'Something new', code: 'new' }, 400), saveRefusalText({ error: 'Something new', code: 'new' }, 400),
+2 -4
View File
@@ -1,7 +1,5 @@
// C9: a failed ChatGPT OAuth check whose only reason is our own fallback // A failed ChatGPT OAuth check whose only reason is our own fallback sentence
// sentence shows that sentence alone — «OAuth check failed: Could not check // shows it alone; a server-sent reason keeps the «OAuth check failed» wrapper.
// ChatGPT sign-in. Try again.» repeated itself. A server-sent reason keeps the
// wrapper.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { oauthCheckFailureText, waitForChatGptOAuth } from './keySetup.js'; import { oauthCheckFailureText, waitForChatGptOAuth } from './keySetup.js';
+4 -4
View File
@@ -1,4 +1,4 @@
// K-26: every status line of the ChatGPT OAuth flow in Provider Settings goes // Every status line of the ChatGPT OAuth flow in Provider Settings goes
// through t(), and the i18n check treats say() as a place UI text lands. // through t(), and the i18n check treats say() as a place UI text lands.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -28,7 +28,7 @@ const OAUTH_STATUS_TEXTS = [
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.', 'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
]; ];
test('K-26: OAuth status-line texts are extractable messages with es/ru entries', () => { test('OAuth status-line texts are extractable messages with es/ru entries', () => {
const messages = extractMessages(); const messages = extractMessages();
const missing = OAUTH_STATUS_TEXTS.filter((text) => !messages.has(text)); const missing = OAUTH_STATUS_TEXTS.filter((text) => !messages.has(text));
assert.deepEqual(missing, [], 'texts not wrapped in t() in src/keySetup.js'); assert.deepEqual(missing, [], 'texts not wrapped in t() in src/keySetup.js');
@@ -39,7 +39,7 @@ test('K-26: OAuth status-line texts are extractable messages with es/ru entries'
} }
}); });
test('K-26: the i18n check sees an unmarked literal passed to say()', () => { test('the i18n check sees an unmarked literal passed to say()', () => {
const sample = const sample =
"const say = (x) => { el.textContent = x; };\nsay('Checking local ChatGPT OAuth sign-in…');\n"; "const say = (x) => { el.textContent = x; };\nsay('Checking local ChatGPT OAuth sign-in…');\n";
const found = unmarkedScript('sample.js', sample); const found = unmarkedScript('sample.js', sample);
@@ -49,7 +49,7 @@ test('K-26: the i18n check sees an unmarked literal passed to say()', () => {
); );
}); });
test('K-26: src/keySetup.js has no unmarked say() literals once say is a sink', () => { test('src/keySetup.js has no unmarked say() literals', () => {
const source = readFileSync( const source = readFileSync(
new URL('./keySetup.js', import.meta.url), new URL('./keySetup.js', import.meta.url),
'utf8', 'utf8',
@@ -103,6 +103,5 @@ for (const locale of ['en', 'es', 'ru']) {
card, card,
inLocale(locale, () => translateFeedError('ECCC HTTP 503')), inLocale(locale, () => translateFeedError('ECCC HTTP 503')),
); );
assert.equal(card, locale === 'en' ? 'ECCC HTTP 503' : 'ECCC: HTTP 503');
}); });
} }
+2 -3
View File
@@ -217,9 +217,8 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
0, 0,
); );
// Readings and errors stay English data (analyst records, stats); the // Readings and errors stay English data (analyst records, stats); the
// card shows them in the UI language, errors as the Layers panel row // card translates them as the Layers panel row does. A provider's own
// does. A provider's own error text has no catalog entry and shows as // error text has no catalog entry and shows as it came.
// it came.
return { return {
readout: true, readout: true,
summary: { summary: {
+2 -2
View File
@@ -15,8 +15,8 @@
* Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a * Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a
* later provider registers against. * later provider registers against.
*/ */
// A portable export (package.json): no i18n here. The labels stay English data // A portable export (package.json): no i18n here. The labels stay English data;
// and the Layers panel shows them translated. // the Weather card (index.js) shows them translated.
const msg = (text) => text; const msg = (text) => text;
/** Band identifiers shared across providers, in ascending severity. */ /** Band identifiers shared across providers, in ascending severity. */
+1 -2
View File
@@ -1,8 +1,7 @@
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js'; import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
// The reason stays English for the voice analyst; the Contacts panel shows it // The reason stays English for the voice analyst; the Contacts panel shows it
// in the UI language from `reasonTemplate`. Kept local so this module stays // in the UI language from `reasonTemplate`.
// free of the i18n import.
const msg = (text) => text; const msg = (text) => text;
const fillTemplate = (template, values = {}) => const fillTemplate = (template, values = {}) =>
template.replace(/\{(\w+)\}/g, (_, name) => String(values[name])); template.replace(/\{(\w+)\}/g, (_, name) => String(values[name]));
+3 -4
View File
@@ -11,10 +11,9 @@ import {
AWARENESS_PAGE_SIZE, AWARENESS_PAGE_SIZE,
} from './policy.js'; } from './policy.js';
// installations/controls.js getStats composes coverageLabel inside the layer // coverageLabel is machine-composed English from installations/controls.js
// model (machine-composed English, pinned by militaryInstallations.test.mjs). // (pinned by src/data/militaryInstallations.test.mjs); translate the known
// This is the presentation boundary: map the closed vocabulary to the // forms here, pass anything else through.
// catalog, passing anything unknown through verbatim (docs/TRANSLATORS.md).
const WITHIN_KM_RE = /^WITHIN (\d+) KM$/; const WITHIN_KM_RE = /^WITHIN (\d+) KM$/;
function localizeCoverageLabel(label) { function localizeCoverageLabel(label) {
-2
View File
@@ -45,8 +45,6 @@ export function isHeadingEstimated(camera) {
*/ */
export function headingHudToken(camera) { export function headingHudToken(camera) {
const hdg = Math.round(Number(camera?.headingDeg) || 0); const hdg = Math.round(Number(camera?.headingDeg) || 0);
// The token copy lives in the layers catalog (en is byte-identical to the
// literals these replace); presentation stays here, translation at read.
return isHeadingEstimated(camera) return isHeadingEstimated(camera)
? t('HDG {value}° (ESTIMATED)', { value: hdg }) ? t('HDG {value}° (ESTIMATED)', { value: hdg })
: t('HDG {value}°', { value: hdg }); : t('HDG {value}°', { value: hdg });
+14 -14
View File
@@ -66,7 +66,7 @@ const RECORDS = [
type: 'bullet', type: 'bullet',
url: 'https://www.cctv-database.com/camera/hikvision-ds-2cd2087g3-li2uy/', url: 'https://www.cctv-database.com/camera/hikvision-ds-2cd2087g3-li2uy/',
fov_deg: '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)', fov_deg: '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)',
// i18n-ignore: datasheet resolution label (camera data) // i18n-ignore: datasheet label
resolution: { mp: 8, w: 3840, h: 2160, label: '8MP (4K)' }, resolution: { mp: 8, w: 3840, h: 2160, label: '8MP (4K)' },
night_vision: { type: 'color', range_m: 40 }, night_vision: { type: 'color', range_m: 40 },
environment: ['outdoor'], environment: ['outdoor'],
@@ -79,7 +79,7 @@ const RECORDS = [
type: 'dome', type: 'dome',
url: 'https://www.cctv-database.com/camera/axis-f4105-lre/', url: 'https://www.cctv-database.com/camera/axis-f4105-lre/',
fov_deg: '110 horizontal, 60 vertical', fov_deg: '110 horizontal, 60 vertical',
// i18n-ignore: datasheet resolution label (camera data) // i18n-ignore: datasheet label
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' }, resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' },
night_vision: { type: 'ir', range_m: 10 }, night_vision: { type: 'ir', range_m: 10 },
environment: ['outdoor'], environment: ['outdoor'],
@@ -97,7 +97,7 @@ const RECORDS = [
mp: 8, mp: 8,
w: 1920, w: 1920,
h: 1080, h: 1080,
// i18n-ignore: datasheet resolution label (camera data) // i18n-ignore: datasheet label
label: '4x 2MP (8MP total; 4x 1080p)', label: '4x 2MP (8MP total; 4x 1080p)',
}, },
lens_count: 4, lens_count: 4,
@@ -120,7 +120,7 @@ const RECORDS = [
mp: 8, mp: 8,
w: 1920, w: 1920,
h: 1080, h: 1080,
// i18n-ignore: datasheet resolution label (camera data) // i18n-ignore: datasheet label
label: '4x 2MP (8MP total; 4x 1080p)', label: '4x 2MP (8MP total; 4x 1080p)',
}, },
lens_count: 4, lens_count: 4,
@@ -134,7 +134,7 @@ const RECORDS = [
url: 'https://www.cctv-database.com/camera/axis-q6054-e/', url: 'https://www.cctv-database.com/camera/axis-q6054-e/',
aliases: ['AXIS Q6054-E', 'Q6054-E 50 Hz', 'Q6054-E 60 Hz'], aliases: ['AXIS Q6054-E', 'Q6054-E 50 Hz', 'Q6054-E 60 Hz'],
fov_deg: '62.9-2.2 horizontal', fov_deg: '62.9-2.2 horizontal',
// i18n-ignore: datasheet resolution label (camera data) // i18n-ignore: datasheet label
resolution: { mp: 1, w: 1280, h: 720, label: '720p HD' }, resolution: { mp: 1, w: 1280, h: 720, label: '720p HD' },
ptz: true, ptz: true,
autotracking: true, autotracking: true,
@@ -149,7 +149,7 @@ const RECORDS = [
url: 'https://www.cctv-database.com/camera/axis-q6155-e/', url: 'https://www.cctv-database.com/camera/axis-q6155-e/',
aliases: ['AXIS Q6155-E', 'Q6155-E 50 Hz', 'Q6155-E 60 Hz'], aliases: ['AXIS Q6155-E', 'Q6155-E 50 Hz', 'Q6155-E 60 Hz'],
fov_deg: '66.7-2.36 horizontal', fov_deg: '66.7-2.36 horizontal',
// i18n-ignore: datasheet resolution label (camera data) // i18n-ignore: datasheet label
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' }, resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' },
ptz: true, ptz: true,
autotracking: true, autotracking: true,
@@ -171,7 +171,7 @@ const RECORDS = [
'VG4-500', 'VG4-500',
], ],
fov_deg: '57.8-1.7 horizontal', fov_deg: '57.8-1.7 horizontal',
// i18n-ignore: datasheet resolution label (camera data) // i18n-ignore: datasheet label
resolution: { mp: 0.4, label: '540 TVL (NTSC/PAL)' }, resolution: { mp: 0.4, label: '540 TVL (NTSC/PAL)' },
ptz: true, ptz: true,
autotracking: true, autotracking: true,
@@ -186,7 +186,7 @@ const RECORDS = [
url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-dynamic-7000-hd/', url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-dynamic-7000-hd/',
aliases: ['AUTODOME IP dynamic 7000 HD', 'AUTODOME 7000 dynamic HD'], aliases: ['AUTODOME IP dynamic 7000 HD', 'AUTODOME 7000 dynamic HD'],
fov_deg: '65-2.3 horizontal', fov_deg: '65-2.3 horizontal',
// i18n-ignore: datasheet resolution label (camera data) // i18n-ignore: datasheet label
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' },
ptz: true, ptz: true,
autotracking: true, autotracking: true,
@@ -206,7 +206,7 @@ const RECORDS = [
'VG5-7130', 'VG5-7130',
], ],
fov_deg: '59-2.1 horizontal', fov_deg: '59-2.1 horizontal',
// i18n-ignore: datasheet resolution label (camera data) // i18n-ignore: datasheet label
resolution: { mp: 1, w: 1280, h: 720, label: '720p HD (1.3MP sensor)' }, resolution: { mp: 1, w: 1280, h: 720, label: '720p HD (1.3MP sensor)' },
ptz: true, ptz: true,
autotracking: true, autotracking: true,
@@ -226,7 +226,7 @@ const RECORDS = [
'NDP-5502-30', 'NDP-5502-30',
], ],
fov_deg: '60.9-2.4 horizontal', fov_deg: '60.9-2.4 horizontal',
// i18n-ignore: datasheet resolution label (camera data) // i18n-ignore: datasheet label
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' },
ptz: true, ptz: true,
varifocal: true, varifocal: true,
@@ -245,7 +245,7 @@ const RECORDS = [
'NDP-7512-Z30K', 'NDP-7512-Z30K',
], ],
fov_deg: '64.7-2.3 horizontal', fov_deg: '64.7-2.3 horizontal',
// i18n-ignore: datasheet resolution label (camera data) // i18n-ignore: datasheet label
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' },
ptz: true, ptz: true,
autotracking: true, autotracking: true,
@@ -279,7 +279,7 @@ const RECORDS = [
'iDome 3925', 'iDome 3925',
'Cohu iDome 3920 Series', 'Cohu iDome 3920 Series',
], ],
// i18n-ignore: datasheet resolution label (camera data) // i18n-ignore: datasheet label
resolution: { mp: 0.4, label: 'NTSC / 470 TVL' }, resolution: { mp: 0.4, label: 'NTSC / 470 TVL' },
ptz: true, ptz: true,
varifocal: true, varifocal: true,
@@ -292,7 +292,7 @@ const RECORDS = [
type: 'ptz', type: 'ptz',
url: 'https://www.cctv-database.com/camera/cohu-3950-iview/', url: 'https://www.cctv-database.com/camera/cohu-3950-iview/',
aliases: ['3950', '3950 Series', 'Cohu 3950 Series iVIEW'], aliases: ['3950', '3950 Series', 'Cohu 3950 Series iVIEW'],
// i18n-ignore: datasheet resolution label (camera data) // i18n-ignore: datasheet label
resolution: { mp: 0.4, label: 'NTSC / 470 TVL' }, resolution: { mp: 0.4, label: 'NTSC / 470 TVL' },
ptz: true, ptz: true,
varifocal: true, varifocal: true,
@@ -305,7 +305,7 @@ const RECORDS = [
type: 'ptz', type: 'ptz',
url: 'https://www.cctv-database.com/camera/cohu-3960/', url: 'https://www.cctv-database.com/camera/cohu-3960/',
aliases: ['3960 Series', '3964', '3965', 'Cohu 3960 iView'], aliases: ['3960 Series', '3964', '3965', 'Cohu 3960 iView'],
// i18n-ignore: datasheet resolution label (camera data) // i18n-ignore: datasheet label
resolution: { mp: 0.4, label: 'NTSC/PAL / 470 TVL' }, resolution: { mp: 0.4, label: 'NTSC/PAL / 470 TVL' },
ptz: true, ptz: true,
varifocal: true, varifocal: true,
+5 -5
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { createCyclonesLayer } from './index.js'; import { createCyclonesLayer } from './index.js';
import { setLocale, t } from '../../i18n/index.js'; import { setLocale, t } from '../../i18n/index.js';
// The exact string the server sends (server/providers/cyclones.js:8). // The exact string the server sends (COVERAGE in server/providers/cyclones.js).
const SERVER_COVERAGE = const SERVER_COVERAGE =
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.'; 'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
const time = '2026-09-16T03:00:00.000Z'; const time = '2026-09-16T03:00:00.000Z';
@@ -59,7 +59,7 @@ function harness(feed) {
const translated = () => t(SERVER_COVERAGE); const translated = () => t(SERVER_COVERAGE);
test('K-35a: es, server snapshot without storms -> coverage line in Spanish', async () => { test('es: server snapshot without storms -> coverage line in Spanish', async () => {
setLocale('es'); setLocale('es');
try { try {
assert.notEqual(translated(), SERVER_COVERAGE, 'es.json has a translation'); assert.notEqual(translated(), SERVER_COVERAGE, 'es.json has a translation');
@@ -77,7 +77,7 @@ test('K-35a: es, server snapshot without storms -> coverage line in Spanish', as
} }
}); });
test('K-35b: es, server snapshot with a storm -> coverage line in Spanish', async () => { test('es: server snapshot with a storm -> coverage line in Spanish', async () => {
setLocale('es'); setLocale('es');
try { try {
const layer = harness({ getSnapshot: async () => snapshot([storm]) }); const layer = harness({ getSnapshot: async () => snapshot([storm]) });
@@ -94,7 +94,7 @@ test('K-35b: es, server snapshot with a storm -> coverage line in Spanish', asyn
} }
}); });
test('K-35c: es, unavailable without reason -> fallback status not the English literal', async () => { test('es: unavailable without reason -> fallback status not the English literal', async () => {
setLocale('es'); setLocale('es');
try { try {
const layer = harness({ const layer = harness({
@@ -115,7 +115,7 @@ test('K-35c: es, unavailable without reason -> fallback status not the English l
} }
}); });
test('K-35: en coverage and fallback status read as before', async () => { test('en: coverage and fallback status read as before', async () => {
const layer = harness({ getSnapshot: async () => snapshot([]) }); const layer = harness({ getSnapshot: async () => snapshot([]) });
layer.enable(); layer.enable();
await layer.update(); await layer.update();
+3 -5
View File
@@ -99,9 +99,8 @@ export const STEP_ANCHOR_FAST_ATTEMPTS = 5;
export const FLIGHT_PROGRESS_MS = 250; export const FLIGHT_PROGRESS_MS = 250;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Display text: distances, durations and maneuvers in the page's language. // Display text in the page's language. The proxy's English `instruction` stays
// The proxy's English `instruction` stays the machine value; the row and the // the machine value; steps are phrased from their OSRM type/modifier/road.
// cards phrase each step from its OSRM type/modifier/road instead.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
@@ -639,8 +638,7 @@ export function directionsStats(state) {
armed, armed,
pointerBlocked, pointerBlocked,
} = state; } = state;
// English in layer state; the layer row translates it. const source = 'OSM routing'; // English in layer state; the layer row translates it
const source = 'OSM routing'; // i18n-ignore: source name, translated by layerPanel
if (pointerBlocked) { if (pointerBlocked) {
return { return {
count: route?.steps.length || 0, count: route?.steps.length || 0,
+5 -6
View File
@@ -1,13 +1,12 @@
// K-34: AIS degradation chip text and the traffic "SIMULATED — <reason>" text // The AIS degradation chip and the traffic "SIMULATED — <reason>" text read in
// must be in the UI language (they stayed English in es before the fix); // the UI language; English keeps its wording.
// English keeps its wording.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { setLocale, translateKnown } from '../i18n/index.js'; import { setLocale, translateKnown } from '../i18n/index.js';
import { deriveAisFeedError } from '../data/aisLiveVessels.js'; import { deriveAisFeedError } from '../data/aisLiveVessels.js';
import { createModel } from './traffic/model.js'; import { createModel } from './traffic/model.js';
test('K-34: AIS degraded-feed reasons are localized in es', () => { test('AIS degraded-feed reasons are localized in es', () => {
setLocale('es'); setLocale('es');
try { try {
const cases = [ const cases = [
@@ -34,7 +33,7 @@ test('K-34: AIS degraded-feed reasons are localized in es', () => {
} }
}); });
test('K-34: traffic "SIMULATED — <reason>" is localized in es', () => { test('traffic "SIMULATED — <reason>" is localized in es', () => {
setLocale('es'); setLocale('es');
try { try {
const { trafficFeedPresentation } = createModel({ const { trafficFeedPresentation } = createModel({
@@ -55,7 +54,7 @@ test('K-34: traffic "SIMULATED — <reason>" is localized in es', () => {
} }
}); });
test('K-34: English degraded-feed wording is unchanged', () => { test('English degraded-feed wording is unchanged', () => {
setLocale('en'); setLocale('en');
assert.equal( assert.equal(
deriveAisFeedError({ status: 'down', reconnectAttempt: 3 }, 0), deriveAisFeedError({ status: 'down', reconnectAttempt: 3 }, 0),
+1
View File
@@ -45,6 +45,7 @@ export function createIngestion({
const sourceStale = snapshot.stale || snapshot.freshness === 'unknown'; const sourceStale = snapshot.stale || snapshot.freshness === 'unknown';
feed._backoff = sourceStale; feed._backoff = sourceStale;
feed._retryAt = 0; feed._retryAt = 0;
// i18n-ignore: English data; the panel shows it through FEED_ERROR_PATTERNS (ui/statusText.js)
feed._lastError = sourceStale feed._lastError = sourceStale
? sourceAgeMs == null ? sourceAgeMs == null
? msg('Source snapshot time unavailable') ? msg('Source snapshot time unavailable')
+1 -2
View File
@@ -41,8 +41,7 @@ export function createAdsbGnssSource({
); );
throw Object.assign( throw Object.assign(
new Error( new Error(
// The status code form is matched by FEED_ERROR_PATTERNS // HTTP errors are matched by FEED_ERROR_PATTERNS in ui/statusText.js.
// (ui/statusText.js) and gnss/index.js.
limited limited
? msg('GNSS feed rate limited (HTTP 429)') ? msg('GNSS feed rate limited (HTTP 429)')
: `GNSS feed unavailable (HTTP ${response.status})`, : `GNSS feed unavailable (HTTP ${response.status})`,
@@ -1,4 +1,4 @@
// K-50: every class the bundled OSM names pack can put on a record has a // Every class the bundled OSM names pack can put on a record has a
// catalog entry, so the selected card shows it in the UI language. // catalog entry, so the selected card shows it in the UI language.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -22,7 +22,7 @@ const englishLabel = Object.fromEntries(
); );
for (const locale of ['es', 'ru']) { for (const locale of ['es', 'ru']) {
test(`every names-pack class is in the ${locale} catalog (K-50)`, () => { test(`every names-pack class is in the ${locale} catalog`, () => {
const catalog = getCatalog(locale); const catalog = getCatalog(locale);
const missing = pack.classes.filter( const missing = pack.classes.filter(
(c) => catalog[`installation class|${englishLabel[c]}`] === undefined, (c) => catalog[`installation class|${englishLabel[c]}`] === undefined,
+2 -4
View File
@@ -7,10 +7,8 @@ import {
} from './policy.js'; } from './policy.js';
import { formatNumber, t, tn } from '../../i18n/index.js'; import { formatNumber, t, tn } from '../../i18n/index.js';
// installationFeedback.js composes its guidance inside the portable // installationFeedback.js is shared with the HUD (src/loadingFeedback.js) and
// node/browser graph (the HUD summary in loadingFeedback.js reads the same // hands back msg()-marked English plus values; getStats translates them here.
// module), so it hands back English marked with msg() plus the values;
// getStats is the browser presentation boundary that translates them.
function localizeInstallationFeedback(feedback) { function localizeInstallationFeedback(feedback) {
if (feedback.text !== undefined) { if (feedback.text !== undefined) {
const values = {}; const values = {};
@@ -6,7 +6,7 @@ import { setLocale } from '../../i18n/index.js';
import { installationTitle } from './classLabel.js'; import { installationTitle } from './classLabel.js';
import { formatAwarenessLabel } from '../../data/militaryAwarenessEngine.js'; import { formatAwarenessLabel } from '../../data/militaryAwarenessEngine.js';
// K-33: unnamed installations get an English label in data ("Firing range", // Unnamed installations get an English label in data ("Firing range",
// "Military land", "Military airfield"; OpenFreeMap polygons: "Military area"). // "Military land", "Military airfield"; OpenFreeMap polygons: "Military area").
// The data stays English; the map card title reads in the UI language. // The data stays English; the map card title reads in the UI language.
const { records } = normalizeMilitaryInstallations({ const { records } = normalizeMilitaryInstallations({
@@ -24,7 +24,7 @@ const { records } = normalizeMilitaryInstallations({
}); });
const names = [...records.map((r) => r.name), UNNAMED_MILITARY_AREA]; const names = [...records.map((r) => r.name), UNNAMED_MILITARY_AREA];
test('K-33: unnamed installations keep English names in data', () => { test('unnamed installations keep English names in data', () => {
assert.deepEqual(names, [ assert.deepEqual(names, [
'Firing range', 'Firing range',
'Military land', 'Military land',
@@ -34,7 +34,7 @@ test('K-33: unnamed installations keep English names in data', () => {
]); ]);
}); });
test('K-33: unnamed installation card title reads in the UI language', () => { test('unnamed installation card title reads in the UI language', () => {
try { try {
for (const locale of ['es', 'ru']) { for (const locale of ['es', 'ru']) {
setLocale(locale); setLocale(locale);
@@ -55,13 +55,13 @@ test('K-33: unnamed installation card title reads in the UI language', () => {
} }
}); });
test('K-33: English card titles are unchanged', () => { test('English card titles are unchanged', () => {
setLocale('en'); setLocale('en');
for (const name of names) assert.equal(installationTitle(name), name); for (const name of names) assert.equal(installationTitle(name), name);
assert.equal(installationTitle(''), ''); assert.equal(installationTitle(''), '');
}); });
test('K-33: the Contacts line names an unnamed installation in the UI language', () => { test('the Contacts line names an unnamed installation in the UI language', () => {
try { try {
setLocale('en'); setLocale('en');
for (const name of names) for (const name of names)
@@ -0,0 +1,85 @@
// A launch without a Launch Library status gets our 'Unknown'. The record keeps
// it English; the mission panel translates it and shows provider statuses as sent.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../../i18n/index.js';
import {
launchStatusAllowsOrbit,
normalizeRocketLaunches,
} from '../../data/rocketLaunches.js';
import { UNKNOWN_LAUNCH_STATUS } from './model.js';
import { createPanel } from './panel.js';
const NOW = new Date('2026-07-27T00:00:00Z');
function shownStatus(status) {
const outputs = new Map();
const state = {
_missionPanel: {
querySelector(selector) {
if (!outputs.has(selector))
outputs.set(selector, {
textContent: '',
closest: () => null,
removeAttribute() {},
});
return outputs.get(selector);
},
closest: () => null,
},
_selectedLaunchId: 'mission-1',
_launches: [
{
id: 'mission-1',
name: 'Example',
status,
payloads: [],
recoveryStages: [],
},
],
_replayTracks: new Map(),
};
createPanel({
state,
parts: {
overlays: { shortMissionLabel: (name) => name },
policyHelpers: { missionPathPresentation: () => ({}) },
replay: { syncReplayButton() {} },
},
}).renderMissionPanel();
return outputs.get('[data-mission-status]').textContent;
}
test('a launch without a status keeps the English fallback in its record', () => {
const [launch] = normalizeRocketLaunches(
{
results: [
{
id: 'no-status',
name: 'Test Flight',
net: '2026-07-20T10:00:00Z',
pad: {
name: 'Pad A',
location: { name: 'Test Range', coordinates: '12.5,45.5' },
},
},
],
},
NOW,
);
assert.equal(UNKNOWN_LAUNCH_STATUS, 'Unknown');
assert.equal(launch.status, 'Unknown');
assert.equal(launchStatusAllowsOrbit(launch.status), true);
});
test('the mission panel translates only the fallback status', (t) => {
t.after(() => setLocale('en'));
setLocale('en');
assert.equal(shownStatus(UNKNOWN_LAUNCH_STATUS), 'Unknown');
setLocale('ru');
assert.equal(shownStatus(UNKNOWN_LAUNCH_STATUS), 'Неизвестно');
assert.equal(shownStatus('Go for Launch'), 'Go for Launch');
setLocale('es');
assert.equal(shownStatus(UNKNOWN_LAUNCH_STATUS), 'Desconocido');
assert.equal(shownStatus('Launch Successful'), 'Launch Successful');
});
+8 -2
View File
@@ -1,6 +1,13 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { msg } from '../../i18n/index.js';
import { WINDOW_DAYS } from './policy.js'; import { WINDOW_DAYS } from './policy.js';
/**
* Fallback when Launch Library sends no status. English in the record
* (launchStatusAllowsOrbit, analyst records); the mission panel translates it.
*/
export const UNKNOWN_LAUNCH_STATUS = msg('Unknown');
export function createModel({ state: layerState, services, parts, source }) { export function createModel({ state: layerState, services, parts, source }) {
function finiteCoordinate(value) { function finiteCoordinate(value) {
const number = Number(value); const number = Number(value);
@@ -192,8 +199,7 @@ export function createModel({ state: layerState, services, parts, source }) {
launch.id || launch.slug || launch.name || `launch-${date}`, launch.id || launch.slug || launch.name || `launch-${date}`,
), ),
name: launch.name || 'Unnamed launch', name: launch.name || 'Unnamed launch',
// i18n-ignore: Launch Library status names are provider data, shown as sent status: launch.status?.name || UNKNOWN_LAUNCH_STATUS,
status: launch.status?.name || 'Unknown',
launchTime: Number.isFinite(date) launchTime: Number.isFinite(date)
? new Date(date).toISOString() ? new Date(date).toISOString()
: null, : null,
+9 -1
View File
@@ -4,6 +4,7 @@ import {
MISSION_CLOSE_VIEW_RANGE_M, MISSION_CLOSE_VIEW_RANGE_M,
MISSION_GLOBE_VIEW_RANGE_M, MISSION_GLOBE_VIEW_RANGE_M,
} from './policy.js'; } from './policy.js';
import { UNKNOWN_LAUNCH_STATUS } from './model.js';
export function createPanel({ state: layerState, services, parts, source }) { export function createPanel({ state: layerState, services, parts, source }) {
/** /**
@@ -191,7 +192,14 @@ export function createPanel({ state: layerState, services, parts, source }) {
layerState._missionPanel.querySelector('[data-mission-title]').textContent = layerState._missionPanel.querySelector('[data-mission-title]').textContent =
parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase(); parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase();
setMissionPanelField('[data-mission-provider]', launch.provider); setMissionPanelField('[data-mission-provider]', launch.provider);
setMissionPanelField('[data-mission-status]', launch.status); // Launch Library status names are provider data, shown as sent; only the
// fallback for a launch without one is ours to translate.
setMissionPanelField(
'[data-mission-status]',
launch.status === UNKNOWN_LAUNCH_STATUS
? t(UNKNOWN_LAUNCH_STATUS)
: launch.status,
);
setMissionPanelField( setMissionPanelField(
'[data-mission-site]', '[data-mission-site]',
launch.launchSite && launch.launchSite !== 'Unknown launch site' launch.launchSite && launch.launchSite !== 'Unknown launch site'
+2 -2
View File
@@ -1,4 +1,4 @@
// K-37: the Local ADS-B click card follows the UI language; English keeps its // The Local ADS-B click card follows the UI language; English keeps its
// wording (index.test.mjs pins the full English card). // wording (index.test.mjs pins the full English card).
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -86,7 +86,7 @@ test('the class line names the aircraft class in the UI language', async () => {
} }
}); });
// C8: the receiver bands follow the UI language too ("1090 МГц"). // The receiver bands follow the UI language too ("1090 МГц").
test('ru: the receiver line names the bands in Russian', async () => { test('ru: the receiver line names the bands in Russian', async () => {
const { localAdsbReceiverLine } = await import('./card.js'); const { localAdsbReceiverLine } = await import('./card.js');
setLocale('ru'); setLocale('ru');
-4
View File
@@ -58,10 +58,6 @@ export function localAdsbReceiverLine(record) {
.join(' · '); .join(' · ');
} }
// policy.js keeps these labels in English (HEARD_BY_RECEIVER, BAND_LABELS,
// SOURCE_LABELS); the card writes them out here so that it can show them
// translated.
/** A receiver band (policy.js BAND_LABELS) in the UI language: "1090 МГц". */ /** A receiver band (policy.js BAND_LABELS) in the UI language: "1090 МГц". */
function bandLabel(band) { function bandLabel(band) {
switch (String(band)) { switch (String(band)) {
@@ -1,41 +0,0 @@
// A Local ADS-B feed problem can be the whole error (nothing heard, WebUSB
// not producing); the row shows it in the UI language like any other piece.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../../i18n/index.js';
import { LayerPanel } from '../../ui/layerPanel.js';
import { localAdsbStatus } from './status.js';
const receiver = {
webUsbSupported: true,
connected: false,
mode: 'adsb',
status: 'idle',
};
function rowFor(status) {
return LayerPanel.prototype._buildMetaText({
id: 'local-adsb',
enabled: true,
stats: { count: 0, lastUpdate: Date.now(), ...status },
});
}
for (const locale of ['es', 'ru']) {
for (const status of ['unreachable', 'invalid']) {
test(`${locale}: a lone ${status} decoder feed with nothing heard is translated`, () => {
setLocale(locale);
try {
const st = localAdsbStatus({
receiver,
feedState: {
configured: true,
feeds: [{ id: 'a', label: '1090', band: '1090', status }],
},
heard: 0,
});
const row = rowFor(st);
assert.doesNotMatch(row, /\b(unreachable|invalid)\b/, row);
} finally {
setLocale('en');
}
});
}
}
+42 -12
View File
@@ -1,13 +1,12 @@
// The Local ADS-B row's working texts ("3 heard · 5.8 msg/s", // The Local ADS-B row's working texts ("3 heard · 5.8 msg/s", "listening",
// "2 feeds live · 14 heard", "listening", "USB error", feed problems) read in // "USB error", feed problems, even as the whole error) read in the UI language.
// the UI language.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { setLocale } from '../../i18n/index.js'; import { setLocale } from '../../i18n/index.js';
import { LayerPanel } from '../../ui/layerPanel.js'; import { LayerPanel } from '../../ui/layerPanel.js';
import { localAdsbStatus } from './status.js'; import { localAdsbStatus } from './status.js';
const receiver = { const streaming = {
webUsbSupported: true, webUsbSupported: true,
connected: true, connected: true,
mode: 'adsb', mode: 'adsb',
@@ -15,24 +14,55 @@ const receiver = {
messagesPerSecond: 5.8, messagesPerSecond: 5.8,
}; };
function rowFor(status) { const idle = {
webUsbSupported: true,
connected: false,
mode: 'adsb',
status: 'idle',
};
function rowFor(status, count) {
return LayerPanel.prototype._buildMetaText({ return LayerPanel.prototype._buildMetaText({
id: 'local-adsb', id: 'local-adsb',
enabled: true, enabled: true,
stats: { count: 3, lastUpdate: Date.now(), ...status }, stats: { count, lastUpdate: Date.now(), ...status },
}); });
} }
function inLocale(locale, run) {
setLocale(locale);
try {
run();
} finally {
setLocale('en');
}
}
for (const locale of ['es', 'ru']) { for (const locale of ['es', 'ru']) {
test(`${locale}: the streaming Local ADS-B row is translated`, () => { test(`${locale}: the streaming Local ADS-B row is translated`, () => {
setLocale(locale); inLocale(locale, () => {
try {
const row = rowFor( const row = rowFor(
localAdsbStatus({ receiver, feedState: null, heard: 3 }), localAdsbStatus({ receiver: streaming, feedState: null, heard: 3 }),
3,
); );
assert.doesNotMatch(row, /\bheard\b/, row); assert.doesNotMatch(row, /\bheard\b/, row);
} finally { });
setLocale('en');
}
}); });
for (const status of ['unreachable', 'invalid']) {
test(`${locale}: a lone ${status} decoder feed with nothing heard is translated`, () => {
inLocale(locale, () => {
const st = localAdsbStatus({
receiver: idle,
feedState: {
configured: true,
feeds: [{ id: 'a', label: '1090', band: '1090', status }],
},
heard: 0,
});
const row = rowFor(st, 0);
assert.doesNotMatch(row, /\b(unreachable|invalid)\b/, row);
});
});
}
} }
+2 -2
View File
@@ -1,4 +1,4 @@
// K-37 / K-29: the selected-fire card of Fire Perimeters and its accessible // The selected-fire card of Fire Perimeters and its accessible
// label follow the UI language, like the legend of the same layer // label follow the UI language, like the legend of the same layer
// (perimeters/index.js). cards.test.mjs pins the English card. // (perimeters/index.js). cards.test.mjs pins the English card.
import test from 'node:test'; import test from 'node:test';
@@ -82,7 +82,7 @@ for (const locale of ['es', 'ru']) {
}); });
} }
// C5 / C11 / E7: known WFIGS values, the cost and the unnamed title follow the // Known WFIGS values, the cost and the unnamed title follow the
// UI language; the row keeps the feed's English for the AI tools. // UI language; the row keeps the feed's English for the AI tools.
const expected = { const expected = {
es: { es: {
+2 -4
View File
@@ -146,10 +146,8 @@ function formatAge(deltaMs) {
* @returns {Object} World-overlay entry without `position`. * @returns {Object} World-overlay entry without `position`.
*/ */
export function buildIncidentCard(row, nowMs, { link = null } = {}) { export function buildIncidentCard(row, nowMs, { link = null } = {}) {
// Feed values stay as WFIGS sends them in the row (and in what the AI tools // The row keeps WFIGS values as sent (AI tools read them); the card translates
// read); the card translates the known cause, behavior and complexity values // the known cause, behavior and complexity values and the wording around them.
// and the wording around them. State, category, county and complex names
// are shown as sent.
const facts = []; const facts = [];
if (Number.isFinite(row.acres)) { if (Number.isFinite(row.acres)) {
const acres = Math.round(row.acres); const acres = Math.round(row.acres);
+2 -2
View File
@@ -62,8 +62,8 @@ export const DEFAULT_RADIO_FILTER = 'all';
export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000; export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000;
// Genre labels are marked for i18n extraction; categories.js translates them. // Genre labels stay English data (the radio source, shared with the AI tools,
// Kept local so this module stays free of the i18n import. // imports this module); categories.js translates them.
const msg = (text) => text; const msg = (text) => text;
export const MUSIC_GENRES = Object.freeze([ export const MUSIC_GENRES = Object.freeze([
+1 -1
View File
@@ -47,8 +47,8 @@ export const SPLIT_PUBLISH_MS = 400;
/** The three modes, in share-link order (`m` = index). */ /** The three modes, in share-link order (`m` = index). */
export const MODES = Object.freeze(['image', 'basemap', 'ab']); export const MODES = Object.freeze(['image', 'basemap', 'ab']);
const COMPARISON_IN_USE = msg('Comparison in use by another scene');
// Kept English in the snapshot (AI tools read it); the card translates them. // Kept English in the snapshot (AI tools read it); the card translates them.
const COMPARISON_IN_USE = msg('Comparison in use by another scene');
const COMPARISON_UNAVAILABLE = msg('Esri map unavailable · no swipe'); const COMPARISON_UNAVAILABLE = msg('Esri map unavailable · no swipe');
const COMPARISON_NEEDS_GLOBE = msg('Swipe needs a globe map'); const COMPARISON_NEEDS_GLOBE = msg('Swipe needs a globe map');
const ESRI_KEPT = msg('Imagery stays on Esri · CLEAR to use Google 3D'); const ESRI_KEPT = msg('Imagery stays on Esri · CLEAR to use Google 3D');
+2 -2
View File
@@ -71,8 +71,8 @@ export function createIngestion({
// At least one group loaded — proceed with a fresh rebuild, but keep the // At least one group loaded — proceed with a fresh rebuild, but keep the
// partial outage visible at the layer control instead of presenting the // partial outage visible at the layer control instead of presenting the
// reduced catalog as a fully healthy refresh. English data; the panel // reduced catalog as a fully healthy refresh.
// shows it through FEED_ERROR_PATTERNS (ui/statusText.js). // i18n-ignore: English data; the panel shows it through FEED_ERROR_PATTERNS (ui/statusText.js)
layerState._lastError = failed.length layerState._lastError = failed.length
? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable` ? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable`
: null; : null;
+1 -1
View File
@@ -704,7 +704,7 @@ export function createLineCoverage({
state.coverage.pending.get(key) !== controller state.coverage.pending.get(key) !== controller
) )
return; return;
state.coverage.lastError = error?.message || 'Coverage tile failed'; state.coverage.lastError = error?.message || msg('Coverage tile failed');
if (error?.keyRequired) state.keyRequired = true; if (error?.keyRequired) state.keyRequired = true;
if (error?.keyRejected) { if (error?.keyRejected) {
// Every other tile would be refused too: stop asking. // Every other tile would be refused too: stop asking.
+1 -1
View File
@@ -1,4 +1,4 @@
// K-37: the traffic road-status notes follow the UI language; English keeps // The traffic road-status notes follow the UI language; English keeps
// its wording. // its wording.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
+1 -2
View File
@@ -476,8 +476,7 @@ export function createIngestion({ state, services, parts, source }) {
`[Data:Transit] ${feed.id} poll failed:`, `[Data:Transit] ${feed.id} poll failed:`,
error?.message || error, error?.message || error,
); );
// English data; the panel shows it through FEED_ERROR_PATTERNS // i18n-ignore: English data, translated on display (ui/statusText.js)
// i18n-ignore: translated on display (ui/statusText.js)
status.error = `${feed.name} feed unavailable`; status.error = `${feed.name} feed unavailable`;
status.retryInSec = Number.isFinite(error?.retryInSec) status.retryInSec = Number.isFinite(error?.retryInSec)
? error.retryInSec ? error.retryInSec
+2 -4
View File
@@ -321,10 +321,8 @@ export function aggregateTransitFeedHealth(statuses, nowMs) {
}; };
} }
// AI tools (src/tools/queries/mobility.js) import this module, so it stays // AI tools (src/tools/queries/mobility.js) import this module, so no i18n here:
// free of i18n: the card copy below calls the translate function `t` that the // the card copy takes `t` from the caller (selection.js); without it, English.
// caller hands in. The browser selection passes the real one from src/i18n;
// without it the copy is English.
const msg = (text) => text; const msg = (text) => text;
/** English {placeholder} interpolation, the default `t` of the card copy. */ /** English {placeholder} interpolation, the default `t` of the card copy. */
+1 -3
View File
@@ -278,10 +278,8 @@ export function createQueries({ state, parts }) {
: {}), : {}),
degraded: count > 0 && health.degraded, degraded: count > 0 && health.degraded,
loading: health.loading && count === 0, loading: health.loading && count === 0,
// English data; the panel shows it through STATUS_PIECE_PATTERNS
// (ui/statusText.js).
loadingLabel: loadingLabel:
// i18n-ignore: translated on display // i18n-ignore: English data, translated on display (ui/statusText.js)
health.loading && count === 0 health.loading && count === 0
? `Loading ${active.map((feed) => feed.name).join(', ')}` ? `Loading ${active.map((feed) => feed.name).join(', ')}`
: undefined, : undefined,
@@ -1,4 +1,4 @@
// K-37: the Transit selection card follows the UI language. The copy builder // The Transit selection card follows the UI language. The copy builder
// stays free of i18n (AI tools import policy.js); the browser hands it t(). // stays free of i18n (AI tools import policy.js); the browser hands it t().
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
+2 -4
View File
@@ -1,10 +1,8 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { t, msg } from '../../i18n/index.js'; import { t, msg } from '../../i18n/index.js';
// The vessels ingestion owner is a portable module graph (it also runs in the // ingestion.js is a portable export (package.json) and writes English labels
// server-side source builds), so it writes stable English feed labels into // into state; translate the known ones here, pass anything else through.
// state. Localize those known labels here, at the presentation read; anything
// else (a bespoke manager label) passes through verbatim.
const FEED_STATE_LABEL_KEYS = Object.freeze({ const FEED_STATE_LABEL_KEYS = Object.freeze({
'refreshing...': msg('refreshing...'), 'refreshing...': msg('refreshing...'),
'loading...': msg('loading...'), 'loading...': msg('loading...'),
@@ -3,9 +3,8 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { setLocale, t } from '../../i18n/index.js'; import { setLocale, t } from '../../i18n/index.js';
import { WIND_FIELDS } from './fields.js'; import { SCALAR_UNAVAILABLE, WIND_FIELDS } from './fields.js';
import { fieldUnavailableText } from './rendering.js'; import { fieldUnavailableText } from './rendering.js';
import { SCALAR_UNAVAILABLE } from './source.js';
for (const locale of ['es', 'ru']) { for (const locale of ['es', 'ru']) {
test(`${locale}: every field's unavailable status has a translation`, () => { test(`${locale}: every field's unavailable status has a translation`, () => {
+11
View File
@@ -1,5 +1,16 @@
import { msg } from '../../i18n/marker.js';
import { normalizeLongitude, sampleWind } from './model.js'; import { normalizeLongitude, sampleWind } from './model.js';
// Imported by src/tools: the catalog-free marker, not src/i18n/index.js. The
// texts stay English data and the wind layer and layer panel show them
// translated.
/** Status of a scalar overlay the provider could not supply, by overlay. */
export const SCALAR_UNAVAILABLE = Object.freeze({
temperature: msg('Temperature field unavailable'),
pressure: msg('Mean sea level pressure field unavailable'),
});
/** Fixed physical scales shared by imagery and the field legend. */ /** Fixed physical scales shared by imagery and the field legend. */
export const WIND_FIELDS = Object.freeze({ export const WIND_FIELDS = Object.freeze({
speed: Object.freeze({ speed: Object.freeze({
+3 -4
View File
@@ -7,17 +7,16 @@ import { orderWeatherImagery } from '../weather/imageryOrder.js';
import { NO_IMAGERY_HOST } from '../weather/imageryHost.js'; import { NO_IMAGERY_HOST } from '../weather/imageryHost.js';
import { msg } from '../../i18n/index.js'; import { msg } from '../../i18n/index.js';
import { createWindGpuRendering } from './gpuRendering.js'; import { createWindGpuRendering } from './gpuRendering.js';
import { SCALAR_UNAVAILABLE } from './source.js';
import { advectParticle, sampleWind } from './model.js'; import { advectParticle, sampleWind } from './model.js';
import { import {
createFieldRaster, createFieldRaster,
SCALAR_UNAVAILABLE,
trailEraseAlpha, trailEraseAlpha,
windTrailColor, windTrailColor,
} from './fields.js'; } from './fields.js';
// A scalar overlay that cannot be drawn, in English for diagnostics; the // English for diagnostics; the row status shows it translated (index.js). The
// panel status shows it translated (index.js). The scalar texts are the ones // scalar texts are the source's own; setOptions accepts only these overlays.
// the source reports. setOptions only accepts these overlays.
const FIELD_UNAVAILABLE = Object.freeze({ const FIELD_UNAVAILABLE = Object.freeze({
speed: msg('Wind speed field unavailable'), speed: msg('Wind speed field unavailable'),
...SCALAR_UNAVAILABLE, ...SCALAR_UNAVAILABLE,
+1 -6
View File
@@ -1,15 +1,10 @@
import { readWindBody } from '../../sources/windBody.js'; import { readWindBody } from '../../sources/windBody.js';
import { SCALAR_UNAVAILABLE } from './fields.js';
// Imported by src/tools: no i18n here. The texts stay English data and the // Imported by src/tools: no i18n here. The texts stay English data and the
// wind layer and layer panel show them translated. // wind layer and layer panel show them translated.
const msg = (text) => text; const msg = (text) => text;
/** Status of a scalar overlay the provider could not supply, by overlay. */
export const SCALAR_UNAVAILABLE = Object.freeze({
temperature: msg('Temperature field unavailable'),
pressure: msg('Mean sea level pressure field unavailable'),
});
/** Create a bounded, cancellable source for the same-origin wind provider. */ /** Create a bounded, cancellable source for the same-origin wind provider. */
export function createWindSource({ export function createWindSource({
fetchImpl = (...args) => globalThis.fetch(...args), fetchImpl = (...args) => globalThis.fetch(...args),
+1 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { setLocale } from '../../i18n/index.js'; import { setLocale } from '../../i18n/index.js';
import { createWindLayer } from './index.js'; import { createWindLayer } from './index.js';
test('stale wind forecast is marked in the UI language (K-40)', async () => { test('stale wind forecast is marked in the UI language', async () => {
const rendering = Object.fromEntries( const rendering = Object.fromEntries(
['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map((n) => [ ['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map((n) => [
n, n,
+1 -1
View File
@@ -1,4 +1,4 @@
// K-38: wind source failures are English data (src/layers/wind/source.js) and // Wind source failures are English data (src/layers/wind/source.js) and
// are shown in the UI language; the HTTP line keeps its status number. // are shown in the UI language; the HTTP line keeps its status number.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
+1 -1
View File
@@ -39,7 +39,7 @@ async function serverManifestOnUpstreamFailure() {
return JSON.parse(body); return JSON.parse(body);
} }
test('en: the upstream failure reason reads as before (K-17)', async () => { test('en: the upstream failure reason reads as before', async () => {
const manifest = await serverManifestOnUpstreamFailure(); const manifest = await serverManifestOnUpstreamFailure();
const layer = harness({ getSnapshot: async () => manifest }); const layer = harness({ getSnapshot: async () => manifest });
await layer.update(); await layer.update();
+4 -4
View File
@@ -244,7 +244,7 @@ export function reduceTrafficSyncFeedback(
// Neutral default: the layer always supplies its own LIVE/SIMULATED // Neutral default: the layer always supplies its own LIVE/SIMULATED
// label, and a fallback string must never claim a live feed on a // label, and a fallback string must never claim a live feed on a
// keyless build. // keyless build.
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary // i18n-ignore: translated in src/ui/shellFeedback.js
label: label || 'syncing road network', label: label || 'syncing road network',
progressText: hasProgress ? `${progressPct}%` : '...', progressText: hasProgress ? `${progressPct}%` : '...',
}; };
@@ -393,7 +393,7 @@ export function presentLoadingFeedback(state, summary, nowMs) {
); );
return { return {
state: 'retry', state: 'retry',
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary // i18n-ignore: translated in src/ui/shellFeedback.js
label: ( label: (
camera.cameraRetry.error || 'Overpass temporarily unavailable' camera.cameraRetry.error || 'Overpass temporarily unavailable'
).toUpperCase(), ).toUpperCase(),
@@ -440,7 +440,7 @@ export function presentLoadingFeedback(state, summary, nowMs) {
if (active.length === 1 && active[0].cameraRetry && !summary.disabling) { if (active.length === 1 && active[0].cameraRetry && !summary.disabling) {
return { return {
state: 'loading', state: 'loading',
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary // i18n-ignore: translated in src/ui/shellFeedback.js
label: active[0].cameraRetry.retrying label: active[0].cameraRetry.retrying
? 'RETRYING ALPR CAMERAS' ? 'RETRYING ALPR CAMERAS'
: 'FETCHING ALPR CAMERAS', : 'FETCHING ALPR CAMERAS',
@@ -455,7 +455,7 @@ export function presentLoadingFeedback(state, summary, nowMs) {
) { ) {
return { return {
state: 'loading', state: 'loading',
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary // i18n-ignore: translated in src/ui/shellFeedback.js
label: active[0].installationRetry.retrying label: active[0].installationRetry.retrying
? 'RETRYING MAPPED SITES' ? 'RETRYING MAPPED SITES'
: 'FETCHING MAPPED SITES', : 'FETCHING MAPPED SITES',
-2
View File
@@ -12,8 +12,6 @@ import { cancelCameraArrival } from './data/cameraArrival.js';
import { guardCameraAboveGround } from './cameraGroundGuard.js'; import { guardCameraAboveGround } from './cameraGroundGuard.js';
import { CITY_POIS } from './cityPois.js'; import { CITY_POIS } from './cityPois.js';
export { CITY_POIS };
/** Newest landmark arrival; an older post-arrival ground guard yields to it. */ /** Newest landmark arrival; an older post-arrival ground guard yields to it. */
let arrivalGeneration = 0; let arrivalGeneration = 0;
+1 -2
View File
@@ -38,8 +38,7 @@ export function mapStackChipModel(stack, activeId) {
const available = stack?.available !== false; const available = stack?.available !== false;
const label = String(stack?.label ?? stack?.id ?? ''); const label = String(stack?.label ?? stack?.id ?? '');
const requiresIon = stack?.requiresIon === true; const requiresIon = stack?.requiresIon === true;
// Stack labels are provider/dataset names (keep-English); the availability // Stack labels are provider names and stay English; the sentence translates.
// sentence around them is copy and localizes here at the model boundary.
const fallbackReason = requiresIon const fallbackReason = requiresIon
? t('Needs {envVars} — add it in Provider Settings', { ? t('Needs {envVars} — add it in Provider Settings', {
envVars: keySetupRequirementEnvVars('cesium-ion'), envVars: keySetupRequirementEnvVars('cesium-ion'),
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff. Show more