i18n: fix review findings and polish ru/es strings

Fixes the 51 P0–P2 findings from the full review of the i18n branch and
the issues found while checking every new string on screen in ru and es.

- Translate errors and statuses that still reached ru/es in English:
  feed and tile errors, provider statuses, the ALPR chip, the traffic
  mode chip, voice plan step labels, fire feed values, receiver bands,
  scene save reasons and the Cesium attribution link.
- Show preset city names in the UI language (src/ui/placeNames.js);
  data and everything sent to the model stay English.
- Share layer names between the layer panel, HUD and voice card
  (src/ui/layerNames.js).
- Keep long ru/es strings whole: voice card error lines, analyst
  titles, the voice tier status, the Nepal evidence button, the ALPR
  retry chip. English layout is unchanged.
- Ask before a language switch drops unsaved work.
- Catalog fixes: glossary terms (пуск, пресет), gender agreement and
  consistent "peticiones" in es, fewer calques in both languages.
- Fix English text that was itself wrong (e.g. "Exit cockpit to fly to
  a weather", raw tool ids in voice plan steps).

Catalogs: es/ru 2535 messages each, complete.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-15 23:49:52 +04:00
1 parent 8029747b7e
commit 00704ddc89
212 files changed
+9871 -909

No files matched your search

+43 -2
View File
@@ -37,7 +37,14 @@ import { ShellFeedback } from './shellFeedback.js';
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
import { getLocale, persistLocaleAndReload, t } from '../i18n/index.js';
import {
describeUnsavedWork,
getLocale,
msg,
persistLocaleAndReload,
registerUnsavedWork,
t,
} from '../i18n/index.js';
import { LOCALES, LOCALE_NAMES } from '../i18n/locale.js';
/**
@@ -710,7 +717,8 @@ export class StyleManager extends ShellFacade {
/**
* Renders the command-dock locale switch: one button per shipped locale,
* labelled with its code and named in its own language. A click stores the
* choice and reloads, so nothing re-renders live.
* choice and reloads, so nothing re-renders live; it asks first when the
* reload would lose work (see _confirmLocaleReload).
* @returns {void}
*/
_initLocaleSelector() {
@@ -718,6 +726,20 @@ export class StyleManager extends ShellFacade {
'#control-panel .dock-locale-switch',
);
if (!container) return;
// Work this shell can see that a reload drops. Other modules register
// their own (registerUnsavedWork in src/i18n/index.js).
this._unsavedWorkChecks = [
registerUnsavedWork(() =>
this.services?.cctvLayer
?.getUIState?.()
?.cameras?.some((camera) => camera.calDirty)
? msg('unsaved CCTV calibration')
: null,
),
registerUnsavedWork(() =>
this.cockpitView?.active ? msg('the open cockpit') : null,
),
];
const active = getLocale();
for (const locale of LOCALES) {
const button = document.createElement('button');
@@ -731,12 +753,29 @@ export class StyleManager extends ShellFacade {
button.classList.toggle('active', isActive);
button.setAttribute('aria-pressed', String(isActive));
button.addEventListener('click', () => {
if (!this._confirmLocaleReload()) return;
persistLocaleAndReload(button.dataset.locale);
});
container.appendChild(button);
}
}
/**
* Switching language reloads the page. While that would lose work the page
* cannot restore, ask first.
* @returns {boolean} true to switch.
*/
_confirmLocaleReload() {
const lost = describeUnsavedWork();
if (!lost.length || typeof globalThis.confirm !== 'function') return true;
return globalThis.confirm(
t(
'Switching language reloads the page. This will be lost: {items}. Switch anyway?',
{ items: lost.join(', ') },
),
);
}
/**
* Gives every Display-panel row label one shared column, sized in
* characters to the longest label of the active language, so the slider
@@ -1718,6 +1757,8 @@ export class StyleManager extends ShellFacade {
this._shareState.destroy();
this._locationNavigation.destroy();
this._lifetime.destroy();
for (const unregister of this._unsavedWorkChecks?.splice(0) ?? [])
unregister();
this._recording.destroy();
this._panelChrome.destroy();
this._feedback.destroy();
+2 -2
View File
@@ -326,7 +326,7 @@ export function bindCameraOrientationControls({
: false;
});
if (result) {
showToast?.(result.tilted ? 'Tilted view' : 'Straight-down view');
showToast?.(result.tilted ? t('Tilted view') : t('Straight-down view'));
// The action reports the state it just commanded, so the button can
// follow it without a second pick.
tilted = result.tilted;
@@ -345,7 +345,7 @@ export function bindCameraOrientationControls({
})
);
});
if (result) showToast?.('North up');
if (result) showToast?.(t('North up'));
sync();
});
+119
View File
@@ -0,0 +1,119 @@
// The blocked-visibility toast speaks the UI language: the Space Missions
// guard reason (English data from contextModePolicy.js) is re-composed with
// the layer name localized, a known lifecycle sentence is looked up, and an
// unknown provider message passes through.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import {
contextLayerEnableBlockReason,
parseContextLayerBlockReason,
} from '../contextModePolicy.js';
import {
_handleContextLayerChange,
localizeVisibilityBlockReason,
} from './contextLayerChanges.js';
function fakeShell() {
const toasts = [];
return {
destroyed: false,
_userFacingContextNotificationTokens: new Set(),
_contextSessionSnapshot: null,
_contextModeChanging: false,
_contextMode: 'space-missions',
_contextModeEntering: null,
_syncContextModeButtons() {},
showToast(message) {
toasts.push(message);
},
toasts,
};
}
const ENGLISH_GUARD =
'Space Missions isolates replay data. Exit the mode to enable Live Flights.';
test.afterEach(() => setLocale('en'));
test('the guard reason is English data and parses back to its layer name', () => {
const reason = contextLayerEnableBlockReason({
contextMode: 'space-missions',
change: { layerId: 'flights', enabled: true, origin: 'user' },
layerName: 'Live Flights',
});
assert.equal(reason, ENGLISH_GUARD);
assert.deepEqual(parseContextLayerBlockReason(reason), {
layer: 'Live Flights',
});
assert.equal(parseContextLayerBlockReason('Data source unavailable'), null);
assert.equal(
parseContextLayerBlockReason(
'Space Missions isolates replay data. Exit the mode to enable .',
),
null,
);
});
test('en: the toast shows the reason unchanged', () => {
setLocale('en');
const shell = fakeShell();
_handleContextLayerChange.call(shell, {
type: 'visibility-blocked',
layerId: 'flights',
enabled: true,
origin: 'user',
reason: ENGLISH_GUARD,
});
assert.deepEqual(shell.toasts, [ENGLISH_GUARD]);
assert.equal(
localizeVisibilityBlockReason('Overpass returned HTTP 503'),
'Overpass returned HTTP 503',
);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: the Space Missions block toast is localized with the layer name`, () => {
setLocale(locale);
const reason = contextLayerEnableBlockReason({
contextMode: 'space-missions',
change: { layerId: 'flights', enabled: true, origin: 'user' },
layerName: 'Live Flights',
});
const shell = fakeShell();
_handleContextLayerChange.call(shell, {
type: 'visibility-blocked',
layerId: 'flights',
enabled: true,
origin: 'user',
reason,
});
assert.equal(shell.toasts.length, 1);
assert.notEqual(shell.toasts[0], ENGLISH_GUARD, 'toast must be localized');
assert.doesNotMatch(shell.toasts[0], /Space Missions isolates/);
assert.doesNotMatch(shell.toasts[0], /Live Flights/);
});
test(`${locale}: a known lifecycle reason is looked up in the catalog`, () => {
setLocale(locale);
const shell = fakeShell();
shell._contextMode = null;
_handleContextLayerChange.call(shell, {
type: 'visibility-blocked',
layerId: 'street-level',
enabled: true,
origin: 'user',
reason: 'Data source unavailable',
});
assert.equal(shell.toasts.length, 1);
assert.notEqual(shell.toasts[0], 'Data source unavailable');
});
test(`${locale}: an unknown provider message passes through verbatim`, () => {
setLocale(locale);
assert.equal(
localizeVisibilityBlockReason('Overpass returned HTTP 503'),
'Overpass returned HTTP 503',
);
});
}
+22 -2
View File
@@ -1,12 +1,32 @@
import { t } from '../i18n/index.js';
import { t, translateKnown } from '../i18n/index.js';
import {
SPACE_MISSIONS_LAYER_BLOCK_REASON,
cockpitEntryAllowed,
isExplicitUserIntentOrigin,
parseContextLayerBlockReason,
recordContextRestoreExplicitChange,
recordContextSessionUserChange,
shouldExitContextForLayerChange,
spaceMissionEntryCancellationDisposition,
} from '../contextModePolicy.js';
import { localizeLayerName } from './layerNames.js';
/**
* A blocked-visibility reason in the UI language. The lifecycle keeps reasons
* English (they also reach voice and tool callers): the Space Missions guard
* is re-composed from its template with the layer name localized, any other
* known sentence ("Data source unavailable") is looked up as is, and an
* unknown one — a live provider message — passes through.
*/
export function localizeVisibilityBlockReason(reason) {
if (typeof reason !== 'string' || !reason) return reason;
const blocked = parseContextLayerBlockReason(reason);
if (blocked)
return t(SPACE_MISSIONS_LAYER_BLOCK_REASON, {
layer: localizeLayerName(blocked.layer),
});
return translateKnown(reason);
}
export function _handleContextLayerChange(change) {
if (this.destroyed) return;
@@ -95,7 +115,7 @@ export function _handleContextLayerChange(change) {
!this._userFacingContextNotificationTokens.has(change.notificationToken)
) {
this.showToast(
change.reason ||
localizeVisibilityBlockReason(change.reason) ||
t('That layer is unavailable in the current Context mode'),
);
}
+34
View File
@@ -0,0 +1,34 @@
// K-49: the CCTV "ACQUIRING FRAME" caption lives in CSS `content:` and the
// i18n scanner (scripts/i18n.mjs) never reads CSS, so es/ru show it in English.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const STYLES = path.join(HERE, 'styles');
const LOCALES = path.join(HERE, '..', 'i18n', 'locales');
// cyber.css carries decorative theme strings (LOC / GRID, SYS 06 // ...);
// street-level.css ' · ESC' is a key cap. Neither is UI copy.
const DECORATIVE = new Set(['cyber.css', 'street-level.css']);
test('K-49: no user-visible text in CSS content: outside decorative themes', () => {
const hits = [];
for (const file of readdirSync(STYLES)) {
if (!file.endsWith('.css') || DECORATIVE.has(file)) continue;
const css = readFileSync(path.join(STYLES, file), 'utf8');
css.split('\n').forEach((line, i) => {
if (/^\s*content:\s*['"][^'"]*[A-Za-z]{2,}/.test(line))
hits.push(`${file}:${i + 1} ${line.trim()}`);
});
}
assert.deepEqual(hits, []);
});
test('K-49: the CCTV acquisition caption has es and ru translations', () => {
for (const lang of ['es', 'ru']) {
const dict = JSON.parse(readFileSync(path.join(LOCALES, `${lang}.json`), 'utf8'));
assert.ok('ACQUIRING FRAME' in dict, `${lang}.json lacks "ACQUIRING FRAME"`);
}
});
+69
View File
@@ -0,0 +1,69 @@
// K-47: Flights / Military feed failure texts reach the DATA LAYERS row in the
// UI language; the feeds keep them in English.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { LayerPanel } from './layerPanel.js';
import { setLocale } from '../i18n/index.js';
import { httpError, LiveSourceError } from '../sources/live/contract.js';
const panel = LayerPanel.prototype;
function flightsRow(error) {
return panel._buildMetaText({
id: 'flights',
enabled: true,
stats: {
count: 0,
stale: true,
error,
status: 429,
retryInSec: 45,
source: 'OpenSky',
lastUpdate: Date.now(),
},
});
}
const cases = [
['OpenSky 429 anonymous', 'OpenSky rate limited (anonymous)'],
['OpenSky auth', 'OpenSky auth required'],
['generic 429', httpError({ status: 429 }, 'Flights').message],
['HTTP 503', httpError({ status: 503 }, 'Military aircraft').message],
[
'network error',
new LiveSourceError('unavailable', 'Flights network error').message,
],
['stale snapshot', 'Source snapshot 7 min old'],
['snapshot time unknown', 'Source snapshot time unavailable'],
['non-LiveSourceError', 'Live data unavailable'],
['malformed', 'Malformed OpenSky response'],
['malformed rows', 'Malformed adsb.lol aircraft rows'],
];
test('en: feed failure texts read as before', () => {
setLocale('en');
for (const [, message] of cases)
assert.equal(
flightsRow(message),
`STALE · OpenSky · ${message} · retry 45s`,
);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: feed failure texts are translated in the layer row`, () => {
setLocale(locale);
try {
for (const [name, message] of cases) {
const row = flightsRow(message);
assert.ok(
!row.includes(message),
`${locale} ${name}: row still contains English detail: ${row}`,
);
}
assert.match(flightsRow('Source snapshot 7 min old'), /\b7\b/);
assert.match(flightsRow('Flights HTTP 503'), /\b503\b/);
} finally {
setLocale('en');
}
});
}
+14 -1
View File
@@ -5,7 +5,14 @@ import {
MAX_IMPORTED_FILES,
loadGeoFile,
} from '../data/geoFileImport.js';
import { formatNumber, t, tn, translateKnown } from '../i18n/index.js';
import {
formatNumber,
msg,
registerUnsavedWork,
t,
tn,
translateKnown,
} from '../i18n/index.js';
import { flyToDataSource } from './flyToDataSource.js';
// One color per file, in import order, from the whiteboard palette.
@@ -44,6 +51,11 @@ export function initGeoFileImport({
let cameraIntent = 0;
const lifetime = new AbortController();
let removeMapSource;
// Imports live for the page session: switching language (a reload) asks
// first while any are on the map.
const unregisterUnsavedWork = registerUnsavedWork(() =>
!destroyed && imports.length ? msg('imported map files') : null,
);
const listen = (target, type, listener, options) => {
if (!target?.addEventListener) return;
@@ -428,6 +440,7 @@ export function initGeoFileImport({
destroy() {
if (destroyed) return;
destroyed = true;
unregisterUnsavedWork();
lifetime.abort();
removeMapSource?.();
clearTimeout(groundTimer);
+15
View File
@@ -8,6 +8,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import { initGeoFileImport, summarize } from './geoFileImport.js';
import { GeoImportError, MAX_IMPORTED_FILES } from '../data/geoFileImport.js';
import { describeUnsavedWork } from '../i18n/index.js';
class FakeClassList {
constructor() {
@@ -211,6 +212,20 @@ const rows = (els) => els['geo-import-list'].children;
const action = (row, name) =>
row.children.find((c) => c.dataset.action === name);
// K-27. Before the other tests: the tools they leave behind would answer the
// same registry.
test('imported files make the language switch ask first', async () => {
const t = setup();
assert.deepEqual(describeUnsavedWork(), []);
await t.tool.importFiles([file('a.geojson')]);
assert.deepEqual(describeUnsavedWork(), ['imported map files']);
t.tool.remove(t.tool.list()[0].id);
assert.deepEqual(describeUnsavedWork(), [], 'nothing left to lose');
await t.tool.importFiles([file('b.kml')]);
t.tool.destroy();
assert.deepEqual(describeUnsavedWork(), [], 'destroy unregisters');
});
test('returns null without its markup or viewer', () => {
const document = { getElementById: () => null };
assert.equal(initGeoFileImport({ viewer: {}, document }), null);
+4 -2
View File
@@ -13,6 +13,7 @@ import {
releasePointer,
} from '../data/inputOwnership.js';
import { validateBox } from '../layers/recentImagery/model.js';
import { msg } from '../i18n/index.js';
/** The id this tool claims the pointer under. */
export const IMAGERY_BOX_POINTER_OWNER = 'recent-imagery-box';
@@ -150,7 +151,7 @@ export function initImageryBoxTool({
if (!active || destroyed || anchor) return;
const point = worldAt(event.position);
if (!point) {
onCancel?.('sky', 'Press on the ground, not the sky');
onCancel?.('sky', msg('Press on the ground, not the sky'));
return;
}
anchor = point;
@@ -169,7 +170,8 @@ export function initImageryBoxTool({
corner = worldAt(event.position) || corner;
const box = dragBox();
const result = validateBox(box);
if (result.reason === 'degenerate') result.message = 'Drag to size the box';
if (result.reason === 'degenerate')
result.message = msg('Drag to size the box');
endDrag();
setActive(false);
if (result.ok) onBox(result.box);
+4 -1
View File
@@ -130,9 +130,12 @@ export class LayerBindings {
]) {
const layer = this._dataManager?.layers?.get(id)?.module;
if (typeof layer?.attachShellServices !== 'function') continue;
// The Cockpit refusal names the destination: a cyclone layer flies to
// a storm, the others to their coverage area.
const noun = id === 'weather-cyclones' ? 'cyclone' : 'weather';
layer.attachShellServices({
runNavigation: (navigate) =>
this.runImmediateNavigation('weather', navigate),
this.runImmediateNavigation(noun, navigate),
imageryHost: this.services.imageryHost,
});
this._weatherShellModules.push(layer);
+137
View File
@@ -0,0 +1,137 @@
/**
* Layer and source names as the page shows them.
*
* Layer ids, English module names (`layer.name`) and declared source names
* stay English in layer state, share links and AI tool output; these helpers
* translate them only at a presentation boundary. The layer panel, the HUD
* and the voice card name a layer the same way through them.
*
* Pure: imports only i18n, no DOM.
* @module ui/layerNames
*/
import { msg, t, translateKnown } from '../i18n/index.js';
/** English module names (`layer.name`) by layer id. */
export const LAYER_NAME_KEYS = Object.freeze({
flights: msg('Live Flights'),
military: msg('Military Flights'),
earthquakes: msg('Earthquakes (24h)'),
satellites: msg('Satellites'),
'rocket-launches': msg('Space Missions (30d)'),
traffic: msg('Street Traffic'),
cctv: msg('CCTV'),
radio: msg('Radio'),
bikeshare: msg('Bikeshare'),
'ais-live-vessels': msg('Live AIS Vessels'),
'military-installations': msg('Mapped Installations'),
'military-awareness': msg('Global Context'),
'local-datacenters': msg('Datacenters'),
'local-dams': msg('Dams'),
'telegeography-submarine-cables': msg('Submarine Cables'),
'local-firms': msg('FIRMS Active Fires'),
'local-adsb': msg('Local ADS-B'),
transit: msg('Transit'),
'recent-imagery': msg('Recent Imagery'),
'fire-perimeters': msg('Fire Perimeters'),
'gnss-integrity': msg('GNSS Integrity'),
wind: msg('Wind'),
'weather-radar': msg('Rain radar'),
'weather-satellite': msg('Satellite clouds'),
'weather-lightning': msg('Lightning density'),
'weather-cyclones': msg('Cyclone advisories'),
directions: msg('Directions'),
'alpr-cameras': msg('ALPR Cameras'),
});
/** The layer panel's own English names, where they differ from the module's. */
export const PANEL_LABEL_KEYS = Object.freeze({
'ais-live-vessels': msg('Live Vessels'),
bikeshare: msg('Bike Share'),
cctv: msg('Cameras'),
'street-level': msg('Street Level'),
'alpr-cameras': msg('Mapped ALPR Cameras'),
'local-datacenters': msg('Data Centers'),
'local-firms': msg('Active Fires'),
});
// Looked up by the English text, so a surface that holds `layer.name` keeps
// its English data and only the shown text translates.
const MODULE_NAME_KEYS = new Map(
[...Object.values(LAYER_NAME_KEYS), ...Object.values(PANEL_LABEL_KEYS)].map(
(key) => [key, key],
),
);
/**
* Translate a layer's English module name for display; any other text
* (a provider name, an unknown layer) is returned unchanged.
* @param {string} name English `layer.name`.
* @returns {string}
*/
export function localizeLayerName(name) {
const key = MODULE_NAME_KEYS.get(String(name ?? '').trim());
return key ? t(key) : name;
}
/**
* A layer id's module name in the active language, or null for an id this
* module does not know.
* @param {string} id Layer id, e.g. `earthquakes`.
* @returns {string|null}
*/
export function layerNameForId(id) {
const key = Object.hasOwn(LAYER_NAME_KEYS, id)
? LAYER_NAME_KEYS[id]
: Object.hasOwn(PANEL_LABEL_KEYS, id)
? PANEL_LABEL_KEYS[id]
: null;
return key ? t(key) : null;
}
/**
* A layer as shown: its English module name or its id, whichever is known,
* in the active language; otherwise the text as given.
* @param {string} [name] English `layer.name`, or an id when the layer had none.
* @param {string} [id] Layer id.
* @returns {string}
*/
export function shownLayerName(name, id) {
const text = String(name ?? '').trim();
if (text && MODULE_NAME_KEYS.has(text)) return t(MODULE_NAME_KEYS.get(text));
return layerNameForId(id || text) || text || String(id ?? '');
}
// Layer-declared source names. Provider names (USGS, CelesTrak, GTFS-RT…)
// are not listed and pass through unchanged unless the catalog knows them.
const SOURCE_LABEL_KEYS = new Map(
[
msg('Flights'),
msg('Military aircraft'),
msg('Vessels'),
msg('CCTV + Street View fallback'),
msg('OpenStreetMap + optional Google Maps Places'),
msg('GFS / ECMWF IFS · FORECAST'),
msg('NOAA nowCOAST · OBSERVED'),
msg('NASA FIRMS · LIVE'),
msg('OSM routing'),
msg('Mapped camera locations'),
msg('OpenStreetMap · community mapped'),
].map((key) => [key, key]),
);
/**
* A layer's declared source name as shown.
* @param {string} source English source name, e.g. `Flights` or `OpenSky`.
* @returns {string}
*/
export function sourceLabel(source) {
if (typeof source !== 'string') return source;
if (source === 'Local') return t('Local', { context: 'data source' });
const key = SOURCE_LABEL_KEYS.get(source);
if (key) return t(key);
// A layer with several providers joins their names ("Mapillary · Google
// Street View"); a name with a translation shows it.
return source.includes(' · ')
? source.split(' · ').map(translateKnown).join(' · ')
: translateKnown(source);
}
+113 -103
View File
@@ -4,8 +4,23 @@ import { layerFeedState } from '../data/feedState.js';
export { layerFeedState } from '../data/feedState.js';
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
import { keyGuidanceText, keyRequirementText } from './keyGuidance.js';
// Layer and source names at this presentation boundary: registry ids and
// English module names stay as they are; a layer without an entry renders its
// module name, and the panel's own English names win (PANEL_LABEL_KEYS).
import {
LAYER_NAME_KEYS,
PANEL_LABEL_KEYS as PANEL_LABELS,
sourceLabel,
} from './layerNames.js';
export { localizeLayerName } from './layerNames.js';
import { createWeatherPanel } from './weatherPanel.js';
import { formatNumber, msg, t, translateKnown } from '../i18n/index.js';
import {
formatNumber,
getLocale,
msg,
t,
translateKnown,
} from '../i18n/index.js';
// Feed-state enum VALUES are machine identifiers (layerFeedState contract, QA
// hooks, CSS feed-* classes); only the final label value translates, at this
@@ -20,39 +35,6 @@ const FEED_STATE_LABEL_KEYS = Object.freeze({
unavailable: msg('UNAVAILABLE'),
});
// Layer display names, localized at the same presentation boundary. Registry
// ids stay English; a layer without an entry here renders its module name,
// falling back to the English panel override when one exists.
const LAYER_NAME_KEYS = Object.freeze({
flights: msg('Live Flights'),
military: msg('Military Flights'),
earthquakes: msg('Earthquakes (24h)'),
satellites: msg('Satellites'),
'rocket-launches': msg('Space Missions (30d)'),
traffic: msg('Street Traffic'),
cctv: msg('CCTV'),
radio: msg('Radio'),
bikeshare: msg('Bikeshare'),
'ais-live-vessels': msg('Live AIS Vessels'),
'military-installations': msg('Mapped Installations'),
'military-awareness': msg('Global Context'),
'local-datacenters': msg('Datacenters'),
'local-dams': msg('Dams'),
'telegeography-submarine-cables': msg('Submarine Cables'),
'local-firms': msg('FIRMS Active Fires'),
'local-adsb': msg('Local ADS-B'),
transit: msg('Transit'),
'recent-imagery': msg('Recent Imagery'),
'fire-perimeters': msg('Fire Perimeters'),
'gnss-integrity': msg('GNSS Integrity'),
wind: msg('Wind'),
'weather-radar': msg('Rain radar'),
'weather-satellite': msg('Satellite clouds'),
'weather-lightning': msg('Lightning density'),
'weather-cyclones': msg('Cyclone advisories'),
directions: msg('Directions'),
});
function lifecycleStateLabel(lifecycleState) {
return t(lifecycleState === 'enabling' ? msg('ENABLING') : msg('DISABLING'));
}
@@ -126,15 +108,6 @@ const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids }) =>
const PANEL_POSITIONS = new Map(
PANEL_ORDER.map(({ id }, index) => [id, index]),
);
const PANEL_LABELS = {
'ais-live-vessels': msg('Live Vessels'),
bikeshare: msg('Bike Share'),
cctv: msg('Cameras'),
'street-level': msg('Street Level'),
'alpr-cameras': msg('Mapped ALPR Cameras'),
'local-datacenters': msg('Data Centers'),
'local-firms': msg('Active Fires'),
};
function englishPanelLabel(layer) {
return PANEL_LABELS[layer.id] || LAYER_NAME_KEYS[layer.id] || layer.name;
@@ -147,59 +120,6 @@ function panelLabel(layer) {
return key ? t(key) : layer.name;
}
// English module names (layer.name) that other surfaces print, such as the
// loading chip's layer roster. Looked up by the English text so that surface
// keeps its English output and only the presentation translates.
const MODULE_NAME_KEYS = new Map(
[
...Object.values(LAYER_NAME_KEYS),
...Object.values(PANEL_LABELS),
msg('ALPR Cameras'),
].map((key) => [key, key]),
);
/**
* Translate a layer's English module name for display; any other text
* (a provider name, an unknown layer) is returned unchanged.
* @param {string} name English `layer.name`.
* @returns {string}
*/
export function localizeLayerName(name) {
const key = MODULE_NAME_KEYS.get(String(name ?? '').trim());
return key ? t(key) : name;
}
// Layer-declared source names stay English in layer state, share links and AI
// tool output; only the row's source line translates them. Provider names
// (USGS, CelesTrak, GTFS-RT…) are not listed and pass through unchanged.
const SOURCE_LABEL_KEYS = new Map(
[
msg('Flights'),
msg('Military aircraft'),
msg('Vessels'),
msg('CCTV + Street View fallback'),
msg('OpenStreetMap + optional Google Maps Places'),
msg('GFS / ECMWF IFS · FORECAST'),
msg('NOAA nowCOAST · OBSERVED'),
msg('NASA FIRMS · LIVE'),
msg('OSM routing'),
msg('Mapped camera locations'),
msg('OpenStreetMap · community mapped'),
].map((key) => [key, key]),
);
function sourceLabel(source) {
if (typeof source !== 'string') return source;
if (source === 'Local') return t('Local', { context: 'data source' });
const key = SOURCE_LABEL_KEYS.get(source);
if (key) return t(key);
// A layer with several providers joins their names ("Mapillary · Google
// Street View"); a name with a translation shows it.
return source.includes(' · ')
? source.split(' · ').map(translateKnown).join(' · ')
: translateKnown(source);
}
/**
* Guidance for a control a missing provider key is holding back.
*
@@ -224,6 +144,92 @@ export function layerKeyRequirementTooltip(layer = {}) {
);
}
// Live-source failures arrive as English data (src/sources/live/contract.js,
// standalone.js, flights and military ingestion), some with a source name or a
// number in them; the row shows them in the UI language. Keep these in step
// with the producers. Anything else shows its catalog entry, or as it came.
const FEED_ERRORS = new Set([
msg('OpenSky OAuth client missing/invalid'),
msg('OpenSky OAuth rejected credentials'),
msg('OpenSky username/password rejected'),
msg('OpenSky auth missing'),
msg('OpenSky auth required'),
msg('OpenSky auth invalid'),
msg('OpenSky auth failed'),
]);
const FEED_ERROR_PATTERNS = [
// Street Level coverage errors (streetLevel/providers/kartaview/source.js),
// ahead of the generic "<source> HTTP <status>".
[
/^KartaView answered HTTP (\d+)$/,
([, status]) => t('KartaView answered HTTP {status}', { status }),
],
[
/^KartaView refused the request \(code (-?\d+)\)$/,
([, code]) => t('KartaView refused the request (code {code})', { code }),
],
// Weather imagery (weather/source.js, infraredImage.js) and vector tile
// (sources/vectorTiles.js) failures carry their own wording; the generic
// pattern below would take "Weather image" for a source name.
[
/^Weather (image )?HTTP (\d+)$/,
([, image, status]) =>
image
? t('Weather image HTTP {status}', { status })
: t('Weather HTTP {status}', { status }),
],
[
/^Vector tiles unavailable \(HTTP (\d+)\)$/,
([, status]) => t('Vector tiles unavailable (HTTP {status})', { status }),
],
[
/^Source snapshot (\d+) min old$/,
([, minutes]) => t('Source snapshot {minutes} min old', { minutes }),
],
[
/^(.+) rate limited \(anonymous\)$/,
([, source]) =>
t('{source} rate limited (anonymous)', { source: sourceLabel(source) }),
],
[
/^(.+) rate limited$/,
([, source]) => t('{source} rate limited', { source: sourceLabel(source) }),
],
[
/^(.+) HTTP (\d{3})$/,
([, source, status]) =>
t('{source} HTTP {status}', { source: sourceLabel(source), status }),
],
[
/^(.+) network error$/,
([, source]) =>
t('{source} network error', { source: sourceLabel(source) }),
],
[
/^Malformed (.+) aircraft rows$/,
([, source]) =>
t('Malformed {source} aircraft rows', { source: sourceLabel(source) }),
],
[
/^Malformed (.+) response$/,
([, source]) =>
t('Malformed {source} response', { source: sourceLabel(source) }),
],
];
/** A layer's error text in the UI language (see FEED_ERRORS). */
export function translateFeedError(text) {
if (typeof text !== 'string') return text;
if (FEED_ERRORS.has(text)) return t(text);
const known = translateKnown(text);
if (known !== text) return known;
for (const [pattern, format] of FEED_ERROR_PATTERNS) {
const match = pattern.exec(text);
if (match) return format(match);
}
return text;
}
const ROW_TEXT_SEPARATOR = ' · ';
/** A loading label is English data, at times several joined by " · ". */
@@ -571,15 +577,19 @@ export class LayerPanel {
/**
* Write a row's count. A count that is a phrase carrying its own number
* ("24 nearby · 2 on screen") moves to its own line under the name: beside
* the toggle it squeezes the name to a word per line in any language. The
* "—" placeholder is marked so it can give way to a wide status chip.
* ("24 nearby · 2 on screen") moves to its own line under the name outside
* English: beside the toggle it squeezes a longer translated name to a word
* per line. English keeps the count beside the toggle. The "—" placeholder
* is marked so it can give way to a wide status chip (translations only,
* radio.css).
*/
_syncCount(row, count, stats) {
const text = this._layerCountText(stats);
setRowText(count, text);
const phrase =
typeof stats.countLabel === 'string' && /\d/.test(stats.countLabel);
getLocale() !== 'en' &&
typeof stats.countLabel === 'string' &&
/\d/.test(stats.countLabel);
row.classList.toggle('data-toggle-row--count-line', phrase);
count.classList.toggle('data-count-empty', text === '—');
}
@@ -743,7 +753,7 @@ export class LayerPanel {
{ source },
);
}
const presentedError = translateKnown(
const presentedError = translateFeedError(
stats.error || stats.lastError || stats.managerRefreshError,
);
if (presentedError) {
@@ -768,7 +778,7 @@ export class LayerPanel {
typeof stats.statusMessage === 'string' &&
stats.statusMessage.trim()
) {
return `${source} · ${stats.statusMessage.trim()}`;
return `${source} · ${translateKnown(stats.statusMessage.trim())}`;
}
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : t('never');
if (stats.loading) {
@@ -885,7 +895,7 @@ export class LayerPanel {
: stateText;
const keyGuidance = unavailable
? String(
translateKnown(layer.stats.error) || t('Data source unavailable'),
translateFeedError(layer.stats.error) || t('Data source unavailable'),
)
: layerKeyRequirementTooltip(layer);
// Name the missing key on the control itself: a row reading KEY REQUIRED
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
test('Local ADS-B guidance hint is translated in the layer row (K-20)', async () => {
const { setLocale, t } = await import('../i18n/index.js');
const { LayerPanel } = await import('./layerPanel.js');
const { localAdsbStatus } = await import('../layers/localAdsb/status.js');
const english = localAdsbStatus({
receiver: { webUsbSupported: true, connected: false },
feedState: null,
heard: 0,
});
assert.equal(
LayerPanel.prototype._buildMetaText.call(LayerPanel.prototype, {
id: 'local-adsb',
enabled: true,
lifecycleState: 'enabled',
stats: { count: 0, lastUpdate: null, ...english },
}),
'RTL-SDR · WebUSB · connect a receiver in Radio',
);
for (const locale of ['es', 'ru']) {
setLocale(locale);
for (const receiver of [
{ webUsbSupported: true, connected: false },
{ webUsbSupported: false, connected: false },
{ webUsbSupported: true, connected: true, mode: 'fm' },
]) {
const stats = localAdsbStatus({ receiver, feedState: null, heard: 0 });
const text = LayerPanel.prototype._buildMetaText.call(
LayerPanel.prototype,
{
id: 'local-adsb',
enabled: true,
lifecycleState: 'enabled',
stats: { count: 0, lastUpdate: null, ...stats },
},
);
assert.notEqual(
t(stats.statusMessage),
stats.statusMessage,
`${locale}: no catalog entry for "${stats.statusMessage}"`,
);
assert.ok(
!text.includes(stats.statusMessage),
`${locale}: row shows English hint: ${text}`,
);
}
}
setLocale('en');
});
+65
View File
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { localSdrCardView } from './localSdrPresentation.js';
import { setLocale } from '../i18n/index.js';
const fmStreaming = {
webUsbSupported: true,
connected: true,
status: 'streaming',
message: 'Streaming',
mode: 'fm',
frequencyHz: 98_500_000,
volume: 0.8,
gain: 'auto',
seeking: false,
seekMessage: '',
decodedMessages: 0,
messagesPerSecond: null,
aircraftHeard: 0,
aircraftPositioned: 0,
deviceLabel: 'RTL2838',
locationStatus: 'unknown',
audioState: 'running',
samplesPerSecond: 2_400_000,
workerBlocks: 3,
iqLevelDbfs: -20,
audioLevelDbfs: -12,
};
test('FM status line names the AudioContext state in the UI language', () => {
try {
for (const [locale, state] of [
['es', 'running'],
['ru', 'running'],
['es', 'suspended'],
['ru', 'closed'],
['es', 'idle'],
['ru', 'idle'],
]) {
setLocale(locale);
const text = localSdrCardView({
...fmStreaming,
audioState: state,
}).statusText;
assert.doesNotMatch(
text,
new RegExp(`audio[: ]+${state}\\b|аудио[: ]+${state}\\b`),
`${locale}: ${text}`,
);
}
} finally {
setLocale('en');
}
});
test('FM status line keeps the English AudioContext state words', () => {
setLocale('en');
for (const state of ['running', 'suspended', 'closed', 'idle']) {
const text = localSdrCardView({
...fmStreaming,
audioState: state,
}).statusText;
assert.ok(text.endsWith(` · audio ${state}`), text);
}
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { t } from '../i18n/index.js';
import {
internetRadioAudioActive,
localSdrCardView,
@@ -198,7 +199,7 @@ export class LocalSdrControls {
// Internet radio just started: it takes over from local FM.
if (radioAudioActive && !this._radioAudioActive && this._fmAudioActive) {
void this.receiver.stop({
message: 'Local FM stopped: internet radio started',
message: t('Local FM stopped: internet radio started'),
});
}
this._radioAudioActive = radioAudioActive;
+58
View File
@@ -0,0 +1,58 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { localSdrFeedLine } from './localSdrPresentation.js';
for (const locale of ['es', 'ru']) {
test(`decoder feed line is translated in ${locale}`, () => {
setLocale(locale);
try {
const feeds = [{ id: '1090', band: '1090', status: 'live' }];
const polling = localSdrFeedLine({
configured: true,
polling: true,
feeds: [{ ...feeds[0], status: 'unreachable' }],
});
const idle = localSdrFeedLine({
configured: true,
polling: false,
feeds,
});
assert.doesNotMatch(polling, /Decoder feeds|unreachable/);
assert.match(polling, /1090/);
assert.doesNotMatch(idle, /Decoder feeds|read while Local ADS-B is on/);
assert.match(idle, /1090/);
} finally {
setLocale('en');
}
});
}
test('an unknown feed status is shown as given', () => {
setLocale('en');
const feeds = [{ band: '1090', status: 'odd' }];
assert.equal(
localSdrFeedLine({ configured: true, polling: true, feeds }),
'Decoder feeds: 1090 odd',
);
});
// B6: a feed status agrees with "fuente" in Spanish; the shared "invalid" key
// (any other masculine noun) is left alone.
test('es: an invalid decoder feed reads "no válida"', () => {
setLocale('es');
try {
const line = localSdrFeedLine({
configured: true,
polling: true,
feeds: [
{ band: '1090', status: 'live' },
{ band: '1090', status: 'invalid' },
],
});
assert.match(line, /no válida/);
assert.doesNotMatch(line, /no válido\b/);
} finally {
setLocale('en');
}
});
@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { SdrController } from '../sdr/controller.js';
import { LocalSdrControls } from './localSdrControls.js';
import { localSdrCardView } from './localSdrPresentation.js';
function stubDocument() {
const elements = new Map();
const doc = {
activeElement: null,
getElementById(id) {
if (!elements.has(id)) {
const listeners = new Map();
elements.set(id, {
id,
value: '',
hidden: false,
disabled: false,
textContent: '',
ownerDocument: doc,
classList: { toggle() {} },
setAttribute() {},
closest: () => null,
addEventListener(type, handler) {
listeners.set(type, handler);
},
});
}
return elements.get(id);
},
};
return doc;
}
function stubRadio() {
const listeners = new Set();
return {
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
stopPlayback() {},
emit(state) {
for (const fn of listeners) fn(state);
},
};
}
async function stopLocalFmForInternetRadio(t, locale) {
setLocale(locale);
const receiver = new SdrController({ webUsb: {}, storage: null });
// Pretend local FM is streaming (no real device: stop() has nothing to tear down).
receiver._setState({ connected: true, status: 'streaming', mode: 'fm' });
const radio = stubRadio();
const controls = new LocalSdrControls({
document: stubDocument(),
receiver,
radio,
});
t.after(() => {
controls.destroy();
setLocale('en');
});
radio.emit({ audioState: 'playing' });
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(receiver.state.connected, false);
return localSdrCardView(receiver.state);
}
for (const locale of ['es', 'ru']) {
test(`FM stop message after internet radio start is shown in ${locale}`, async (t) => {
const view = await stopLocalFmForInternetRadio(t, locale);
assert.notEqual(
view.connectLabel,
'CONNECT',
'surrounding UI is translated',
);
assert.notEqual(
view.statusText,
'Local FM stopped: internet radio started',
`status must not be the English literal under ${locale}`,
);
});
}
test('FM stop message after internet radio start reads as before in English', async (t) => {
const view = await stopLocalFmForInternetRadio(t, 'en');
assert.equal(view.statusText, 'Local FM stopped: internet radio started');
});
+50
View File
@@ -0,0 +1,50 @@
// C14: the SDR status line and stats write numbers in the UI language, like
// the frequency next to them ("Сканирование 101,1 МГц…").
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { localSdrCardView } from './localSdrPresentation.js';
const fm = {
connected: true,
status: 'streaming',
mode: 'fm',
message: 'Tuned',
seekMessage: '',
samplesPerSecond: 2_400_000,
iqLevelDbfs: -21.44,
audioLevelDbfs: -18.26,
workerBlocks: 0,
audioState: 'running',
gain: 20.7,
frequencyHz: 101_100_000,
volume: 0.5,
messagesPerSecond: 13.8,
webUsbSupported: true,
};
test('en: SDR readings keep their English digits', () => {
setLocale('en');
const view = localSdrCardView(fm);
assert.match(view.statusText, /· 2\.40 MS\/s IQ ·/);
assert.match(view.statusText, /· RF -21\.4 dBFS ·/);
assert.match(view.statusText, /-18\.3 dBFS audio/);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: SDR readings use the locale's decimal comma`, () => {
setLocale(locale);
try {
const view = localSdrCardView(fm);
assert.match(view.statusText, /· 2,40 MS\/s IQ ·/);
assert.match(view.statusText, /· RF -21,4 dBFS ·/);
assert.match(view.statusText, /-18,3 dBFS/);
const adsb = localSdrCardView({ ...fm, mode: 'adsb' });
assert.equal(adsb.stats.rate, '13,8');
assert.equal(adsb.stats.iq, '-21,4 dBFS');
assert.equal(adsb.gainValue, '20.7', 'input values stay machine');
} finally {
setLocale('en');
}
});
}
+53 -9
View File
@@ -1,5 +1,5 @@
import { localReceiverFeedName } from '../layers/localAdsb/feedNames.js';
import { msg, t, tn } from '../i18n/index.js';
import { formatNumber, msg, t, tn } from '../i18n/index.js';
import { formatQuantity } from './unitLabels.js';
/**
@@ -22,6 +22,39 @@ function statusLabel(status) {
return STATUS_LABELS[key] ? t(STATUS_LABELS[key]) : key.toUpperCase();
}
// AudioContext.state values (plus the controller's "idle") as shown in the
// FM status line; the value itself stays machine.
const AUDIO_STATE_LABELS = Object.freeze({
idle: msg('idle'),
running: msg('running'),
suspended: msg('suspended'),
closed: msg('closed'),
});
function audioStateLabel(audioState) {
const key = String(audioState || 'idle');
return AUDIO_STATE_LABELS[key] ? t(AUDIO_STATE_LABELS[key]) : key;
}
// Decoder feed statuses from the server route, as shown on the card. They
// carry their own context: the word agrees with "feed" (es "fuente no válida"),
// unlike the shared "invalid" / "stale" elsewhere.
function feedStatusLabel(status) {
const key = String(status || 'unreachable');
switch (key) {
case 'live':
return t('live', { context: 'feed status' });
case 'stale':
return t('stale', { context: 'feed status' });
case 'unreachable':
return t('unreachable', { context: 'feed status' });
case 'invalid':
return t('invalid', { context: 'feed status' });
default:
return key;
}
}
/**
* Text for a numeric gain option: the option value stays the machine value
* ("28.0"); only the label follows the locale ("28,0 дБ").
@@ -39,9 +72,18 @@ function formatGain(gain) {
return gain === 'auto' || !Number.isFinite(gain) ? 'auto' : gain.toFixed(1);
}
/** A reading with a fixed number of decimals, in the UI language ("2,40"). */
function fixed(value, digits) {
return formatNumber(value, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
useGrouping: false,
});
}
function formatIq(state) {
return Number.isFinite(state.iqLevelDbfs)
? `${state.iqLevelDbfs.toFixed(1)} dBFS`
? `${fixed(state.iqLevelDbfs, 1)} dBFS`
: '--';
}
@@ -49,7 +91,7 @@ function statusText(state) {
const fmActive = state.mode === 'fm';
const iqStatus =
state.samplesPerSecond > 0
? `${(state.samplesPerSecond / 1_000_000).toFixed(2)} MS/s IQ`
? `${fixed(state.samplesPerSecond / 1_000_000, 2)} MS/s IQ`
: t('waiting for IQ');
const device = state.deviceLabel ? ` · ${state.deviceLabel}` : '';
if (state.connected && !fmActive) {
@@ -70,9 +112,11 @@ function statusText(state) {
: t('DSP waiting');
const rfStatus = `RF ${formatIq(state)}`;
const audioSignal = Number.isFinite(state.audioLevelDbfs)
? t('{level} dBFS audio', { level: state.audioLevelDbfs.toFixed(1) })
? t('{level} dBFS audio', { level: fixed(state.audioLevelDbfs, 1) })
: t('audio signal --');
const audio = t('audio {state}', { state: state.audioState });
const audio = t('audio {state}', {
state: audioStateLabel(state.audioState),
});
return `${detail}${device} · ${iqStatus} · ${workerStatus} · ${rfStatus} · ${audioSignal} · ${audio}`;
}
return detail;
@@ -121,7 +165,7 @@ export function localSdrCardView(state) {
statsHidden: !(adsbActive && state.connected),
stats: {
rate: Number.isFinite(state.messagesPerSecond)
? state.messagesPerSecond.toFixed(1)
? fixed(state.messagesPerSecond, 1)
: '--',
heard: String(state.aircraftHeard || 0),
positioned: String(state.aircraftPositioned || 0),
@@ -174,9 +218,9 @@ export function localSdrFeedLine(feedState) {
const names = feeds.map((feed) => {
const name = localReceiverFeedName(feed, feeds);
return feedState.polling || feed?.status === 'invalid'
? `${name} ${feed?.status || 'unreachable'}`
? `${name} ${feedStatusLabel(feed?.status)}`
: name;
});
const suffix = feedState.polling ? '' : ' · read while Local ADS-B is on';
return `Decoder feeds: ${names.join(' · ')}${suffix}`;
const suffix = feedState.polling ? '' : t(' · read while Local ADS-B is on');
return `${t('Decoder feeds: {feeds}', { feeds: names.join(' · ') })}${suffix}`;
}
+142
View File
@@ -0,0 +1,142 @@
// K-27: the dock language switch reloads the page, so it asks first while the
// reload would lose work (unsaved CCTV calibration, the open cockpit, anything
// a module registers).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { StyleManager } from './applicationShell.js';
import {
describeUnsavedWork,
msg,
registerUnsavedWork,
setLocale,
} from '../i18n/index.js';
function fakeButton() {
const listeners = {};
return {
dataset: {},
classList: { toggle() {} },
setAttribute() {},
addEventListener: (type, listener) => (listeners[type] = listener),
click: () => listeners.click(),
};
}
/** Render the switch into a fake dock with the given shell state. */
function renderSwitch({ calDirty = false, cockpit = false } = {}) {
const buttons = [];
const previous = {
document: globalThis.document,
location: globalThis.location,
history: globalThis.history,
confirm: globalThis.confirm,
};
const reloads = [];
const prompts = [];
let answer = true;
globalThis.document = {
querySelector: () => ({ appendChild: (button) => buttons.push(button) }),
createElement: fakeButton,
};
globalThis.location = {
href: 'https://gev.example/#v=2',
assign() {},
reload: () => reloads.push(true),
};
globalThis.history = { replaceState() {} };
globalThis.confirm = (text) => {
prompts.push(text);
return answer;
};
const shell = {
services: {
cctvLayer: {
getUIState: () => ({ cameras: [{ calDirty: false }, { calDirty }] }),
},
},
cockpitView: { active: cockpit },
_confirmLocaleReload: StyleManager.prototype._confirmLocaleReload,
};
StyleManager.prototype._initLocaleSelector.call(shell);
const button = (locale) =>
buttons.find((candidate) => candidate.dataset.locale === locale);
return {
button,
reloads,
prompts,
answer: (value) => (answer = value),
restore() {
for (const unregister of shell._unsavedWorkChecks) unregister();
Object.assign(globalThis, previous);
},
};
}
const storage = globalThis.localStorage;
test.before(() => {
globalThis.localStorage = { setItem() {}, getItem: () => null };
});
test.after(() => {
globalThis.localStorage = storage;
});
test('nothing to lose: the switch reloads without asking', () => {
const view = renderSwitch();
try {
view.button('es').click();
assert.deepEqual(view.prompts, []);
assert.equal(view.reloads.length, 1);
} finally {
view.restore();
}
});
test('unsaved calibration or the open cockpit: the switch asks first', () => {
const view = renderSwitch({ calDirty: true, cockpit: true });
try {
view.answer(false);
view.button('ru').click();
assert.equal(view.prompts.length, 1);
assert.match(view.prompts[0], /unsaved CCTV calibration/);
assert.match(view.prompts[0], /the open cockpit/);
assert.equal(view.reloads.length, 0, 'declined: no reload');
view.answer(true);
view.button('ru').click();
assert.equal(view.reloads.length, 1, 'confirmed: reload');
} finally {
view.restore();
}
});
test('modules register their own unsaved work', () => {
const unregister = registerUnsavedWork(() => msg('drawings on the map'));
const failing = registerUnsavedWork(() => {
throw new Error('broken check');
});
const view = renderSwitch();
try {
view.answer(false);
view.button('es').click();
assert.match(view.prompts[0], /drawings on the map/);
assert.equal(view.reloads.length, 0);
} finally {
view.restore();
unregister();
failing();
}
assert.deepEqual(describeUnsavedWork(), []);
});
test('the prompt reads in the active language', () => {
setLocale('es');
const view = renderSwitch({ calDirty: true });
try {
view.answer(false);
view.button('en').click();
assert.equal(view.prompts.length, 1);
assert.doesNotMatch(view.prompts[0], /Switching language/);
} finally {
view.restore();
setLocale('en');
}
});
+10 -2
View File
@@ -1,5 +1,6 @@
import { locationMiniStatus } from '../locationStatus.js';
import { t } from '../i18n/index.js';
import { localizePlaceName } from './placeNames.js';
const POI_KEYS = ['Q', 'W', 'E', 'R', 'T'];
@@ -51,7 +52,7 @@ export class LocationControls {
pill.type = 'button';
pill.className = 'location-pill';
pill.dataset.locationId = id;
pill.textContent = city.name;
pill.textContent = localizePlaceName(city.name);
this.bind(pill, 'click', () => onCity(id));
elements.pills.appendChild(pill);
}
@@ -142,9 +143,16 @@ export class LocationControls {
for (const pill of this.elements.pills.querySelectorAll('.location-pill'))
pill.classList.toggle('active', pill.dataset.locationId === id);
}
renderStatus(state) {
renderStatus(record) {
if (this.destroyed || !this.elements.statusCity || !this.elements.statusPoi)
return;
// A preset city shows its name in the UI language; the record stays English.
const state = record?.city?.name
? {
...record,
city: { ...record.city, name: localizePlaceName(record.city.name) },
}
: record;
const lines = locationMiniStatus(state);
// The initial/placeholder states get catalog keys at the writer so a
// non-English boot never leaks the English sentinels back over the
+1 -1
View File
@@ -119,7 +119,7 @@ export class LocationNavigation {
}),
onResult: (destination) => {
if (destination.outlineUnavailable)
this._showToast('Detailed outline unavailable');
this._showToast(t('Detailed outline unavailable'));
},
onError: (error) => console.error('[Search] Geocoding failed:', error),
});
+3 -3
View File
@@ -1,5 +1,5 @@
/** Own panel position preferences, viewport clamping and drag listeners. */
import { t } from '../i18n/index.js';
import { t, msg } from '../i18n/index.js';
import { RESIZE_DIRECTIONS, resizeBox } from './panelResize.js';
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
@@ -32,7 +32,7 @@ const VIEWPORT_MARGIN_PX = 6;
*/
const PANEL_OBSTACLE_IDS = ['command-dock', 'gev-voice-control'];
const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`;
const PANEL_FLOAT_HINT = 'Double-click the header to snap the panel back';
const PANEL_FLOAT_HINT = msg('Double-click the header to snap the panel back');
/** Header children whose own interaction wins over a drag or a snap-back. */
const INTERACTIVE_SELECTOR =
'input, select, option, textarea, button, a, [role="button"]';
@@ -477,7 +477,7 @@ export class PanelPositionControls {
if (this._floatHintShown) return;
this._floatHintShown = true;
}
this._showToast(PANEL_FLOAT_HINT);
this._showToast(t(PANEL_FLOAT_HINT));
}
/**
+44
View File
@@ -0,0 +1,44 @@
/**
* Preset place names as the page shows them.
*
* The preset cities in src/locations.js keep their English names in data:
* those are what voice tools, the AI model, share links and lookups match on.
* This helper translates a known English name at a presentation boundary
* only; any other place (a geocoded address, a name the model chose) is shown
* as given.
*
* Pure: imports only i18n, no DOM.
* @module ui/placeNames
*/
import { msg, t } from '../i18n/index.js';
// The preset cities (CITY_POIS names, kept in step by placeNames.test.mjs),
// and Los Angeles, which voice results name for the analyst's region scopes.
const PLACE_NAME_KEYS = new Map(
[
msg('Austin'),
msg('San Francisco'),
msg('New York'),
msg('Tokyo'),
msg('London'),
msg('Paris'),
msg('Dubai'),
msg('Washington DC'),
msg('Tallinn'),
msg('Los Angeles'),
].map((key) => [key, key]),
);
/**
* A preset place name in the active language; any other text unchanged.
* @param {string} name English place name, e.g. `Tokyo`.
* @returns {string}
*/
export function localizePlaceName(name) {
if (typeof name !== 'string') return name;
const key = PLACE_NAME_KEYS.get(name.trim());
return key ? t(key) : name;
}
/** The English names this module translates (for tests). */
export const KNOWN_PLACE_NAMES = Object.freeze([...PLACE_NAME_KEYS.keys()]);
+82
View File
@@ -0,0 +1,82 @@
// Preset place names show in the UI language at the presentation boundary
// (voice results, location pills, HUD); the preset data stays English.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { CITY_POIS } from '../locations.js';
import { KNOWN_PLACE_NAMES, localizePlaceName } from './placeNames.js';
import { LocationControls } from './locationControls.js';
test.afterEach(() => setLocale('en'));
test('every preset city has a display name entry', () => {
for (const city of Object.values(CITY_POIS))
assert.ok(KNOWN_PLACE_NAMES.includes(city.name), city.name);
});
test('en: place names pass through unchanged', () => {
setLocale('en');
for (const name of KNOWN_PLACE_NAMES)
assert.equal(localizePlaceName(name), name);
});
for (const [locale, tokyo, losAngeles] of [
['ru', 'Токио', 'Лос-Анджелес'],
['es', 'Tokio', 'Los Ángeles'],
]) {
test(`${locale}: preset names are translated, other places shown as given`, () => {
setLocale(locale);
assert.equal(localizePlaceName('Tokyo'), tokyo);
assert.equal(localizePlaceName('Los Angeles'), losAngeles);
assert.equal(localizePlaceName('Ulaanbaatar'), 'Ulaanbaatar');
assert.equal(localizePlaceName(undefined), undefined);
});
}
function element() {
return {
children: [],
dataset: {},
textContent: '',
addEventListener() {},
removeEventListener() {},
appendChild(child) {
this.children.push(child);
},
replaceChildren() {
this.children = [];
},
};
}
test('ru: location pills and the mini-status show the translated city', () => {
setLocale('ru');
const elements = {
pills: element(),
poiRow: element(),
divider: element(),
search: element(),
searchToggle: element(),
resetButtons: [],
statusCity: element(),
statusPoi: element(),
};
const doc = { ...element(), createElement: element };
const cities = { tokyo: CITY_POIS.tokyo };
const controls = new LocationControls({
elements,
cities,
getExpandedCity: () => null,
onCity() {},
onPoi() {},
onSearch() {},
onReset() {},
doc,
requestFrame: () => 0,
cancelFrame() {},
});
assert.equal(elements.pills.children[0].textContent, 'Токио');
controls.renderStatus({ city: CITY_POIS.tokyo, currentPoi: null });
assert.equal(elements.statusCity.textContent, '📍 Токио');
assert.equal(CITY_POIS.tokyo.name, 'Tokyo');
});
+5 -3
View File
@@ -179,9 +179,11 @@ export class RadioControls {
'aria-expanded',
String(radioExpanded),
);
const label = radioExpanded
? 'Go to expanded Radio section'
: 'Expand Radio section in Context';
const label = t(
radioExpanded
? msg('Go to expanded Radio section')
: msg('Expand Radio section in Context'),
);
this._contextRadioToggleBtn.setAttribute('aria-label', label);
this._contextRadioToggleBtn.title = label;
return;
+10 -5
View File
@@ -32,10 +32,10 @@ export function renderRadioState(state) {
}
this._syncContextRadioLauncherState();
this._radioLayerState?.classList.toggle('active', enabled);
// Lifecycle enum values map to status-chip keys; counts stay machine.
const lifecycleLabel =
lifecycleState === 'enabling' ? t('ENABLING') : t('DISABLING');
if (this._radioLayerState) {
// Lifecycle enum values map to status-chip keys; counts stay machine.
const lifecycleLabel =
lifecycleState === 'enabling' ? t('ENABLING') : t('DISABLING');
this._radioLayerState.textContent = transitioning
? lifecycleLabel
: uncertain
@@ -307,7 +307,10 @@ export function renderRadioState(state) {
buffering: t('Buffering broadcaster stream…'),
playing: t('Playing {station}', { station: stationName }),
paused: t('Paused {station}', { station: stationName }),
error: state.audioError || t('Broadcaster stream unavailable'),
// Playback keeps its messages in English (msg()); shown in the UI language.
error: state.audioError
? t(state.audioError)
: t('Broadcaster stream unavailable'),
};
const voiceSuffix = state.voiceDucked
? t(' · muted during voice interaction')
@@ -332,7 +335,9 @@ export function renderRadioState(state) {
const uncertainMessage = uncertain
? t('Radio lifecycle is uncertain — use Enable or Disable to reconcile')
: null;
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || t('Ready')}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
// Directory errors are msg() literals from the radio layer's ingestion.
const directoryError = state.error ? t(state.error) : null;
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || directoryError || messages[state.audioState] || t('Ready')}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
this._radioPlaybackState.classList.toggle(
'error',
Boolean(
@@ -0,0 +1,71 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { renderRadioState } from './radioPresentation.js';
function el() {
const classes = new Set();
return {
textContent: '',
classList: {
toggle(name, on) {
on ? classes.add(name) : classes.delete(name);
},
contains: (name) => classes.has(name),
},
};
}
function render(audioError) {
globalThis.document = { getElementById: () => null, activeElement: null };
const host = {
destroyed: false,
_radioPanel: el(),
_radioPlaybackState: el(),
_syncContextRadioLauncherState() {},
actions: {
getLifecycle: () => null,
preservePanelStateDuringClear: () => false,
setPanelCollapsed() {},
scheduleLayout() {},
},
};
renderRadioState.call(host, {
enabled: true,
audioState: 'error',
audioError,
filteredCount: 1,
stationCount: 1,
volume: 1,
});
return host._radioPlaybackState.textContent;
}
const PLAYBACK_ERRORS = [
'Playback requires a direct click or tap.',
'Broadcaster stream could not be started.',
'Broadcaster stream is unavailable or blocked by the browser.',
];
for (const locale of ['es', 'ru']) {
test(`radio playback errors are localized in ${locale}`, () => {
setLocale(locale);
try {
for (const message of PLAYBACK_ERRORS) {
assert.notEqual(render(message), message, `${locale}: ${message}`);
}
// Control: the fallback (no audioError) is translated.
assert.notEqual(render(null), 'Broadcaster stream unavailable');
} finally {
setLocale('en');
}
});
}
test('radio playback errors read as before in English', () => {
setLocale('en');
for (const message of PLAYBACK_ERRORS) {
assert.equal(render(message), message);
}
assert.equal(render(null), 'Broadcaster stream unavailable');
});
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { renderRadioState } from './radioPresentation.js';
function el() {
const attrs = new Map();
return {
textContent: '',
hidden: false,
disabled: false,
style: {},
dataset: {},
classList: { toggle() {}, add() {}, remove() {}, contains: () => false },
setAttribute: (k, v) => attrs.set(k, String(v)),
getAttribute: (k) => attrs.get(k),
attrs,
};
}
for (const lifecycleState of ['enabling', 'disabling']) {
test(`Radio render survives the ${lifecycleState} transition`, () => {
const prior = globalThis.document;
globalThis.document = { getElementById: () => null };
try {
const enable = el();
const ctx = {
destroyed: false,
_radioPanel: el(),
_radioEnableBtn: enable,
_syncContextRadioLauncherState() {},
actions: {
getLifecycle: () => ({
lifecycleState,
enabled: lifecycleState === 'disabling',
uncertain: false,
}),
},
};
const state = {
enabled: false,
categories: [],
filteredCount: 0,
stationCount: 0,
audioState: 'idle',
};
try {
renderRadioState.call(ctx, state);
} catch (error) {
// Anything thrown after the transitional label is a different
// missing fixture field, not the bug under test.
assert.ok(
!(
error instanceof ReferenceError &&
/lifecycleLabel/.test(error.message)
),
`render threw: ${error.message}`,
);
}
assert.equal(enable.attrs.get('aria-busy'), 'true');
assert.equal(enable.textContent, lifecycleState.toUpperCase());
} finally {
globalThis.document = prior;
}
});
}
+12 -3
View File
@@ -2,9 +2,18 @@ import { formatUtcDayMonth, t } from '../i18n/index.js';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
};
/**
* "HH:MM UTC" of a time, or '' when it is not one. Computed on its own rather
* than cut from the dated readout: the localized day and month vary in length
* ("3/9" in Spanish, "09-03" in English).
*/
export const utcClock = (tick) =>
Number.isFinite(Date.parse(tick))
? `${new Date(tick).toISOString().slice(11, 16)} UTC`
: '';
const utc = (tick) =>
Number.isFinite(Date.parse(tick))
? `${formatUtcDayMonth(tick)} ${new Date(tick).toISOString().slice(11, 16)} UTC`
? `${formatUtcDayMonth(tick)} ${utcClock(tick)}`
: '';
/** Stable native transport with local preview and a coalesced 150 ms drag commit. */
@@ -115,8 +124,8 @@ export function createRailTimeline({
String(Math.max(0, Math.min(props.index, ticks.length - 1))),
);
valueText(ticks[Number(slider.value)]);
set(oldest, 'textContent', utc(ticks[0]).slice(6));
set(newest, 'textContent', utc(ticks.at(-1)).slice(6));
set(oldest, 'textContent', utcClock(ticks[0]));
set(newest, 'textContent', utcClock(ticks.at(-1)));
set(readout, 'textContent', props.readout || '');
}
for (const node of [slider, latest, play])
+28 -1
View File
@@ -19,6 +19,7 @@ import {
} from '../layers/recentImagery/model.js';
import { displayDay, displayMetres } from '../layers/recentImagery/display.js';
import { formatQuantity } from './unitLabels.js';
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
const PANEL_ID = 'recent-imagery-panel';
const OVERVIEW_MIN_BOX_KM = 25;
@@ -32,12 +33,38 @@ const LAYER_NOTICES = new Set([
msg('Esri map unavailable · no swipe'),
msg('Swipe needs a globe map'),
msg('Imagery stays on Esri · CLEAR to use Google 3D'),
// Box refusals the layer writes itself (layers/recentImagery/index.js).
msg('Point the camera at the ground to use the view'),
msg('That point cannot anchor a box'),
]);
const TOO_LARGE = /^(Box|View) is ([\d,]+) km wide · limit ([\d,]+) km$/;
// Refusals with a value in them (layers/recentImagery/model.js and
// imageryBoxTool.js); fixed refusals there are msg() text for translateKnown.
const POLAR = /^Imagery stops at ±([\d.]+)° latitude$/;
const POINTER_BUSY = /^(.+) is using the pointer — close it first$/;
function localizeNotice(text) {
/**
* The notice line in the UI language: notices and box refusals arrive in
* English. Exported for tests.
* @param {string} text
* @returns {string}
*/
export function localizeNotice(text) {
if (!text) return '';
if (LAYER_NOTICES.has(text)) return t(text);
const polar = POLAR.exec(text);
if (polar)
return t('Imagery stops at ±{limit}° latitude', {
limit: formatNumber(Number(polar[1]), {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
});
const busy = POINTER_BUSY.exec(text);
if (busy)
return t('{owner} is using the pointer — close it first', {
owner: translateKnown(pointerOwnerName(busy[1])),
});
const tooLarge = TOO_LARGE.exec(text);
if (!tooLarge) return translateKnown(text);
const values = {
+103
View File
@@ -0,0 +1,103 @@
// K-43: box-tool and model refusals on the Recent Imagery notice line are
// shown in the UI language; the layer keeps them in English.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { validateBox } from '../layers/recentImagery/model.js';
import { localizeNotice } from './recentImagery.js';
const REFUSALS = [
'Drag to size the box', // imageryBoxTool.js, click without drag
'Press on the ground, not the sky', // imageryBoxTool.js
'draw is using the pointer — close it first', // imageryBoxTool.js, pointer busy
'Point the camera at the ground to use the view', // layers/recentImagery/index.js
'That point cannot anchor a box', // layers/recentImagery/index.js
...[
{ west: 170, south: 10, east: -170, north: 20 }, // dateline
{ west: 10, south: 80, east: 20, north: 89 }, // polar
{ west: 10, south: 10, east: 10, north: 20 }, // degenerate
{ west: 10, south: 10, east: 200, north: 20 }, // longitude range
{ west: Number.NaN, south: 10, east: 20, north: 20 }, // not finite
].map((box) => {
const result = validateBox(box);
assert.equal(result.ok, false);
return result.message;
}),
];
// The pointer-busy refusal names the owner by its internal id; the notice
// shows the tool's name instead, in English too.
const SHOWN_EN = new Map([
[
'draw is using the pointer — close it first',
'Draw is using the pointer — close it first',
],
]);
test('K-43 en: refusals read as before', () => {
setLocale('en');
for (const text of REFUSALS)
assert.equal(localizeNotice(text), SHOWN_EN.get(text) ?? text);
assert.ok(REFUSALS.includes('Imagery stops at ±85.05° latitude'));
});
test('E3/E12: the pointer owner is named by its tool, never by its id', () => {
const owners = {
en: {
draw: 'Draw',
directions: 'Directions',
'recent-imagery-box': 'Recent imagery',
},
ru: {
draw: 'Рисование',
directions: 'Маршруты',
'recent-imagery-box': 'Свежие снимки',
},
es: {
draw: 'Dibujo',
directions: 'Indicaciones',
'recent-imagery-box': 'Imágenes recientes',
},
};
try {
for (const [locale, names] of Object.entries(owners)) {
setLocale(locale);
for (const [id, name] of Object.entries(names)) {
const shown = localizeNotice(
`${id} is using the pointer — close it first`,
);
assert.ok(shown.includes(name), `${locale} ${id}: ${shown}`);
assert.ok(!shown.includes(id), `${locale} ${id} leaked: ${shown}`);
}
}
setLocale('en');
assert.equal(
localizeNotice('measure is using the pointer — close it first'),
'measure is using the pointer — close it first',
'an unregistered owner falls back to its id',
);
} finally {
setLocale('en');
}
});
for (const locale of ['es', 'ru']) {
test(`K-43 ${locale}: box-tool and model refusals are translated`, () => {
setLocale(locale);
try {
for (const text of REFUSALS)
assert.notEqual(localizeNotice(text), text, `untranslated: ${text}`);
assert.match(
localizeNotice('Imagery stops at ±85.05° latitude'),
/85,05/,
);
assert.doesNotMatch(
localizeNotice('draw is using the pointer — close it first'),
/\bdraw\b/,
);
} finally {
setLocale('en');
}
});
}
+11 -6
View File
@@ -1,5 +1,5 @@
/** Own Scene panel listeners and presentation through project reads and actions. */
import { t } from '../i18n/index.js';
import { t, msg } from '../i18n/index.js';
import {
sceneElements,
renderSceneOptions,
@@ -46,7 +46,7 @@ export class SceneControls {
this.listen(elements.start, 'click', () =>
this.run('start', this.read().selectedSceneId),
);
this.listen(elements.stop, 'click', () => this.run('stop', 'Stopped'));
this.listen(elements.stop, 'click', () => this.run('stop', msg('Stopped')));
this.listen(elements.import, 'click', () => elements.file?.click());
this.listen(elements.file, 'change', async () => {
const file = elements.file?.files?.[0];
@@ -141,8 +141,8 @@ export class SceneControls {
createScene() {
if (this.destroyed) return;
const name = window.prompt(
'New scene name',
`Scene ${this.read().scenes.length + 1}`,
t('New scene name'),
t('Scene {n}', { n: this.read().scenes.length + 1 }),
);
if (name) this.run('create', name);
}
@@ -153,7 +153,12 @@ export class SceneControls {
const scene = state.scenes.find(
(item) => item.id === state.selectedSceneId,
);
if (scene && window.confirm(`Delete scene "${scene.title}" and all shots?`))
if (
scene &&
window.confirm(
t('Delete scene "{scene}" and all shots?', { scene: scene.title }),
)
)
this.run('deleteScene');
}
@@ -221,7 +226,7 @@ export class SceneControls {
} else if (!this.destroyed && !this.playbackKeyRemover) {
const onKeyDown = (event) => {
if (!this.destroyed && event.key === 'Escape' && this.read().running)
this.run('stop', 'Stopped (Esc)');
this.run('stop', msg('Stopped (Esc)'));
};
document.addEventListener('keydown', onKeyDown);
this.playbackKeyRemover = () =>
+11 -7
View File
@@ -87,21 +87,25 @@ export function createSceneDialog(title, onClose) {
}
/**
* The browser draws a file input's button and "no file" text in its own
* language. Hide it visually (it stays focusable and is still opened by its
* label) and show a translated button and selection line instead.
* language. Keep the native input itself as the whole control — stretched
* over the row below, fully transparent — so clicking, keyboard focus and
* dropping files or folders from the desktop all stay native (a shrunken
* input would only accept drops on its own 1 px), and draw a translated
* button and selection line underneath it.
*/
function fileChooser(wrapper, node) {
wrapper.style.position = 'relative';
Object.assign(node.style, {
position: 'absolute',
width: '1px',
height: '1px',
inset: '0',
width: '100%',
height: '100%',
margin: '0',
padding: '0',
overflow: 'hidden',
clip: 'rect(0 0 0 0)',
whiteSpace: 'nowrap',
border: '0',
opacity: '0',
cursor: 'pointer',
zIndex: '1',
});
const row = document.createElement('span');
row.setAttribute('aria-hidden', 'true');
+49 -10
View File
@@ -1,9 +1,56 @@
import { LayerStateCoordinator } from '../data/layerStateCoordinator.js';
import { stampInitialShareGesture } from '../navigationPolicy.js';
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
import { t } from '../i18n/index.js';
import { t, msg } from '../i18n/index.js';
import { UiLifetime } from './uiLifetime.js';
// One ready-made sentence per restore policy (SHARE_TRACKING_RESTORE_POLICIES
// label) and outcome: a label dropped into a template cannot take the case or
// article the sentence needs in every language. The generic template stays
// for a label this table does not know.
const SHARE_RESTORE_NOTICES = Object.freeze({
expired: Object.freeze({
flight: msg('Shared flight follow expired'),
'military flight': msg('Shared military flight follow expired'),
satellite: msg('Shared satellite follow expired'),
}),
'source-unavailable': Object.freeze({
flight: msg('Shared flight could not be restored — feed unavailable'),
'military flight': msg(
'Shared military flight could not be restored — feed unavailable',
),
satellite: msg('Shared satellite could not be restored — feed unavailable'),
}),
unavailable: Object.freeze({
flight: msg('Shared flight is unavailable'),
'military flight': msg('Shared military flight is unavailable'),
satellite: msg('Shared satellite is unavailable'),
}),
});
/**
* The status notice for a terminal share-tracking restore result.
* @param {{classification?: string, label?: string}} result
* @returns {string}
*/
export function shareTrackingRestoreNotice(result) {
const outcome =
result?.classification === 'expired' ||
result?.classification === 'source-unavailable'
? result.classification
: 'unavailable';
const ready = SHARE_RESTORE_NOTICES[outcome][result?.label];
if (ready) return t(ready);
const subject = result?.label ? t(result.label) : t('entity');
return outcome === 'expired'
? t('Shared {subject} follow expired', { subject })
: outcome === 'source-unavailable'
? t('Shared {subject} could not be restored — feed unavailable', {
subject,
})
: t('Shared {subject} is unavailable', { subject });
}
/** Own initial share restoration, durable layer state and restoration notices. */
export class ShareRestoration {
constructor({
@@ -219,15 +266,7 @@ export class ShareRestoration {
const noticeGeneration = ownsAcquiringNotice
? this._shareTrackingNoticeGeneration
: ++this._shareTrackingNoticeGeneration;
const subject = result.label ? t(result.label) : t('entity');
const message =
result.classification === 'expired'
? t('Shared {subject} follow expired', { subject })
: result.classification === 'source-unavailable'
? t('Shared {subject} could not be restored — feed unavailable', {
subject,
})
: t('Shared {subject} is unavailable', { subject });
const message = shareTrackingRestoreNotice(result);
const showAfterStartupCover = () => {
this._lifetime.frame(() => {
if (
+79
View File
@@ -0,0 +1,79 @@
// Share-tracking restore notices are ready-made sentences per policy: a label
// dropped into a template cannot take the case (ru) or article (es) the
// sentence needs. English stays exactly as it was composed before.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { SHARE_TRACKING_RESTORE_POLICIES } from '../data/layerState.js';
import { shareTrackingRestoreNotice } from './shareRestoration.js';
const { flights, military, satellites } = SHARE_TRACKING_RESTORE_POLICIES;
test.afterEach(() => setLocale('en'));
test('en: the notices read as before for every policy and outcome', () => {
setLocale('en');
const notice = (policy, classification) =>
shareTrackingRestoreNotice({ ...policy, classification });
assert.equal(notice(flights, 'expired'), 'Shared flight follow expired');
assert.equal(
notice(military, 'expired'),
'Shared military flight follow expired',
);
assert.equal(
notice(satellites, 'source-unavailable'),
'Shared satellite could not be restored — feed unavailable',
);
assert.equal(
notice(flights, 'source-unavailable'),
'Shared flight could not be restored — feed unavailable',
);
assert.equal(
notice(military, 'unavailable'),
'Shared military flight is unavailable',
);
assert.equal(
notice(satellites, 'unavailable'),
'Shared satellite is unavailable',
);
// Unknown label and classification: the generic template still applies.
assert.equal(
shareTrackingRestoreNotice({ label: 'contact', classification: 'odd' }),
'Shared contact is unavailable',
);
assert.equal(
shareTrackingRestoreNotice({ classification: 'expired' }),
'Shared entity follow expired',
);
});
test('ru: the restore notices do not glue the nominative label into a genitive slot', () => {
setLocale('ru');
for (const policy of [flights, military, satellites]) {
for (const classification of [
'expired',
'source-unavailable',
'unavailable',
]) {
const message = shareTrackingRestoreNotice({ ...policy, classification });
// "Срок отслеживания" takes the genitive: рейса / спутника, never the
// nominative label as stored in the policy.
assert.doesNotMatch(
message,
/отслеживания (военный )?(рейс|спутник) /,
message,
);
assert.doesNotMatch(message, /Shared|\{subject\}/, message);
}
}
});
test('es: the restore notices read with an article before the subject', () => {
setLocale('es');
const message = shareTrackingRestoreNotice({
...flights,
classification: 'expired',
});
assert.doesNotMatch(message, /seguimiento de vuelo/, message);
assert.doesNotMatch(message, /Shared/, message);
});
+52 -9
View File
@@ -9,8 +9,8 @@ import {
reduceTrafficSyncFeedback,
} from '../loadingFeedback.js';
import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js';
import { t, msg } from '../i18n/index.js';
import { localizeLayerName } from './layerPanel.js';
import { getLocale, t, msg } from '../i18n/index.js';
import { localizeLayerName, translateFeedError } from './layerPanel.js';
// loadingFeedback.js composes the shared status copy inside the portable
// node/browser graph (the server-side HUD summary reads the same modules), so
@@ -29,24 +29,66 @@ const STATUS_LABEL_KEYS = Object.freeze({
'RETRYING ALPR CAMERAS': msg('RETRYING ALPR CAMERAS'),
'FETCHING ALPR CAMERAS': msg('FETCHING ALPR CAMERAS'),
'OVERPASS TEMPORARILY UNAVAILABLE': msg('OVERPASS TEMPORARILY UNAVAILABLE'),
// The other mapped-site failure reasons (src/data/installationFeedback.js),
// upper-cased by presentGlobalLoadingStatus.
'OVERPASS RATE-LIMITED': msg('OVERPASS RATE-LIMITED'),
'OVERPASS TIMED OUT': msg('OVERPASS TIMED OUT'),
'OVERPASS COULD NOT COMPLETE THE QUERY': msg(
'OVERPASS COULD NOT COMPLETE THE QUERY',
),
'MAP TILES TEMPORARILY UNAVAILABLE': msg('MAP TILES TEMPORARILY UNAVAILABLE'),
'MAPPED NAMES TEMPORARILY UNAVAILABLE': msg(
'MAPPED NAMES TEMPORARILY UNAVAILABLE',
),
'TURNING OFF LIVE DATA': msg('TURNING OFF LIVE DATA'),
'REFRESHING LIVE DATA': msg('REFRESHING LIVE DATA'),
'syncing road network': msg('syncing road network'),
});
// The ALPR retry chip shows the layer's English error upper-cased
// (loadingFeedback.js). These are the camera and vector tile failures it can
// carry, by their upper-cased text; each is translated through its own
// sentence-case key, the one the layer row shows, and upper-cased again.
const CHIP_ERROR_KEYS = new Map(
[
msg('Vector tile metadata unavailable'),
msg('Vector tiles unavailable'),
msg('Vector tile request timed out'),
msg('Camera coverage unavailable'),
msg('Camera source temporarily unavailable'),
].map((key) => [key.toUpperCase(), key]),
);
const CHIP_TILE_HTTP_RE = /^VECTOR TILES UNAVAILABLE \(HTTP (\d+)\)$/;
function localizeStatusLabel(label) {
if (typeof label !== 'string' || !label) return label;
const key = STATUS_LABEL_KEYS[label.trim()];
return key ? t(key) : label;
const text = label.trim();
const key = STATUS_LABEL_KEYS[text];
if (key) return t(key);
const errorKey = CHIP_ERROR_KEYS.get(text);
const http = errorKey ? null : CHIP_TILE_HTTP_RE.exec(text);
if (!errorKey && !http) return label;
return translateFeedError(
errorKey || `Vector tiles unavailable (HTTP ${http[1]})`,
).toLocaleUpperCase(getLocale());
}
const RETRY_DETAIL_RE = /^(?:ALPR cameras · )?retrying in (\d+)s$/;
// A retry detail is "retrying in Ns" or "retry pending", optionally prefixed
// by the ALPR layer name (loadingFeedback.js). The prefix is kept in every
// language: it is what tells the chip apart from a mapped-sites retry.
const RETRY_DETAIL_RE =
/^(?:(ALPR cameras) · )?(?:retrying in (\d+)s|(retry pending))$/;
function localizeStatusDetail(detail) {
if (typeof detail !== 'string' || !detail) return detail;
const match = detail.match(RETRY_DETAIL_RE);
if (match) return t('retrying in {seconds}s', { seconds: match[1] });
if (detail.trim() === 'retry pending') return t('retry pending');
const match = detail.trim().match(RETRY_DETAIL_RE);
if (match) {
const [, prefix, seconds, pending] = match;
const retry = pending
? t('retry pending')
: t('retrying in {seconds}s', { seconds });
return prefix ? `${t('ALPR cameras')} · ${retry}` : retry;
}
return localizeLayerRoster(detail);
}
@@ -64,7 +106,8 @@ function localizeLayerRoster(detail) {
);
}
function localizeLoadingPresentation(presentation) {
/** The chip's label and detail in the UI language (exported for tests). */
export function localizeLoadingPresentation(presentation) {
if (!presentation) return presentation;
return {
...presentation,
@@ -0,0 +1,110 @@
// The global loading chip translates the closed status vocabulary of
// loadingFeedback.js at the presentation boundary. The ALPR retry detail keeps
// its layer-name prefix in every language (it is what tells the chip apart
// from a mapped-sites retry), and every mapped-site failure reason label has a
// catalog entry, not only the default one.
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
const elements = {
'global-loading-status': { hidden: true, dataset: {} },
'global-loading-detail': { textContent: '' },
};
globalThis.document ??= {
getElementById: (id) => elements[id] ?? null,
addEventListener() {},
removeEventListener() {},
hidden: false,
};
const { ShellFeedback, localizeLoadingPresentation } =
await import('./shellFeedback.js');
function alprChipDetail(locale, retryInMs) {
setLocale(locale);
const layer = {
id: 'alpr-cameras',
name: 'ALPR cameras',
enabled: true,
stats: {
status: 'unavailable',
error: 'Unavailable',
retryAt: Date.now() + retryInMs,
},
};
const feedback = new ShellFeedback({ readLayers: () => [layer] });
feedback._armLoadingFeedbackTicker = () => {};
feedback._updateGlobalLoadingFeedback(0);
return elements['global-loading-detail'].textContent;
}
test.afterEach(() => setLocale('en'));
test('English ALPR retry chip keeps the layer name (as on main)', () => {
assert.equal(alprChipDetail('en', 12000), 'ALPR cameras · retrying in 12s');
assert.equal(alprChipDetail('en', -1000), 'ALPR cameras · retry pending');
});
test('Spanish ALPR retry chip keeps the layer name', () => {
assert.match(alprChipDetail('es', 12000), /ALPR.* · reintento en 12 s/);
});
test('Spanish ALPR retry-pending chip is translated', () => {
const detail = alprChipDetail('es', -1000);
assert.match(detail, /ALPR/);
assert.doesNotMatch(detail, /retry pending/);
});
test('English presentation passes through unchanged', () => {
setLocale('en');
for (const label of [
'OVERPASS TEMPORARILY UNAVAILABLE',
'OVERPASS RATE-LIMITED',
'OVERPASS TIMED OUT',
'OVERPASS COULD NOT COMPLETE THE QUERY',
'MAP TILES TEMPORARILY UNAVAILABLE',
'MAPPED NAMES TEMPORARILY UNAVAILABLE',
]) {
assert.deepEqual(
localizeLoadingPresentation({
state: 'retry',
label,
detail: 'retrying in 12s',
}),
{ state: 'retry', label, detail: 'retrying in 12s' },
);
}
});
for (const locale of ['es', 'ru']) {
test(`${locale}: every mapped-site retry label is translated with its detail`, () => {
setLocale(locale);
for (const label of [
'OVERPASS TEMPORARILY UNAVAILABLE',
'OVERPASS RATE-LIMITED',
'OVERPASS TIMED OUT',
'OVERPASS COULD NOT COMPLETE THE QUERY',
'MAP TILES TEMPORARILY UNAVAILABLE',
'MAPPED NAMES TEMPORARILY UNAVAILABLE',
]) {
const localized = localizeLoadingPresentation({
state: 'retry',
label,
detail: 'retrying in 12s',
});
assert.notEqual(localized.label, label, `label stays English: ${label}`);
assert.notEqual(localized.detail, 'retrying in 12s');
}
});
test(`${locale}: a live provider error label passes through verbatim`, () => {
setLocale(locale);
const localized = localizeLoadingPresentation({
state: 'retry',
label: 'HTTP 503 FROM OVERPASS-API.DE',
detail: 'retry pending',
});
assert.equal(localized.label, 'HTTP 503 FROM OVERPASS-API.DE');
assert.notEqual(localized.detail, 'retry pending');
});
}
+108
View File
@@ -0,0 +1,108 @@
// Shell toasts, prompts and launcher labels that reach the page through
// `showToast?.(`, `this._showToast(`, `window.prompt/confirm` or a constant
// are invisible to the unmarked-text checker, so pin them here: marked in the
// code, present in every shipped catalog, and no bare literal left at the
// call sites.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
extractMessages,
readCatalog,
shippedLocales,
} from '../../scripts/i18n.mjs';
import { setLocale } from '../i18n/index.js';
import {
beginDeferredNavigation,
reassertNavigationHandoff,
runExplicitNavigation,
} from '../navigationPolicy.js';
const KEYS = [
'Tilted view',
'Straight-down view',
'North up',
'Exit cockpit to fly to a camera',
'Exit cockpit to fly to a location',
'Exit cockpit to fly to a photo',
'Exit cockpit to fly to a shared view',
'Exit cockpit to fly to a {noun}',
'Go to expanded Radio section',
'Expand Radio section in Context',
'Double-click the header to snap the panel back',
'New scene name',
'Delete scene "{scene}" and all shots?',
'Detailed outline unavailable',
];
const messages = extractMessages();
for (const key of KEYS) {
test(`marked for translation: ${key}`, () => {
assert.ok(messages.has(key), `not marked with t()/msg(): ${key}`);
});
for (const locale of shippedLocales()) {
test(`${locale} catalog translates: ${key}`, () => {
const value = readCatalog(locale)[key];
assert.ok(value, `missing in ${locale}.json`);
assert.notEqual(value, key);
});
}
}
test('no bare literal reaches showToast in the orientation and navigation code', () => {
for (const file of [
'../ui/cameraOrientationControls.js',
'../navigationPolicy.js',
'../ui/locationNavigation.js',
'../ui/panelPositionControls.js',
]) {
const source = readFileSync(
fileURLToPath(new URL(file, import.meta.url)),
'utf8',
);
assert.doesNotMatch(
source,
/_?showToast\??\.?\(\s*['"`]/,
`${file} passes a bare literal to showToast`,
);
}
});
test.afterEach(() => setLocale('en'));
test('the Cockpit refusal keeps its English wording per noun', () => {
setLocale('en');
const log = [];
const showToast = (text) => log.push(text);
runExplicitNavigation({ cockpitActive: true, noun: 'camera', showToast });
beginDeferredNavigation({ cockpitActive: true, showToast });
reassertNavigationHandoff({
generation: 1,
currentGeneration: 1,
cockpitActive: true,
showToast,
});
runExplicitNavigation({ cockpitActive: true, noun: 'contact', showToast });
assert.deepEqual(log, [
'Exit cockpit to fly to a camera',
'Exit cockpit to fly to a location',
'Exit cockpit to fly to a location',
'Exit cockpit to fly to a contact',
]);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: the Cockpit refusal is translated`, () => {
setLocale(locale);
const log = [];
runExplicitNavigation({
cockpitActive: true,
noun: 'camera',
showToast: (text) => log.push(text),
});
assert.equal(log.length, 1);
assert.doesNotMatch(log[0], /Exit cockpit/);
});
}
+31
View File
@@ -0,0 +1,31 @@
// The Street Level meta-line test must not depend on the system locale: the
// code formats the count in the app language (formatNumber), so a contributor
// whose terminal sets LANG=de_DE or es_ES must see the same green run as CI's
// C.UTF-8. Runs the presentation test file under three process locales.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const here = path.dirname(fileURLToPath(import.meta.url));
const target = path.join(here, 'streetLevelPresentation.test.mjs');
for (const locale of ['de_DE.UTF-8', 'es_ES.UTF-8', 'C.UTF-8']) {
test(`streetLevelPresentation tests pass under LC_ALL=${locale}`, () => {
// Drop the node:test child markers so the nested run is a real top-level run.
const env = { ...process.env, LC_ALL: locale, LANG: locale };
delete env.NODE_TEST_CONTEXT;
delete env.NODE_TEST_WORKER_ID;
const result = spawnSync(process.execPath, ['--test', target], {
encoding: 'utf8',
env,
});
const failed = result.stdout.match(/^ℹ fail (\d+)/m)?.[1] ?? '?';
assert.equal(
result.status,
0,
`exit status ${result.status}, ${failed} failing test(s) under ${locale}\n${result.stdout.slice(-700)}`,
);
});
}
+34 -4
View File
@@ -97,6 +97,35 @@ function presentStatus(state) {
: { text: t('OFF'), tone: '', pressed, title };
}
// Provider refusals with a number in them stay English data
// (providers/kartaview/source.js); they go back onto a placeholder here.
const ERROR_PATTERNS = [
[
/^KartaView answered HTTP (\d+)$/,
([, status]) => t('KartaView answered HTTP {status}', { status }),
],
[
/^KartaView refused the request \(code (-?\d+)\)$/,
([, code]) => t('KartaView refused the request (code {code})', { code }),
],
];
/**
* A provider or viewer error in the UI language. Exported for tests.
* @param {string} text English error text from a provider.
* @returns {string}
*/
export function translateStreetLevelError(text) {
if (typeof text !== 'string' || !text) return text;
const known = translateKnown(text);
if (known !== text) return known;
for (const [pattern, format] of ERROR_PATTERNS) {
const match = pattern.exec(text);
if (match) return format(match);
}
return text;
}
/** A chip is lit only while the layer and its provider are both on. */
function presentProviders(state) {
const enabled = state.enabled === true;
@@ -107,11 +136,11 @@ function presentProviders(state) {
? t('{name} imagery on', { name: provider.name })
: t('{name} imagery off', { name: provider.name });
if (provider.keyRejected && provider.error)
title = `${provider.name}: ${translateKnown(provider.error)}`;
title = `${provider.name}: ${translateStreetLevelError(provider.error)}`;
else if (keyRequired && provider.requiresKeyId)
title = `${provider.name}: ${keyRequirementText(provider.requiresKeyId)}`;
else if (provider.error)
title = `${provider.name}: ${translateKnown(provider.error)}`;
title = `${provider.name}: ${translateStreetLevelError(provider.error)}`;
return {
id: provider.id,
label: translateKnown(provider.label),
@@ -262,8 +291,9 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
providers: presentProviders(state),
error: keyMissing
? null
: translateKnown(state.street.error || state.coverage.error || '') ||
null,
: translateStreetLevelError(
state.street.error || state.coverage.error || '',
) || null,
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
filterControls: {
pano: state.filterControls?.pano !== false,
+3 -1
View File
@@ -446,9 +446,11 @@ test('the meta line keeps the sequence count and adds the other providers’ hin
},
}),
);
// The count follows the app locale (formatNumber under the default 'en'),
// not the system locale of the Node process running the tests.
assert.equal(
view.meta,
`${(2514).toLocaleString()} sequences in view · click a line for its photos · click a street to open Street View`,
'2,514 sequences in view · click a line for its photos · click a street to open Street View',
);
});
+64 -22
View File
@@ -281,22 +281,16 @@
border-top: 3px solid #6be8ff;
}
/* The title takes the full card width and up to two lines, so a translated
title keeps its place name; the link sits beside the status below it. */
.bhote-embedded-callout footer strong {
grid-column: 1 / -1;
display: -webkit-box;
overflow: hidden;
font-size: 11px;
line-height: 1.25;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-overflow: ellipsis;
white-space: nowrap;
}
.bhote-embedded-callout footer small {
grid-column: 1;
align-self: center;
color: rgba(151, 211, 224, 0.7);
font-size: 8px;
line-height: 1.35;
@@ -304,14 +298,34 @@
.bhote-embedded-callout footer a {
grid-column: 2;
grid-row: 2;
grid-row: 1 / span 2;
align-self: center;
white-space: nowrap;
color: #6be8ff;
font-size: 8px;
text-decoration: none;
}
/* The title takes the full card width and up to two lines, so a translated
title keeps its place name; the link sits beside the status below it. */
html:not([lang='en']) .bhote-embedded-callout footer strong {
grid-column: 1 / -1;
display: -webkit-box;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
html:not([lang='en']) .bhote-embedded-callout footer small {
align-self: center;
}
html:not([lang='en']) .bhote-embedded-callout footer a {
grid-row: 2;
white-space: nowrap;
}
.bhote-embedded-callout footer a:hover,
.bhote-embedded-callout footer a:focus-visible {
text-decoration: underline;
@@ -423,8 +437,9 @@ html:not([lang='en']) .bhote-event-section-head span:last-child {
color: rgba(174, 208, 216, 0.62);
}
/* Keep "16 OCT 2021" together so the year never wraps alone. */
.bhote-event-date {
/* Keep a translated date ("16 OCT 2021") together so the year never wraps
alone. */
html:not([lang='en']) .bhote-event-date {
white-space: nowrap;
}
@@ -458,7 +473,7 @@ html:not([lang='en']) .bhote-event-section-head span:last-child {
align-content: center;
column-gap: 9px;
min-width: 0;
padding: 5px 10px;
padding: 7px 10px;
border: 1px solid rgba(88, 196, 218, 0.18);
background: rgba(6, 19, 27, 0.72);
}
@@ -470,26 +485,45 @@ html:not([lang='en']) .bhote-event-section-head span:last-child {
line-height: 1.25;
}
/* Up to two lines: a translated beat title keeps its subject. Two title lines
and one meta line still fit the 54px arrow buttons, so the controls stay put. */
.bhote-event-story-current strong {
display: -webkit-box;
overflow: hidden;
color: var(--text-primary);
font-size: 11px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.bhote-event-story-current small {
grid-column: 1 / -1;
margin-top: 4px;
overflow: hidden;
color: rgba(116, 216, 235, 0.7);
text-overflow: ellipsis;
white-space: nowrap;
}
html:not([lang='en']) .bhote-event-story-current {
padding: 5px 10px;
}
/* Up to two lines: a translated beat title keeps its subject. Two title lines
and one meta line still fit the 54px arrow buttons, so the controls stay put. */
html:not([lang='en']) .bhote-event-story-current strong {
display: -webkit-box;
text-overflow: clip;
white-space: normal;
overflow-wrap: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
/* Phase / time / sources run long in translation: allow a second line. */
.bhote-event-story-current small {
html:not([lang='en']) .bhote-event-story-current small {
display: -webkit-box;
grid-column: 1 / -1;
margin-top: 3px;
overflow: hidden;
color: rgba(116, 216, 235, 0.7);
text-overflow: clip;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
@@ -514,14 +548,22 @@ html:not([lang='en']) .bhote-event-section-head span:last-child {
cursor: pointer;
}
/* One line in English; a longer translation wraps to a second line. */
.bhote-event-actions button {
min-width: 0;
min-height: 30px;
padding: 3px 5px;
padding: 0 5px;
overflow: hidden;
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A longer translation wraps to a second line instead of being cut. */
html:not([lang='en']) .bhote-event-actions button {
padding: 3px 5px;
line-height: 1.2;
text-overflow: clip;
white-space: normal;
overflow-wrap: break-word;
}
+8 -4
View File
@@ -279,12 +279,12 @@ html[lang='es'] .cctv-controls {
animation: cctv-frame-shimmer 1.4s linear infinite;
}
/* "Acquiring" caption only when no previous frame is showing underneath */
.cctv-frame-wrap.loading:not(.has-frame)::before {
content: 'ACQUIRING FRAME';
/* "Acquiring" caption only when no previous frame is showing underneath.
The text lives in the markup (data-i18n) so it is translated. */
.cctv-frame-caption {
display: none;
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
@@ -294,6 +294,10 @@ html[lang='es'] .cctv-controls {
pointer-events: none;
}
.cctv-frame-wrap.loading:not(.has-frame) .cctv-frame-caption {
display: flex;
}
@keyframes cctv-frame-shimmer {
to {
transform: translateX(100%);
+23 -10
View File
@@ -575,7 +575,9 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed))
text-align: center;
vertical-align: -0.08em;
}
.cockpit-roll-token.is-separator {
/* Grouping and decimal separators (a space or comma in Spanish and Russian)
get a narrow cell; English keeps the full 0.56em cell. */
html:not([lang='en']) .cockpit-roll-token.is-separator {
min-width: 0.3em;
}
.cockpit-roll-token.is-rolling {
@@ -1508,9 +1510,11 @@ body.cockpit-mode #view-switcher button {
.cockpit-signal-header > div {
display: grid;
gap: 2px;
}
html:not([lang='en']) .cockpit-signal-header > div {
min-width: 0;
}
.cockpit-signal-kicker {
html:not([lang='en']) .cockpit-signal-kicker {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1543,7 +1547,7 @@ body.cockpit-mode #view-switcher button {
}
/* A longer translated state label widens the cycle button; the other
controls keep their size instead of being squeezed. */
.cockpit-brief-actions > * {
html:not([lang='en']) .cockpit-brief-actions > * {
flex-shrink: 0;
}
.cockpit-brief-actions button {
@@ -1568,12 +1572,15 @@ body.cockpit-mode #view-switcher button {
font-size: 14px;
}
#cockpit-brief-auto {
width: auto;
min-width: 50px;
padding: 0 4px;
width: 50px;
color: rgba(244, 251, 255, 0.42);
font: 700 6px/1 var(--font-mono);
letter-spacing: 0.05em;
}
html:not([lang='en']) #cockpit-brief-auto {
width: auto;
min-width: 50px;
padding: 0 4px;
white-space: nowrap;
}
#cockpit-brief-auto[aria-pressed='true'] {
@@ -1880,18 +1887,24 @@ body.cockpit-mode #view-switcher button {
letter-spacing: 0.1em;
}
.cockpit-signal-footer > span {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cockpit-brief-tabs {
display: flex;
flex: 0 0 auto;
gap: 3px;
}
.cockpit-brief-tabs button {
/* Translated tab names are longer: the tabs keep their words whole and the
footer text beside them gives way. */
html:not([lang='en']) .cockpit-signal-footer > span {
flex: 1 1 auto;
min-width: 0;
}
html:not([lang='en']) .cockpit-brief-tabs {
flex: 0 0 auto;
}
html:not([lang='en']) .cockpit-brief-tabs button {
white-space: nowrap;
}
.cockpit-brief-tabs button {
+31 -14
View File
@@ -240,8 +240,6 @@ html:not([lang='en'])
}
.panel-collapse-btn {
/* A long translated title must not squeeze the toggle into a sliver. */
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 6px;
@@ -258,6 +256,11 @@ html:not([lang='en'])
color var(--transition-fast);
}
/* A long translated title must not squeeze the toggle into a sliver. */
html:not([lang='en']) .panel-collapse-btn {
flex-shrink: 0;
}
.panel-collapse-btn:hover {
border-color: var(--glass-border-hover);
color: var(--text-primary);
@@ -787,8 +790,9 @@ html:not([lang='en'])
/* One label column across the Display rows: --pp-label-chars is the longest
label of the active language (set by applicationShell), and the labels are
monospace, so every slider track starts at the same x whatever the
language. 0.125em is the 1px letter-spacing at 8px. */
.pp-slider-row > .pp-slider-mini-label {
language. 0.125em is the 1px letter-spacing at 8px. English keeps its
own label widths. */
html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
flex: 0 0 auto;
min-width: calc(var(--pp-label-chars, 0) * (1ch + 0.125em));
}
@@ -989,9 +993,9 @@ html:not([lang='en'])
#draw-mode-row {
min-width: 184px;
}
/* Equal thirds with room around the label, so a long shape name never runs
edge to edge in its segment. */
#draw-mode-row .pp-mode-btn {
/* Equal thirds with room around the label, so a long translated shape name
never runs edge to edge in its segment. */
html:not([lang='en']) #draw-mode-row .pp-mode-btn {
padding: 0 4px;
}
#draw-label-row {
@@ -1015,9 +1019,11 @@ html:not([lang='en'])
.pp-text-input:focus {
border-color: var(--accent);
}
/* Sized to its longest colour name so a translated one is never clipped;
74px keeps the English width. */
.draw-color-select {
flex: 0 0 74px;
}
/* Sized to its longest colour name so a translated one is never clipped. */
html:not([lang='en']) .draw-color-select {
flex: 0 0 auto;
min-width: 74px;
}
@@ -1339,20 +1345,31 @@ body.ui-clean-view #scene-runtime.active {
);
}
/* One label column for every parameter, as wide as the longest label in the
active language, so all slider tracks start and end at the same x. */
#param-sliders {
.param-slider-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.param-slider-row:last-child {
margin-bottom: 0;
}
/* One label column for every parameter, as wide as the longest translated
label, so all slider tracks start and end at the same x. */
html:not([lang='en']) #param-sliders {
display: grid;
grid-template-columns: max-content minmax(0, 1fr) max-content;
align-items: center;
gap: 10px;
}
.param-slider-row {
html:not([lang='en']) .param-slider-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
margin-bottom: 0;
}
.param-label {
+212
View File
@@ -0,0 +1,212 @@
// K-44 (+ refront-opus-1): layout rules written for long translations must not change the
// English rendering ("English renders as before"). Every rule below is read
// from the stylesheet without a `html:not([lang='en'])` guard, so it applies
// to English too; the expected values are the ones on main (9beadbda).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const css = (name) => readFileSync(join(here, name), 'utf8');
/** Declarations of every rule whose (unguarded) selector list is `selector`. */
function unguardedRules(source, selector) {
const norm = (s) => s.replace(/\s+/g, ' ').trim();
const want = norm(selector);
const out = [];
const re = /([^{}]+)\{([^{}]*)\}/g;
let m;
while ((m = re.exec(source))) {
// The captured "selector" may carry the tail of a preceding block
// (comments, an @media prelude); keep what follows the last `}` or `*/`.
const raw = m[1].split(/\*\/|\}/).pop();
const sel = norm(raw);
if (sel === want && !sel.includes("html:not([lang='en'])")) out.push(m[2]);
}
return out;
}
function declaration(block, prop) {
const m = block.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`));
return m ? m[1].replace(/\s+/g, ' ').trim() : undefined;
}
test('K-44: layer status chip keeps its English width (82px)', () => {
const rules = unguardedRules(
css('radio.css'),
`.data-toggle-btn.feed-loading, .data-toggle-btn.feed-degraded,
.data-toggle-btn.feed-stale, .data-toggle-btn.feed-partial,
.data-toggle-btn.feed-fallback, .data-toggle-btn.feed-unavailable`,
);
assert.equal(rules.length, 1);
assert.equal(declaration(rules[0], 'min-width'), '82px');
});
test('K-44: Directions steps keep one line with an ellipsis in English', () => {
const [rule] = unguardedRules(css('radio.css'), '.data-row-list-text');
assert.ok(rule);
assert.equal(declaration(rule, 'white-space'), 'nowrap');
assert.equal(declaration(rule, 'text-overflow'), 'ellipsis');
});
test('K-44: count-line layout is not reachable from an unguarded selector', () => {
const source = css('radio.css');
assert.equal(
unguardedRules(source, '.data-toggle-row--count-line .data-count').length,
0,
);
});
test('K-44: weather status line keeps its fixed English height', () => {
const [rule] = unguardedRules(
css('weather.css'),
".weather-card [data-line-id='status']",
);
assert.ok(rule);
assert.equal(declaration(rule, 'height'), '1.45em');
});
test('K-44: cockpit number separators keep the English cell width', () => {
assert.equal(
unguardedRules(css('cockpit.css'), '.cockpit-roll-token.is-separator')
.length,
0,
);
});
test('K-44: Bhote Koshi callout keeps its English footer layout', () => {
const source = css('bhote-koshi.css');
const [strong] = unguardedRules(
source,
'.bhote-embedded-callout footer strong',
);
assert.equal(declaration(strong, 'white-space'), 'nowrap');
const [link] = unguardedRules(source, '.bhote-embedded-callout footer a');
assert.equal(declaration(link, 'grid-row'), '1 / span 2');
});
test('K-44: SCENES actions stay on one line in English (refront-opus-1)', () => {
assert.equal(
unguardedRules(css('scenes.css'), '#scene-panel .scene-controls .scene-btn')
.length,
0,
);
const [select] = unguardedRules(css('scenes.css'), '#scene-select');
assert.equal(declaration(select, 'white-space'), undefined);
});
test('K-44: Display and parameter rows keep their English layout', () => {
const source = css('controls.css');
assert.equal(
unguardedRules(source, '.pp-slider-row > .pp-slider-mini-label').length,
0,
);
assert.equal(unguardedRules(source, '#param-sliders').length, 0);
const [row] = unguardedRules(source, '.param-slider-row');
assert.equal(declaration(row, 'display'), 'flex');
const [color] = unguardedRules(source, '.draw-color-select');
assert.equal(declaration(color, 'flex'), '0 0 74px');
});
test('K-44: weather setting rows keep the 56px English label column', () => {
const source = css('weather.css');
assert.equal(unguardedRules(source, '.rail-card-settings').length, 0);
const [row] = unguardedRules(source, '.rail-card-setting');
assert.equal(
declaration(row, 'grid-template-columns'),
'56px minmax(0, 1fr)',
);
});
test('K-44: cockpit brief controls keep their English sizes', () => {
const source = css('cockpit.css');
const [auto] = unguardedRules(source, '#cockpit-brief-auto');
assert.equal(declaration(auto, 'width'), '50px');
assert.equal(unguardedRules(source, '.cockpit-brief-actions > *').length, 0);
});
test('K-44: the count-line class is set only outside English', async () => {
const { setLocale } = await import('../../i18n/index.js');
const { LayerPanel } = await import('../layerPanel.js');
const classes = () => {
const toggled = new Map();
const node = () => ({
textContent: '',
classList: { toggle: (name, on) => toggled.set(name, on) },
appendChild() {},
});
LayerPanel.prototype._syncCount.call(LayerPanel.prototype, node(), node(), {
countLabel: '693 nearby · 93 on screen',
});
return toggled.get('data-toggle-row--count-line');
};
globalThis.document ??= { createElement: () => ({}) };
try {
setLocale('en');
assert.equal(classes(), false);
setLocale('es');
assert.equal(classes(), true);
} finally {
setLocale('en');
}
});
test('K-44: Recent Imagery readout keeps its English layout', () => {
const source = css('recent-imagery.css');
const one = (selector) => {
const rules = unguardedRules(source, selector);
assert.equal(rules.length, 1, selector);
return rules[0];
};
// The SHOW chip shares its row with the reserved B half.
assert.equal(
unguardedRules(
source,
".recent-imagery-readout:not([data-mode='ab']) .ri-card-chips .ri-chip[data-slot='a']",
).length,
0,
);
assert.equal(
unguardedRules(
source,
".recent-imagery-readout:not([data-mode='ab']) .ri-card-chips .ri-chip[data-slot='b']",
).length,
0,
);
// Slot rows: their own fixed 44px tag column, no subgrid.
const selection = one('.recent-imagery-readout .ri-selection');
assert.equal(declaration(selection, 'grid-template-columns'), undefined);
assert.equal(declaration(selection, 'column-gap'), undefined);
assert.equal(
declaration(one('.recent-imagery-readout .ri-mode'), 'grid-column'),
undefined,
);
const slot = one('.recent-imagery-readout .ri-slot');
assert.equal(
declaration(slot, 'grid-template-columns'),
'44px minmax(0, 1fr) 22px',
);
assert.equal(declaration(slot, 'grid-column'), undefined);
assert.equal(declaration(slot, 'height'), '20px');
assert.equal(
declaration(one('.recent-imagery-readout .ri-slot-tag'), 'padding'),
undefined,
);
// Opacity / SWAP / export row: fixed English columns.
assert.equal(
declaration(
one('.recent-imagery-readout .ri-controls'),
'grid-template-columns',
),
'76px minmax(0, 1fr) 40px 128px',
);
const label = one('.recent-imagery-readout .ri-opacity-label');
assert.equal(declaration(label, 'flex-wrap'), undefined);
assert.equal(declaration(label, 'gap'), '4px');
assert.equal(declaration(label, 'line-height'), undefined);
const swap = one('.recent-imagery-readout .ri-swap');
assert.equal(declaration(swap, 'width'), '40px');
assert.equal(declaration(swap, 'min-width'), undefined);
});
+83 -43
View File
@@ -250,12 +250,8 @@
gap: 5px;
}
.radio-transport button {
min-width: 0;
min-height: 30px;
padding: 6px 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid rgba(65, 220, 231, 0.28);
border-radius: 5px;
color: #8feaf2;
@@ -264,6 +260,13 @@
letter-spacing: 0.06em;
cursor: pointer;
}
/* A longer translated action stays on one line, cut with an ellipsis. */
html:not([lang='en']) .radio-transport button {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.radio-transport button:hover:not(:disabled),
.radio-transport button:focus-visible:not(:disabled) {
border-color: #65f7ff;
@@ -614,14 +617,10 @@
gap: 8px;
}
/* The name takes the room the status column leaves; a long word wraps inside
it instead of pushing the toggle out of line. */
.data-toggle-left {
display: flex;
flex: 1 1 auto;
align-items: center;
gap: 8px;
min-width: 0;
}
.data-icon {
@@ -629,37 +628,16 @@
line-height: 1;
}
/* A fixed icon box, so every name (and the meta line under it, 26px in)
starts at the same x whatever the glyph's width. */
.data-toggle-left .data-icon {
flex: none;
width: 18px;
text-align: center;
}
.data-name {
min-width: 0;
font-family: var(--font-sans);
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
letter-spacing: 0.3px;
overflow-wrap: anywhere;
}
/* Translated layer names are longer: break between words, and hyphenate a
word that still does not fit instead of splitting it anywhere. */
html:not([lang='en']) .data-name {
overflow-wrap: break-word;
hyphens: auto;
/* No two-letter pieces such as "es-paciales": a word hyphenates only when
at least four letters stay on each side. */
hyphenate-limit-chars: 8 4 4;
}
.data-toggle-right {
display: flex;
flex: 0 1 auto;
align-items: center;
gap: 10px;
}
@@ -669,34 +647,68 @@ html:not([lang='en']) .data-name {
font-size: 11px;
letter-spacing: 0.5px;
color: var(--text-secondary);
min-width: 30px;
text-align: right;
}
/* Longer translations below; English keeps the layout above. */
/* The name takes the room the status column leaves; a long word wraps inside
it instead of pushing the toggle out of line. */
html:not([lang='en']) .data-toggle-left {
flex: 1 1 auto;
min-width: 0;
}
/* A fixed icon box, so every name (and the meta line under it, 26px in)
starts at the same x whatever the glyph's width. */
html:not([lang='en']) .data-toggle-left .data-icon {
flex: none;
width: 18px;
text-align: center;
}
/* Translated layer names are longer: break between words, and hyphenate a
word that still does not fit instead of splitting it anywhere. */
html:not([lang='en']) .data-name {
min-width: 0;
overflow-wrap: break-word;
hyphens: auto;
/* No two-letter pieces such as "es-paciales": a word hyphenates only when
at least four letters stay on each side. */
hyphenate-limit-chars: 8 4 4;
}
html:not([lang='en']) .data-count {
min-width: auto;
text-wrap: balance;
}
/* Row text pieces that must not break inside: a hyphenated code (ADS-B) and
the space before a " · " separator (see setRowText in layerPanel.js). */
.data-keep {
html:not([lang='en']) .data-keep {
white-space: nowrap;
}
/* A count phrase ("24 nearby · 2 on screen") takes its own line under the
name instead of squeezing it beside the toggle. */
.data-toggle-row--count-line .data-toggle-top {
name instead of squeezing it beside the toggle. layerPanel.js sets the
class only outside English. */
html:not([lang='en']) .data-toggle-row--count-line .data-toggle-top {
flex-wrap: wrap;
row-gap: 2px;
}
.data-toggle-row--count-line .data-toggle-right {
html:not([lang='en']) .data-toggle-row--count-line .data-toggle-right {
display: contents;
}
/* A zero basis, so a long name wraps beside the toggle instead of pushing the
toggle onto the count's line. */
.data-toggle-row--count-line .data-toggle-left {
html:not([lang='en']) .data-toggle-row--count-line .data-toggle-left {
flex-basis: 0;
}
.data-toggle-row--count-line .data-count {
html:not([lang='en']) .data-toggle-row--count-line .data-count {
order: 1;
flex: 1 0 100%;
padding-left: 26px;
@@ -706,7 +718,8 @@ html:not([lang='en']) .data-name {
/* The "—" placeholder gives way to a wide status chip, so the name keeps the
room it needs. */
.data-toggle-right:has(
html:not([lang='en'])
.data-toggle-right:has(
.data-toggle-btn:is(
.transitioning,
.feed-loading,
@@ -739,9 +752,12 @@ html:not([lang='en']) .data-name {
min-width var(--transition-fast),
letter-spacing var(--transition-fast);
outline: none;
flex: none;
min-width: 38px;
text-align: center;
}
html:not([lang='en']) .data-toggle-btn {
flex: none;
white-space: nowrap;
}
@@ -751,18 +767,30 @@ html:not([lang='en']) .data-name {
outline-offset: -3px;
}
/* Status chips up to eight characters share one width; a longer word grows
its own chip rather than widening every chip and squeezing every name. */
.data-toggle-btn.feed-loading,
.data-toggle-btn.feed-degraded,
.data-toggle-btn.feed-stale,
.data-toggle-btn.feed-partial,
.data-toggle-btn.feed-fallback,
.data-toggle-btn.feed-unavailable {
min-width: 72px;
min-width: 82px;
letter-spacing: 0.8px;
}
/* Status chips up to eight characters share one width; a longer word grows
its own chip rather than widening every chip and squeezing every name. */
html:not([lang='en'])
.data-toggle-btn:is(
.feed-loading,
.feed-degraded,
.feed-stale,
.feed-partial,
.feed-fallback,
.feed-unavailable
) {
min-width: 72px;
}
.data-toggle-btn:hover {
border-color: var(--glass-border-hover);
background: rgba(255, 255, 255, 0.06);
@@ -964,15 +992,27 @@ html:not([lang='en']) .data-name {
.data-row-list-lead {
min-width: 34px;
flex: none;
white-space: nowrap;
opacity: 0.85;
}
/* The whole instruction wraps under itself rather than losing its tail:
"Turn right onto …" says nothing, and Russian or Spanish run longer. */
.data-row-list-text {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A translated instruction wraps under itself rather than losing its tail:
"Gira a la derecha hacia …" says nothing. */
html:not([lang='en']) .data-row-list-lead {
white-space: nowrap;
}
html:not([lang='en']) .data-row-list-text {
min-width: 0;
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
+45 -24
View File
@@ -293,34 +293,32 @@
height: 18px;
font-weight: 700;
}
/* Outside A / B there is one chip (SHOW): it takes the whole row, so a long
translation is not cut to a stub. */
.recent-imagery-readout:not([data-mode='ab'])
/* Outside A / B there is one chip (SHOW): in a translation it takes the
whole row, so a long label is not cut to a stub. English keeps the
reserved B half. */
html:not([lang='en'])
.recent-imagery-readout:not([data-mode='ab'])
.ri-card-chips
.ri-chip[data-slot='a'] {
grid-column: 1 / -1;
}
.recent-imagery-readout:not([data-mode='ab'])
html:not([lang='en'])
.recent-imagery-readout:not([data-mode='ab'])
.ri-card-chips
.ri-chip[data-slot='b'] {
display: none;
}
/* 4. The selection bar: mode, then two rows that are always there. The slot
rows share the bar's columns (subgrid), so the tag column fits the longest
tag in either row and both rows stay aligned. */
/* 4. The selection bar: mode, then two rows that are always there. */
.recent-imagery-readout .ri-selection {
display: grid;
grid-template-columns: minmax(44px, max-content) minmax(0, 1fr) 22px;
grid-template-rows: 20px 20px 20px;
column-gap: 6px;
row-gap: 4px;
padding: 5px;
border: 1px solid var(--weather-card-border);
border-radius: 6px;
}
.recent-imagery-readout .ri-mode {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
@@ -329,16 +327,14 @@
height: 20px;
}
.recent-imagery-readout .ri-slot {
grid-column: 1 / -1;
display: grid;
grid-template-columns: subgrid;
grid-template-columns: 44px minmax(0, 1fr) 22px;
align-items: center;
column-gap: 6px;
height: 20px;
}
.recent-imagery-readout .ri-slot-tag {
height: 16px;
padding: 0 3px;
line-height: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 3px;
@@ -366,12 +362,26 @@
color: var(--text-secondary);
font-style: italic;
}
/* A translated slot line (date · source · resolution · state) is longer:
/* In a translation the slot rows share the bar's columns (subgrid), so the
tag column fits the longest tag in either row and both rows stay aligned.
A translated slot line (date · source · resolution · state) is longer:
let it take a second line rather than cut off the state at the end. */
html:not([lang='en']) .recent-imagery-readout .ri-selection {
grid-template-columns: minmax(44px, max-content) minmax(0, 1fr) 22px;
column-gap: 6px;
}
html:not([lang='en']) .recent-imagery-readout .ri-mode {
grid-column: 1 / -1;
}
html:not([lang='en']) .recent-imagery-readout .ri-slot {
grid-column: 1 / -1;
grid-template-columns: subgrid;
height: auto;
min-height: 20px;
}
html:not([lang='en']) .recent-imagery-readout .ri-slot-tag {
padding: 0 3px;
}
html:not([lang='en']) .recent-imagery-readout .ri-slot-value {
white-space: normal;
}
@@ -383,28 +393,35 @@ html:not([lang='en']) .recent-imagery-readout .ri-slot-value {
}
/* 5. Opacity (both images), SWAP and export, one row. SWAP keeps its slot
while no swipe is live. A label longer than its 76px column widens it to
the label's longest word, and the value wraps under the label. */
while no swipe is live. */
.recent-imagery-readout .ri-controls {
display: grid;
grid-template-columns:
minmax(76px, min-content) minmax(0, 1fr) minmax(40px, max-content)
128px;
grid-template-columns: 76px minmax(0, 1fr) 40px 128px;
align-items: center;
column-gap: 6px;
height: 22px;
}
.recent-imagery-readout .ri-opacity-label {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-content: center;
column-gap: 4px;
gap: 4px;
font-size: 8px;
line-height: 10px;
letter-spacing: 1px;
white-space: nowrap;
}
/* In a translation a label longer than its 76px column widens it to the
label's longest word, and the value wraps under the label. */
html:not([lang='en']) .recent-imagery-readout .ri-controls {
grid-template-columns:
minmax(76px, min-content) minmax(0, 1fr) minmax(40px, max-content)
128px;
}
html:not([lang='en']) .recent-imagery-readout .ri-opacity-label {
flex-wrap: wrap;
align-content: center;
row-gap: 0;
line-height: 10px;
}
.recent-imagery-readout .ri-opacity-value {
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
@@ -419,9 +436,13 @@ html:not([lang='en']) .recent-imagery-readout .ri-slot-value {
opacity: 0.35;
}
.recent-imagery-readout .ri-swap {
min-width: 40px;
width: 40px;
padding: 0 2px;
}
html:not([lang='en']) .recent-imagery-readout .ri-swap {
width: auto;
min-width: 40px;
}
.recent-imagery-readout .ri-exports .rail-card-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
+9 -6
View File
@@ -160,16 +160,19 @@
padding: 6px 8px;
font-family: var(--font-sans);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
outline: none;
}
html:not([lang='en']) #scene-select {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A translated action longer than its button wraps inside the button instead
of running past it. English labels fit on one line, so the English layout
is unchanged. */
#scene-panel .scene-controls .scene-btn {
of running past it. English keeps its one-line buttons ("EXPORT PRESETS"
included). */
html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
white-space: normal;
overflow-wrap: break-word;
}
+5
View File
@@ -123,6 +123,11 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* Translated retry details ("cámaras ALPR · reintento en 53 s") run longer
than the English one; English keeps its 220px. */
html:not([lang='en']) #global-loading-detail {
max-width: min(300px, 46vw);
}
#global-loading-status:is(
[data-state='complete'],
[data-state='cancelled'],
+3
View File
@@ -231,6 +231,7 @@
font-family: var(--font-sans);
font-size: 0.78rem;
font-weight: 600;
overflow-wrap: break-word;
}
.gev-voice-card-chips {
@@ -271,6 +272,8 @@
font-size: 0.66rem;
line-height: 1.4;
color: rgba(223, 245, 250, 0.82);
/* A whole error line or a long headline wraps inside the card. */
overflow-wrap: break-word;
}
.gev-voice-card-referents {
+5 -3
View File
@@ -123,9 +123,10 @@ html:not([lang='en']) #command-dock .gev-voice-cost-value {
letter-spacing: 0;
}
/* Live hints ("Ask or command", "Requesting microphone") run longer in other
languages; give them a second line from the spare space under the readout
rather than cutting them mid-word. */
/* Live hints ("Ask or command", "Requesting microphone") and the tier switch
("СТАНДАРТ — со следующей сессии") run longer in other languages; give them
a second line from the spare space under the readout, without the tracking
that would push the tier line onto a third, rather than cutting them. */
html:not([lang='en'])
#command-dock
#gev-voice-control:is(
@@ -139,6 +140,7 @@ html:not([lang='en'])
-webkit-line-clamp: 2;
line-clamp: 2;
line-height: 1.15;
letter-spacing: 0;
white-space: normal;
overflow-wrap: anywhere;
}
+21 -10
View File
@@ -147,12 +147,18 @@
.rail-card-line {
white-space: pre-line;
}
/* One line stays reserved so the card does not jump when a status appears;
a long (or translated) status wraps instead of being cut. */
.weather-card [data-line-id='status'] {
min-height: 1.45em;
height: 1.45em;
overflow-y: auto;
color: var(--accent);
}
/* One line stays reserved so the card does not jump when a status appears;
a translated status runs longer and wraps instead of being cut. */
html:not([lang='en']) .weather-card [data-line-id='status'] {
height: auto;
min-height: 1.45em;
overflow-y: visible;
}
.rail-card-ramp {
position: relative;
height: 4px;
@@ -259,10 +265,12 @@ body.recording-mode .gev-wind-inspection-marker {
top: 8px;
left: 0;
transform: translateX(-50%);
white-space: nowrap;
font: 9px/1.45 var(--font-mono);
color: var(--text-dim);
}
html:not([lang='en']) .rail-card-zero::after {
white-space: nowrap;
}
.weather-observed-group {
border: 1px solid var(--weather-card-border);
@@ -313,21 +321,24 @@ body.recording-mode .gev-wind-inspection-marker {
.rail-card-compact.status {
color: var(--accent);
}
.rail-card-setting {
display: grid;
grid-template-columns: 56px minmax(0, 1fr);
align-items: baseline;
gap: 6px;
margin-top: 6px;
}
/* One label column per card, as wide as its longest label: chips line up
across rows, and a long translated label never runs under its chips. */
.rail-card-settings {
html:not([lang='en']) .rail-card-settings {
display: grid;
grid-template-columns: minmax(56px, max-content) minmax(0, 1fr);
column-gap: 6px;
}
.rail-card-setting {
display: grid;
html:not([lang='en']) .rail-card-setting {
grid-column: 1 / -1;
grid-template-columns: minmax(56px, max-content) minmax(0, 1fr);
grid-template-columns: subgrid;
align-items: baseline;
gap: 6px;
margin-top: 6px;
}
.rail-card-setting-label {
color: var(--text-dim);
+1
View File
@@ -23,6 +23,7 @@
</div>
<div class="cyber-panel-body" data-rail-scroller>
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
<span class="cctv-frame-caption" aria-hidden="true" data-i18n>ACQUIRING FRAME</span>
<img id="cctv-frame" alt="CCTV feed frame" />
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
</div>
+106
View File
@@ -0,0 +1,106 @@
// Vector tile and weather image failures reach the DATA LAYERS row and the
// ALPR retry chip in the UI language; the sources keep them in English, and
// English shows them as before.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { createVectorTileSource } from '../sources/vectorTiles.js';
globalThis.document ??= {
getElementById: () => null,
addEventListener() {},
removeEventListener() {},
hidden: false,
};
const { LayerPanel } = await import('./layerPanel.js');
const { localizeLoadingPresentation } = await import('./shellFeedback.js');
const row = (id, error) =>
LayerPanel.prototype._buildMetaText({
id,
enabled: true,
stats: { status: 'unavailable', error, source: 'OpenSky' },
});
const chipLabel = (label) =>
localizeLoadingPresentation({
state: 'retry',
label,
detail: 'ALPR cameras · retrying in 53s',
}).label;
test.afterEach(() => setLocale('en'));
test('the vector tile source fails with the catalog key', async () => {
const source = createVectorTileSource({
tileJsonUrl: 'https://tiles.example/index.json',
decode: () => [],
fetchImpl: async () => new Response('', { status: 503 }),
});
await assert.rejects(source.getMetadata(), {
message: 'Vector tile metadata unavailable',
});
});
test('en: tile and weather image failures read as before', () => {
setLocale('en');
assert.match(
row('alpr-cameras', 'Vector tile metadata unavailable'),
/ · Vector tile metadata unavailable$/,
);
assert.match(
row('weather-radar', 'Weather image HTTP 404'),
/Weather image HTTP 404$/,
);
assert.equal(
chipLabel('VECTOR TILE METADATA UNAVAILABLE'),
'VECTOR TILE METADATA UNAVAILABLE',
);
assert.equal(
chipLabel('VECTOR TILES UNAVAILABLE (HTTP 503)'),
'VECTOR TILES UNAVAILABLE (HTTP 503)',
);
assert.equal(chipLabel('SOMETHING ELSE'), 'SOMETHING ELSE');
});
const EXPECTED = {
ru: {
metadata: 'Метаданные векторных тайлов недоступны',
http: 'Векторные тайлы недоступны (HTTP 503)',
weather: 'Снимок погоды: HTTP 404',
},
es: {
metadata: 'Metadatos de teselas vectoriales no disponibles',
http: 'Teselas vectoriales no disponibles (HTTP 503)',
weather: 'Imagen del clima: HTTP 404',
},
};
for (const [locale, expected] of Object.entries(EXPECTED)) {
test(`${locale}: tile failures are translated in the row and the chip`, () => {
setLocale(locale);
assert.match(
row('alpr-cameras', 'Vector tile metadata unavailable'),
new RegExp(` · ${expected.metadata}$`),
);
assert.ok(
row('alpr-cameras', 'Vector tiles unavailable (HTTP 503)').endsWith(
expected.http,
),
);
assert.equal(
chipLabel('VECTOR TILE METADATA UNAVAILABLE'),
expected.metadata.toLocaleUpperCase(locale),
);
assert.equal(
chipLabel('VECTOR TILES UNAVAILABLE (HTTP 503)'),
expected.http.toLocaleUpperCase(locale),
);
});
test(`${locale}: a weather image failure is not read as a source name`, () => {
setLocale(locale);
assert.ok(
row('weather-radar', 'Weather image HTTP 404').endsWith(expected.weather),
);
});
}
+105
View File
@@ -0,0 +1,105 @@
// K-38: refusals and failures that source modules produce in English reach
// the page translated — through a placeholder at the point of display, or
// through a catalog entry when the text is stored as data.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale, t, translateKnown } from '../i18n/index.js';
import { presentStreetLevelPanel } from './streetLevelPresentation.js';
import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
import { createWeatherSource } from '../layers/weather/source.js';
import {
presentRoutingFailure,
presentRoutingRefusal,
} from '../layers/directions/index.js';
import { AIM_AT_GROUND_MESSAGE } from '../layers/streetLevel/policy.js';
const NO_IMAGERY = 'No street-level imagery within 50 m of that point';
function snapshot(error) {
return {
enabled: true,
keyRequired: false,
filter: { pano: 'all', sinceDays: 0 },
providers: [providerSnapshot()],
coverage: { loading: false, count: 0, hint: '', error: null },
legend: [],
sequence: { providerId: null, selectedId: null, images: 0, loading: false },
street: { open: false, loading: false, error, providerId: null },
groundClickReady: true,
};
}
for (const locale of ['es', 'ru']) {
test(`K-38 (${locale}): the nearest-image refusal is built in the language and kept by the panel`, () => {
setLocale(locale);
try {
// What streetLevel/index.js openNearest writes into street.error.
const refusal = t(
'No street-level imagery within {radius} m of that point',
{ radius: 50 },
);
assert.notEqual(refusal, NO_IMAGERY, 'should be translated');
assert.match(refusal, /50/, 'keeps the radius');
assert.equal(presentStreetLevelPanel(snapshot(refusal)).error, refusal);
// The screen-centre refusal is stored as English data and translated
// by the panel.
const aim = presentStreetLevelPanel(snapshot(AIM_AT_GROUND_MESSAGE));
assert.notEqual(aim.error, AIM_AT_GROUND_MESSAGE, 'should be translated');
} finally {
setLocale('en');
}
});
test(`K-38 (${locale}): the route proxy's refusals are shown translated`, () => {
setLocale(locale);
try {
const shown = t('Routing failed: {error}', {
error: presentRoutingRefusal('route leg too long'),
});
assert.doesNotMatch(shown, /route leg too long/);
const limited = presentRoutingFailure(
Object.assign(new Error('rate limited — try again in a moment'), {
status: 429,
refusal: 'rate limited',
}),
);
assert.doesNotMatch(limited, /rate limited|try again in a moment/);
const http = presentRoutingFailure(
Object.assign(new Error('Routing unavailable (HTTP 503)'), {
status: 503,
}),
);
assert.notEqual(http, 'Routing unavailable (HTTP 503)');
assert.match(http, /503/);
// A text the proxy never sends is shown as given.
assert.equal(presentRoutingRefusal('something else'), 'something else');
} finally {
setLocale('en');
}
});
test(`K-38 (${locale}): a weather manifest timeout is shown translated`, async () => {
const source = createWeatherSource({
fetchImpl: (_url, { signal }) =>
new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason)),
),
timeoutMs: 10,
});
let message = null;
try {
await source.getSnapshot({ product: 'radar' });
} catch (error) {
message = error.message;
}
assert.equal(message, 'Weather request timed out', 'stored in English');
setLocale(locale);
try {
// The layer shows `t(problem)` (weather/index.js presentWeatherProblem).
assert.notEqual(t(message), message, 'should be translated');
assert.notEqual(translateKnown(message), message);
} finally {
setLocale('en');
}
});
}
+5 -4
View File
@@ -1,6 +1,6 @@
import { formatUtcDayMonth, t } from '../i18n/index.js';
import { createRailCards } from './railCards.js';
import { createRailTimeline } from './railTimeline.js';
import { createRailTimeline, utcClock } from './railTimeline.js';
// Remounting controls must not reopen a panel the user already collapsed.
const appearedDocuments = new WeakSet();
@@ -15,8 +15,9 @@ const ORDER = [
const OBSERVED = new Set(ORDER.slice(2));
const utc = (time) =>
Number.isFinite(Date.parse(time))
? `${formatUtcDayMonth(time)} ${new Date(time).toISOString().slice(11, 16)} UTC`
? `${formatUtcDayMonth(time)} ${utcClock(time)}`
: t('Unavailable');
const clockTime = (time) => utcClock(time) || t('Unavailable');
const age = (time) => {
const minutes = Math.max(
0,
@@ -31,7 +32,7 @@ const age = (time) => {
};
const historyTime = (time) =>
t('{time} · {minutes} min ago', {
time: utc(time).slice(6),
time: clockTime(time),
minutes: Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60_000)),
});
const dated = (time) => `${utc(time)} · ${age(time)}`;
@@ -172,7 +173,7 @@ export function createWeatherPanel({
const shown = product?.shown ?? summary.shownTime;
if (state.mode === 'history' && product?.selected === null) {
const gap = summary.maxGapMinutes || 30;
const time = utc(state.target).slice(6);
const time = clockTime(state.target);
detail =
gap < 60
? t('No frame within {gap} min of {time}', { gap, time })
+32
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { createWeatherPanel } from './weatherPanel.js';
import { railFixture } from './railTestFixture.mjs';
const ticks = ['2026-09-22T01:00:00Z', '2026-09-22T01:05:00Z'];
@@ -654,3 +655,34 @@ test('a change of open card scrolls its header into view once; refreshes never m
assert.deepEqual(intoView, [], 'ancestor scrollers are never moved');
view.destroy();
});
test('history time keeps the hour whatever the length of the localized date (K-6)', () => {
for (const locale of ['en', 'es', 'ru']) {
setLocale(locale);
try {
const f = fixture();
const view = createWeatherPanel(f);
view.update([wind, radar]);
const ends = f.find((n) => n.className === 'rail-timeline-endpoints');
assert.deepEqual(
ends.children.map((n) => n.textContent),
['01:00 UTC', '01:05 UTC'],
locale,
);
f.state({ mode: 'history', target: ticks[1] });
assert.match(
line(f, radar.id, 'time').textContent,
/(^|[^\d:])01:05 UTC/,
locale,
);
f.state({ products: [{ id: radar.id, shown: null, selected: null }] });
assert.match(
line(f, radar.id, 'time').textContent,
/(^|[^\d:])01:05 UTC/,
locale,
);
view.destroy();
} finally {
setLocale('en');
}
}
});