From 20f4cf3763cf29f346e79429f8b529d6983a77a3 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 21:00:28 +0400 Subject: [PATCH] i18n: fix the LGTM review findings and tidy the branch texts - i18n scanner: lastError sink, no false sinks on defaults and ternaries - shared src/i18n/marker.js for modules that only mark strings - wind field messages move to wind/fields.js; CITY_POIS imports from cityPois.js - drop review IDs, change history and stale references from comments and docs Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 ++- docs/CURRENT-STATE.md | 15 ++-- docs/I18N.md | 13 +-- docs/TRANSLATORS.md | 2 +- scripts/i18n.mjs | 38 ++++++--- scripts/package-boundaries.json | 83 +++++++++++++++--- scripts/qa-scene-dialog-file-drop.mjs | 10 +-- server/standalone/key-setup.js | 3 +- src/annotations/annotationEngine.js | 18 ++-- src/annotations/drawToolBehaviour.test.mjs | 5 +- src/app/globePanelRuntime.js | 12 ++- src/app/layers/cctv.js | 4 +- src/cityPois.js | 1 - src/cityPresets.test.mjs | 3 +- src/cockpitMath.js | 5 +- src/contextModePolicy.js | 10 +-- src/data/aircraftClass.js | 6 +- src/data/aircraftEmergency.js | 5 +- src/data/aisWatchdog.js | 8 +- src/data/bhoteKoshiEmbeddedMedia.js | 6 +- src/data/bhoteKoshiEvent.js | 8 +- src/data/bhoteKoshiEvidenceFit.test.mjs | 7 +- src/data/bhoteKoshiEvidencePublisher.test.mjs | 4 +- src/data/bhoteKoshiLocator.js | 5 +- src/data/geoFileImport.js | 4 +- src/data/installationFeedback.js | 6 +- src/data/installationNames.js | 14 ++- src/data/layerState.js | 4 +- src/data/militaryInstallationData.js | 7 -- src/data/pointerOwnerNames.js | 16 ++-- src/data/satelliteClass.js | 24 ++---- src/data/trackedCardLocale.test.mjs | 6 +- src/data/vesselLabels.js | 5 +- src/data/vesselTypeCardLabel.test.mjs | 2 +- src/director/sharing/preview.js | 5 +- src/firstRunExperience.js | 9 +- src/hud.js | 23 +++-- src/hudLocality.js | 10 +-- src/hudLocality.test.mjs | 2 - src/i18n/catalog.test.mjs | 67 +++++++++++++++ src/i18n/empty-translation.test.mjs | 2 +- src/i18n/index.js | 2 +- src/i18n/kartaviewMessages.test.mjs | 6 +- src/i18n/layerStatusText.test.mjs | 12 ++- src/i18n/locale.js | 6 +- src/i18n/locales/es.json | 4 +- src/i18n/locales/ru.json | 4 +- src/i18n/marker.js | 7 ++ src/i18n/plural-english.test.mjs | 9 +- src/i18n/stale-index.test.mjs | 4 +- src/keySetup.js | 17 ++-- src/keySetup.test.mjs | 1 + src/keySetupCore.mjs | 19 ++--- src/keySetupErrorLocale.test.mjs | 8 +- src/keySetupOAuthFailure.test.mjs | 6 +- src/keySetupOAuthI18n.test.mjs | 8 +- .../airQuality/airQualityLocale.test.mjs | 1 - src/layers/airQuality/index.js | 5 +- src/layers/airQuality/model.js | 4 +- src/layers/awareness/queries.js | 3 +- src/layers/awareness/subject.js | 7 +- src/layers/cctv/headingConfidence.js | 2 - src/layers/cctv/modelSpecs.js | 28 +++--- src/layers/cyclones/coverage.test.mjs | 10 +-- src/layers/directions/index.js | 8 +- src/layers/feedDegradationI18n.test.mjs | 11 ++- src/layers/flights/ingestion.js | 1 + src/layers/gnss/source.js | 3 +- .../classLabel.packClasses.test.mjs | 4 +- src/layers/installations/controls.js | 6 +- .../unnamedInstallationTitle.test.mjs | 10 +-- .../launches/missionStatus.i18n.test.mjs | 85 +++++++++++++++++++ src/layers/launches/model.js | 10 ++- src/layers/launches/panel.js | 10 ++- src/layers/localAdsb/card.i18n.test.mjs | 4 +- src/layers/localAdsb/card.js | 4 - .../singleFeedProblemLocale.test.mjs | 41 --------- src/layers/localAdsb/statusLocale.test.mjs | 54 +++++++++--- src/layers/perimeters/cards.i18n.test.mjs | 4 +- src/layers/perimeters/cards.js | 6 +- src/layers/radio/policy.js | 4 +- src/layers/recentImagery/index.js | 2 +- src/layers/satellites/ingestion.js | 4 +- src/layers/streetLevel/lineCoverage.js | 2 +- src/layers/traffic/roadStatus.i18n.test.mjs | 2 +- src/layers/transit/ingestion.js | 3 +- src/layers/transit/policy.js | 6 +- src/layers/transit/queries.js | 4 +- .../transit/selectionCopy.i18n.test.mjs | 2 +- src/layers/vessels/queries.js | 6 +- .../wind/fieldUnavailableLocale.test.mjs | 3 +- src/layers/wind/fields.js | 11 +++ src/layers/wind/rendering.js | 7 +- src/layers/wind/source.js | 7 +- src/layers/wind/staleInfo.test.mjs | 2 +- src/layers/wind/windProblemLocale.test.mjs | 2 +- src/layers/wind/windUpstreamReason.test.mjs | 2 +- src/loadingFeedback.js | 8 +- src/locations.js | 2 - src/mapStackChips.js | 3 +- src/maps/imageryHost.js | 6 +- src/navigationPolicy.test.mjs | 1 - .../worldOverlayAllocation.worker.mjs | 20 ++--- src/scenes/director.js | 11 +-- src/scenes/nepalEvidencePack.js | 24 +++--- src/scenes/recipes.js | 61 +++++++------ src/scenes/sceneSaveError.js | 14 +-- src/scenes/sceneSaveError.test.mjs | 5 +- src/scenes/sceneTitleDisplay.test.mjs | 2 +- src/sharelink.lang.test.mjs | 11 +-- src/sources/httpBody.js | 6 +- src/sources/httpBody.test.mjs | 19 ++++- src/standalone/application.js | 2 +- src/ui/applicationShell.js | 52 +++--------- src/ui/cockpitBriefing.js | 10 +-- src/ui/cockpitInstruments.js | 2 - src/ui/cockpitPresentation.js | 8 +- src/ui/composition.js | 2 +- src/ui/contextLayerChanges.i18n.test.mjs | 5 +- src/ui/contextLayerChanges.js | 8 +- src/ui/cssContentCaption.test.mjs | 6 +- src/ui/feedErrorI18n.test.mjs | 4 +- src/ui/geoFileImport.test.mjs | 3 +- src/ui/layerBindings.js | 2 - src/ui/layerNames.test.mjs | 22 ++++- src/ui/layerPanel.js | 39 +++------ src/ui/localAdsbGuidanceI18n.test.mjs | 2 +- src/ui/localSdrFeedLine.i18n.test.mjs | 2 +- src/ui/localSdrFmStopMessage.i18n.test.mjs | 2 +- src/ui/localSdrNumbers.i18n.test.mjs | 2 +- src/ui/localePicker.js | 6 +- src/ui/localePicker.test.mjs | 2 +- src/ui/localeSwitchReload.test.mjs | 2 +- src/ui/locationControls.js | 9 +- src/ui/panelChrome.js | 3 +- src/ui/placeNames.js | 2 +- src/ui/placeNames.test.mjs | 2 +- src/ui/radioBindings.js | 6 +- src/ui/radioPresentation.js | 1 - .../radioPresentation.playbackError.test.mjs | 2 +- src/ui/recentImagery.js | 2 +- src/ui/recentImageryNotice.test.mjs | 6 +- src/ui/sceneSharing.js | 7 +- src/ui/shareRestorationNotice.test.mjs | 5 +- src/ui/shellElements.js | 1 - src/ui/shellFeedback.js | 8 +- .../shellFeedbackStatusLocalization.test.mjs | 7 +- src/ui/statusText.js | 26 +++--- src/ui/statusText.test.mjs | 33 ++----- src/ui/streetLevelPresentation.js | 4 +- src/ui/styles/cctv.css | 15 ++-- src/ui/styles/controls.css | 6 +- src/ui/styles/english-css-parity.test.mjs | 33 ++++--- src/ui/styles/recent-imagery.css | 7 +- src/ui/styles/scenes.css | 18 ++-- src/ui/styles/street-level.css | 3 +- src/ui/styles/voice-cost.css | 20 ++--- src/ui/templates/provider-settings.html | 5 +- src/ui/tileErrorI18n.test.mjs | 5 +- src/ui/untranslatedErrors.test.mjs | 8 +- src/ui/weatherPanel.test.mjs | 2 +- src/voice/analystCardLocale.test.mjs | 10 +-- src/voice/analystEmptyNotes.test.mjs | 56 +++++++++--- src/voice/analystTransitNote.test.mjs | 71 ---------------- src/voice/control.js | 8 +- src/voice/gevActions.js | 3 +- src/voice/realtimeController.js | 3 +- .../realtimeCostIncompleteNote.i18n.test.mjs | 2 +- src/voice/realtimeCostTierStatus.test.mjs | 2 +- src/voice/resultDisplay.js | 7 +- src/voice/session.test.mjs | 2 +- src/voice/sessionCommands.js | 8 +- src/voice/speech.js | 22 ++--- src/voice/voiceCardErrorLines.i18n.test.mjs | 10 +-- src/voice/voiceCardLocale.test.mjs | 4 +- src/voice/voiceCardPlanSteps.i18n.test.mjs | 35 ++++---- src/voice/voiceCardPresentation.js | 6 +- src/voice/voiceCost.js | 4 +- 178 files changed, 918 insertions(+), 948 deletions(-) create mode 100644 src/i18n/marker.js create mode 100644 src/layers/launches/missionStatus.i18n.test.mjs delete mode 100644 src/layers/localAdsb/singleFeedProblemLocale.test.mjs delete mode 100644 src/voice/analystTransitNote.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f07ded53..3b3f4dfe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -168,12 +168,11 @@ `t()`, `tn()` (plurals) and `msg()` in code and `data-i18n*` in markup, keyed by the English text itself; each language is one flat JSON catalog (`src/i18n/locales/ru.json`). The language comes from `?lang=`, the language - switch in the Visual Presets dock, then the browser language; English stays - the default and the fallback. `npm run i18n`, also run by `npm test`, fails on - missing, unused or broken translations. Built on the i18n foundation by - @meirm (#601), with the Russian catalog started from #312. It also fails on UI text that is - not marked at all, so the whole interface, including surfaces added after - the original extraction, is translated (1,679 messages). + picker (the globe button in the bottom-right corner), then the browser + language; English stays the default and the fallback. `npm run i18n`, also + run by `npm test`, fails on missing, unused or broken translations. Built on + the i18n foundation by @meirm (#601), with the Russian catalog started + from #312. It also fails on UI text that is not marked at all. - Street Level counts unique tiles before ranking so overlapping dateline halves in polar views do not waste slots in the 16-tile overview budget. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3780f67d1..91e900d4f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3241,14 +3241,13 @@ Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives value). English has no catalog; every other locale is `src/i18n/locales/.json`, and a missing entry falls back to English. - **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) → - `navigator.languages` → English. `?lang=` is not stored: it applies while it - stays in the address, and the copied share link drops it, so a recipient keeps - their own language. The language picker in the bottom-right corner (a globe - button beside POWER UP, `src/ui/localePicker.js`) stores the choice, removes `?lang=` (keeps it only where storage is blocked) and reloads, - so nothing re-renders live; it asks first when the reload would lose work - (unsaved CCTV calibration, the open cockpit, Draw marks on the board, - imported map files, an active voice session — each registered through - `registerUnsavedWork`). + `navigator.languages` → English. `?lang=` is not stored, and share links drop + it, so a recipient keeps their own language. The globe picker in the + bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops + `?lang=` (keeps it only when storage is blocked) and reloads. It asks first + when the reload would lose work registered with `registerUnsavedWork`: + unsaved keys or CCTV calibration, Draw marks, imported map files, the open + cockpit, a voice session. - **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`) extracts every message and fails on missing, empty, unused, placeholder-mismatched, still-English (plural forms included) or diff --git a/docs/I18N.md b/docs/I18N.md index dcd90dd59..9b5220917 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -23,6 +23,8 @@ const FEED_STATE_LABELS = { loading: msg('LOADING'), stale: msg('STALE') }; label.textContent = t(FEED_STATE_LABELS[state]); ``` +A module the server, tools or other portable code also imports must not load the catalogs: it imports `msg` from `src/i18n/marker.js`, which only marks the text, and the UI translates it with `t()` where it shows it. (Older modules keep a local `const msg = (text) => text`, which works the same.) + `msg()` exists for two reasons. `npm run i18n` finds messages only as string literals inside `t(…)`, `tn(…)` and `msg(…)`, so `t(FEED_STATE_LABELS[state])` alone tells it nothing. And a `t()` call at module load runs before the page has picked its language, so it would freeze the English. ## Markup @@ -42,7 +44,7 @@ label.textContent = t(FEED_STATE_LABELS[state]); - **Use `tn()` for counts**, not `count === 1 ? … : …`. Russian has three forms. - **Translate the label, not the value.** Layer ids, enums, share-link parameters, API fields, voice tool schemas and feed data (callsigns, vessel and place names) stay as they are; translate only the text shown for them. - **No `t()` at module top level.** Keep the English in `msg()` and call `t()` when rendering. -- **Numbers and dates** go through `formatNumber`, `formatDate` and `formatRelativeTime` from `src/i18n/index.js`, which follow the active language. +- **Numbers and dates** go through `formatNumber`, `formatDate`, `formatRelativeTime` and `formatUtcDayMonth` from `src/i18n/index.js`, which follow the active language. - **Identical English means identical translation,** unless you give it a context: `t('CLEAR', { context: 'action' })` (a button) is its own catalog entry, `action|CLEAR`, while `t('CLEAR')` (the sky) stays separate. English output is the same. - **Text that should stay English** (a brand, a code, a unit symbol) is simply not wrapped. @@ -54,18 +56,18 @@ npm run i18n -- --missing ru # the missing ru entries as JSON npm run i18n -- --prune # drop entries whose English no longer exists ``` -Changing the English changes the key, so the old translation goes unused and the new text shows as missing. Add the translation to `src/i18n/locales/ru.json` (or ask a translator), prune, and run `npm test`, which runs the same check and fails until every language is complete. +Changing the English changes the key: the old translation goes unused and the new text shows as missing. Add the translation to every `src/i18n/locales/.json` (or ask a translator), prune, and run `npm test`; it fails until every language is complete. To see a language, open the app with `?lang=ru`, or use the language picker (the globe button in the bottom-right corner). -An empty translation is reported, and so is one that is exactly the English ("Bloom" → "Bloom"), including any plural form left as the English: it is reported as still English. When it is right on purpose, such as a code (HDG, CRT), a product name or a word spelled the same in that language, list it under the language in `src/i18n/same-as-english.json`. +An empty translation is reported, and so is one left as the English, plural forms included. When English is right on purpose (a code such as CRT, a product name), list it under the language in `src/i18n/same-as-english.json`. ## Text the check reports as unmarked -`npm run i18n` also lists UI text that is not marked at all, and fails while there is any; `npm test` runs the same check: +`npm run i18n` and `npm test` fail on UI text that is not marked: - **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`. -- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `innerText`, `innerHTML`, `title`, `placeholder`; passed to `setAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …)`, `createTextNode`, `insertAdjacentHTML`, `showToast` (also `showToast?.(…)` and `this._showToast(…)`), `toast`, `setHint`, `announce`, `say`; assigned to a layer's `error`, `_error`, `loadingLabel`, `statusMessage` or `status` (`this._error = …`, `stats.loadingLabel = …`; a `const`/`let` local is not checked); or kept in `label:`, `title:`, `tooltip:`, `placeholder:`, `hint:`, `ariaLabel:`, `accessibilityLabel:`, `caption:`, `subtitle:`, `kicker:`, `emptyText:`, `blurb:`, `countLabel:`, `status:`, `loadingLabel:`, `statusMessage:` properties. Lower-case codes without spaces, such as `status: 'auth-failed'`, are not reported. +- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `title` and the like; passed to `setAttribute('title' | 'aria-label' | …)`, `createTextNode`, `showToast` and other notice helpers; assigned to a layer's `error`, `lastError`, `loadingLabel`, `statusMessage` or `status` (a plain `error = …` too); or kept in `label:`, `title:`, `status:` and similar properties. Not reported: declarations (`let status = …`), default values, ternary branches and lower-case codes (`'auth-failed'`). The full list is `JS_SINKS` in `scripts/i18n.mjs`. Fix it by marking the text. When the text must stay as it is, say so where it lives: @@ -82,6 +84,7 @@ Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowerca | File | What it holds | | ------------------------------- | ------------------------------------------------------------------------------ | | `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language | +| `src/i18n/marker.js` | `msg` alone, for modules the server or tools import (no catalogs) | | `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot | | `src/i18n/locales/.json` | one catalog per language, keyed by English text | | `src/i18n/same-as-english.json` | per language, the entries that stay as in English on purpose | diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index 782446ecd..2131ab7a3 100644 --- a/docs/TRANSLATORS.md +++ b/docs/TRANSLATORS.md @@ -32,7 +32,7 @@ npm run i18n -- --missing ru # missing ru entries as JSON, ready to transla npm run i18n -- --prune # drop entries the code no longer uses ``` -`npm test` runs the same check (`src/i18n/catalog.test.mjs`). It fails when a translation is missing or empty, when an entry is no longer used, when placeholders differ from the English, when a plural entry lacks a form, or when a translation, or any form of a plural entry, is still the English text. The `--missing` template is not a translation: its strings are empty and its plural forms are the English, so the check fails until every one is translated. If an entry is right as it is in English (a code such as HDG, a product name, a word spelled the same in your language), add it under your language code in `src/i18n/same-as-english.json`. +`npm test` runs the same check. It fails on a missing, empty or unused entry, on placeholders that differ from the English, on a missing plural form, and on text or plural forms still in English. The `--missing` template fails until every entry is translated. If an entry is right in English (a code such as CRT, a product name), add it under your language code in `src/i18n/same-as-english.json`. Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune. diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index bb4259c4d..0ee5d60ec 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -4,9 +4,8 @@ // npm run i18n -- --prune also drop entries the code no longer uses // npm run i18n -- --missing ru print what ru lacks, as JSON to translate // -// A translation identical to the English is reported too, unless it is -// listed for that locale in src/i18n/same-as-english.json (codes, product -// names, words spelled the same in both languages). +// A translation left as the English fails unless src/i18n/same-as-english.json +// lists it. // // Messages are the English literals passed to t(), tn() and msg() in src/, // plus the markup opted in with data-i18n* in src/ui/templates and index.html. @@ -220,12 +219,30 @@ const JS_SINKS = [ // Also showToast?.(…) and this._showToast(…). /\b_?(?:showToast|toast|setHint|announce|say)(?:\?\.)?\(\s*/g, // `status` values are often codes ('ok', 'auth-failed'); visibleText drops - // those, so only readable text is reported. - /(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|accessibilityLabel|caption|subtitle|kicker|emptyText|blurb|countLabel|status|loadingLabel|statusMessage)\s*:\s*/gm, - // Layer status set by assignment: this._error = …, stats.loadingLabel = …. - // A declared local (`const error = …`) is scratch, not a status field. - /(?])\s*/g, + // those, so only readable text is reported. A name right after `?` is a + // ternary branch (`ok ? status : 'Fallback'`), not a property. + /(?:^|[{,\s])(?])\s*/g; +JS_SINKS.push(ASSIGNMENT_SINK); + +/** + * Whether `name = value` at `nameStart` is a default (`(status = 'idle') =>`, + * `const { error = '…' } = options`): it follows `(`, `[`, `{` or `,` and its + * value ends at `,` or a closing bracket. An assignment statement ends at `;`. + */ +function defaultValue(source, nameStart, valueStart) { + const before = source.slice(0, nameStart).trimEnd().at(-1); + if (!before || !'([{,'.includes(before)) return false; + const end = valueStart + expressionAt(source, valueStart).length; + const after = source.slice(end).trimStart()[0]; + return Boolean(after) && ',)]}'.includes(after); +} const MARKERS = /\b(?:t|tn|msg)\(/y; /** The expression starting at `start`, up to its end at bracket depth 0. */ @@ -364,6 +381,8 @@ export function unmarkedScript(file, original) { const start = match.index + match[0].length; const line = lineAt(source, start); if (ignored(line)) continue; + if (sink === ASSIGNMENT_SINK && defaultValue(source, match.index, start)) + continue; const html = /HTML/.test(match[0]); for (const literal of valueLiterals(expressionAt(source, start))) { const text = visibleText(literal, html); @@ -377,8 +396,7 @@ export function unmarkedScript(file, original) { /** * UI text that is not marked for translation: markup without data-i18n* and * string literals that reach the page without t(), tn() or msg(). - * Opt out with translate="no" in markup or an `// i18n-ignore` comment on - * (or just above) the line in code. + * Opt-outs: see unmarkedMarkup; in code, `// i18n-ignore` on or above the line. * @returns {{where: string, text: string}[]} */ export function findUnmarked() { diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index cac4417c9..a9c49f6c3 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -53,7 +53,8 @@ "src/data/adsbLolFallback.js", "src/data/aisStreamAdapter.js", "src/data/aisWatchdog.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/i18n/marker.js" ], "external": ["ws"] }, @@ -79,6 +80,7 @@ "src/localRequestGate.mjs", "src/data/routeSteps.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/rateLimit.js" ], "external": [] @@ -97,7 +99,8 @@ "server/providers/space/launch-library.js", "server/providers/common/http.js", "src/data/spaceProviderRequests.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/i18n/marker.js" ], "external": [] }, @@ -127,6 +130,7 @@ "server/providers/traffic.js", "server/providers/common/http.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/data/tomtomTiles.js" ], "external": [] @@ -148,6 +152,7 @@ "server/providers/firms.js", "server/providers/common/http.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/data/firmsCsv.js" ], "external": [] @@ -161,7 +166,8 @@ "server/providers/common/rate-limit.js", "src/sources/rateLimit.js", "src/data/gbfsSource.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/i18n/marker.js" ], "external": [] }, @@ -192,7 +198,8 @@ "scripts/google-server-key.mjs", "src/data/cctvFootprint.js", "src/sources/cctvTypes.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/i18n/marker.js" ], "external": [] }, @@ -210,7 +217,8 @@ "server/providers/common/rate-limit.js", "src/sources/rateLimit.js", "server/providers/common/http.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/i18n/marker.js" ], "external": ["pbf"] }, @@ -226,7 +234,8 @@ "src/data/radioCountry.js", "server/providers/common/http.js", "src/sources/radioBrowser.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/i18n/marker.js" ], "external": [] }, @@ -248,6 +257,7 @@ "src/data/placeProviderPayloads.js", "src/data/routeSteps.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/overpass.js", "src/sources/rateLimit.js" ], @@ -268,6 +278,7 @@ "server/providers/overpass/geometry.js", "server/providers/overpass/transport.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/rateLimit.js" ], "external": [] @@ -290,6 +301,7 @@ "src/data/regionalModel.js", "src/nominatimGeocode.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/rateLimit.js", "src/data/local_data/natural_earth/marine.json", "src/data/local_data/natural_earth/regions.json", @@ -303,6 +315,7 @@ "runtime": "node", "exports": ["./server/providers/openai"], "modules": [ + "src/i18n/marker.js", "src/hudLayoutPolicy.js", "server/providers/common/rate-limit.js", "server/providers/common/request.js", @@ -335,6 +348,7 @@ "runtime": "node", "exports": ["./server/standalone/key-setup"], "modules": [ + "src/i18n/marker.js", "scripts/pinokio-environment.mjs", "server/providers/common/source-root.js", "server/standalone/key-setup.js", @@ -367,6 +381,7 @@ "src/data/local_data/natural_earth/regions.json", "src/data/local_data/natural_earth/marine.json", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/nominatim.js" ], "external": [] @@ -435,6 +450,7 @@ "map-source-controls": { "exports": ["./ui/maps"], "modules": [ + "src/i18n/marker.js", "src/ui/mapSource.js", "src/ui/mapSourceControls.js", "src/mapStackChips.js", @@ -450,6 +466,7 @@ "layer-panel": { "exports": ["./ui/layers", "./ui/layers/feedback"], "modules": [ + "src/i18n/marker.js", "src/ui/layers.js", "src/ui/layerPanel.js", "src/ui/layerNames.js", @@ -528,6 +545,7 @@ "context-controls": { "exports": ["./ui/context", "./ui/context/policy"], "modules": [ + "src/i18n/marker.js", "src/ui/context.js", "src/ui/contextActions.js", "src/ui/contextBindings.js", @@ -598,6 +616,7 @@ "ui-shell": { "exports": ["./ui/shell"], "modules": [ + "src/i18n/marker.js", "src/ui/placeNames.js", "src/hudLayouts.js", "src/hudLayoutPolicy.js", @@ -794,6 +813,7 @@ "src/director/interactions/session.js", "src/director/sharing/bundle.js", "src/director/sharing/preview.js", + "src/i18n/marker.js", "src/director/sharing/lifetime.js", "src/director/authoring.js" ], @@ -844,6 +864,7 @@ "src/data/inputOwnership.js", "src/director/sharing/bundle.js", "src/director/sharing/preview.js", + "src/i18n/marker.js", "src/director/sharing/lifetime.js", "src/director/authoring.js", "src/scenes/sharing.js", @@ -860,6 +881,7 @@ "live-observations": { "exports": ["./sources/live"], "modules": [ + "src/i18n/marker.js", "src/data/aircraftEmergency.js", "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -872,6 +894,7 @@ "civil-flights": { "exports": ["./layers/flights"], "modules": [ + "src/i18n/marker.js", "src/cyberSonar.js", "src/data/aircraftClass.js", "src/data/aircraftEmergency.js", @@ -921,6 +944,7 @@ "military-flights": { "exports": ["./layers/military"], "modules": [ + "src/i18n/marker.js", "src/cyberSonar.js", "src/data/aircraftClass.js", "src/data/aircraftEmergency.js", @@ -965,6 +989,7 @@ "aircraft-mechanics": { "exports": ["./aircraft"], "modules": [ + "src/i18n/marker.js", "src/data/aircraftClass.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", @@ -1032,6 +1057,7 @@ "recent-imagery": { "exports": ["./layers/recent-imagery"], "modules": [ + "src/i18n/marker.js", "src/layers/recentImagery/index.js", "src/layers/recentImagery/source.js", "src/layers/recentImagery/model.js", @@ -1159,6 +1185,7 @@ "src/data/tomtomTiles.js", "src/sources/capability.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/openFreeMap.js", "src/sources/militaryTileGeometry.js", "src/sources/vectorTiles.js", @@ -1181,6 +1208,7 @@ "awareness-layer": { "exports": ["./layers/awareness"], "modules": [ + "src/i18n/marker.js", "src/cyberSonar.js", "src/cockpitMath.js", "src/data/iconOrientation.js", @@ -1244,6 +1272,7 @@ "src/sources/overpassRoads.js", "src/sources/capability.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/openFreeMap.js", "src/data/installationNames.js", "src/sources/militaryTileGeometry.js", @@ -1411,6 +1440,7 @@ "src/layers/alpr/tileRecords.js", "src/sources/capability.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/vectorTiles.js", "src/sources/phaseTiming.js", "src/layers/alpr/floors.js", @@ -1513,6 +1543,7 @@ "./maps/defaults" ], "modules": [ + "src/i18n/marker.js", "src/maps/availability.js", "src/maps/catalog.js", "src/maps/controller.js", @@ -2237,6 +2268,7 @@ "src/search/nominatim.js", "src/nominatimGeocode.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/nominatim.js", "src/app/layers/transit.js", "src/data/transit.js", @@ -2863,6 +2895,7 @@ "src/nominatimGeocode.js", "src/data/regionalModel.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/nominatim.js", "src/app/layers/transit.js", "src/data/transitFeeds.js", @@ -3012,9 +3045,7 @@ "src/layers/airQuality/model.js", "src/layers/airQuality/source.js", "src/ui/statusText.js", - "src/ui/layerNames.js", - "src/layers/wind/source.js", - "src/sources/windBody.js" + "src/ui/layerNames.js" ], "external": [ "hls.js", @@ -3074,6 +3105,7 @@ "src/nominatimGeocode.js", "src/data/regionalModel.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/nominatim.js", "src/sources/featureGeometry.js", "src/sources/overpassFeaturesRecords.js", @@ -3114,6 +3146,7 @@ "ui-share-restoration": { "exports": ["./ui/share-restoration"], "modules": [ + "src/i18n/marker.js", "src/data/installationFeedback.js", "src/data/idSwitches.js", "src/data/layerState.js", @@ -3189,6 +3222,7 @@ "civil-flight-records": { "exports": ["./layers/flights/records"], "modules": [ + "src/i18n/marker.js", "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", "src/data/aircraftMeta.js", @@ -3209,6 +3243,7 @@ "military-flight-records": { "exports": ["./layers/military/records"], "modules": [ + "src/i18n/marker.js", "src/layers/military/records.js", "src/layers/military/recordPolicy.js", "src/data/aircraftMeta.js", @@ -3260,6 +3295,7 @@ "directions-layer": { "exports": ["./layers/directions"], "modules": [ + "src/i18n/marker.js", "src/layers/directions/index.js", "src/data/pointerOwnerNames.js", "src/layers/directions/source.js", @@ -3335,6 +3371,7 @@ "src/data/tomtomTiles.js", "src/layers/alpr/tileRecords.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/vectorTiles.js", "src/sources/phaseTiming.js" ], @@ -3372,6 +3409,7 @@ "src/data/tomtomTiles.js", "src/sources/capability.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/openFreeMap.js", "src/sources/militaryTileGeometry.js", "src/sources/vectorTiles.js", @@ -3409,6 +3447,7 @@ "src/layers/traffic/source.js", "src/sources/overpassRoads.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/openFreeMap.js", "src/data/installationNames.js", "src/sources/militaryTileGeometry.js", @@ -3426,6 +3465,7 @@ "src/layers/earthquakes/source.js", "src/layers/earthquakes/records.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/layers/perimeters/source.js", "src/layers/perimeters/records.js", "src/layers/submarineCables/bundledSource.js", @@ -3444,13 +3484,14 @@ "src/search/geospatial.js", "src/data/regionalModel.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/nominatim.js" ], "external": [] }, "source-http-body": { "exports": ["./sources/http-body"], - "modules": ["src/sources/httpBody.js"], + "modules": ["src/sources/httpBody.js", "src/i18n/marker.js"], "external": [] }, "overpass-lexer": { @@ -3465,7 +3506,8 @@ "src/nominatimGeocode.js", "src/search/geospatial.js", "src/data/regionalModel.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/i18n/marker.js" ], "external": [] }, @@ -3481,6 +3523,7 @@ "src/data/transitFeeds.js", "src/data/transitProxy.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/transitHistory.js", "src/sources/transitService.js", "src/sources/transitHistoryStore.js", @@ -3620,6 +3663,7 @@ "src/data/transitProxy.js", "src/data/gtfsRealtime.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/transitHistory.js", "src/sources/transitService.js", "src/sources/transitHistoryStore.js", @@ -3643,6 +3687,7 @@ "modules": [ "src/layers/perimeters/index.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/layers/perimeters/source.js", "src/layers/perimeters/records.js", "src/layers/perimeters/cards.js", @@ -3659,6 +3704,7 @@ "modules": [ "src/layers/gnss/index.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/layers/gnss/source.js", "src/layers/gnss/records.js", "src/i18n/index.js", @@ -3672,7 +3718,11 @@ }, "gnss-source": { "exports": ["./layers/gnss/source"], - "modules": ["src/sources/httpBody.js", "src/layers/gnss/source.js"], + "modules": [ + "src/sources/httpBody.js", + "src/i18n/marker.js", + "src/layers/gnss/source.js" + ], "external": [] }, "perimeters-source": { @@ -3680,6 +3730,7 @@ "modules": [ "src/layers/perimeters/records.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/layers/perimeters/source.js" ], "external": [] @@ -3713,6 +3764,7 @@ "src/layers/wind/inspection.js", "src/layers/wind/model.js", "src/layers/wind/fields.js", + "src/i18n/marker.js", "src/tools/queries/imagery.js", "src/tools/queries/cables.js", "src/tools/queries/surveillance.js", @@ -3766,6 +3818,7 @@ "src/layers/cctv/sourcePolicy.js", "src/layers/cyclones/source.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/layers/earthquakes/source.js", "src/layers/earthquakes/records.js", "src/layers/firms/source.js", @@ -3812,6 +3865,8 @@ "src/layers/weather/source.js", "src/layers/wind/source.js", "src/sources/windBody.js", + "src/layers/wind/fields.js", + "src/layers/wind/model.js", "src/layers/recentImagery/catalog.js", "src/layers/recentImagery/model.js", "src/layers/submarineCables/bundledSource.js", @@ -3841,6 +3896,7 @@ "src/layers/airQuality/model.js", "src/layers/airQuality/source.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", @@ -3855,7 +3911,8 @@ "modules": [ "src/layers/airQuality/model.js", "src/layers/airQuality/source.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/i18n/marker.js" ], "external": [] } diff --git a/scripts/qa-scene-dialog-file-drop.mjs b/scripts/qa-scene-dialog-file-drop.mjs index abf92fc86..9b48555ad 100644 --- a/scripts/qa-scene-dialog-file-drop.mjs +++ b/scripts/qa-scene-dialog-file-drop.mjs @@ -3,12 +3,10 @@ * qa-scene-dialog-file-drop — the SHARE SCENE dialog's file control accepts a * file dropped from the desktop, proved in a real browser. * - * The dialog replaces the browser-drawn "Choose files / No file chosen" line - * with a translated one (src/ui/sceneSharing.js, fileChooser). The native - * input must stay the whole control underneath it: a shrunken 1 px input - * accepts drops only on its own pixel, so dragging a data-pack file onto the - * visible row selected nothing. This harness drops a file onto the centre of - * the visible row through CDP and expects `input.files` to hold it. + * The dialog draws a translated row over the native file input + * (src/ui/sceneSharing.js, fileChooser). The input must still cover the row, + * or drops miss it. Drops a file on the row's centre via CDP and expects + * `input.files` to hold it. * * Usage: * node scripts/qa-scene-dialog-file-drop.mjs diff --git a/server/standalone/key-setup.js b/server/standalone/key-setup.js index 84277c3ac..4a36cc8a0 100644 --- a/server/standalone/key-setup.js +++ b/server/standalone/key-setup.js @@ -134,8 +134,7 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) { }; // The gate itself is pure and unit-tested (admitKeySetupRequest in // src/keySetupCore.mjs) — this just feeds it the request. - // A refusal as the panel reads it: `code` (and `params`) pick its localized - // sentence in src/keySetup.js; `error` is the English for logs and scripts. + // `code`/`params` pick the panel's sentence (src/keySetup.js); `error` is for logs. const refusal = ({ error, code, params }) => ({ error, code, params }); const admit = (req) => admitKeySetupRequest({ diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index a85a143f9..645aa3f3d 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1137,14 +1137,14 @@ export function createAnnotationEngine({ { type: 'highlight', target: 'Palace of Fine Arts, San Francisco', - // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'Palace of Fine Arts', color: 'amber', }, { type: 'area', target: 'Presidio of San Francisco', - // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'The Presidio (former Army base)', color: 'green', footprint: true, @@ -1152,7 +1152,7 @@ export function createAnnotationEngine({ { type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', - // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'ILM / Lucasfilm', color: 'cyan', }, @@ -1160,7 +1160,7 @@ export function createAnnotationEngine({ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina District, San Francisco', - // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'next to the Marina', }, ], @@ -1190,7 +1190,7 @@ export function createAnnotationEngine({ { type: 'highlight', target: 'Palace of Fine Arts, San Francisco', - // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'Palace of Fine Arts', color: 'amber', }, @@ -1205,7 +1205,7 @@ export function createAnnotationEngine({ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina Green, San Francisco', - // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'next to the Marina', color: 'cyan', }, @@ -1229,7 +1229,7 @@ export function createAnnotationEngine({ { type: 'area', target: 'Presidio of San Francisco', - // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'The Presidio — a former Army base', color: 'green', footprint: true, @@ -1244,7 +1244,7 @@ export function createAnnotationEngine({ { type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', - // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'ILM / Lucasfilm', color: 'red', }, @@ -1258,7 +1258,7 @@ export function createAnnotationEngine({ { type: 'route', color: 'amber', - // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'Crissy Field shoreline', points: [ { target: 'Palace of Fine Arts, San Francisco' }, diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index 8a8558cb2..f8b9afcd1 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -241,8 +241,7 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK; /* ── the tests ─────────────────────────────────────────────────────────── */ -// K-27. First in the file: the tools other tests leave behind would answer the -// same registry. +// First in the file: tools left by later tests would answer the same registry. test('a lasting mark or a half-drawn shape makes the language switch ask first', async () => { const h = harness(); try { @@ -288,7 +287,7 @@ test('Draw refuses to start when something else holds the pointer', async () => h.element('draw-toggle').emit('click'); assert.equal(h.tool.active, false, 'it must not half-start'); assert.equal(pointerOwner(), 'directions', 'and must not steal the claim'); - // Named by its tool, not by the internal owner id (E3/E12). + // Named by its tool, not by the internal owner id. assert.equal( h.element('draw-hint').textContent, 'Directions is using the pointer — close it first.', diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js index ce8250624..d10e5717d 100644 --- a/src/app/globePanelRuntime.js +++ b/src/app/globePanelRuntime.js @@ -541,7 +541,7 @@ export function panelRuntime(config) { const notice = document.createElement('div'); notice.id = 'status'; notice.textContent = - // i18n-ignore: panel runtime runs as source text in the host page, with no i18n module or app locale + // i18n-ignore: host-panel script, no app catalogs "This panel lost its 3D graphics, likely because other God's Eye " + "View panels in this conversation hold them. Use Open in God's " + 'Eye View above, or show it in a new conversation.'; @@ -569,20 +569,18 @@ export function panelRuntime(config) { queued = view; if (started) return; started = true; - // i18n-ignore: this script runs as source text in the host panel, without the app catalogs + // i18n-ignore: host-panel script, no app catalogs say("Loading God's Eye View…"); startApp(url, view).catch((error) => - // i18n-ignore: this script runs as source text in the host panel, without the app catalogs + // i18n-ignore: host-panel script, no app catalogs say(`God's Eye View could not load here: ${error?.message || error}.`), ); setTimeout(() => { - // i18n-ignore: this script runs as source text in the host panel if (!ready) say( // i18n-ignore: see above "God's Eye View did not load here" + (blocked ? ` (${blocked})` : '') + - // i18n-ignore: see above ". Use Open in God's Eye View above.", ); }, config.loadTimeoutMs); @@ -622,7 +620,7 @@ export function panelRuntime(config) { if (context?.displayMode) displayMode = context.displayMode; if (context?.availableDisplayModes) expand.hidden = !context.availableDisplayModes.includes('fullscreen'); - // i18n-ignore: panel runtime runs as source text in the host page, with no i18n module or app locale + // i18n-ignore: host-panel script, no app catalogs expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand'; }; expand.addEventListener('click', () => { @@ -663,7 +661,7 @@ export function panelRuntime(config) { }, (error) => say( - // i18n-ignore: this script runs as source text in the host panel, without the app catalogs + // i18n-ignore: host-panel script, no app catalogs "This client did not accept the God's Eye View panel" + (error?.message ? `: ${error.message}` : '.'), ), diff --git a/src/app/layers/cctv.js b/src/app/layers/cctv.js index 60cc64d0f..99bf3fa6f 100644 --- a/src/app/layers/cctv.js +++ b/src/app/layers/cctv.js @@ -3,7 +3,7 @@ import * as credits from '../../data/dataCredits.js'; import * as sprites from '../../data/spriteOrder.js'; import * as activation from '../../cctvFocusRequest.js'; import * as overlays from '../../overlays/worldOverlay.js'; -import * as locations from '../../locations.js'; +import { CITY_POIS } from '../../cityPois.js'; import * as picking from '../../data/pickRegistry.js'; import * as focus from '../../data/focusDeemphasis.js'; import * as render from '../../renderGovernor.js'; @@ -18,7 +18,7 @@ export function createApplicationCctv({ surface, source }) { sprites, activation, overlays, - locations, + locations: { CITY_POIS }, picking, terrain, ground, diff --git a/src/cityPois.js b/src/cityPois.js index d0fc8c04c..f16394030 100644 --- a/src/cityPois.js +++ b/src/cityPois.js @@ -2,7 +2,6 @@ * @module cityPois * @description The preset cities and their landmarks — plain data with no * Cesium, so modules that only need the names (voice cards) can import it. - * `locations.js` re-exports it. */ /** diff --git a/src/cityPresets.test.mjs b/src/cityPresets.test.mjs index dc9ef0145..2aaec446f 100644 --- a/src/cityPresets.test.mjs +++ b/src/cityPresets.test.mjs @@ -8,7 +8,8 @@ // say nothing about whether a destination is framed well. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { CITY_POIS, LOCATIONS } from './locations.js'; +import { CITY_POIS } from './cityPois.js'; +import { LOCATIONS } from './locations.js'; const DESTINATIONS = Object.entries(CITY_POIS); const REQUIRED_POI_KEYS = ['name', 'lat', 'lon', 'alt', 'pitch', 'heading']; diff --git a/src/cockpitMath.js b/src/cockpitMath.js index b118bf40b..62015bb70 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -119,10 +119,7 @@ export function formatCockpitEmergency(emergency, format = ENGLISH_EMERGENCY) { }); } -/** - * The English Context scope line. The cockpit passes its own formatter so the - * page shows it in the active language; this module stays i18n-free. - */ +/** The English Context scope line; see formatCockpitContextScope. */ const ENGLISH_CONTEXT_SCOPE = Object.freeze({ window: ({ label, radiusKm }) => `${label} · ${radiusKm ?? '—'} KM AIR/SEA WINDOW`, diff --git a/src/contextModePolicy.js b/src/contextModePolicy.js index 25bb05afa..4c0b7c9cb 100644 --- a/src/contextModePolicy.js +++ b/src/contextModePolicy.js @@ -1,3 +1,5 @@ +import { msg } from './i18n/marker.js'; + const CONTEXT_DEPENDENCIES = Object.freeze({ flights: new Set([ 'military-awareness', @@ -9,10 +11,6 @@ const CONTEXT_DEPENDENCIES = Object.freeze({ 'space-missions': new Set(['rocket-launches', 'satellites']), }); const CONTEXT_COMPANIONS = new Set(['radio']); -// This module is shared with voice actions and scene policy, so the refusal -// reason stays English data; the Context shell translates it with t() at the -// toast (src/ui/contextLayerChanges.js). -const msg = (text) => text; /** The Space Missions refusal, with the blocked layer's name as {layer}. */ export const SPACE_MISSIONS_LAYER_BLOCK_REASON = msg( 'Space Missions isolates replay data. Exit the mode to enable {layer}.', @@ -350,8 +348,8 @@ export function contextLayerEnableBlockReason({ } /** - * Split a Space Missions refusal back into its template and layer name so - * the presentation can translate the sentence and localize the name. + * The layer name from a Space Missions refusal, so the UI can rebuild the + * sentence in its language. * @param {string} reason * @returns {{layer: string}|null} The blocked layer's name, or null when the * reason is not a Space Missions refusal. diff --git a/src/data/aircraftClass.js b/src/data/aircraftClass.js index 4e9f10794..1be15e84c 100644 --- a/src/data/aircraftClass.js +++ b/src/data/aircraftClass.js @@ -7,6 +7,8 @@ * integer category (states index 17) and ADS-B emitter strings ("A1".."B1"). */ +import { msg } from '../i18n/marker.js'; + const HELI = new Set([ 'EC20', 'EC25', @@ -438,10 +440,6 @@ export function classifyAircraft({ typeCode, category } = {}) { return 'airliner'; } -// Portable flight records import this module, so it has no i18n: msg() only -// marks the labels, and the card translates them where it shows them. -const msg = (text) => text; - /** Readable name of each class, for cards and readouts (English data). */ export const AIRCRAFT_CLASS_LABELS = Object.freeze({ light: msg('Light aircraft'), diff --git a/src/data/aircraftEmergency.js b/src/data/aircraftEmergency.js index 505c9c0ae..84643b162 100644 --- a/src/data/aircraftEmergency.js +++ b/src/data/aircraftEmergency.js @@ -13,12 +13,11 @@ * broadcast, which does not confirm an incident or its resolution. */ +import { msg } from '../i18n/marker.js'; + /** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */ export const EMERGENCY_TINT_CSS = '#FF4444'; -// Marks catalog text without importing i18n: tools and the server load this module. -const msg = (text) => text; - const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' }; const KINDS = { diff --git a/src/data/aisWatchdog.js b/src/data/aisWatchdog.js index 253482727..a83c281d9 100644 --- a/src/data/aisWatchdog.js +++ b/src/data/aisWatchdog.js @@ -249,7 +249,7 @@ export function createAisWatchdog(options = {}) { if (!env.hasKey) { const actions = terminateOwned('key-absent'); status = 'missing-key'; - // i18n-ignore: server detail; the chip shows a translated reason (vessels/queries.js) + // i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason error = 'AISSTREAM_API_KEY is not set'; reconnectAttempt = 0; keyFingerprint = null; @@ -258,7 +258,7 @@ export function createAisWatchdog(options = {}) { if (env.hasTransport === false) { const actions = terminateOwned('no-transport'); status = 'unsupported'; - // i18n-ignore: server detail; the chip shows a translated reason (vessels/queries.js) + // i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason error = 'Node WebSocket transport is unavailable'; return actions; } @@ -331,7 +331,7 @@ export function createAisWatchdog(options = {}) { if (!silenceWatchArmed) return []; const silentFor = monoNow - silenceSinceMono; if (silentFor >= recycleAfterMs) { - // i18n-ignore: server detail; the chip shows a translated reason (vessels/queries.js) + // i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason error = `AISStream delivered no data for ${Math.round(silentFor / 1000)}s`; const actions = terminateOwned('silent'); scheduleRetry('transport'); @@ -404,7 +404,7 @@ export function createAisWatchdog(options = {}) { function onClose(eventGeneration) { if (!ownsGeneration(eventGeneration)) return []; release(); - // i18n-ignore: server detail; the chip shows a translated reason (vessels/queries.js) + // i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason if (!error) error = 'AISStream websocket closed'; scheduleRetry('transport'); return []; diff --git a/src/data/bhoteKoshiEmbeddedMedia.js b/src/data/bhoteKoshiEmbeddedMedia.js index 294991d2d..1ea0ccec9 100644 --- a/src/data/bhoteKoshiEmbeddedMedia.js +++ b/src/data/bhoteKoshiEmbeddedMedia.js @@ -40,10 +40,8 @@ const MEDIA_PRECONNECT_ORIGINS = Object.freeze({ facebook: Object.freeze(['https://www.facebook.com']), }); -// Authored English in public/events/bhote-koshi-2026/event.json. The pack is -// data and stays English; these known strings translate at display. Anything -// else (a newer pack, a credit, a source's own title) shows as authored. A -// string that is not known whole translates by its " · " parts. +// Known English from public/events/bhote-koshi-2026/event.json, translated at +// display (whole or by " · " parts). Anything else shows as authored. const EVENT_PACK_TEXT = new Set([ // Field reports: our labels (credits after " · " stay) and their statuses. msg('Rasuwagadhi witness video'), diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 2a2973610..2baaf7031 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -826,11 +826,9 @@ export function updateEvidenceFrame( const EVIDENCE_SOURCE_MIN_FONT_SCALE = 0.8; /** - * Set the font for the «{publisher} / OPEN» line and return the line so it - * fits `maxWidth`: a translation («ИСТОЧНИК YOUTUBE / ОТКРЫТЬ») is longer than - * the English and the portrait card cut its action word. A line that fits is - * drawn exactly as before; one that does not first shrinks its font (to 80%), - * then shortens the publisher — the «/ OPEN» action always stays whole. + * Set the font for the «{publisher} / OPEN» line and return a line that fits + * `maxWidth`. Translations run longer: first shrink the font (to 80%), then + * shorten the publisher; «/ OPEN» always stays whole. * @param {CanvasRenderingContext2D} ctx * @param {string} publisher Display publisher, already upper-cased. * @param {string} font CSS font with a px size. diff --git a/src/data/bhoteKoshiEvidenceFit.test.mjs b/src/data/bhoteKoshiEvidenceFit.test.mjs index cd4e0af50..0a0459b87 100644 --- a/src/data/bhoteKoshiEvidenceFit.test.mjs +++ b/src/data/bhoteKoshiEvidenceFit.test.mjs @@ -1,7 +1,6 @@ -// Proposal 5 / E4: the evidence frame's «{publisher} / OPEN» line is drawn on -// a canvas, so CSS cannot widen it; on the portrait card the Russian -// «ИСТОЧНИК YOUTUBE / ОТКРЫТЬ» lost its last letter. The line is measured and -// shrunk to fit, keeping the action word whole; English draws as before. +// The «{publisher} / OPEN» line is drawn on a canvas, so CSS cannot wrap it: +// a long translation shrinks to fit the portrait card, action word whole. +// English is drawn at full size. import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; diff --git a/src/data/bhoteKoshiEvidencePublisher.test.mjs b/src/data/bhoteKoshiEvidencePublisher.test.mjs index 19591d83e..732dee372 100644 --- a/src/data/bhoteKoshiEvidencePublisher.test.mjs +++ b/src/data/bhoteKoshiEvidencePublisher.test.mjs @@ -2,8 +2,8 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -// K-30: the publisher on the evidence frame ("YouTube source") is pack text -// and is shown in the UI language; English stays "YOUTUBE SOURCE / OPEN". +// The publisher on the evidence frame ("YouTube source") is pack text shown +// in the UI language; English stays "YOUTUBE SOURCE / OPEN". import { setLocale } from '../i18n/index.js'; import { createEvidenceFrame } from './bhoteKoshiEvent.js'; diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js index 29a882f14..f3917c463 100644 --- a/src/data/bhoteKoshiLocator.js +++ b/src/data/bhoteKoshiLocator.js @@ -52,9 +52,8 @@ const TRIGGER_ORBIT_DURATION_SEC = 2.8; const TRIGGER_SOURCE_URL = 'https://earthquake.usgs.gov/earthquakes/eventpage/us7000tbwb/executive'; -// Overview pin titles that are more than a place name translate at display, -// through the same entries the event panel uses. The data stays English and -// place names stay Latin; everything else shows as authored. +// Descriptive pin titles translate at display (same entries as the event +// panel); place names stay Latin, anything else shows as authored. const PLACE_TITLE_TEXT = new Map( [ msg('Collapse viewpoint'), diff --git a/src/data/geoFileImport.js b/src/data/geoFileImport.js index bcec2080f..fc6a5e079 100644 --- a/src/data/geoFileImport.js +++ b/src/data/geoFileImport.js @@ -2,9 +2,7 @@ import * as Cesium from 'cesium'; import { createImportImageBudget } from './geoImportImages.js'; import { createImportedPins } from './geoImportPins.js'; - -// Marks catalog text without importing i18n; the UI translates the message. -const msg = (text) => text; +import { msg } from '../i18n/marker.js'; /** Largest file accepted, in bytes. */ export const MAX_IMPORT_BYTES = 50 * 1024 * 1024; diff --git a/src/data/installationFeedback.js b/src/data/installationFeedback.js index bd2900087..13e92c8b9 100644 --- a/src/data/installationFeedback.js +++ b/src/data/installationFeedback.js @@ -1,8 +1,6 @@ -// Portable node/browser module (the HUD summary in loadingFeedback.js reads -// it too), so it marks its copy with a local msg() instead of importing i18n; -// installations/controls.js translates describeInstallationFeedback(). -const msg = (text) => text; +import { msg } from '../i18n/marker.js'; +// layers/installations/controls.js translates describeInstallationFeedback(). const REASONS = Object.freeze({ rate_limited: msg('Overpass rate-limited'), timeout: msg('Overpass timed out'), diff --git a/src/data/installationNames.js b/src/data/installationNames.js index 8b3597f1e..8ef78d8f1 100644 --- a/src/data/installationNames.js +++ b/src/data/installationNames.js @@ -1,14 +1,10 @@ +import { msg } from '../i18n/marker.js'; + /** - * The English names the data gives a military installation that has no - * mapped name. They stay English in data — the server provider, the AI tools - * and the voice context read the records — and the display translates them - * (installationTitle in src/layers/installations/classLabel.js and - * formatAwarenessLabel in src/data/militaryAwarenessEngine.js). - * - * The server, tools and source modules load this file, so it marks the names - * with a local msg() instead of importing i18n. + * English names for installations without a mapped name. Data keeps them + * English (server, AI tools, voice context); classLabel.js and + * militaryAwarenessEngine.js translate them. */ -const msg = (text) => text; /** Name of an unnamed feature, by installation class. */ export const UNNAMED_INSTALLATION_LABEL_BY_CLASS = Object.freeze({ diff --git a/src/data/layerState.js b/src/data/layerState.js index a07fac214..dd79eb12a 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -1,5 +1,5 @@ -// Marks restore labels for i18n extraction; shareRestoration translates them. -// Kept local so this module stays free of the i18n import. +// Restore labels are marked with msg(); ui/shareRestoration.js translates them. +// Local, not i18n/marker.js: the token-ledger tests run this file in isolation. const msg = (text) => text; import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js'; import reservationRows from './layerStateTokenReservations.json' with { type: 'json' }; diff --git a/src/data/militaryInstallationData.js b/src/data/militaryInstallationData.js index 25092014d..8adde1a03 100644 --- a/src/data/militaryInstallationData.js +++ b/src/data/militaryInstallationData.js @@ -32,13 +32,6 @@ const CLASS_BY_MILITARY_TAG = { const LABEL_BY_CLASS = UNNAMED_INSTALLATION_LABEL_BY_CLASS; const UNNAMED_FALLBACK = UNNAMED_INSTALLATION_FALLBACK; -/** Every name humanizeInstallationClass gives a record of a class - * CLASS_BY_MILITARY_TAG can produce, for the display to translate. */ -export const UNNAMED_INSTALLATION_NAMES = Object.freeze([ - ...Object.values(LABEL_BY_CLASS), - UNNAMED_FALLBACK, -]); - const MAX_FOOTPRINT_POINTS = 400; /** diff --git a/src/data/pointerOwnerNames.js b/src/data/pointerOwnerNames.js index 53a992b10..057f7f221 100644 --- a/src/data/pointerOwnerNames.js +++ b/src/data/pointerOwnerNames.js @@ -1,15 +1,9 @@ -/** - * The tool name to show for a pointer owner. - * - * Pointer-owner ids (src/data/inputOwnership.js) are stable internal - * identifiers — 'draw', 'recent-imagery-box' — and never belong on screen. - * This maps each to the English name of the tool, which the caller translates - * with translateKnown() where it is shown. Pure and stateless, so any package - * can carry it without a second copy of the pointer claim. - */ +import { msg } from '../i18n/marker.js'; -// English marker for the i18n extractor; this module imports no i18n. -const msg = (text) => text; +/** + * Tool name for a pointer owner. Owner ids (inputOwnership.js) never go on + * screen; callers translate the English name with translateKnown(). + */ const POINTER_OWNER_NAMES = Object.freeze({ draw: msg('Draw'), diff --git a/src/data/satelliteClass.js b/src/data/satelliteClass.js index ef2c32b6a..df4f25c6a 100644 --- a/src/data/satelliteClass.js +++ b/src/data/satelliteClass.js @@ -20,53 +20,43 @@ import { t, msg } from '../i18n/index.js'; /** - * Class registry. `label` is the card/legend token, `color` the point color, - * `blurb` the plain-language gloss for the legend tooltip. - * @type {Readonly>} + * Class registry: the point color of each class. The card and legend text is + * in CLASS_DISPLAY_KEYS. + * @type {Readonly>} */ export const SATELLITE_CLASSES = Object.freeze({ station: Object.freeze({ - label: 'STATION', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS // Warm white — "crewed", and the brightest class in the sky. Deliberately // NOT amber: 40-48deg amber is the app's known-military convention // (flights.js MIL_TINT / militaryFlights.js MIL_ICON_COLOR) and must not // leak into another domain. Slightly warm so it also separates from the // pure white the civilian air fleet draws in. color: '#fff6e5', - blurb: 'Crewed stations and their visiting vehicles', // i18n-ignore: shown through CLASS_DISPLAY_KEYS }), nav: Object.freeze({ - label: 'NAV', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS // Cyan — the strongest accent goes to the class users actually name // ("how many GPS satellites are up?"). Takes over the slot the catch-all // VISUAL group used to occupy, so this adds no new hue to the app. color: '#4fd8ff', - blurb: 'GNSS navigation — GPS, GLONASS, Galileo', // i18n-ignore: shown through CLASS_DISPLAY_KEYS }), geo: Object.freeze({ - label: 'GEO', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS // Violet, unchanged. Already the app's "space" semantic (the detection // overlay paints the SAT tier #bda4ff), and the geostationary belt draws // as one clean equatorial ring at globe scale. color: '#c89bff', - blurb: 'Geostationary belt — comms and weather, fixed over the equator', // i18n-ignore: shown through CLASS_DISPLAY_KEYS }), visual: Object.freeze({ - label: 'VISUAL', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS // Muted blue-gray. This is the catch-all bucket, so it gives up the bright // cyan it used to hold and recedes behind the three classes that mean // something. Still well clear of the dense shell by luminance. color: '#9fb3c4', - blurb: 'Brightest naked-eye objects — CelesTrak visual group', // i18n-ignore: shown through CLASS_DISPLAY_KEYS }), comms: Object.freeze({ - label: 'COMMS', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS // Dim slate — thousands of these appear in DENSE mode. They must read as // texture behind the core catalog, never compete with it. Rec.601 luma // ~0.40 against VISUAL's ~0.69 keeps the two separable even when the // NVG/FLIR shaders collapse the scene to a single channel. color: '#54697f', - blurb: 'Broadband constellation shell — shown only in DENSE mode', // i18n-ignore: shown through CLASS_DISPLAY_KEYS }), }); @@ -79,9 +69,7 @@ export const SATELLITE_CLASS_ORDER = Object.freeze([ 'comms', ]); -// Display keys for the class table above: `label`/`blurb` stay the English -// source-of-truth tokens (tests read them), while the card and legend accessors -// translate through these keys at the presentation boundary. +// What the card and legend show for each class. const CLASS_DISPLAY_KEYS = Object.freeze({ station: Object.freeze({ label: msg('STATION'), @@ -164,7 +152,8 @@ export function satelliteClassColor(group) { * The ISS is always a STATION regardless of which group ingested it — see * satelliteClassOf, which owns that rule for every consumer. * @param {string|undefined|null} group CelesTrak group tag. - * @param {{ isIss?: boolean }} [options] Whether this is the ISS. + * @param {{ isIss?: boolean, localize?: boolean }} [options] isIss: the ISS; + * localize: false keeps the English label (data for the AI tools). * @returns {string} Display label. */ export function satelliteClassLabel( @@ -172,7 +161,6 @@ export function satelliteClassLabel( { isIss = false, localize = true } = {}, ) { const { klass, subtype } = satelliteClassOf(group, { isIss }); - // localize: false keeps the English label for data the AI tools query. const label = CLASS_DISPLAY_KEYS[klass].label; const base = localize ? t(label) : label; return subtype ? `${base} · ${subtype}` : base; diff --git a/src/data/trackedCardLocale.test.mjs b/src/data/trackedCardLocale.test.mjs index e5f72c3f9..6877ea41f 100644 --- a/src/data/trackedCardLocale.test.mjs +++ b/src/data/trackedCardLocale.test.mjs @@ -48,9 +48,8 @@ function run(locale) { return details; } -test('K-32 es: tracked ISS card', () => { +test('es: tracked ISS card is localized', () => { const details = run('es'); - console.log('es details:', JSON.stringify(details)); assert.equal(details[0], 'ESTACIÓN · ISS'); assert.doesNotMatch( details[2], @@ -59,9 +58,8 @@ test('K-32 es: tracked ISS card', () => { ); }); -test('K-32 ru: tracked ISS card', () => { +test('ru: tracked ISS card is localized', () => { const details = run('ru'); - console.log('ru details:', JSON.stringify(details)); assert.equal(details[0], 'СТАНЦИЯ · ISS'); assert.match(details[1], /км/, 'ru glossary: km → км'); assert.doesNotMatch( diff --git a/src/data/vesselLabels.js b/src/data/vesselLabels.js index c3936bcfe..311a238c6 100644 --- a/src/data/vesselLabels.js +++ b/src/data/vesselLabels.js @@ -68,9 +68,8 @@ const NUMERIC_TYPE_FAMILIES = { 9: 'OTHER', }; -// Display keys for the normalized English type tokens above. The tokens stay -// machine values — TYPE_STYLES matches on them and tests pin them — so card -// text translates only through this map at the presentation boundary. +// Card text for the type tokens above. The tokens stay English: TYPE_STYLES +// and tests match on them. const TYPE_DISPLAY_KEYS = Object.freeze({ FISHING: msg('FISHING'), TOWING: msg('TOWING'), diff --git a/src/data/vesselTypeCardLabel.test.mjs b/src/data/vesselTypeCardLabel.test.mjs index c9b108689..e197c6958 100644 --- a/src/data/vesselTypeCardLabel.test.mjs +++ b/src/data/vesselTypeCardLabel.test.mjs @@ -1,4 +1,4 @@ -// K-12: a translated vessel type is never cut mid-word on the card. +// A translated vessel type is never cut mid-word on the card. import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; diff --git a/src/director/sharing/preview.js b/src/director/sharing/preview.js index 631ecc48b..06f9fabdd 100644 --- a/src/director/sharing/preview.js +++ b/src/director/sharing/preview.js @@ -1,8 +1,7 @@ +import { msg } from '../../i18n/marker.js'; import { BUNDLE_SOURCE } from './bundle.js'; -// Pack statuses stay English in the report; the import dialog shows them -// through t() (scenes/sharing.js). -const msg = (text) => text; +// Statuses stay English; scenes/sharing.js translates them for the dialog. /** Describe an import without fetching assets, applying state or exposing source configuration. */ export function describeSceneShare( diff --git a/src/firstRunExperience.js b/src/firstRunExperience.js index 2fe8dbc2b..59ddf3e6a 100644 --- a/src/firstRunExperience.js +++ b/src/firstRunExperience.js @@ -36,8 +36,6 @@ export const FIRST_RUN_SESSION_KEY = 'gev:first-run-mission-session:v1'; */ export const ENVIRONMENTAL_LABEL_CHOICE = 'ENVIRONMENTAL'; -// Catalog keys per label choice. Resolved through t() at CALL time (init), -// never at module load — the locale is not resolved until main.js's body runs. const ENVIRONMENTAL_LABEL_KEYS = Object.freeze({ ENVIRONMENTAL: msg('ENVIRONMENTAL'), EARTH_WATCH: msg('EARTH WATCH'), @@ -361,11 +359,8 @@ export function initFirstRunExperience({ } // The tile name is owner-switchable from one constant, so paint it from the - // module rather than trusting the markup to have been edited to match. The - // persuasive description line and the tile's feed subcopy are the two - // phase-2 exceptions whose static sites are pinned verbatim by - // firstRunExperience.test.mjs — the runtime write is their only legal - // localization point (docs/TRANSLATORS.md, runtime-only static sites). + // module rather than trusting the markup to match. The description and the + // tile subcopy are data-i18n-runtime in welcome.html and translated here. const environmentalTitle = root.querySelector( '[data-first-run-environmental-title]', ); diff --git a/src/hud.js b/src/hud.js index 7f2d5df88..783e1d566 100644 --- a/src/hud.js +++ b/src/hud.js @@ -16,7 +16,7 @@ import { applicationServices } from './services/application.js'; import * as Cesium from 'cesium'; import { forward as toMGRS } from 'mgrs'; -import { CITY_POIS } from './locations.js'; +import { CITY_POIS } from './cityPois.js'; import { composeLocalityTag } from './hudLocality.js'; import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js'; import { @@ -64,9 +64,8 @@ const HUD_COLORS = { }; /** - * Mode names shown for each style. Same English as STYLE_STATUS_LABELS in - * ui/visualPresets.js, so the top-left mode and the dock's active style - * translate alike. + * Mode names per style: the same English as STYLE_STATUS_LABELS + * (ui/visualPresets.js), so both translate alike. */ const MODE_LABELS = Object.freeze({ normal: msg('NORMAL'), @@ -262,7 +261,7 @@ export class IntelHUD { this._el = document.getElementById('intel-hud'); if (!this._el) return; - // i18n-ignore: sensor readout codes (MGRS, GSD, NIIRS, ONA, BAND…) and a classification banner; translated parts use t() + // i18n-ignore: sensor codes and a classification banner; the rest uses t() this._el.innerHTML = ` @@ -433,11 +432,11 @@ export class IntelHUD { const formatted = this._formatMGRS(mgrsStr); mgrsLabel = formatted; const el = document.getElementById('hud-mgrs'); - // i18n-ignore: sensor readout codes, same in every language + // i18n-ignore: sensor codes if (el) el.textContent = `MGRS: ${formatted}`; } catch { const el = document.getElementById('hud-mgrs'); - // i18n-ignore: sensor readout codes, same in every language + // i18n-ignore: sensor codes if (el) el.textContent = 'MGRS: ---'; } @@ -446,7 +445,7 @@ export class IntelHUD { if (llEl) llEl.textContent = `${latDMS} ${lonDMS}`; const bottomEl = document.getElementById('hud-bottom-line'); if (bottomEl) { - // i18n-ignore: sensor readout codes, same in every language + // i18n-ignore: sensor codes bottomEl.textContent = `MGRS: ${mgrsLabel} LAT: ${latDMS} LON: ${lonDMS}`; } @@ -462,7 +461,7 @@ export class IntelHUD { ); const gsdEl = document.getElementById('hud-gsd'); if (gsdEl) - // i18n-ignore: sensor readout codes, same in every language + // i18n-ignore: sensor codes gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`; // Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw @@ -475,7 +474,7 @@ export class IntelHUD { const altMslM = ellipsoidalToMslDisplayM(altM, geoidN); const sunEl = this._estimateSunElevation(latDeg, lonDeg); if (altEl) - // i18n-ignore: sensor readout codes, same in every language + // i18n-ignore: sensor codes altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`; // Collection timestamp @@ -485,7 +484,7 @@ export class IntelHUD { const h = String(now.getUTCHours()).padStart(2, '0'); const m = String(now.getUTCMinutes()).padStart(2, '0'); const s = String(now.getUTCSeconds()).padStart(2, '0'); - // i18n-ignore: sensor readout codes, same in every language + // i18n-ignore: sensor codes collEl.textContent = `COLL: ${h}:${m}:${s}Z`; } @@ -494,7 +493,7 @@ export class IntelHUD { const pitchDeg = Cesium.Math.toDegrees(camera.pitch); const ona = Math.max(0, 90 + pitchDeg); const onaEl = document.getElementById('hud-ona'); - // i18n-ignore: sensor readout codes, same in every language + // i18n-ignore: sensor codes if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`; // `altM` stays the raw ellipsoidal camera height the sensor model reads diff --git a/src/hudLocality.js b/src/hudLocality.js index 5c66a18f9..482d4f3ad 100644 --- a/src/hudLocality.js +++ b/src/hudLocality.js @@ -23,10 +23,7 @@ */ export const NEAR_POI_MAX_KM = 150; -/** - * The English locality line. The HUD passes its own formatter so the page - * shows it in the active language; this default stays i18n-free. - */ +/** The English locality line; the HUD passes hudLocalityFormat.js instead. */ const ENGLISH_LOCALITY = Object.freeze({ near: ({ poi, city, distKm }) => `NEAR ${poi.toUpperCase()} (${city.toUpperCase()}) ${Math.round(distKm)}KM`, @@ -54,9 +51,8 @@ function coordinateTag(value, positive, negative) { * @param {number} latDeg Camera latitude in decimal degrees. * @param {number} lonDeg Camera longitude in decimal degrees. * @param {{near: Function, sector: Function}} [format] Builds the line from - * `{ poi, city, distKm }` or `{ latDeg, lonDeg }`. The names arrive as - * catalogued (`Tokyo`), so a formatter can translate them first and - * upper-case the result in the page's language. + * `{ poi, city, distKm }` or `{ latDeg, lonDeg }`; names arrive as + * catalogued (`Tokyo`), not upper-cased. * @returns {string} `NEAR () KM` within the bound (inclusive), * otherwise `SECTOR `. */ diff --git a/src/hudLocality.test.mjs b/src/hudLocality.test.mjs index 5bd5f0c9b..99b683c51 100644 --- a/src/hudLocality.test.mjs +++ b/src/hudLocality.test.mjs @@ -124,8 +124,6 @@ test('a missing or malformed nearest POI never crashes the summary', () => { assert.match(composeLocalityTag({ ...LINCOLN }, 10, 10), /^SECTOR /); }); -// The names reach a formatter as catalogued, so the HUD can translate the -// city before upper-casing it: the place-name table knows `Tokyo`, not `TOKYO`. test('a formatter builds the line from the same parts', () => { const format = { near: ({ poi, city, distKm }) => `near:${poi}|${city}|${distKm}`, diff --git a/src/i18n/catalog.test.mjs b/src/i18n/catalog.test.mjs index 6697c547f..487bde9aa 100644 --- a/src/i18n/catalog.test.mjs +++ b/src/i18n/catalog.test.mjs @@ -211,3 +211,70 @@ test('unmarkedScript sees human-readable status, loadingLabel and statusMessage assert.deepEqual(texts("return { status: 'auth-failed' };"), []); assert.deepEqual(texts("return { status: msg('Source unavailable') };"), []); }); + +// The Layers panel shows `stats.error || stats.lastError`, so a +// lastError is status text like error. +test('unmarkedScript sees status text assigned to lastError', () => { + const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text); + assert.deepEqual( + texts( + "state.coverage.lastError = error?.message || 'Coverage tile failed';", + ), + ['Coverage tile failed'], + ); + assert.deepEqual(texts("layerState._lastError = 'CelesTrak unreachable';"), [ + 'CelesTrak unreachable', + ]); + assert.deepEqual( + texts( + "state.coverage.lastError = error?.message || msg('Coverage tile failed');", + ), + [], + ); + assert.deepEqual(texts('this._lastError = message;'), []); +}); + +test('unmarkedScript skips default values and ternary branches, not later assignments', () => { + const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text); + // A default value in a parameter list or destructuring is a local's. + assert.deepEqual(texts("function f(error = 'Feed went away') {}"), []); + assert.deepEqual(texts("const f = (status = 'Not ready', x) => x;"), []); + assert.deepEqual( + texts("function f(\n status = 'Not ready',\n x,\n) {}"), + [], + ); + assert.deepEqual(texts("const { status = 'Ready now' } = options;"), []); + assert.deepEqual( + texts("const {\n error = 'No data yet',\n} = options;"), + [], + ); + assert.deepEqual( + texts("for (const [id, status = 'Waiting'] of rows) {}"), + [], + ); + // A name right after `?` is a ternary branch, not a `status:` property. + assert.deepEqual(texts("const s = ok ? status : 'Source unavailable';"), []); + assert.deepEqual(texts("const s = ok\n ? title\n : 'Untitled view';"), []); + // …while the property itself still is. + assert.deepEqual(texts("return { title: x ? title : 'Untitled view' };"), [ + 'Untitled view', + ]); + // A later assignment is checked even when the name is a declared local: + // a layer keeps its status in `let error` (wind, AIS watchdog) and builds + // `loadingLabel` in a `let` before returning it (FIRMS). + assert.deepEqual( + texts( + "function f() {\n let error = null;\n if (x) error = 'Feed closed';\n}", + ), + ['Feed closed'], + ); + assert.deepEqual( + texts( + "let loadingLabel = '';\nif (x) {\n loadingLabel = 'KEY REQUIRED';\n}", + ), + ['KEY REQUIRED'], + ); + assert.deepEqual(texts("let a = 1,\n error = 'Feed closed';"), [ + 'Feed closed', + ]); +}); diff --git a/src/i18n/empty-translation.test.mjs b/src/i18n/empty-translation.test.mjs index f80b07809..5d040597f 100644 --- a/src/i18n/empty-translation.test.mjs +++ b/src/i18n/empty-translation.test.mjs @@ -1,4 +1,4 @@ -// K-21: an empty or blank translation must not count as done. +// An empty or blank translation must not count as done. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { diff --git a/src/i18n/index.js b/src/i18n/index.js index c5b6bd238..ccc54a5e6 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -179,7 +179,7 @@ const UTC_DAY_MONTH = Object.freeze({ /** * The UTC day and month of a time, for "MM-DD HH:MM UTC" readouts: "10-13" in - * English (as before), "13.10" in Russian, "13/10" in Spanish, never read as a + * English, "13.10" in Russian, "13/10" in Spanish, never read as a * range. Takes an ISO string (its date part is used as written) or a time. * @param {string|number|Date} value * @returns {string} '' when the value is not a time. diff --git a/src/i18n/kartaviewMessages.test.mjs b/src/i18n/kartaviewMessages.test.mjs index 78e062a2f..4f0521c76 100644 --- a/src/i18n/kartaviewMessages.test.mjs +++ b/src/i18n/kartaviewMessages.test.mjs @@ -1,7 +1,5 @@ -// K-194: the KartaView provider's failure and state texts are English data -// (msg() in kartaview/{policy,source,viewer,index}.js) that the Street Level -// panel and the chip tooltip show through translateKnown, so every one needs -// an entry in each language's catalog. +// KartaView texts are English data (msg() in providers/kartaview/) shown +// through translateKnown, so each needs an entry in every catalog. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { setLocale, translateKnown } from './index.js'; diff --git a/src/i18n/layerStatusText.test.mjs b/src/i18n/layerStatusText.test.mjs index cb10d6f1e..0f9186442 100644 --- a/src/i18n/layerStatusText.test.mjs +++ b/src/i18n/layerStatusText.test.mjs @@ -3,10 +3,8 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { setLocale, translateKnown } from './index.js'; -// Status and error texts the layers write straight into their state -// (loadingLabel / _error / street.error) as English data; the layer panel and -// the Street Level panel show them through translateKnown, so each needs a -// catalog entry in every language (K-36). +// Layers keep these status texts as English data, and the panels show them +// through translateKnown, so each needs an entry in every catalog. const SITES = [ ['../layers/streetLevel/index.js', 'loading coverage...'], ['../layers/firms/ingestion.js', 'live feed unavailable'], @@ -33,8 +31,8 @@ const SITES = [ ['../layers/wind/source.js', 'Wind request timed out'], ['../layers/wind/source.js', 'Malformed wind manifest'], ['../layers/wind/source.js', 'Malformed wind grid'], - ['../layers/wind/source.js', 'Temperature field unavailable'], - ['../layers/wind/source.js', 'Mean sea level pressure field unavailable'], + ['../layers/wind/fields.js', 'Temperature field unavailable'], + ['../layers/wind/fields.js', 'Mean sea level pressure field unavailable'], ]; // A fallback that reaches the panel must be marked, or `npm run i18n` cannot @@ -55,7 +53,7 @@ for (const locale of ['es', 'ru']) { for (const [file, text] of SITES) { test(`${locale}: "${text}" from ${file} is translated`, () => { const source = readFileSync(new URL(file, import.meta.url), 'utf8'); - assert.ok(source.includes(`'${text}'`), `literal still in ${file}`); + assert.ok(source.includes(`'${text}'`), `literal not found in ${file}`); setLocale(locale); try { assert.notEqual(translateKnown(text), text); diff --git a/src/i18n/locale.js b/src/i18n/locale.js index f943d44ff..626303ad7 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -7,11 +7,7 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1'; /** Source language and fallback. */ export const DEFAULT_LOCALE = 'en'; -/** - * Shipped locales. The picker (src/ui/localePicker.js) lists the active one - * first and the rest by LOCALE_NAMES. Adding one: locales/.json, an - * import in index.js, and an entry here (see docs/TRANSLATORS.md). - */ +/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */ export const LOCALES = Object.freeze(['en', 'es', 'ru']); /** Each locale named in its own language, for the selector. */ diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index acbb540e3..3cfc891a1 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2883,5 +2883,7 @@ "Included in bundle": "Incluido en el paquete", "Missing bundle file — reimport its bundle": "Falta el archivo en el paquete — vuelve a importar su paquete", "Source configured; file checked when loaded": "Fuente configurada; el archivo se comprobará al cargarlo", - "Source unavailable": "Fuente no disponible" + "Source unavailable": "Fuente no disponible", + "Coverage tile failed": "No se pudo cargar la tesela de cobertura", + "Unknown": "Desconocido" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b4b7b6af6..008e2fec6 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2950,5 +2950,7 @@ "Included in bundle": "Входит в архив", "Missing bundle file — reimport its bundle": "Файла нет в архиве — импортируйте этот архив снова", "Source configured; file checked when loaded": "Источник настроен; файл будет проверен при загрузке", - "Source unavailable": "Источник недоступен" + "Source unavailable": "Источник недоступен", + "Coverage tile failed": "Не удалось загрузить тайл покрытия", + "Unknown": "Неизвестно" } diff --git a/src/i18n/marker.js b/src/i18n/marker.js new file mode 100644 index 000000000..bb59369e3 --- /dev/null +++ b/src/i18n/marker.js @@ -0,0 +1,7 @@ +// msg() for modules the server, tools or other portable code also import: +// marks English that stays in data for `npm run i18n`, without loading the +// catalogs (src/i18n/index.js). The UI shows it later through t(). +// Browser-only modules import msg from src/i18n/index.js instead. + +/** Mark English text that is stored in data and translated later by t(). */ +export const msg = (text) => text; diff --git a/src/i18n/plural-english.test.mjs b/src/i18n/plural-english.test.mjs index 1a2a67f38..8e86917b8 100644 --- a/src/i18n/plural-english.test.mjs +++ b/src/i18n/plural-english.test.mjs @@ -6,9 +6,7 @@ import { missingTemplate, } from '../../scripts/i18n.mjs'; -// A plural entry whose every form is still the English text must be reported -// the same way a string entry is ("is still English"). Today the plural -// branch of checkCatalog never compares forms with the English. +// A plural entry with forms left in English is reported like a string entry. const fixture = new Map([ ['{count} cameras', { plural: true, one: '{count} camera' }], ['Save keys', { plural: false }], @@ -64,9 +62,8 @@ for (const [locale, entry] of [ }); } -// `npm run i18n -- --missing ` prints a template; pasted without edits, -// none of its entries may pass as a translation (K-21, K-52), and a plural -// entry carries every form the language needs (K-97). +// The `--missing` template, pasted without edits, must fail entry by entry, +// and its plural entries carry every form the language needs. const messages = extractMessages(); for (const locale of ['de', 'ja', 'es', 'ru']) { test(`${locale}: an untouched --missing template fails the check entry by entry`, () => { diff --git a/src/i18n/stale-index.test.mjs b/src/i18n/stale-index.test.mjs index 3690610ca..9d462d6d2 100644 --- a/src/i18n/stale-index.test.mjs +++ b/src/i18n/stale-index.test.mjs @@ -1,5 +1,5 @@ -// K-23: scripts/i18n.mjs must skip index entries that are gone from the -// working tree (plain `mv`/`rm` without `git mv`/`git rm`). +// scripts/i18n.mjs must skip index entries gone from the working tree +// (plain `mv`/`rm` without `git mv`/`git rm`). import { test } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; diff --git a/src/keySetup.js b/src/keySetup.js index 536b5e3fd..aab3d62b0 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -23,9 +23,7 @@ import { import { t, msg, tn, registerUnsavedWork } from './i18n/index.js'; /** - * Chip label — pure, exported for tests. The exact English values are pinned - * by keySetup.test.mjs; t() resolves them from the catalog, so the default - * locale renders byte-identical text and 'es' localizes at the same site. + * Chip label — pure, exported for tests (keySetup.test.mjs pins the English). * @param {{total?: number, setCount?: number}|null} status * @returns {string} */ @@ -55,10 +53,9 @@ export function collectKeyUpdates(fields) { } /** - * Why /api/setup/keys refused a save, by the `code` the server sends with its - * English `error` (src/keySetupCore.mjs, server/standalone/key-setup.js). The - * English here is the server's own sentence, so the default locale reads the - * same as before. + * Why /api/setup/keys refused a save, by the `code` the server sends beside + * its English `error` (src/keySetupCore.mjs, server/standalone/key-setup.js). + * The English matches the server's text. */ const SAVE_REFUSALS = Object.freeze({ proxied: msg('Provider Settings does not answer proxied requests'), @@ -141,10 +138,8 @@ function sleepWithSignal(ms, signal) { } /** - * An OAuth check error. A server-sent reason is kept as-is and later wrapped - * in «OAuth check failed: …»; when there is none, the error carries our own - * already-translated sentence and is flagged so the toast shows it unwrapped - * (the wrapper would only repeat it). + * An OAuth check error. A server reason is later wrapped in «OAuth check + * failed: …»; our own translated fallback is flagged to show unwrapped. * @param {unknown} serverError * @param {string} fallback Already-translated fallback sentence. * @returns {Error} diff --git a/src/keySetup.test.mjs b/src/keySetup.test.mjs index fcea0fc15..e7e6c70ae 100644 --- a/src/keySetup.test.mjs +++ b/src/keySetup.test.mjs @@ -174,6 +174,7 @@ test('aborting pending setup removes its surface and ignores a late response', a resolveResponse({ ok: true, json: async () => ({ keys: [] }) }); assert.equal(await pending, null); }); + test('a pasted, unsaved key makes the language switch ask first until teardown', async () => { const node = () => ({ dataset: {}, diff --git a/src/keySetupCore.mjs b/src/keySetupCore.mjs index cdc554558..30c695370 100644 --- a/src/keySetupCore.mjs +++ b/src/keySetupCore.mjs @@ -1,8 +1,9 @@ /** * Key setup ("POWER UP") — the pure core. * - * One registry and pure functions; its only import is the proxy-header check - * shared with the other local gates (localRequestGate.mjs). The dev server's + * One registry and pure functions; it imports only the proxy-header check + * shared with the other local gates (localRequestGate.mjs) and the msg() + * marker (src/i18n/marker.js). The dev server's * /api/setup endpoints (vite.config.js) and the in-app panel (keySetup.js) * are both thin shells over this module, so what a key is called, what it * unlocks, and how a .env line is written each live in exactly one place. @@ -12,6 +13,7 @@ * behavior below unit-testable. */ +import { msg } from './i18n/marker.js'; import { hasProxySignals } from './localRequestGate.mjs'; /** Longest accepted key/token value. Real provider keys are all far shorter. */ @@ -32,10 +34,6 @@ export const KEY_SETUP_APPEND_HEADER = * bundle by design (restrict them at the provider, per SECURITY.md). * `hidden` keeps advanced configuration out of the panel and missing-key count. */ -// Marks `unlocks` copy for i18n extraction; Provider Settings translates it -// with t(). Kept local so the server can import this module without i18n. -const msg = (text) => text; - export const KEY_SETUP_KEYS = Object.freeze([ Object.freeze({ id: 'google-maps', @@ -397,13 +395,11 @@ export function knownKeySetupEnvVars() { return names; } -/** Tooltip guidance for a control gated by one registry entry. */ /** - * Env-var requirements for a registry entry, as machine values for - * presentation layers that localize the guidance sentence themselves - * (this module also runs in node-side key-setup builds, which stay English). + * Env-var names a registry entry needs, joined with ' + ', for callers that + * build the guidance sentence in the UI language (src/ui/keyGuidance.js). * @param {string} id Registry entry id. - * @returns {string|null} Joined env-var names, or null when nothing is required. + * @returns {string|null} null for an unknown or hidden entry. */ export function keySetupRequirementEnvVars(id) { const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id); @@ -411,6 +407,7 @@ export function keySetupRequirementEnvVars(id) { return entry.envVars.join(' + '); } +/** English tooltip guidance for a control gated by one registry entry. */ export function keySetupRequirement(id) { const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id); if (!entry || entry.hidden) return ''; diff --git a/src/keySetupErrorLocale.test.mjs b/src/keySetupErrorLocale.test.mjs index 570bf2ac4..c24ec1c03 100644 --- a/src/keySetupErrorLocale.test.mjs +++ b/src/keySetupErrorLocale.test.mjs @@ -1,7 +1,5 @@ -// K-25: a refused key save reads in the panel's language. The server sends a -// `code` (and `params`) beside its English `error`; the panel builds the -// sentence through t(), so English stays the server's own text and es/ru get -// the catalog's. +// A refused key save reads in the panel's language: the server sends `code` +// and `params` beside its English `error`, the panel builds the sentence. import test from 'node:test'; import assert from 'node:assert/strict'; import { @@ -94,7 +92,7 @@ test('in English the panel shows exactly the server sentence', () => { assert.equal(saveRefusalText(refusal, 400), refusal.error); }); -test('an unknown code or a bare error falls back as before', () => { +test('an unknown code or a bare error falls back to the server text or the status', () => { setLocale('en'); assert.equal( saveRefusalText({ error: 'Something new', code: 'new' }, 400), diff --git a/src/keySetupOAuthFailure.test.mjs b/src/keySetupOAuthFailure.test.mjs index 46d1ea586..63d01b44d 100644 --- a/src/keySetupOAuthFailure.test.mjs +++ b/src/keySetupOAuthFailure.test.mjs @@ -1,7 +1,5 @@ -// C9: a failed ChatGPT OAuth check whose only reason is our own fallback -// sentence shows that sentence alone — «OAuth check failed: Could not check -// ChatGPT sign-in. Try again.» repeated itself. A server-sent reason keeps the -// wrapper. +// A failed ChatGPT OAuth check whose only reason is our own fallback sentence +// shows it alone; a server-sent reason keeps the «OAuth check failed» wrapper. import test from 'node:test'; import assert from 'node:assert/strict'; import { oauthCheckFailureText, waitForChatGptOAuth } from './keySetup.js'; diff --git a/src/keySetupOAuthI18n.test.mjs b/src/keySetupOAuthI18n.test.mjs index 73ac30a3e..74db387b2 100644 --- a/src/keySetupOAuthI18n.test.mjs +++ b/src/keySetupOAuthI18n.test.mjs @@ -1,4 +1,4 @@ -// K-26: every status line of the ChatGPT OAuth flow in Provider Settings goes +// Every status line of the ChatGPT OAuth flow in Provider Settings goes // through t(), and the i18n check treats say() as a place UI text lands. import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -28,7 +28,7 @@ const OAUTH_STATUS_TEXTS = [ 'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.', ]; -test('K-26: OAuth status-line texts are extractable messages with es/ru entries', () => { +test('OAuth status-line texts are extractable messages with es/ru entries', () => { const messages = extractMessages(); const missing = OAUTH_STATUS_TEXTS.filter((text) => !messages.has(text)); assert.deepEqual(missing, [], 'texts not wrapped in t() in src/keySetup.js'); @@ -39,7 +39,7 @@ test('K-26: OAuth status-line texts are extractable messages with es/ru entries' } }); -test('K-26: the i18n check sees an unmarked literal passed to say()', () => { +test('the i18n check sees an unmarked literal passed to say()', () => { const sample = "const say = (x) => { el.textContent = x; };\nsay('Checking local ChatGPT OAuth sign-in…');\n"; const found = unmarkedScript('sample.js', sample); @@ -49,7 +49,7 @@ test('K-26: the i18n check sees an unmarked literal passed to say()', () => { ); }); -test('K-26: src/keySetup.js has no unmarked say() literals once say is a sink', () => { +test('src/keySetup.js has no unmarked say() literals', () => { const source = readFileSync( new URL('./keySetup.js', import.meta.url), 'utf8', diff --git a/src/layers/airQuality/airQualityLocale.test.mjs b/src/layers/airQuality/airQualityLocale.test.mjs index 8249ce148..66f4942f4 100644 --- a/src/layers/airQuality/airQualityLocale.test.mjs +++ b/src/layers/airQuality/airQualityLocale.test.mjs @@ -103,6 +103,5 @@ for (const locale of ['en', 'es', 'ru']) { card, inLocale(locale, () => translateFeedError('ECCC HTTP 503')), ); - assert.equal(card, locale === 'en' ? 'ECCC HTTP 503' : 'ECCC: HTTP 503'); }); } diff --git a/src/layers/airQuality/index.js b/src/layers/airQuality/index.js index 87f79e4f9..febee5423 100644 --- a/src/layers/airQuality/index.js +++ b/src/layers/airQuality/index.js @@ -217,9 +217,8 @@ export function createAirQualityLayer({ source, overlayHost } = {}) { 0, ); // Readings and errors stay English data (analyst records, stats); the - // card shows them in the UI language, errors as the Layers panel row - // does. A provider's own error text has no catalog entry and shows as - // it came. + // card translates them as the Layers panel row does. A provider's own + // error text has no catalog entry and shows as it came. return { readout: true, summary: { diff --git a/src/layers/airQuality/model.js b/src/layers/airQuality/model.js index d5fded2fb..49b8a16a3 100644 --- a/src/layers/airQuality/model.js +++ b/src/layers/airQuality/model.js @@ -15,8 +15,8 @@ * Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a * later provider registers against. */ -// A portable export (package.json): no i18n here. The labels stay English data -// and the Layers panel shows them translated. +// A portable export (package.json): no i18n here. The labels stay English data; +// the Weather card (index.js) shows them translated. const msg = (text) => text; /** Band identifiers shared across providers, in ascending severity. */ diff --git a/src/layers/awareness/queries.js b/src/layers/awareness/queries.js index ad2568175..593c85ebf 100644 --- a/src/layers/awareness/queries.js +++ b/src/layers/awareness/queries.js @@ -1,8 +1,7 @@ import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js'; // The reason stays English for the voice analyst; the Contacts panel shows it -// in the UI language from `reasonTemplate`. Kept local so this module stays -// free of the i18n import. +// in the UI language from `reasonTemplate`. const msg = (text) => text; const fillTemplate = (template, values = {}) => template.replace(/\{(\w+)\}/g, (_, name) => String(values[name])); diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index 1ed8d854c..1baf54ec4 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -11,10 +11,9 @@ import { AWARENESS_PAGE_SIZE, } from './policy.js'; -// installations/controls.js getStats composes coverageLabel inside the layer -// model (machine-composed English, pinned by militaryInstallations.test.mjs). -// This is the presentation boundary: map the closed vocabulary to the -// catalog, passing anything unknown through verbatim (docs/TRANSLATORS.md). +// coverageLabel is machine-composed English from installations/controls.js +// (pinned by src/data/militaryInstallations.test.mjs); translate the known +// forms here, pass anything else through. const WITHIN_KM_RE = /^WITHIN (\d+) KM$/; function localizeCoverageLabel(label) { diff --git a/src/layers/cctv/headingConfidence.js b/src/layers/cctv/headingConfidence.js index f6db110d7..26a877558 100644 --- a/src/layers/cctv/headingConfidence.js +++ b/src/layers/cctv/headingConfidence.js @@ -45,8 +45,6 @@ export function isHeadingEstimated(camera) { */ export function headingHudToken(camera) { const hdg = Math.round(Number(camera?.headingDeg) || 0); - // The token copy lives in the layers catalog (en is byte-identical to the - // literals these replace); presentation stays here, translation at read. return isHeadingEstimated(camera) ? t('HDG {value}° (ESTIMATED)', { value: hdg }) : t('HDG {value}°', { value: hdg }); diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index d5c1a09f2..2d93166a7 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -66,7 +66,7 @@ const RECORDS = [ type: 'bullet', url: 'https://www.cctv-database.com/camera/hikvision-ds-2cd2087g3-li2uy/', fov_deg: '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)', - // i18n-ignore: datasheet resolution label (camera data) + // i18n-ignore: datasheet label resolution: { mp: 8, w: 3840, h: 2160, label: '8MP (4K)' }, night_vision: { type: 'color', range_m: 40 }, environment: ['outdoor'], @@ -79,7 +79,7 @@ const RECORDS = [ type: 'dome', url: 'https://www.cctv-database.com/camera/axis-f4105-lre/', fov_deg: '110 horizontal, 60 vertical', - // i18n-ignore: datasheet resolution label (camera data) + // i18n-ignore: datasheet label resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' }, night_vision: { type: 'ir', range_m: 10 }, environment: ['outdoor'], @@ -97,7 +97,7 @@ const RECORDS = [ mp: 8, w: 1920, h: 1080, - // i18n-ignore: datasheet resolution label (camera data) + // i18n-ignore: datasheet label label: '4x 2MP (8MP total; 4x 1080p)', }, lens_count: 4, @@ -120,7 +120,7 @@ const RECORDS = [ mp: 8, w: 1920, h: 1080, - // i18n-ignore: datasheet resolution label (camera data) + // i18n-ignore: datasheet label label: '4x 2MP (8MP total; 4x 1080p)', }, lens_count: 4, @@ -134,7 +134,7 @@ const RECORDS = [ url: 'https://www.cctv-database.com/camera/axis-q6054-e/', aliases: ['AXIS Q6054-E', 'Q6054-E 50 Hz', 'Q6054-E 60 Hz'], fov_deg: '62.9-2.2 horizontal', - // i18n-ignore: datasheet resolution label (camera data) + // i18n-ignore: datasheet label resolution: { mp: 1, w: 1280, h: 720, label: '720p HD' }, ptz: true, autotracking: true, @@ -149,7 +149,7 @@ const RECORDS = [ url: 'https://www.cctv-database.com/camera/axis-q6155-e/', aliases: ['AXIS Q6155-E', 'Q6155-E 50 Hz', 'Q6155-E 60 Hz'], fov_deg: '66.7-2.36 horizontal', - // i18n-ignore: datasheet resolution label (camera data) + // i18n-ignore: datasheet label resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' }, ptz: true, autotracking: true, @@ -171,7 +171,7 @@ const RECORDS = [ 'VG4-500', ], fov_deg: '57.8-1.7 horizontal', - // i18n-ignore: datasheet resolution label (camera data) + // i18n-ignore: datasheet label resolution: { mp: 0.4, label: '540 TVL (NTSC/PAL)' }, ptz: true, autotracking: true, @@ -186,7 +186,7 @@ const RECORDS = [ url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-dynamic-7000-hd/', aliases: ['AUTODOME IP dynamic 7000 HD', 'AUTODOME 7000 dynamic HD'], fov_deg: '65-2.3 horizontal', - // i18n-ignore: datasheet resolution label (camera data) + // i18n-ignore: datasheet label resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, ptz: true, autotracking: true, @@ -206,7 +206,7 @@ const RECORDS = [ 'VG5-7130', ], fov_deg: '59-2.1 horizontal', - // i18n-ignore: datasheet resolution label (camera data) + // i18n-ignore: datasheet label resolution: { mp: 1, w: 1280, h: 720, label: '720p HD (1.3MP sensor)' }, ptz: true, autotracking: true, @@ -226,7 +226,7 @@ const RECORDS = [ 'NDP-5502-30', ], fov_deg: '60.9-2.4 horizontal', - // i18n-ignore: datasheet resolution label (camera data) + // i18n-ignore: datasheet label resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, ptz: true, varifocal: true, @@ -245,7 +245,7 @@ const RECORDS = [ 'NDP-7512-Z30K', ], fov_deg: '64.7-2.3 horizontal', - // i18n-ignore: datasheet resolution label (camera data) + // i18n-ignore: datasheet label resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, ptz: true, autotracking: true, @@ -279,7 +279,7 @@ const RECORDS = [ 'iDome 3925', 'Cohu iDome 3920 Series', ], - // i18n-ignore: datasheet resolution label (camera data) + // i18n-ignore: datasheet label resolution: { mp: 0.4, label: 'NTSC / 470 TVL' }, ptz: true, varifocal: true, @@ -292,7 +292,7 @@ const RECORDS = [ type: 'ptz', url: 'https://www.cctv-database.com/camera/cohu-3950-iview/', aliases: ['3950', '3950 Series', 'Cohu 3950 Series iVIEW'], - // i18n-ignore: datasheet resolution label (camera data) + // i18n-ignore: datasheet label resolution: { mp: 0.4, label: 'NTSC / 470 TVL' }, ptz: true, varifocal: true, @@ -305,7 +305,7 @@ const RECORDS = [ type: 'ptz', url: 'https://www.cctv-database.com/camera/cohu-3960/', aliases: ['3960 Series', '3964', '3965', 'Cohu 3960 iView'], - // i18n-ignore: datasheet resolution label (camera data) + // i18n-ignore: datasheet label resolution: { mp: 0.4, label: 'NTSC/PAL / 470 TVL' }, ptz: true, varifocal: true, diff --git a/src/layers/cyclones/coverage.test.mjs b/src/layers/cyclones/coverage.test.mjs index 9043ea746..b2e722123 100644 --- a/src/layers/cyclones/coverage.test.mjs +++ b/src/layers/cyclones/coverage.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { createCyclonesLayer } from './index.js'; import { setLocale, t } from '../../i18n/index.js'; -// The exact string the server sends (server/providers/cyclones.js:8). +// The exact string the server sends (COVERAGE in server/providers/cyclones.js). const SERVER_COVERAGE = 'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.'; const time = '2026-09-16T03:00:00.000Z'; @@ -59,7 +59,7 @@ function harness(feed) { const translated = () => t(SERVER_COVERAGE); -test('K-35a: es, server snapshot without storms -> coverage line in Spanish', async () => { +test('es: server snapshot without storms -> coverage line in Spanish', async () => { setLocale('es'); try { assert.notEqual(translated(), SERVER_COVERAGE, 'es.json has a translation'); @@ -77,7 +77,7 @@ test('K-35a: es, server snapshot without storms -> coverage line in Spanish', as } }); -test('K-35b: es, server snapshot with a storm -> coverage line in Spanish', async () => { +test('es: server snapshot with a storm -> coverage line in Spanish', async () => { setLocale('es'); try { const layer = harness({ getSnapshot: async () => snapshot([storm]) }); @@ -94,7 +94,7 @@ test('K-35b: es, server snapshot with a storm -> coverage line in Spanish', asyn } }); -test('K-35c: es, unavailable without reason -> fallback status not the English literal', async () => { +test('es: unavailable without reason -> fallback status not the English literal', async () => { setLocale('es'); try { const layer = harness({ @@ -115,7 +115,7 @@ test('K-35c: es, unavailable without reason -> fallback status not the English l } }); -test('K-35: en coverage and fallback status read as before', async () => { +test('en: coverage and fallback status read as before', async () => { const layer = harness({ getSnapshot: async () => snapshot([]) }); layer.enable(); await layer.update(); diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index da23094c9..2b8f8875e 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -99,9 +99,8 @@ export const STEP_ANCHOR_FAST_ATTEMPTS = 5; export const FLIGHT_PROGRESS_MS = 250; // --------------------------------------------------------------------------- -// Display text: distances, durations and maneuvers in the page's language. -// The proxy's English `instruction` stays the machine value; the row and the -// cards phrase each step from its OSRM type/modifier/road instead. +// Display text in the page's language. The proxy's English `instruction` stays +// the machine value; steps are phrased from their OSRM type/modifier/road. // --------------------------------------------------------------------------- /** @@ -639,8 +638,7 @@ export function directionsStats(state) { armed, pointerBlocked, } = state; - // English in layer state; the layer row translates it. - const source = 'OSM routing'; // i18n-ignore: source name, translated by layerPanel + const source = 'OSM routing'; // English in layer state; the layer row translates it if (pointerBlocked) { return { count: route?.steps.length || 0, diff --git a/src/layers/feedDegradationI18n.test.mjs b/src/layers/feedDegradationI18n.test.mjs index b7bda49f4..eb97435b4 100644 --- a/src/layers/feedDegradationI18n.test.mjs +++ b/src/layers/feedDegradationI18n.test.mjs @@ -1,13 +1,12 @@ -// K-34: AIS degradation chip text and the traffic "SIMULATED — " text -// must be in the UI language (they stayed English in es before the fix); -// English keeps its wording. +// The AIS degradation chip and the traffic "SIMULATED — " text read in +// the UI language; English keeps its wording. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { setLocale, translateKnown } from '../i18n/index.js'; import { deriveAisFeedError } from '../data/aisLiveVessels.js'; import { createModel } from './traffic/model.js'; -test('K-34: AIS degraded-feed reasons are localized in es', () => { +test('AIS degraded-feed reasons are localized in es', () => { setLocale('es'); try { const cases = [ @@ -34,7 +33,7 @@ test('K-34: AIS degraded-feed reasons are localized in es', () => { } }); -test('K-34: traffic "SIMULATED — " is localized in es', () => { +test('traffic "SIMULATED — " is localized in es', () => { setLocale('es'); try { const { trafficFeedPresentation } = createModel({ @@ -55,7 +54,7 @@ test('K-34: traffic "SIMULATED — " is localized in es', () => { } }); -test('K-34: English degraded-feed wording is unchanged', () => { +test('English degraded-feed wording is unchanged', () => { setLocale('en'); assert.equal( deriveAisFeedError({ status: 'down', reconnectAttempt: 3 }, 0), diff --git a/src/layers/flights/ingestion.js b/src/layers/flights/ingestion.js index 8b40b48d7..0e537a66f 100644 --- a/src/layers/flights/ingestion.js +++ b/src/layers/flights/ingestion.js @@ -45,6 +45,7 @@ export function createIngestion({ const sourceStale = snapshot.stale || snapshot.freshness === 'unknown'; feed._backoff = sourceStale; feed._retryAt = 0; + // i18n-ignore: English data; the panel shows it through FEED_ERROR_PATTERNS (ui/statusText.js) feed._lastError = sourceStale ? sourceAgeMs == null ? msg('Source snapshot time unavailable') diff --git a/src/layers/gnss/source.js b/src/layers/gnss/source.js index 1bbd64822..a63fdfd7f 100644 --- a/src/layers/gnss/source.js +++ b/src/layers/gnss/source.js @@ -41,8 +41,7 @@ export function createAdsbGnssSource({ ); throw Object.assign( new Error( - // The status code form is matched by FEED_ERROR_PATTERNS - // (ui/statusText.js) and gnss/index.js. + // HTTP errors are matched by FEED_ERROR_PATTERNS in ui/statusText.js. limited ? msg('GNSS feed rate limited (HTTP 429)') : `GNSS feed unavailable (HTTP ${response.status})`, diff --git a/src/layers/installations/classLabel.packClasses.test.mjs b/src/layers/installations/classLabel.packClasses.test.mjs index 6319e0f52..b0220b6a4 100644 --- a/src/layers/installations/classLabel.packClasses.test.mjs +++ b/src/layers/installations/classLabel.packClasses.test.mjs @@ -1,4 +1,4 @@ -// K-50: every class the bundled OSM names pack can put on a record has a +// Every class the bundled OSM names pack can put on a record has a // catalog entry, so the selected card shows it in the UI language. import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -22,7 +22,7 @@ const englishLabel = Object.fromEntries( ); for (const locale of ['es', 'ru']) { - test(`every names-pack class is in the ${locale} catalog (K-50)`, () => { + test(`every names-pack class is in the ${locale} catalog`, () => { const catalog = getCatalog(locale); const missing = pack.classes.filter( (c) => catalog[`installation class|${englishLabel[c]}`] === undefined, diff --git a/src/layers/installations/controls.js b/src/layers/installations/controls.js index 9f8c0075f..2fa31e59e 100644 --- a/src/layers/installations/controls.js +++ b/src/layers/installations/controls.js @@ -7,10 +7,8 @@ import { } from './policy.js'; import { formatNumber, t, tn } from '../../i18n/index.js'; -// installationFeedback.js composes its guidance inside the portable -// node/browser graph (the HUD summary in loadingFeedback.js reads the same -// module), so it hands back English marked with msg() plus the values; -// getStats is the browser presentation boundary that translates them. +// installationFeedback.js is shared with the HUD (src/loadingFeedback.js) and +// hands back msg()-marked English plus values; getStats translates them here. function localizeInstallationFeedback(feedback) { if (feedback.text !== undefined) { const values = {}; diff --git a/src/layers/installations/unnamedInstallationTitle.test.mjs b/src/layers/installations/unnamedInstallationTitle.test.mjs index ba47337e8..83cef6d24 100644 --- a/src/layers/installations/unnamedInstallationTitle.test.mjs +++ b/src/layers/installations/unnamedInstallationTitle.test.mjs @@ -6,7 +6,7 @@ import { setLocale } from '../../i18n/index.js'; import { installationTitle } from './classLabel.js'; import { formatAwarenessLabel } from '../../data/militaryAwarenessEngine.js'; -// K-33: unnamed installations get an English label in data ("Firing range", +// Unnamed installations get an English label in data ("Firing range", // "Military land", "Military airfield"; OpenFreeMap polygons: "Military area"). // The data stays English; the map card title reads in the UI language. const { records } = normalizeMilitaryInstallations({ @@ -24,7 +24,7 @@ const { records } = normalizeMilitaryInstallations({ }); const names = [...records.map((r) => r.name), UNNAMED_MILITARY_AREA]; -test('K-33: unnamed installations keep English names in data', () => { +test('unnamed installations keep English names in data', () => { assert.deepEqual(names, [ 'Firing range', 'Military land', @@ -34,7 +34,7 @@ test('K-33: unnamed installations keep English names in data', () => { ]); }); -test('K-33: unnamed installation card title reads in the UI language', () => { +test('unnamed installation card title reads in the UI language', () => { try { for (const locale of ['es', 'ru']) { setLocale(locale); @@ -55,13 +55,13 @@ test('K-33: unnamed installation card title reads in the UI language', () => { } }); -test('K-33: English card titles are unchanged', () => { +test('English card titles are unchanged', () => { setLocale('en'); for (const name of names) assert.equal(installationTitle(name), name); assert.equal(installationTitle(''), ''); }); -test('K-33: the Contacts line names an unnamed installation in the UI language', () => { +test('the Contacts line names an unnamed installation in the UI language', () => { try { setLocale('en'); for (const name of names) diff --git a/src/layers/launches/missionStatus.i18n.test.mjs b/src/layers/launches/missionStatus.i18n.test.mjs new file mode 100644 index 000000000..45b03ec4a --- /dev/null +++ b/src/layers/launches/missionStatus.i18n.test.mjs @@ -0,0 +1,85 @@ +// A launch without a Launch Library status gets our 'Unknown'. The record keeps +// it English; the mission panel translates it and shows provider statuses as sent. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setLocale } from '../../i18n/index.js'; +import { + launchStatusAllowsOrbit, + normalizeRocketLaunches, +} from '../../data/rocketLaunches.js'; +import { UNKNOWN_LAUNCH_STATUS } from './model.js'; +import { createPanel } from './panel.js'; + +const NOW = new Date('2026-07-27T00:00:00Z'); + +function shownStatus(status) { + const outputs = new Map(); + const state = { + _missionPanel: { + querySelector(selector) { + if (!outputs.has(selector)) + outputs.set(selector, { + textContent: '', + closest: () => null, + removeAttribute() {}, + }); + return outputs.get(selector); + }, + closest: () => null, + }, + _selectedLaunchId: 'mission-1', + _launches: [ + { + id: 'mission-1', + name: 'Example', + status, + payloads: [], + recoveryStages: [], + }, + ], + _replayTracks: new Map(), + }; + createPanel({ + state, + parts: { + overlays: { shortMissionLabel: (name) => name }, + policyHelpers: { missionPathPresentation: () => ({}) }, + replay: { syncReplayButton() {} }, + }, + }).renderMissionPanel(); + return outputs.get('[data-mission-status]').textContent; +} + +test('a launch without a status keeps the English fallback in its record', () => { + const [launch] = normalizeRocketLaunches( + { + results: [ + { + id: 'no-status', + name: 'Test Flight', + net: '2026-07-20T10:00:00Z', + pad: { + name: 'Pad A', + location: { name: 'Test Range', coordinates: '12.5,45.5' }, + }, + }, + ], + }, + NOW, + ); + assert.equal(UNKNOWN_LAUNCH_STATUS, 'Unknown'); + assert.equal(launch.status, 'Unknown'); + assert.equal(launchStatusAllowsOrbit(launch.status), true); +}); + +test('the mission panel translates only the fallback status', (t) => { + t.after(() => setLocale('en')); + setLocale('en'); + assert.equal(shownStatus(UNKNOWN_LAUNCH_STATUS), 'Unknown'); + setLocale('ru'); + assert.equal(shownStatus(UNKNOWN_LAUNCH_STATUS), 'Неизвестно'); + assert.equal(shownStatus('Go for Launch'), 'Go for Launch'); + setLocale('es'); + assert.equal(shownStatus(UNKNOWN_LAUNCH_STATUS), 'Desconocido'); + assert.equal(shownStatus('Launch Successful'), 'Launch Successful'); +}); diff --git a/src/layers/launches/model.js b/src/layers/launches/model.js index 3c445b04f..0a9dd67ea 100644 --- a/src/layers/launches/model.js +++ b/src/layers/launches/model.js @@ -1,6 +1,13 @@ import * as Cesium from 'cesium'; +import { msg } from '../../i18n/index.js'; import { WINDOW_DAYS } from './policy.js'; +/** + * Fallback when Launch Library sends no status. English in the record + * (launchStatusAllowsOrbit, analyst records); the mission panel translates it. + */ +export const UNKNOWN_LAUNCH_STATUS = msg('Unknown'); + export function createModel({ state: layerState, services, parts, source }) { function finiteCoordinate(value) { const number = Number(value); @@ -192,8 +199,7 @@ export function createModel({ state: layerState, services, parts, source }) { launch.id || launch.slug || launch.name || `launch-${date}`, ), name: launch.name || 'Unnamed launch', - // i18n-ignore: Launch Library status names are provider data, shown as sent - status: launch.status?.name || 'Unknown', + status: launch.status?.name || UNKNOWN_LAUNCH_STATUS, launchTime: Number.isFinite(date) ? new Date(date).toISOString() : null, diff --git a/src/layers/launches/panel.js b/src/layers/launches/panel.js index 291a0ecba..f2e237b72 100644 --- a/src/layers/launches/panel.js +++ b/src/layers/launches/panel.js @@ -4,6 +4,7 @@ import { MISSION_CLOSE_VIEW_RANGE_M, MISSION_GLOBE_VIEW_RANGE_M, } from './policy.js'; +import { UNKNOWN_LAUNCH_STATUS } from './model.js'; export function createPanel({ state: layerState, services, parts, source }) { /** @@ -191,7 +192,14 @@ export function createPanel({ state: layerState, services, parts, source }) { layerState._missionPanel.querySelector('[data-mission-title]').textContent = parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase(); setMissionPanelField('[data-mission-provider]', launch.provider); - setMissionPanelField('[data-mission-status]', launch.status); + // Launch Library status names are provider data, shown as sent; only the + // fallback for a launch without one is ours to translate. + setMissionPanelField( + '[data-mission-status]', + launch.status === UNKNOWN_LAUNCH_STATUS + ? t(UNKNOWN_LAUNCH_STATUS) + : launch.status, + ); setMissionPanelField( '[data-mission-site]', launch.launchSite && launch.launchSite !== 'Unknown launch site' diff --git a/src/layers/localAdsb/card.i18n.test.mjs b/src/layers/localAdsb/card.i18n.test.mjs index b1ff42f4a..6d8826dba 100644 --- a/src/layers/localAdsb/card.i18n.test.mjs +++ b/src/layers/localAdsb/card.i18n.test.mjs @@ -1,4 +1,4 @@ -// K-37: the Local ADS-B click card follows the UI language; English keeps its +// The Local ADS-B click card follows the UI language; English keeps its // wording (index.test.mjs pins the full English card). import test from 'node:test'; import assert from 'node:assert/strict'; @@ -86,7 +86,7 @@ test('the class line names the aircraft class in the UI language', async () => { } }); -// C8: the receiver bands follow the UI language too ("1090 МГц"). +// The receiver bands follow the UI language too ("1090 МГц"). test('ru: the receiver line names the bands in Russian', async () => { const { localAdsbReceiverLine } = await import('./card.js'); setLocale('ru'); diff --git a/src/layers/localAdsb/card.js b/src/layers/localAdsb/card.js index 8a9baeb80..1b0122925 100644 --- a/src/layers/localAdsb/card.js +++ b/src/layers/localAdsb/card.js @@ -58,10 +58,6 @@ export function localAdsbReceiverLine(record) { .join(' · '); } -// policy.js keeps these labels in English (HEARD_BY_RECEIVER, BAND_LABELS, -// SOURCE_LABELS); the card writes them out here so that it can show them -// translated. - /** A receiver band (policy.js BAND_LABELS) in the UI language: "1090 МГц". */ function bandLabel(band) { switch (String(band)) { diff --git a/src/layers/localAdsb/singleFeedProblemLocale.test.mjs b/src/layers/localAdsb/singleFeedProblemLocale.test.mjs deleted file mode 100644 index 552150320..000000000 --- a/src/layers/localAdsb/singleFeedProblemLocale.test.mjs +++ /dev/null @@ -1,41 +0,0 @@ -// A Local ADS-B feed problem can be the whole error (nothing heard, WebUSB -// not producing); the row shows it in the UI language like any other piece. -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { setLocale } from '../../i18n/index.js'; -import { LayerPanel } from '../../ui/layerPanel.js'; -import { localAdsbStatus } from './status.js'; -const receiver = { - webUsbSupported: true, - connected: false, - mode: 'adsb', - status: 'idle', -}; -function rowFor(status) { - return LayerPanel.prototype._buildMetaText({ - id: 'local-adsb', - enabled: true, - stats: { count: 0, lastUpdate: Date.now(), ...status }, - }); -} -for (const locale of ['es', 'ru']) { - for (const status of ['unreachable', 'invalid']) { - test(`${locale}: a lone ${status} decoder feed with nothing heard is translated`, () => { - setLocale(locale); - try { - const st = localAdsbStatus({ - receiver, - feedState: { - configured: true, - feeds: [{ id: 'a', label: '1090', band: '1090', status }], - }, - heard: 0, - }); - const row = rowFor(st); - assert.doesNotMatch(row, /\b(unreachable|invalid)\b/, row); - } finally { - setLocale('en'); - } - }); - } -} diff --git a/src/layers/localAdsb/statusLocale.test.mjs b/src/layers/localAdsb/statusLocale.test.mjs index 273da2b66..8fe8245db 100644 --- a/src/layers/localAdsb/statusLocale.test.mjs +++ b/src/layers/localAdsb/statusLocale.test.mjs @@ -1,13 +1,12 @@ -// The Local ADS-B row's working texts ("3 heard · 5.8 msg/s", -// "2 feeds live · 14 heard", "listening", "USB error", feed problems) read in -// the UI language. +// The Local ADS-B row's working texts ("3 heard · 5.8 msg/s", "listening", +// "USB error", feed problems, even as the whole error) read in the UI language. import assert from 'node:assert/strict'; import test from 'node:test'; import { setLocale } from '../../i18n/index.js'; import { LayerPanel } from '../../ui/layerPanel.js'; import { localAdsbStatus } from './status.js'; -const receiver = { +const streaming = { webUsbSupported: true, connected: true, mode: 'adsb', @@ -15,24 +14,55 @@ const receiver = { messagesPerSecond: 5.8, }; -function rowFor(status) { +const idle = { + webUsbSupported: true, + connected: false, + mode: 'adsb', + status: 'idle', +}; + +function rowFor(status, count) { return LayerPanel.prototype._buildMetaText({ id: 'local-adsb', enabled: true, - stats: { count: 3, lastUpdate: Date.now(), ...status }, + stats: { count, lastUpdate: Date.now(), ...status }, }); } +function inLocale(locale, run) { + setLocale(locale); + try { + run(); + } finally { + setLocale('en'); + } +} + for (const locale of ['es', 'ru']) { test(`${locale}: the streaming Local ADS-B row is translated`, () => { - setLocale(locale); - try { + inLocale(locale, () => { const row = rowFor( - localAdsbStatus({ receiver, feedState: null, heard: 3 }), + localAdsbStatus({ receiver: streaming, feedState: null, heard: 3 }), + 3, ); assert.doesNotMatch(row, /\bheard\b/, row); - } finally { - setLocale('en'); - } + }); }); + + for (const status of ['unreachable', 'invalid']) { + test(`${locale}: a lone ${status} decoder feed with nothing heard is translated`, () => { + inLocale(locale, () => { + const st = localAdsbStatus({ + receiver: idle, + feedState: { + configured: true, + feeds: [{ id: 'a', label: '1090', band: '1090', status }], + }, + heard: 0, + }); + const row = rowFor(st, 0); + assert.doesNotMatch(row, /\b(unreachable|invalid)\b/, row); + }); + }); + } } diff --git a/src/layers/perimeters/cards.i18n.test.mjs b/src/layers/perimeters/cards.i18n.test.mjs index e6ca86f5a..95fa090a1 100644 --- a/src/layers/perimeters/cards.i18n.test.mjs +++ b/src/layers/perimeters/cards.i18n.test.mjs @@ -1,4 +1,4 @@ -// K-37 / K-29: the selected-fire card of Fire Perimeters and its accessible +// The selected-fire card of Fire Perimeters and its accessible // label follow the UI language, like the legend of the same layer // (perimeters/index.js). cards.test.mjs pins the English card. import test from 'node:test'; @@ -82,7 +82,7 @@ for (const locale of ['es', 'ru']) { }); } -// C5 / C11 / E7: known WFIGS values, the cost and the unnamed title follow the +// Known WFIGS values, the cost and the unnamed title follow the // UI language; the row keeps the feed's English for the AI tools. const expected = { es: { diff --git a/src/layers/perimeters/cards.js b/src/layers/perimeters/cards.js index 7ef2017a1..d5505ec8d 100644 --- a/src/layers/perimeters/cards.js +++ b/src/layers/perimeters/cards.js @@ -146,10 +146,8 @@ function formatAge(deltaMs) { * @returns {Object} World-overlay entry without `position`. */ export function buildIncidentCard(row, nowMs, { link = null } = {}) { - // Feed values stay as WFIGS sends them in the row (and in what the AI tools - // read); the card translates the known cause, behavior and complexity values - // and the wording around them. State, category, county and complex names - // are shown as sent. + // The row keeps WFIGS values as sent (AI tools read them); the card translates + // the known cause, behavior and complexity values and the wording around them. const facts = []; if (Number.isFinite(row.acres)) { const acres = Math.round(row.acres); diff --git a/src/layers/radio/policy.js b/src/layers/radio/policy.js index 89e0c453a..85777ed74 100644 --- a/src/layers/radio/policy.js +++ b/src/layers/radio/policy.js @@ -62,8 +62,8 @@ export const DEFAULT_RADIO_FILTER = 'all'; export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000; -// Genre labels are marked for i18n extraction; categories.js translates them. -// Kept local so this module stays free of the i18n import. +// Genre labels stay English data (the radio source, shared with the AI tools, +// imports this module); categories.js translates them. const msg = (text) => text; export const MUSIC_GENRES = Object.freeze([ diff --git a/src/layers/recentImagery/index.js b/src/layers/recentImagery/index.js index 6dd6e34a1..68a0cc2f6 100644 --- a/src/layers/recentImagery/index.js +++ b/src/layers/recentImagery/index.js @@ -47,8 +47,8 @@ export const SPLIT_PUBLISH_MS = 400; /** The three modes, in share-link order (`m` = index). */ export const MODES = Object.freeze(['image', 'basemap', 'ab']); -const COMPARISON_IN_USE = msg('Comparison in use by another scene'); // Kept English in the snapshot (AI tools read it); the card translates them. +const COMPARISON_IN_USE = msg('Comparison in use by another scene'); const COMPARISON_UNAVAILABLE = msg('Esri map unavailable · no swipe'); const COMPARISON_NEEDS_GLOBE = msg('Swipe needs a globe map'); const ESRI_KEPT = msg('Imagery stays on Esri · CLEAR to use Google 3D'); diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 9021485f6..7284cdd8c 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -71,8 +71,8 @@ export function createIngestion({ // At least one group loaded — proceed with a fresh rebuild, but keep the // partial outage visible at the layer control instead of presenting the - // reduced catalog as a fully healthy refresh. English data; the panel - // shows it through FEED_ERROR_PATTERNS (ui/statusText.js). + // reduced catalog as a fully healthy refresh. + // i18n-ignore: English data; the panel shows it through FEED_ERROR_PATTERNS (ui/statusText.js) layerState._lastError = failed.length ? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable` : null; diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index a52853f5b..fbd992f88 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -704,7 +704,7 @@ export function createLineCoverage({ state.coverage.pending.get(key) !== controller ) return; - state.coverage.lastError = error?.message || 'Coverage tile failed'; + state.coverage.lastError = error?.message || msg('Coverage tile failed'); if (error?.keyRequired) state.keyRequired = true; if (error?.keyRejected) { // Every other tile would be refused too: stop asking. diff --git a/src/layers/traffic/roadStatus.i18n.test.mjs b/src/layers/traffic/roadStatus.i18n.test.mjs index dd72e21f8..655ae6557 100644 --- a/src/layers/traffic/roadStatus.i18n.test.mjs +++ b/src/layers/traffic/roadStatus.i18n.test.mjs @@ -1,4 +1,4 @@ -// K-37: the traffic road-status notes follow the UI language; English keeps +// The traffic road-status notes follow the UI language; English keeps // its wording. import test from 'node:test'; import assert from 'node:assert/strict'; diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index 5cba24b4e..6fd46211f 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -476,8 +476,7 @@ export function createIngestion({ state, services, parts, source }) { `[Data:Transit] ${feed.id} poll failed:`, error?.message || error, ); - // English data; the panel shows it through FEED_ERROR_PATTERNS - // i18n-ignore: translated on display (ui/statusText.js) + // i18n-ignore: English data, translated on display (ui/statusText.js) status.error = `${feed.name} feed unavailable`; status.retryInSec = Number.isFinite(error?.retryInSec) ? error.retryInSec diff --git a/src/layers/transit/policy.js b/src/layers/transit/policy.js index 1d9d0f00c..2fb5101ce 100644 --- a/src/layers/transit/policy.js +++ b/src/layers/transit/policy.js @@ -321,10 +321,8 @@ export function aggregateTransitFeedHealth(statuses, nowMs) { }; } -// AI tools (src/tools/queries/mobility.js) import this module, so it stays -// free of i18n: the card copy below calls the translate function `t` that the -// caller hands in. The browser selection passes the real one from src/i18n; -// without it the copy is English. +// AI tools (src/tools/queries/mobility.js) import this module, so no i18n here: +// the card copy takes `t` from the caller (selection.js); without it, English. const msg = (text) => text; /** English {placeholder} interpolation, the default `t` of the card copy. */ diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index bd3f3328e..b86613160 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -278,10 +278,8 @@ export function createQueries({ state, parts }) { : {}), degraded: count > 0 && health.degraded, loading: health.loading && count === 0, - // English data; the panel shows it through STATUS_PIECE_PATTERNS - // (ui/statusText.js). loadingLabel: - // i18n-ignore: translated on display + // i18n-ignore: English data, translated on display (ui/statusText.js) health.loading && count === 0 ? `Loading ${active.map((feed) => feed.name).join(', ')}` : undefined, diff --git a/src/layers/transit/selectionCopy.i18n.test.mjs b/src/layers/transit/selectionCopy.i18n.test.mjs index 7b0059a06..81fdfa0bb 100644 --- a/src/layers/transit/selectionCopy.i18n.test.mjs +++ b/src/layers/transit/selectionCopy.i18n.test.mjs @@ -1,4 +1,4 @@ -// K-37: the Transit selection card follows the UI language. The copy builder +// The Transit selection card follows the UI language. The copy builder // stays free of i18n (AI tools import policy.js); the browser hands it t(). import test from 'node:test'; import assert from 'node:assert/strict'; diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index d8aaf0870..c848acc2d 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -1,10 +1,8 @@ import * as Cesium from 'cesium'; import { t, msg } from '../../i18n/index.js'; -// The vessels ingestion owner is a portable module graph (it also runs in the -// server-side source builds), so it writes stable English feed labels into -// state. Localize those known labels here, at the presentation read; anything -// else (a bespoke manager label) passes through verbatim. +// ingestion.js is a portable export (package.json) and writes English labels +// into state; translate the known ones here, pass anything else through. const FEED_STATE_LABEL_KEYS = Object.freeze({ 'refreshing...': msg('refreshing...'), 'loading...': msg('loading...'), diff --git a/src/layers/wind/fieldUnavailableLocale.test.mjs b/src/layers/wind/fieldUnavailableLocale.test.mjs index f6284c0b8..8be2fa347 100644 --- a/src/layers/wind/fieldUnavailableLocale.test.mjs +++ b/src/layers/wind/fieldUnavailableLocale.test.mjs @@ -3,9 +3,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { setLocale, t } from '../../i18n/index.js'; -import { WIND_FIELDS } from './fields.js'; +import { SCALAR_UNAVAILABLE, WIND_FIELDS } from './fields.js'; import { fieldUnavailableText } from './rendering.js'; -import { SCALAR_UNAVAILABLE } from './source.js'; for (const locale of ['es', 'ru']) { test(`${locale}: every field's unavailable status has a translation`, () => { diff --git a/src/layers/wind/fields.js b/src/layers/wind/fields.js index a03d25a70..a740d9478 100644 --- a/src/layers/wind/fields.js +++ b/src/layers/wind/fields.js @@ -1,5 +1,16 @@ +import { msg } from '../../i18n/marker.js'; import { normalizeLongitude, sampleWind } from './model.js'; +// Imported by src/tools: the catalog-free marker, not src/i18n/index.js. The +// texts stay English data and the wind layer and layer panel show them +// translated. + +/** Status of a scalar overlay the provider could not supply, by overlay. */ +export const SCALAR_UNAVAILABLE = Object.freeze({ + temperature: msg('Temperature field unavailable'), + pressure: msg('Mean sea level pressure field unavailable'), +}); + /** Fixed physical scales shared by imagery and the field legend. */ export const WIND_FIELDS = Object.freeze({ speed: Object.freeze({ diff --git a/src/layers/wind/rendering.js b/src/layers/wind/rendering.js index d7723f3dd..462e9aca0 100644 --- a/src/layers/wind/rendering.js +++ b/src/layers/wind/rendering.js @@ -7,17 +7,16 @@ import { orderWeatherImagery } from '../weather/imageryOrder.js'; import { NO_IMAGERY_HOST } from '../weather/imageryHost.js'; import { msg } from '../../i18n/index.js'; import { createWindGpuRendering } from './gpuRendering.js'; -import { SCALAR_UNAVAILABLE } from './source.js'; import { advectParticle, sampleWind } from './model.js'; import { createFieldRaster, + SCALAR_UNAVAILABLE, trailEraseAlpha, windTrailColor, } from './fields.js'; -// A scalar overlay that cannot be drawn, in English for diagnostics; the -// panel status shows it translated (index.js). The scalar texts are the ones -// the source reports. setOptions only accepts these overlays. +// English for diagnostics; the row status shows it translated (index.js). The +// scalar texts are the source's own; setOptions accepts only these overlays. const FIELD_UNAVAILABLE = Object.freeze({ speed: msg('Wind speed field unavailable'), ...SCALAR_UNAVAILABLE, diff --git a/src/layers/wind/source.js b/src/layers/wind/source.js index dbd7b59bc..9347d34c5 100644 --- a/src/layers/wind/source.js +++ b/src/layers/wind/source.js @@ -1,15 +1,10 @@ import { readWindBody } from '../../sources/windBody.js'; +import { SCALAR_UNAVAILABLE } from './fields.js'; // Imported by src/tools: no i18n here. The texts stay English data and the // wind layer and layer panel show them translated. const msg = (text) => text; -/** Status of a scalar overlay the provider could not supply, by overlay. */ -export const SCALAR_UNAVAILABLE = Object.freeze({ - temperature: msg('Temperature field unavailable'), - pressure: msg('Mean sea level pressure field unavailable'), -}); - /** Create a bounded, cancellable source for the same-origin wind provider. */ export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args), diff --git a/src/layers/wind/staleInfo.test.mjs b/src/layers/wind/staleInfo.test.mjs index dbe997feb..fab2b95a0 100644 --- a/src/layers/wind/staleInfo.test.mjs +++ b/src/layers/wind/staleInfo.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { setLocale } from '../../i18n/index.js'; import { createWindLayer } from './index.js'; -test('stale wind forecast is marked in the UI language (K-40)', async () => { +test('stale wind forecast is marked in the UI language', async () => { const rendering = Object.fromEntries( ['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map((n) => [ n, diff --git a/src/layers/wind/windProblemLocale.test.mjs b/src/layers/wind/windProblemLocale.test.mjs index a5fba5395..0e820b119 100644 --- a/src/layers/wind/windProblemLocale.test.mjs +++ b/src/layers/wind/windProblemLocale.test.mjs @@ -1,4 +1,4 @@ -// K-38: wind source failures are English data (src/layers/wind/source.js) and +// Wind source failures are English data (src/layers/wind/source.js) and // are shown in the UI language; the HTTP line keeps its status number. import test from 'node:test'; import assert from 'node:assert/strict'; diff --git a/src/layers/wind/windUpstreamReason.test.mjs b/src/layers/wind/windUpstreamReason.test.mjs index 72eb19a17..53b70c52c 100644 --- a/src/layers/wind/windUpstreamReason.test.mjs +++ b/src/layers/wind/windUpstreamReason.test.mjs @@ -39,7 +39,7 @@ async function serverManifestOnUpstreamFailure() { return JSON.parse(body); } -test('en: the upstream failure reason reads as before (K-17)', async () => { +test('en: the upstream failure reason reads as before', async () => { const manifest = await serverManifestOnUpstreamFailure(); const layer = harness({ getSnapshot: async () => manifest }); await layer.update(); diff --git a/src/loadingFeedback.js b/src/loadingFeedback.js index 4dff1f853..b4ea03ba1 100644 --- a/src/loadingFeedback.js +++ b/src/loadingFeedback.js @@ -244,7 +244,7 @@ export function reduceTrafficSyncFeedback( // Neutral default: the layer always supplies its own LIVE/SIMULATED // label, and a fallback string must never claim a live feed on a // keyless build. - // i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary + // i18n-ignore: translated in src/ui/shellFeedback.js label: label || 'syncing road network', progressText: hasProgress ? `${progressPct}%` : '...', }; @@ -393,7 +393,7 @@ export function presentLoadingFeedback(state, summary, nowMs) { ); return { state: 'retry', - // i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary + // i18n-ignore: translated in src/ui/shellFeedback.js label: ( camera.cameraRetry.error || 'Overpass temporarily unavailable' ).toUpperCase(), @@ -440,7 +440,7 @@ export function presentLoadingFeedback(state, summary, nowMs) { if (active.length === 1 && active[0].cameraRetry && !summary.disabling) { return { state: 'loading', - // i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary + // i18n-ignore: translated in src/ui/shellFeedback.js label: active[0].cameraRetry.retrying ? 'RETRYING ALPR CAMERAS' : 'FETCHING ALPR CAMERAS', @@ -455,7 +455,7 @@ export function presentLoadingFeedback(state, summary, nowMs) { ) { return { state: 'loading', - // i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary + // i18n-ignore: translated in src/ui/shellFeedback.js label: active[0].installationRetry.retrying ? 'RETRYING MAPPED SITES' : 'FETCHING MAPPED SITES', diff --git a/src/locations.js b/src/locations.js index 28f2ac923..1c5e94f2f 100644 --- a/src/locations.js +++ b/src/locations.js @@ -12,8 +12,6 @@ import { cancelCameraArrival } from './data/cameraArrival.js'; import { guardCameraAboveGround } from './cameraGroundGuard.js'; import { CITY_POIS } from './cityPois.js'; -export { CITY_POIS }; - /** Newest landmark arrival; an older post-arrival ground guard yields to it. */ let arrivalGeneration = 0; diff --git a/src/mapStackChips.js b/src/mapStackChips.js index 71dd4d9ec..1f77ec7b2 100644 --- a/src/mapStackChips.js +++ b/src/mapStackChips.js @@ -38,8 +38,7 @@ export function mapStackChipModel(stack, activeId) { const available = stack?.available !== false; const label = String(stack?.label ?? stack?.id ?? ''); const requiresIon = stack?.requiresIon === true; - // Stack labels are provider/dataset names (keep-English); the availability - // sentence around them is copy and localizes here at the model boundary. + // Stack labels are provider names and stay English; the sentence translates. const fallbackReason = requiresIon ? t('Needs {envVars} — add it in Provider Settings', { envVars: keySetupRequirementEnvVars('cesium-ion'), diff --git a/src/maps/imageryHost.js b/src/maps/imageryHost.js index 3953ef69a..e3086819f 100644 --- a/src/maps/imageryHost.js +++ b/src/maps/imageryHost.js @@ -5,11 +5,9 @@ * neither exists there is nowhere to drape and the caller says so. */ -/** Guidance shown when the active map stack cannot host draped imagery. */ -// Marks text for i18n extraction without importing i18n here; the UI -// translates it where it is shown. -const msg = (text) => text; +import { msg } from '../i18n/marker.js'; +/** Guidance shown when the active map stack cannot host draped imagery. */ export const NO_IMAGERY_HOST = msg( 'Hidden by this map source · choose a globe map', ); diff --git a/src/navigationPolicy.test.mjs b/src/navigationPolicy.test.mjs index 1cdacc011..9826846ca 100644 --- a/src/navigationPolicy.test.mjs +++ b/src/navigationPolicy.test.mjs @@ -133,7 +133,6 @@ test('cockpit refuses without stamping or releasing anything', () => { }); test('weather layers name their destination in the Cockpit refusal', () => { - // «Exit cockpit to fly to a weather» was the template with a bare noun. const expected = { weather: 'Exit cockpit to view weather coverage', cyclone: 'Exit cockpit to fly to a cyclone', diff --git a/src/overlays/worldOverlayAllocation.worker.mjs b/src/overlays/worldOverlayAllocation.worker.mjs index c9237eb95..e2a4c6240 100644 --- a/src/overlays/worldOverlayAllocation.worker.mjs +++ b/src/overlays/worldOverlayAllocation.worker.mjs @@ -357,7 +357,7 @@ function buildWorkload(count) { id: `mover-${index}`, position: () => position, variant: 'label', - // i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI + // i18n-ignore: benchmark fixture title: `TRK ${1000 + index}`, details: [`ALT ${10_000 + index * 25}`], priority: index % 7, @@ -468,7 +468,7 @@ function buildPhase3FirmsWorkload(count) { position, gapPx: 10, accent: '224, 82, 82', - // i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI + // i18n-ignore: benchmark fixture title: `▲ ${50 + fireIndex} MW`, details: ['high · 2h · N20'], selected: false, @@ -559,7 +559,7 @@ function buildPhase3VesselsWorkload(count) { position, gapPx: 10, accent: '224, 82, 82', - // i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI + // i18n-ignore: benchmark fixture title: `▲ ${50 + fireIndex} MW`, details: ['high · 2h · N20'], selected: false, @@ -579,7 +579,7 @@ function buildPhase3VesselsWorkload(count) { position, gapPx: selected ? 12 : 10, accent: '57, 213, 255', - // i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI + // i18n-ignore: benchmark fixture title: selected ? 'SELECTED VESSEL' : `VESSEL ${vesselIndex}`, details: selected ? ['CARGO · 14.5KT · 231°', 'MMSI 353136000 · POS: LIVE'] @@ -654,7 +654,7 @@ function buildPhase3TrackedWorkload(count) { gevTrackedId: 'flights:allocation-probe', gevDisplayPosition: selected.position, gevLabelModel: { - // i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI + // i18n-ignore: benchmark fixture title: 'ALLOC01', details: ['FL350 · 451 kts', 'TEST AIR · A320'], accent: '#39d0ff', @@ -705,7 +705,7 @@ function buildPhase4CctvWorkload(count) { const entry = createCctvThumbnailOverlayEntry({ id: active ? 'cctv:active' : `cctv:${index}`, position, - // i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI + // i18n-ignore: benchmark fixture title: active ? 'ACTIVE CAMERA' : `CAMERA ${index}`, frameSlot: slot, rank: index, @@ -879,7 +879,7 @@ function buildPhase5CivilWorkload(count) { const entry = registration?.entries[0]; if (!entry) throw new Error('phase5-civil requires the protected tracked entry'); - // i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI + // i18n-ignore: benchmark fixture entry.title = 'ALLOC01 · FL350 · 451 kts'; entry.details = ['TEST AIR · A320', 'AUS → LAX']; return workload; @@ -894,7 +894,7 @@ function buildPhase5MilitaryWorkload(count) { if (!entry) throw new Error('phase5-military requires the protected tracked entry'); entry.id = 'military:allocation-probe'; - // i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI + // i18n-ignore: benchmark fixture entry.title = 'RCH451'; entry.details = ['C17 · 05-8152', 'USAF · 28000 ft · 400 kt']; entry.accent = '#ffd166'; @@ -1081,7 +1081,7 @@ function appendSubmarineCableWorkload(workload, count) { id: `${kind}-reference-${index}-alloc`, kind, label: - // i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI + // i18n-ignore: benchmark fixture kind === 'cable' ? `Cable System ${index}` : `Landing Point ${index}`, tip: position, distanceM: 200_000 + index * 40_000, @@ -1160,7 +1160,7 @@ function buildAirQualityWorkload(count) { const entry = createAirQualityOverlayEntry({ id: `air-${index}`, position, - // i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI + // i18n-ignore: benchmark fixture title: `Station ${index} ${1 + (index % 10)}`, accent: '#4cc9f0', value: 1 + (index % 10), diff --git a/src/scenes/director.js b/src/scenes/director.js index 6379b9192..1a6fed403 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -65,12 +65,6 @@ import { } from '../director/document.js'; import { sceneSaveFailureReason } from './sceneSaveError.js'; -// Built-in recipe DISPLAY names. Recipe ids, the URL 'scene' param, stored -// project titles, and voice-matching titles all stay English (keep-English -// boundary + persisted data); only the presentation of an UNRENAMED built-in -// localizes, so an operator's renamed scene is never masked by the catalog. -// Recipe and built-in shot titles are marked with msg() in recipes.js. - /** @constant {string} localStorage key for the serialized project */ const STORAGE_KEY = 'godsEyeView.sceneProject.v2'; const STORAGE_CHECKPOINT_KEY = 'godsEyeView.sceneProject.checkpoint.v1'; @@ -413,9 +407,8 @@ export class SceneDirector { this._controls = new SceneControls({ subscribe: (listener) => this.subscribe(listener), read: () => ({ - // Scene titles surface in the panel through SceneControls; built-in - // recipe and shot titles display their localized name until renamed - // by the user. Shots keep the stored title for editing and matching. + // The panel shows display titles; shots keep the stored title for + // editing and matching. scenes: this._project.scenes.map((scene) => ({ ...scene, title: displaySceneTitle(scene), diff --git a/src/scenes/nepalEvidencePack.js b/src/scenes/nepalEvidencePack.js index 32571e5da..19ed4174a 100644 --- a/src/scenes/nepalEvidencePack.js +++ b/src/scenes/nepalEvidencePack.js @@ -7,7 +7,7 @@ import { SOURCE_PATH_BEAT_IDS } from '../data/bhoteKoshiShotPaths.js'; const ADDITIONAL_SHOTS = [ { id: 'second-landslide', - // i18n-ignore: shot title (mostly a place name), matched by title on upgrade + // i18n-ignore: stored shot title, matched verbatim title: 'Second landslide', lat: 28.399234, lon: 85.535806, @@ -18,7 +18,7 @@ const ADDITIONAL_SHOTS = [ }, { id: 'dhunche', - // i18n-ignore: shot title (mostly a place name), matched by title on upgrade + // i18n-ignore: stored shot title, matched verbatim title: 'Dhunche', lat: 28.161431, lon: 85.324278, @@ -29,7 +29,7 @@ const ADDITIONAL_SHOTS = [ }, { id: 'mailung-upper-trishuli', - // i18n-ignore: shot title (mostly a place name), matched by title on upgrade + // i18n-ignore: stored shot title, matched verbatim title: 'Mailung / Upper Trishuli bridge', lat: 28.140642, lon: 85.265996, @@ -40,7 +40,7 @@ const ADDITIONAL_SHOTS = [ }, { id: 'mailung-bazzar', - // i18n-ignore: shot title (mostly a place name), matched by title on upgrade + // i18n-ignore: stored shot title, matched verbatim title: 'Mailung Bazzar, Dandaguan', lat: 28.126526, lon: 85.248027, @@ -51,7 +51,7 @@ const ADDITIONAL_SHOTS = [ }, { id: 'dandagaun', - // i18n-ignore: shot title (mostly a place name), matched by title on upgrade + // i18n-ignore: stored shot title, matched verbatim title: 'Dandagaun, Rasuwa', lat: 28.124424, lon: 85.245154, @@ -62,7 +62,7 @@ const ADDITIONAL_SHOTS = [ }, { id: 'dandagaun-viewpoint', - // i18n-ignore: shot title (mostly a place name), matched by title on upgrade + // i18n-ignore: stored shot title, matched verbatim title: 'Dandagaun viewpoint restaurant', lat: 28.086663, lon: 85.229914, @@ -73,7 +73,7 @@ const ADDITIONAL_SHOTS = [ }, { id: 'betrawati-bazaar', - // i18n-ignore: shot title (mostly a place name), matched by title on upgrade + // i18n-ignore: stored shot title, matched verbatim title: 'Betrawati Bazaar', lat: 28.029827, lon: 85.227694, @@ -84,7 +84,7 @@ const ADDITIONAL_SHOTS = [ }, { id: 'bhainse', - // i18n-ignore: shot title (mostly a place name), matched by title on upgrade + // i18n-ignore: stored shot title, matched verbatim title: 'Bhainse', lat: 28.023087, lon: 85.229449, @@ -95,7 +95,7 @@ const ADDITIONAL_SHOTS = [ }, { id: 'devighat-taadi-khola-bridge', - // i18n-ignore: shot title (mostly a place name), matched by title on upgrade + // i18n-ignore: stored shot title, matched verbatim title: 'Devighat / Taadi Khola Bridge', lat: 27.921132, lon: 85.177113, @@ -106,7 +106,7 @@ const ADDITIONAL_SHOTS = [ }, { id: 'charaudi', - // i18n-ignore: shot title (mostly a place name), matched by title on upgrade + // i18n-ignore: stored shot title, matched verbatim title: 'Charaudi', lat: 27.865095, lon: 84.838424, @@ -117,7 +117,7 @@ const ADDITIONAL_SHOTS = [ }, { id: 'final-view', - // i18n-ignore: shot title (mostly a place name), matched by title on upgrade + // i18n-ignore: stored shot title, matched verbatim title: 'Final view', lat: 28.0834, lon: 85.206, @@ -351,7 +351,7 @@ export function expandNepalEvidencePack(base) { shotPatches: [ ...(base.shotPatches || []), { - // i18n-ignore: shot title (mostly a place name), matched by title on upgrade + // i18n-ignore: stored shot title, matched verbatim title: 'Final view', layers: finalView.layers, }, diff --git a/src/scenes/recipes.js b/src/scenes/recipes.js index c996a329c..191bb1ac1 100644 --- a/src/scenes/recipes.js +++ b/src/scenes/recipes.js @@ -27,7 +27,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ fromShotTitles: ['Shot 1', 'Shot 2', 'Shot 3'], cameraPath: [ { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Global Incident Context', lat: 27.5962, lon: 78.5718, @@ -45,7 +45,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Nepal-Focused Globe Rotation', lat: 28.7248, lon: 81.3226, @@ -63,7 +63,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Bhote Koshi Regional Approach', lat: 27.0354, lon: 84.6287, @@ -81,7 +81,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Bhote Koshi Nearby Cities', lat: 27.891, lon: 85.428, @@ -99,7 +99,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Bhote Koshi Incident Corridor', lat: 28.0529, lon: 85.2189, @@ -117,7 +117,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Bhote Koshi Flood Path', lat: 28.1691, lon: 85.352, @@ -135,7 +135,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Bhote Koshi Corridor Overview', lat: 27.8986, lon: 84.8723, @@ -153,7 +153,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Bhote Koshi Upper Valley', lat: 28.4187, lon: 85.4341, @@ -230,7 +230,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, shotPatches: [ { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Global Incident Context', visual: { mapStack: 'photoreal' }, layers: { @@ -246,7 +246,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Nepal-Focused Globe Rotation', visual: { mapStack: 'photoreal' }, layers: { @@ -262,7 +262,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Bhote Koshi Regional Approach', visual: { mapStack: 'photoreal' }, layers: { @@ -278,7 +278,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Bhote Koshi Nearby Cities', visual: { mapStack: 'photoreal' }, layers: { @@ -294,7 +294,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Bhote Koshi Incident Corridor', camera: { lat: 28.0529, @@ -319,7 +319,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Bhote Koshi Flood Path', visual: { mapStack: 'photoreal' }, layers: { @@ -335,7 +335,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Bhote Koshi Corridor Overview', visual: { mapStack: 'photoreal' }, layers: { @@ -351,7 +351,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Bhote Koshi Upper Valley', holdSec: 8, visual: { mapStack: 'photoreal' }, @@ -374,7 +374,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Upper Valley Collapse', camera: { lat: 28.417, @@ -398,29 +398,29 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Nepal-China Border Gate', visual: { mapStack: 'photoreal' }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Timure Evidence Cluster', visual: { mapStack: 'photoreal' }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Syabru Besi Passage', visual: { mapStack: 'photoreal' }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Bidur / Trishuli Consequence', visual: { mapStack: 'photoreal' }, }, ], cameraPath: [ { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Upper Valley Collapse', lat: 28.417, lon: 85.3937, @@ -444,7 +444,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Debris-Dammed Lake', lat: 28.392914, lon: 85.570921, @@ -468,7 +468,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Nepal-China Border Gate', mapStack: 'photoreal', lat: 28.316789, @@ -499,7 +499,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Timure Evidence Cluster', mapStack: 'photoreal', lat: 28.285968, @@ -528,7 +528,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Syabru Besi Passage', mapStack: 'photoreal', lat: 28.210306, @@ -557,7 +557,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ }, }, { - // i18n-ignore: shot title, matched by title when the recipe upgrades a saved project + // i18n-ignore: stored shot title, matched verbatim title: 'Bidur / Trishuli Consequence', mapStack: 'photoreal', lat: 27.981737, @@ -1057,10 +1057,9 @@ export function displaySceneTitle(scene) { * Display title for a shot: built-in titles and the numbered "Shot N" * fallback localize; any other title shows as stored. * - * With the shot itself, a built-in title localizes only when the shot came - * from a recipe (`sourcePackId`), so an operator-typed title that happens to - * match a built-in one is never masked by the catalog. Without it, provenance - * is unknown and the title alone decides, as before. + * With `shot`, a built-in title localizes only for a recipe shot + * (`sourcePackId`), so a typed title that matches a built-in one stays as + * typed. Without `shot`, the title alone decides. * @param {string} title * @param {{sourcePackId?: string}} [shot] * @param {{id?: string}} [scene] The shot's scene, for projects saved before diff --git a/src/scenes/sceneSaveError.js b/src/scenes/sceneSaveError.js index d69ebb06d..462467b2a 100644 --- a/src/scenes/sceneSaveError.js +++ b/src/scenes/sceneSaveError.js @@ -1,14 +1,8 @@ /** - * The reason shown in «Scene not saved — {reason}» when the project fails its - * own validation on save. - * - * A SceneDocumentError names a JSON path and an English check - * («$.scenes[6].title: expected nonempty text, at most 4096 characters») — - * right for the console and for import diagnostics, meaningless in a toast. - * The failures an author can cause from the panel get a plain sentence with - * 1-based scene and shot numbers. Anything else keeps the technical text in - * English and gets a general sentence in a translation; director.js logs the - * error either way. + * The reason for «Scene not saved — {reason}» when the project fails + * validation. The validator's text («$.scenes[6].title: expected …») is for + * the console: known failures get a sentence with 1-based numbers, others a + * general one (English keeps the raw text). director.js logs the full error. */ import { formatNumber, getLocale, t } from '../i18n/index.js'; diff --git a/src/scenes/sceneSaveError.test.mjs b/src/scenes/sceneSaveError.test.mjs index eaa7c92fa..22bf74155 100644 --- a/src/scenes/sceneSaveError.test.mjs +++ b/src/scenes/sceneSaveError.test.mjs @@ -1,6 +1,5 @@ -// Proposal 8: «Scene not saved — {reason}» showed the validator's JSON path -// and English check. Known failures read as a sentence; the rest get a -// general reason (the technical text is logged by director.js). +// «Scene not saved — {reason}» never shows the validator's JSON path: known +// failures read as a sentence, the rest get a general reason. import { test } from 'node:test'; import assert from 'node:assert/strict'; diff --git a/src/scenes/sceneTitleDisplay.test.mjs b/src/scenes/sceneTitleDisplay.test.mjs index 3c6043aac..604e01c49 100644 --- a/src/scenes/sceneTitleDisplay.test.mjs +++ b/src/scenes/sceneTitleDisplay.test.mjs @@ -58,7 +58,7 @@ test('a built-in shot title localizes only for a recipe-sourced shot', () => { userScene, ); assert.notEqual(fromRecipe, title); - // Without provenance the title alone decides, as before. + // Without provenance the title alone decides. assert.equal(displayShotTitle(title), fromRecipe); // A project saved before shots recorded their source pack: the scene's // recipe still vouches for a title its keyframes declare (only when such a diff --git a/src/sharelink.lang.test.mjs b/src/sharelink.lang.test.mjs index 7ffa1b388..fee4241f1 100644 --- a/src/sharelink.lang.test.mjs +++ b/src/sharelink.lang.test.mjs @@ -1,4 +1,4 @@ -// K-22: a copied share link must not carry the sender's ?lang override: on the +// A copied share link must not carry the sender's ?lang override: on the // recipient's side ?lang would beat their own stored choice. import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -28,7 +28,7 @@ function installClipboard(writeText) { }); } -test('K-22: the copied link does not carry the sender ?lang override', async () => { +test('the copied link does not carry the sender ?lang override', async () => { let copied = null; installClipboard(async (url) => { copied = url; @@ -38,8 +38,6 @@ test('K-22: the copied link does not carry the sender ?lang override', async () const link = new URL(copied); assert.equal(link.searchParams.get('lang'), null, `copied link: ${copied}`); - // Why it matters: on the recipient's side ?lang beats the stored choice and - // the browser language. const store = new Map([['gev:locale:v1', 'es']]); const recipient = resolveLocale({ location: { search: link.search }, @@ -49,7 +47,7 @@ test('K-22: the copied link does not carry the sender ?lang override', async () assert.equal(recipient, 'es'); }); -test('K-22: with storage blocked the dock switch keeps the choice in ?lang', () => { +test('with storage blocked the language picker keeps the choice in ?lang', () => { let href = 'https://gev.example/#v=2'; const location = { get href() { @@ -71,7 +69,6 @@ test('K-22: with storage blocked the dock switch keeps the choice in ?lang', () }, }; persistLocaleAndReload('ru', { location, history, storage: blocked }); - // Documented behaviour (persistLocaleAndReload): the choice survives only in - // the URL, which is why copyLink has to drop it. + // Storage blocked: the choice survives only in ?lang. assert.equal(new URL(href).searchParams.get('lang'), 'ru'); }); diff --git a/src/sources/httpBody.js b/src/sources/httpBody.js index e20af628b..eb6c49754 100644 --- a/src/sources/httpBody.js +++ b/src/sources/httpBody.js @@ -1,6 +1,6 @@ -// Imported by the server: no i18n here. The text stays English data; layers -// show it translated (translateKnown). -const msg = (text) => text; +// Imported by the server: the catalog-free marker, not src/i18n/index.js. The +// text stays English data; layers show it translated (translateKnown). +import { msg } from '../i18n/marker.js'; const tooLarge = () => Object.assign(new Error(msg('Upstream response too large')), { diff --git a/src/sources/httpBody.test.mjs b/src/sources/httpBody.test.mjs index aa8e77d1a..82c0bed43 100644 --- a/src/sources/httpBody.test.mjs +++ b/src/sources/httpBody.test.mjs @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readResponseBytesCapped } from './httpBody.js'; +import { setLocale, translateKnown } from '../i18n/index.js'; +import { readResponseBytesCapped, readResponseTextCapped } from './httpBody.js'; /** * A Response that streams `chunk` forever until the reader cancels it. The @@ -131,3 +132,19 @@ test('falls back to arrayBuffer() for a body with no stream reader', async () => assert.equal(buf.byteLength, 16); assert.equal(buf[0], 9); }); + +for (const locale of ['es', 'ru']) { + test(`${locale}: an oversized upstream response is translated`, async () => { + const response = new Response('x'.repeat(10), { + headers: { 'content-length': '10' }, + }); + const error = await readResponseTextCapped(response, 4).catch((e) => e); + assert.equal(error.message, 'Upstream response too large'); + setLocale(locale); + try { + assert.notEqual(translateKnown(error.message), error.message); + } finally { + setLocale('en'); + } + }); +} diff --git a/src/standalone/application.js b/src/standalone/application.js index 25728439d..c73daf374 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -1,6 +1,6 @@ import { createStandaloneCatalog } from './catalog.js'; import { createStandalonePlaceSearch } from './placeSearch.js'; -import { CITY_POIS } from '../locations.js'; +import { CITY_POIS } from '../cityPois.js'; import { createApplication } from '../app/application.js'; import { createStandaloneScene } from './scene.js'; import { createStandaloneControls } from './controls.js'; diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 56fbc7f43..fda0d0837 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -715,10 +715,8 @@ export class StyleManager extends ShellFacade { } /** - * Mounts the interface language picker in the bottom-right corner - * (src/ui/localePicker.js). Choosing another language stores it and - * reloads, so nothing re-renders live; it asks first when the reload would - * lose work (see _chooseLocale). + * Mounts the language picker (src/ui/localePicker.js). A choice reloads the + * page; _confirmLocaleReload asks first when that would lose work. * @returns {void} */ _initLocaleSelector() { @@ -776,11 +774,9 @@ export class StyleManager extends ShellFacade { } /** - * Gives every Display-panel row label one shared column, sized in - * characters to the longest label of the active language, so the slider - * tracks of separate rows start at the same x. The labels are monospace and - * often hidden (collapsed rows have no layout), so this counts characters - * instead of measuring; the CSS turns the count into a width. + * Sizes one Display-panel label column to the longest label in the active + * language, so slider tracks line up. Counts characters (labels are + * monospace and often hidden); CSS turns the count into a width. * @returns {void} */ _sizeDisplayLabelColumn() { @@ -800,11 +796,8 @@ export class StyleManager extends ShellFacade { } /** - * Rewrites a header's trailing text node in place, preserving the leading - * dock-label-icon span that a textContent write would destroy (the phase-2 - * "key-only" headers: .panel-title / .pp-header-label share their element - * with the icon, so no data-i18n attribute could target them — see - * docs/TRANSLATORS.md, runtime-only static sites). + * Rewrites a header's last text node, keeping the leading icon span that a + * textContent write would destroy. * @param {Element|null} element * @param {string} text * @returns {void} @@ -821,9 +814,8 @@ export class StyleManager extends ShellFacade { } /** - * Localizes the key-only dock/panel headers at boot: VISUAL PRESETS, - * LOCATION, and the DISPLAY rail label. English output is byte-identical to - * the static markup these replace. + * Translates at boot the header texts that share their element with an + * icon or a
(data-i18n-runtime in the templates). * @returns {void} */ _applyRuntimeStaticHeaderText() { @@ -835,22 +827,7 @@ export class StyleManager extends ShellFacade { document.querySelector('#location-bar .location-toolbar-label'), t('LOCATION'), ); - this._setHeaderText( - document.querySelector('#pp-toggles .pp-header-label'), - t('DISPLAY'), - ); - // The cockpit context kicker ('CONTACT') is visible on the map itself, and - // the briefing kicker shares its element with the live-dot ; both are - // phase-2 key-only sites (see docs/TRANSLATORS.md). The briefing - // text node keeps renderBriefPage's leading-space convention. - this._setHeaderText( - document.querySelector('.cockpit-context-kicker'), - t('CONTACT'), - ); - // The phase-2 key-only standby description: one span holds BOTH mode - // descriptions split by a literal
, so no attribute write was possible - // without destroying the markup (docs/TRANSLATORS.md). Split into - // two keys and write them at boot, preserving the
. + // One span holds both mode descriptions around a
; keep the
. const standbyDescription = document.querySelector( '#context-mode-standby span', ); @@ -865,6 +842,7 @@ export class StyleManager extends ShellFacade { ), ); } + // Leading space after the live dot, as showBriefPage writes it. const briefKicker = document.querySelector('#cockpit-brief-kicker'); if (briefKicker) { this._setHeaderText(briefKicker, ` ${t('LIVE SIGNALS')}`); @@ -880,14 +858,6 @@ export class StyleManager extends ShellFacade { _initMapStackControl() { if (!this.mapStackController) return; - // Key-only i18n sites (mapStackChips.test.mjs pins the static markup - // verbatim, so no data-i18n* attribute can be added): localize at this - // runtime write instead. The locale resolves before UI init and never - // changes without a reload, so an init-time write is sufficient. - if (this._mapSourceLabel) - this._mapSourceLabel.textContent = t('MAP SOURCE'); - this._mapStackChips.setAttribute('aria-label', t('Map source')); - this._mapSourceControls?.destroy(); this._mapSourceControls = createMapSourceControls({ container: this._mapStackChips, diff --git a/src/ui/cockpitBriefing.js b/src/ui/cockpitBriefing.js index 8be9b1ff9..47b100867 100644 --- a/src/ui/cockpitBriefing.js +++ b/src/ui/cockpitBriefing.js @@ -12,9 +12,8 @@ import { formatCockpitWindDirection, } from './cockpitPresentation.js'; -// regionalModel.weatherCodeLabel lives in the shared node/browser source graph, -// so it returns stable English labels; localize them here, at the cockpit -// presentation boundary (docs/TRANSLATORS.md). +// regionalModel.weatherCodeLabel is shared with node code and returns +// English; translate it here. const WEATHER_CONDITION_KEYS = Object.freeze({ 'CONDITIONS UNKNOWN': msg('CONDITIONS UNKNOWN'), CLEAR: msg('CLEAR'), @@ -79,9 +78,8 @@ export function setBriefAutoRotate(enabled) { : COCKPIT_BRIEF_CYCLE_OFF_HELP; this.briefAutoToggle.setAttribute('aria-label', label); this.briefAutoToggle.title = help; - // The English writes above are pinned verbatim by cockpitMarkup.test.mjs; - // these t() re-writes are same-value overrides that localize the toggle - // under other locales (byte-identical under 'en'). + // cockpitMarkup.test.mjs pins the English writes above; these translate + // them. const labelKey = this.briefAutoRotateEnabled ? msg('CYCLE ON') : msg('CYCLE OFF'); diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index dec9035cf..1904b6cea 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -146,8 +146,6 @@ export function updateHud( this.position.textContent = `${lat} · ${lon}`; } if (this.aircraftMeta) { - // State-sibling keys at the writer: each feed state translates - // independently so no state can leak English under other locales. const feedState = this.surfaceAcquiring ? t('ACQUIRING SURFACE') : this.surfaceFallback diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index 6b8c26c4d..f8930d4e0 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -55,7 +55,6 @@ export const COCKPIT_BRIEF_PAGES = [ id: 'news', kickerKey: msg('REGIONAL NEWS'), subtitleKey: msg('LATEST LOCATION-MATCHED REPORTING'), - // The RSS provider name stays English; the rest of the line translates. sourceKey: msg('{source} · LOCATION QUERY · RECENT'), sourceName: 'GOOGLE NEWS RSS', // i18n-ignore: provider name }, @@ -63,7 +62,7 @@ export const COCKPIT_BRIEF_PAGES = [ id: 'local', kickerKey: msg('LOCAL INFO'), subtitleKey: msg('PLACE / CONDITIONS / POSITION'), - // Data-source attribution is a keep-English boundary string. + // Attribution stays English. source: 'NATURAL EARTH · OPEN-METEO · UTC', }, ]; @@ -117,7 +116,7 @@ export function compassPointLabel(code) { : code; } -/** `30.203°N` in the active language: `30,203°С`. */ +/** `30.203°N` in the active language: `30,203° с. ш.` in Russian. */ export function formatCockpitCoordinate(value, positive, negative) { const amount = formatNumber(Math.abs(value), { useGrouping: false, @@ -125,8 +124,7 @@ export function formatCockpitCoordinate(value, positive, negative) { maximumFractionDigits: 3, }); const code = value >= 0 ? positive : negative; - // Russian writes the hemisphere out ("30,203° с. ш."): a lone «С» after a - // degree sign reads as Celsius. + // Not «30,203°С»: after a degree sign «С» reads as Celsius. switch (code) { case 'N': return t('{value}°N', { value: amount }); diff --git a/src/ui/composition.js b/src/ui/composition.js index 6f8ac6bc5..797157567 100644 --- a/src/ui/composition.js +++ b/src/ui/composition.js @@ -1,8 +1,8 @@ /** Compose UI controls with the application's existing engines and layer instances. */ import { StyleManager as ApplicationShell } from './applicationShell.js'; import { LocationSearch } from './location.js'; +import { CITY_POIS } from '../cityPois.js'; import { - CITY_POIS, GLOBE_VIEW, flyToGlobeView, flyToPresetLocation, diff --git a/src/ui/contextLayerChanges.i18n.test.mjs b/src/ui/contextLayerChanges.i18n.test.mjs index 254a62eda..2bc973764 100644 --- a/src/ui/contextLayerChanges.i18n.test.mjs +++ b/src/ui/contextLayerChanges.i18n.test.mjs @@ -1,7 +1,4 @@ -// 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. +// The blocked-visibility toast reason in the UI language. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { setLocale } from '../i18n/index.js'; diff --git a/src/ui/contextLayerChanges.js b/src/ui/contextLayerChanges.js index 7c1965e90..fc5abe3a7 100644 --- a/src/ui/contextLayerChanges.js +++ b/src/ui/contextLayerChanges.js @@ -12,11 +12,9 @@ import { 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. + * A blocked-visibility reason in the UI language. Reasons stay English (voice + * and tools read them): the Space Missions refusal is rebuilt with the layer + * name localized, a known sentence is looked up, anything else passes. */ export function localizeVisibilityBlockReason(reason) { if (typeof reason !== 'string' || !reason) return reason; diff --git a/src/ui/cssContentCaption.test.mjs b/src/ui/cssContentCaption.test.mjs index f5e556ac9..35a0aaee9 100644 --- a/src/ui/cssContentCaption.test.mjs +++ b/src/ui/cssContentCaption.test.mjs @@ -1,4 +1,4 @@ -// K-49: the CCTV "ACQUIRING FRAME" caption lives in CSS `content:` and the +// 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'; @@ -13,7 +13,7 @@ const LOCALES = path.join(HERE, '..', 'i18n', 'locales'); // 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', () => { +test('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; @@ -26,7 +26,7 @@ test('K-49: no user-visible text in CSS content: outside decorative themes', () assert.deepEqual(hits, []); }); -test('K-49: the CCTV acquisition caption has es and ru translations', () => { +test('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"`); diff --git a/src/ui/feedErrorI18n.test.mjs b/src/ui/feedErrorI18n.test.mjs index 0a2cc65fd..145acdfa0 100644 --- a/src/ui/feedErrorI18n.test.mjs +++ b/src/ui/feedErrorI18n.test.mjs @@ -1,4 +1,4 @@ -// K-47: Flights / Military feed failure texts reach the DATA LAYERS row in the +// 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'; @@ -40,7 +40,7 @@ const cases = [ ['malformed rows', 'Malformed adsb.lol aircraft rows'], ]; -test('en: feed failure texts read as before', () => { +test('en: feed failure texts stay English', () => { setLocale('en'); for (const [, message] of cases) assert.equal( diff --git a/src/ui/geoFileImport.test.mjs b/src/ui/geoFileImport.test.mjs index 347481238..eb4bdfa99 100644 --- a/src/ui/geoFileImport.test.mjs +++ b/src/ui/geoFileImport.test.mjs @@ -212,8 +212,7 @@ 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. +// Runs first: tools left by later tests would answer the same registry. test('imported files make the language switch ask first', async () => { const t = setup(); assert.deepEqual(describeUnsavedWork(), []); diff --git a/src/ui/layerBindings.js b/src/ui/layerBindings.js index 0641e22b2..af71a5ce8 100644 --- a/src/ui/layerBindings.js +++ b/src/ui/layerBindings.js @@ -130,8 +130,6 @@ 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) => diff --git a/src/ui/layerNames.test.mjs b/src/ui/layerNames.test.mjs index bfb3c7268..df15c79bf 100644 --- a/src/ui/layerNames.test.mjs +++ b/src/ui/layerNames.test.mjs @@ -1,7 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { APPLICATION_LAYER_METADATA } from '../app/constructCatalog.js'; -import { LAYER_NAME_KEYS, PANEL_LABEL_KEYS } from './layerNames.js'; +import { setLocale, t } from '../i18n/index.js'; +import { FEED_SOURCE, COMBINED_SOURCE } from '../layers/localAdsb/status.js'; +import { + LAYER_NAME_KEYS, + PANEL_LABEL_KEYS, + sourceLabel, +} from './layerNames.js'; // Layers whose module name is a proper name and stays as it is in every // language: the Bhote Koshi pair belong to a scripted scene. @@ -31,3 +37,17 @@ test('the name tables list only catalog layers', () => { ].filter((id) => !catalogIds.includes(id)); assert.deepEqual(unknown, []); }); + +for (const locale of ['es', 'ru']) { + test(`${locale}: Local ADS-B source names translate without their own entry`, () => { + setLocale(locale); + try { + for (const source of [FEED_SOURCE, COMBINED_SOURCE]) { + assert.notEqual(t(source), source, 'the catalog has the entry'); + assert.equal(sourceLabel(source), t(source)); + } + } finally { + setLocale('en'); + } + }); +} diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index d876e916c..109d96c60 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -4,9 +4,6 @@ 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, @@ -14,7 +11,7 @@ import { } from './layerNames.js'; export { localizeLayerName } from './layerNames.js'; import { createWeatherPanel } from './weatherPanel.js'; -import { translateFeedError, translateLoadingLabel } from './statusText.js'; +import { translateFeedError, translateStatusLine } from './statusText.js'; import { formatNumber, getLocale, @@ -23,9 +20,8 @@ import { 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 -// presentation boundary. See docs/TRANSLATORS.md keep-English boundary. +// Feed-state values are machine ids (QA hooks, CSS feed-* classes); only the +// label translates. const FEED_STATE_LABEL_KEYS = Object.freeze({ nominal: msg('ON'), loading: msg('LOADING'), @@ -39,15 +35,6 @@ const FEED_STATE_LABEL_KEYS = Object.freeze({ function lifecycleStateLabel(lifecycleState) { return t(lifecycleState === 'enabling' ? msg('ENABLING') : msg('DISABLING')); } -const FEED_STATE_LABELS = Object.freeze({ - nominal: 'ON', - loading: 'LOADING', - degraded: 'DEGRADED', - stale: 'STALE', - partial: 'PARTIAL', - fallback: 'FALLBACK', - unavailable: 'UNAVAILABLE', -}); // Presentation order is independent of catalog registration and startup order. const PANEL_GROUPS = [ @@ -115,7 +102,7 @@ function englishPanelLabel(layer) { return PANEL_LABELS[layer.id] || LAYER_NAME_KEYS[layer.id] || layer.name; } -// The panel's own English names win, as before i18n; module names otherwise. +// PANEL_LABELS win over layer-name keys; otherwise the module's own name. function panelLabel(layer) { if (PANEL_LABELS[layer.id]) return t(PANEL_LABELS[layer.id]); const key = LAYER_NAME_KEYS[layer.id]; @@ -485,12 +472,10 @@ 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 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). + * Write a row's count. Outside English a count phrase ("24 nearby · 2 on + * screen") gets its own line, or it squeezes a longer translated name to a + * word per line. "—" is marked so a wide status chip can replace it + * (radio.css). */ _syncCount(row, count, stats) { const text = this._layerCountText(stats); @@ -693,14 +678,14 @@ export class LayerPanel { if (stats.loading) { const loadingLabel = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim() - ? translateLoadingLabel(stats.loadingLabel) + ? translateStatusLine(stats.loadingLabel.trim()) : t('loading...'); return t('{source} · {loadingLabel}', { source, loadingLabel }); } if (feedState === 'fallback') { const detail = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim() - ? translateLoadingLabel(stats.loadingLabel) + ? translateStatusLine(stats.loadingLabel.trim()) : stats.coverage || ago; return t('{state} · {source} · {detail}', { state: stateLabel, @@ -745,7 +730,7 @@ export class LayerPanel { if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) { return t('{source} · {loadingLabel}', { source, - loadingLabel: translateLoadingLabel(stats.loadingLabel), + loadingLabel: translateStatusLine(stats.loadingLabel.trim()), }); } return t('{source} · {ago}', { source, ago }); @@ -767,7 +752,7 @@ export class LayerPanel { button.classList.toggle('enabling', layer.lifecycleState === 'enabling'); button.classList.toggle('disabling', layer.lifecycleState === 'disabling'); button.classList.toggle('lifecycle-uncertain', uncertain); - for (const state of Object.keys(FEED_STATE_LABELS)) { + for (const state of Object.keys(FEED_STATE_LABEL_KEYS)) { button.classList.toggle( `feed-${state}`, (layer.enabled || unavailable) && !uncertain && feedState === state, diff --git a/src/ui/localAdsbGuidanceI18n.test.mjs b/src/ui/localAdsbGuidanceI18n.test.mjs index c8c8c55c7..34809af9b 100644 --- a/src/ui/localAdsbGuidanceI18n.test.mjs +++ b/src/ui/localAdsbGuidanceI18n.test.mjs @@ -1,7 +1,7 @@ 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 () => { +test('Local ADS-B guidance hint is translated in the layer row', async () => { const { setLocale, t } = await import('../i18n/index.js'); const { LayerPanel } = await import('./layerPanel.js'); const { localAdsbStatus } = await import('../layers/localAdsb/status.js'); diff --git a/src/ui/localSdrFeedLine.i18n.test.mjs b/src/ui/localSdrFeedLine.i18n.test.mjs index 6fec081e9..1d5ea5c60 100644 --- a/src/ui/localSdrFeedLine.i18n.test.mjs +++ b/src/ui/localSdrFeedLine.i18n.test.mjs @@ -37,7 +37,7 @@ test('an unknown feed status is shown as given', () => { ); }); -// B6: a feed status agrees with "fuente" in Spanish; the shared "invalid" key +// 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'); diff --git a/src/ui/localSdrFmStopMessage.i18n.test.mjs b/src/ui/localSdrFmStopMessage.i18n.test.mjs index 762d9faff..7ec72b7ca 100644 --- a/src/ui/localSdrFmStopMessage.i18n.test.mjs +++ b/src/ui/localSdrFmStopMessage.i18n.test.mjs @@ -86,7 +86,7 @@ for (const locale of ['es', 'ru']) { }); } -test('FM stop message after internet radio start reads as before in English', async (t) => { +test('FM stop message after internet radio start stays English', async (t) => { const view = await stopLocalFmForInternetRadio(t, 'en'); assert.equal(view.statusText, 'Local FM stopped: internet radio started'); }); diff --git a/src/ui/localSdrNumbers.i18n.test.mjs b/src/ui/localSdrNumbers.i18n.test.mjs index 5765f12ec..201939f5e 100644 --- a/src/ui/localSdrNumbers.i18n.test.mjs +++ b/src/ui/localSdrNumbers.i18n.test.mjs @@ -1,4 +1,4 @@ -// C14: the SDR status line and stats write numbers in the UI language, like +// 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'; diff --git a/src/ui/localePicker.js b/src/ui/localePicker.js index 7de5103aa..d60b4e91a 100644 --- a/src/ui/localePicker.js +++ b/src/ui/localePicker.js @@ -2,10 +2,8 @@ * Interface language picker: a HUD button in the bottom-right corner that * opens a listbox of the shipped languages. * - * The ordering, search filter, keyboard model and choice rule are pure - * functions below, testable without a DOM (localePicker.test.mjs). - * mountLocalePicker() binds them to the #locale-picker markup; the shell only - * mounts it and decides what choosing another language does (confirm, reload). + * The logic is pure and DOM-free; mountLocalePicker() only renders it. The + * shell decides what a choice does (confirm, reload). * * The list follows the ARIA listbox pattern: the button opens it, focus moves * into the list (or into the search field, which is a combobox, once there are diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index 7c7003eec..35b473e42 100644 --- a/src/ui/localePicker.test.mjs +++ b/src/ui/localePicker.test.mjs @@ -84,7 +84,7 @@ test('the current language comes first, the rest by their own names', () => { ); }); -test('each row names the language in the interface language too, unless that only repeats it', () => { +test('each row gets the interface-language name for search, unless it only repeats the native one', () => { const local = createLocalNameReader('ru'); const rows = buildLocaleOptions({ locales: LOCALES, diff --git a/src/ui/localeSwitchReload.test.mjs b/src/ui/localeSwitchReload.test.mjs index 0cd2da5cc..eed8d2f52 100644 --- a/src/ui/localeSwitchReload.test.mjs +++ b/src/ui/localeSwitchReload.test.mjs @@ -1,4 +1,4 @@ -// K-27: the language picker reloads the page, so it asks first while the +// The language picker reloads the page, so it asks first while the // reload would lose work (unsaved CCTV calibration, the open cockpit, anything // a module registers). Choosing the language already active does nothing. import { test } from 'node:test'; diff --git a/src/ui/locationControls.js b/src/ui/locationControls.js index 791287cb6..f2b67c7db 100644 --- a/src/ui/locationControls.js +++ b/src/ui/locationControls.js @@ -5,10 +5,8 @@ import { localizePlaceName } from './placeNames.js'; const POI_KEYS = ['Q', 'W', 'E', 'R', 'T']; /** - * locationStatus.js sentinel outputs used as localized-rewrite triggers for - * the collapsed LOCATION mini-status. Built by calling the module with no - * real data, so the comparisons track that module's copy without restating - * English strings here. Resolved destinations (place names) stay verbatim. + * locationStatus.js placeholder lines, built by the module itself; renderStatus + * swaps them for translated text. Place names are shown as they are. */ const LOCATION_MINI_EMPTY = Object.freeze(locationMiniStatus()); const LOCATION_MINI_SEARCHED_PLACEHOLDER = Object.freeze( @@ -154,9 +152,6 @@ export class LocationControls { } : 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 - // localized static readout (resolved place names stay verbatim data). this.elements.statusCity.textContent = lines.city === LOCATION_MINI_EMPTY.city ? t('📍 Location: --') diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 0d6df6f71..359ac3792 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -318,8 +318,7 @@ export class PanelChrome { btn.title = t(titleKey, { name: panelName }); btn.setAttribute('aria-label', t(titleKey, { name: panelName })); if (panelEl.id === 'radio-panel') { - // Expand state reuses the layers.* keys seeded on the static button; - // the collapse state is runtime-only, so it owns cockpit.* keys. + // Radio keeps the wording of its static button (context.html). btn.title = t( collapsed ? msg('Expand Radio') : msg('Collapse Radio'), ); diff --git a/src/ui/placeNames.js b/src/ui/placeNames.js index e57fb16b4..48ed69894 100644 --- a/src/ui/placeNames.js +++ b/src/ui/placeNames.js @@ -1,7 +1,7 @@ /** * Preset place names as the page shows them. * - * The preset cities in src/locations.js keep their English names in data: + * The preset cities in src/cityPois.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 diff --git a/src/ui/placeNames.test.mjs b/src/ui/placeNames.test.mjs index c84742321..eff49b942 100644 --- a/src/ui/placeNames.test.mjs +++ b/src/ui/placeNames.test.mjs @@ -3,7 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { setLocale } from '../i18n/index.js'; -import { CITY_POIS } from '../locations.js'; +import { CITY_POIS } from '../cityPois.js'; import { KNOWN_PLACE_NAMES, localizePlaceName } from './placeNames.js'; import { LocationControls } from './locationControls.js'; diff --git a/src/ui/radioBindings.js b/src/ui/radioBindings.js index 2b0932e4f..88756d5bc 100644 --- a/src/ui/radioBindings.js +++ b/src/ui/radioBindings.js @@ -362,10 +362,8 @@ export function bindRadioControls() { origin: 'user', notificationToken, }), - // Lifecycle toast copy: the English template below is pinned - // verbatim by contextSessionOrdering.test.mjs, so it stays the - // argument and localizedMessage rewrites it at the toast boundary - // (identical value under 'en'). + // contextSessionOrdering.test.mjs pins this English argument; the + // toast shows localizedMessage. `Radio could not ${enabling ? 'start' : 'stop'} cleanly`, { localizedMessage: t('Radio could not {action} cleanly', { diff --git a/src/ui/radioPresentation.js b/src/ui/radioPresentation.js index fbd80c23a..94431fdec 100644 --- a/src/ui/radioPresentation.js +++ b/src/ui/radioPresentation.js @@ -32,7 +32,6 @@ 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) { diff --git a/src/ui/radioPresentation.playbackError.test.mjs b/src/ui/radioPresentation.playbackError.test.mjs index f5c59dd23..a89b7f3df 100644 --- a/src/ui/radioPresentation.playbackError.test.mjs +++ b/src/ui/radioPresentation.playbackError.test.mjs @@ -62,7 +62,7 @@ for (const locale of ['es', 'ru']) { }); } -test('radio playback errors read as before in English', () => { +test('radio playback errors stay English', () => { setLocale('en'); for (const message of PLAYBACK_ERRORS) { assert.equal(render(message), message); diff --git a/src/ui/recentImagery.js b/src/ui/recentImagery.js index 21aad25a6..300e2536b 100644 --- a/src/ui/recentImagery.js +++ b/src/ui/recentImagery.js @@ -37,9 +37,9 @@ const LAYER_NOTICES = new Set([ 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 TOO_LARGE = /^(Box|View) is ([\d,]+) km wide · limit ([\d,]+) km$/; const POLAR = /^Imagery stops at ±([\d.]+)° latitude$/; const POINTER_BUSY = /^(.+) is using the pointer — close it first$/; diff --git a/src/ui/recentImageryNotice.test.mjs b/src/ui/recentImageryNotice.test.mjs index 6428e0394..4fc1e3d0e 100644 --- a/src/ui/recentImageryNotice.test.mjs +++ b/src/ui/recentImageryNotice.test.mjs @@ -1,4 +1,4 @@ -// K-43: box-tool and model refusals on the Recent Imagery notice line are +// 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'; @@ -35,7 +35,7 @@ const SHOWN_EN = new Map([ ], ]); -test('K-43 en: refusals read as before', () => { +test('en: refusals stay English', () => { setLocale('en'); for (const text of REFUSALS) assert.equal(localizeNotice(text), SHOWN_EN.get(text) ?? text); @@ -83,7 +83,7 @@ test('E3/E12: the pointer owner is named by its tool, never by its id', () => { }); for (const locale of ['es', 'ru']) { - test(`K-43 ${locale}: box-tool and model refusals are translated`, () => { + test(`${locale}: box-tool and model refusals are translated`, () => { setLocale(locale); try { for (const text of REFUSALS) diff --git a/src/ui/sceneSharing.js b/src/ui/sceneSharing.js index f37f33d1b..245561b8d 100644 --- a/src/ui/sceneSharing.js +++ b/src/ui/sceneSharing.js @@ -87,11 +87,8 @@ export function createSceneDialog(title, onClose) { } /** * The browser draws a file input's button and "no file" text in its own - * 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. + * language. Stretch the native input, transparent, over the whole row (so + * click, focus and drops stay native) and draw translated text under it. */ function fileChooser(wrapper, node) { wrapper.style.position = 'relative'; diff --git a/src/ui/shareRestorationNotice.test.mjs b/src/ui/shareRestorationNotice.test.mjs index aed860192..d4c6a81d2 100644 --- a/src/ui/shareRestorationNotice.test.mjs +++ b/src/ui/shareRestorationNotice.test.mjs @@ -1,6 +1,5 @@ // 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. +// dropped into a template cannot take the case (ru) or article (es) needed. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { setLocale } from '../i18n/index.js'; @@ -11,7 +10,7 @@ const { flights, military, satellites } = SHARE_TRACKING_RESTORE_POLICIES; test.afterEach(() => setLocale('en')); -test('en: the notices read as before for every policy and outcome', () => { +test('en: the notices for every policy and outcome', () => { setLocale('en'); const notice = (policy, classification) => shareTrackingRestoreNotice({ ...policy, classification }); diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index dcdbf3a63..9134b9045 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -54,7 +54,6 @@ export function readShellElements(document = globalThis.document) { _scopeFeatherValue: document.getElementById('scope-feather-value'), _mapStackChips: document.getElementById('map-stack-chips'), _mapStackStatus: document.getElementById('map-stack-status'), - _mapSourceLabel: document.getElementById('map-source-label'), _cleanViewBtn: document.getElementById('clean-view-toggle'), _cleanViewExitBtn: document.getElementById('clean-view-exit'), _dataPanel: document.getElementById('data-panel'), diff --git a/src/ui/shellFeedback.js b/src/ui/shellFeedback.js index b737b2c2d..332fa50af 100644 --- a/src/ui/shellFeedback.js +++ b/src/ui/shellFeedback.js @@ -13,11 +13,9 @@ import { getLocale, t, msg } from '../i18n/index.js'; import { localizeLayerName } from './layerPanel.js'; import { translateFeedError } from './statusText.js'; -// loadingFeedback.js composes the shared status copy inside the portable -// node/browser graph (the server-side HUD summary reads the same modules), so -// its labels stay English there. This chip is the presentation boundary: map -// the closed label vocabulary to the catalog, and pass anything unrecognized -// (a live provider error) through verbatim (docs/TRANSLATORS.md). +// The server also loads loadingFeedback.js (through the HUD summary), so its +// labels stay English. Translate the known ones; pass anything else (a live +// provider error) through. const STATUS_LABEL_KEYS = Object.freeze({ 'LOADING LIVE DATA': msg('LOADING LIVE DATA'), 'LOAD COMPLETE': msg('LOAD COMPLETE'), diff --git a/src/ui/shellFeedbackStatusLocalization.test.mjs b/src/ui/shellFeedbackStatusLocalization.test.mjs index 10f2992eb..9639fe834 100644 --- a/src/ui/shellFeedbackStatusLocalization.test.mjs +++ b/src/ui/shellFeedbackStatusLocalization.test.mjs @@ -1,8 +1,5 @@ -// 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. +// The global loading chip in the UI language: the status vocabulary, the ALPR +// retry prefix and every mapped-site failure reason. import test from 'node:test'; import assert from 'node:assert/strict'; import { setLocale, t } from '../i18n/index.js'; diff --git a/src/ui/statusText.js b/src/ui/statusText.js index d4308c596..b0899d55e 100644 --- a/src/ui/statusText.js +++ b/src/ui/statusText.js @@ -28,7 +28,7 @@ const FEED_ERRORS = new Set([ msg('OpenSky auth failed'), ]); const FEED_ERROR_PATTERNS = [ - // Street Level coverage errors (streetLevel/providers/kartaview/source.js), + // Street Level provider errors (providers/{kartaview,panoramax}/source.js), // ahead of the generic " HTTP ". [ /^KartaView answered HTTP (\d+)$/, @@ -133,9 +133,11 @@ export function translateFeedError(text) { if (typeof text !== 'string') return text; const whole = feedErrorText(text); if (whole !== null) return whole; + // feedErrorText has already looked the text up in the catalog. + if (!text.includes(STATUS_SEPARATOR)) return statusPatternText(text) ?? text; return text .split(STATUS_SEPARATOR) - .map((piece) => feedErrorText(piece) ?? translateStatusPiece(piece)) + .map((piece) => feedErrorText(piece) ?? statusPatternText(piece) ?? piece) .join(STATUS_SEPARATOR); } @@ -206,15 +208,20 @@ const STATUS_PIECE_PATTERNS = [ ], ]; -/** One piece of a status line in the UI language, or as it came. */ -export function translateStatusPiece(piece) { - const known = translateKnown(piece); - if (known !== piece) return known; +/** A status piece by its shape in the UI language, or null. */ +function statusPatternText(piece) { for (const [pattern, format] of STATUS_PIECE_PATTERNS) { const match = pattern.exec(piece); if (match) return format(match); } - return piece; + return null; +} + +/** One piece of a status line in the UI language, or as it came. */ +export function translateStatusPiece(piece) { + const known = translateKnown(piece); + if (known !== piece) return known; + return statusPatternText(piece) ?? piece; } /** A status line of " · " joined pieces in the UI language, piece by piece. */ @@ -224,8 +231,3 @@ export function translateStatusLine(text) { .map(translateStatusPiece) .join(STATUS_SEPARATOR); } - -/** A loading label is English data, at times several joined by " · ". */ -export function translateLoadingLabel(label) { - return translateStatusLine(label.trim()); -} diff --git a/src/ui/statusText.test.mjs b/src/ui/statusText.test.mjs index a154e7da6..a64975c48 100644 --- a/src/ui/statusText.test.mjs +++ b/src/ui/statusText.test.mjs @@ -2,15 +2,8 @@ // their shape; English shows exactly as it came. import assert from 'node:assert/strict'; import test from 'node:test'; -import { setLocale, t, translateKnown } from '../i18n/index.js'; -import { sourceLabel } from './layerNames.js'; -import { - translateFeedError, - translateLoadingLabel, - translateStatusLine, -} from './statusText.js'; -import { FEED_SOURCE, COMBINED_SOURCE } from '../layers/localAdsb/status.js'; -import { readResponseTextCapped } from '../sources/httpBody.js'; +import { setLocale, t } from '../i18n/index.js'; +import { translateFeedError, translateStatusLine } from './statusText.js'; function inLocale(locale, run) { setLocale(locale); @@ -41,7 +34,6 @@ test('en: status lines and errors show as they came', () => { ]) { assert.equal(translateFeedError(text), text); assert.equal(translateStatusLine(text), text); - assert.equal(translateLoadingLabel(` ${text} `), text); } }); @@ -75,24 +67,11 @@ for (const locale of ['es', 'ru']) { }); }); - test(`${locale}: an oversized upstream response is translated`, async () => { - const response = new Response('x'.repeat(10), { - headers: { 'content-length': '10' }, - }); - const error = await readResponseTextCapped(response, 4).catch((e) => e); - assert.equal(error.message, 'Upstream response too large'); + test(`${locale}: a known error shows its catalog entry`, () => { inLocale(locale, () => { - assert.notEqual(translateKnown(error.message), error.message); - assert.equal(translateFeedError(error.message), t(error.message)); - }); - }); - - test(`${locale}: Local ADS-B source names translate without their own entry`, () => { - inLocale(locale, () => { - for (const source of [FEED_SOURCE, COMBINED_SOURCE]) { - assert.notEqual(t(source), source, 'the catalog has the entry'); - assert.equal(sourceLabel(source), t(source)); - } + const text = 'Upstream response too large'; + assert.notEqual(t(text), text, 'the catalog has the entry'); + assert.equal(translateFeedError(text), t(text)); }); }); } diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 0f468edd6..d7d31da2e 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -97,8 +97,8 @@ 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. +// Provider refusals with a number stay English data (kartaview and panoramax +// source.js); they go back onto a placeholder here. const ERROR_PATTERNS = [ [ /^KartaView answered HTTP (\d+)$/, diff --git a/src/ui/styles/cctv.css b/src/ui/styles/cctv.css index ff3259122..d6f895681 100644 --- a/src/ui/styles/cctv.css +++ b/src/ui/styles/cctv.css @@ -70,17 +70,13 @@ margin-bottom: 0; } -/* Spanish labels run ~25% longer than English and overflow the nowrap - .scene-btn rows inside the fixed-width CCTV panel (e.g. ENFOCAR · - COBERTURA DESACTIVADA · AUTO HOP DESACTIVADO). Let the rows wrap under - es only; the English layout stays pixel-identical. */ +/* Spanish labels overflow the nowrap .scene-btn row of the fixed-width + panel; wrap the row under es only, English stays as is. */ html[lang='es'] .cctv-controls { flex-wrap: wrap; } -/* A translated label longer than its button wraps inside the button instead - of running past the panel edge. English is excluded so its labels (AUTO HOP - OFF) stay on one line. */ +/* A label longer than its button wraps inside it, not past the panel edge. */ html:not([lang='en']) .cctv-controls .scene-btn { white-space: normal; overflow-wrap: break-word; @@ -389,9 +385,8 @@ html:not([lang='en']) .cctv-controls .scene-btn { text-shadow: 0 0 10px rgba(0, 212, 255, 0.18); } -/* Longer translated labels and units: tighter calibration chips keep the - seven values to two rows so SAVE / RESET stay in view, and the camera - select uses the compact mono face. English keeps its original look. */ +/* Tighter calibration chips keep the seven values to two rows, so + SAVE / RESET stay in view. */ html:not([lang='en']) .cctv-cal-readout { gap: 4px; } diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index cd2f1989d..24104e66a 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -788,10 +788,8 @@ html:not([lang='en']) .panel-collapse-btn { } /* 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. English keeps its - own label widths. */ + label of the active language (set in applicationShell.js); the labels are + monospace. 0.125em is the 1px letter-spacing at 8px. */ 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)); diff --git a/src/ui/styles/english-css-parity.test.mjs b/src/ui/styles/english-css-parity.test.mjs index 3ed1d2642..7d9a1ae4c 100644 --- a/src/ui/styles/english-css-parity.test.mjs +++ b/src/ui/styles/english-css-parity.test.mjs @@ -1,7 +1,6 @@ -// 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). +// Layout rules written for long translations must not change the English +// rendering: every rule checked here has no `html:not([lang='en'])` guard, +// so it applies to English too. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -33,7 +32,7 @@ function declaration(block, prop) { return m ? m[1].replace(/\s+/g, ' ').trim() : undefined; } -test('K-44: layer status chip keeps its English width (82px)', () => { +test('layer status chip keeps its English width (82px)', () => { const rules = unguardedRules( css('radio.css'), `.data-toggle-btn.feed-loading, .data-toggle-btn.feed-degraded, @@ -44,14 +43,14 @@ test('K-44: layer status chip keeps its English width (82px)', () => { assert.equal(declaration(rules[0], 'min-width'), '82px'); }); -test('K-44: Directions steps keep one line with an ellipsis in English', () => { +test('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', () => { +test('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, @@ -59,7 +58,7 @@ test('K-44: count-line layout is not reachable from an unguarded selector', () = ); }); -test('K-44: weather status line keeps its fixed English height', () => { +test('weather status line keeps its fixed English height', () => { const [rule] = unguardedRules( css('weather.css'), ".weather-card [data-line-id='status']", @@ -68,7 +67,7 @@ test('K-44: weather status line keeps its fixed English height', () => { assert.equal(declaration(rule, 'height'), '1.45em'); }); -test('K-44: cockpit number separators keep the English cell width', () => { +test('cockpit number separators keep the English cell width', () => { assert.equal( unguardedRules(css('cockpit.css'), '.cockpit-roll-token.is-separator') .length, @@ -76,7 +75,7 @@ test('K-44: cockpit number separators keep the English cell width', () => { ); }); -test('K-44: Bhote Koshi callout keeps its English footer layout', () => { +test('Bhote Koshi callout keeps its English footer layout', () => { const source = css('bhote-koshi.css'); const [strong] = unguardedRules( source, @@ -87,7 +86,7 @@ test('K-44: Bhote Koshi callout keeps its English footer layout', () => { assert.equal(declaration(link, 'grid-row'), '1 / span 2'); }); -test('K-44: SCENES actions stay on one line in English (refront-opus-1)', () => { +test('SCENES actions stay on one line in English', () => { assert.equal( unguardedRules(css('scenes.css'), '#scene-panel .scene-controls .scene-btn') .length, @@ -97,7 +96,7 @@ test('K-44: SCENES actions stay on one line in English (refront-opus-1)', () => assert.equal(declaration(select, 'white-space'), undefined); }); -test('K-44: Display and parameter rows keep their English layout', () => { +test('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, @@ -110,7 +109,7 @@ test('K-44: Display and parameter rows keep their English layout', () => { assert.equal(declaration(color, 'flex'), '0 0 74px'); }); -test('K-44: weather setting rows keep the 56px English label column', () => { +test('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'); @@ -120,14 +119,14 @@ test('K-44: weather setting rows keep the 56px English label column', () => { ); }); -test('K-44: cockpit brief controls keep their English sizes', () => { +test('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 () => { +test('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 = () => { @@ -153,7 +152,7 @@ test('K-44: the count-line class is set only outside English', async () => { } }); -test('K-44: Recent Imagery readout keeps its English layout', () => { +test('Recent Imagery readout keeps its English layout', () => { const source = css('recent-imagery.css'); const one = (selector) => { const rules = unguardedRules(source, selector); @@ -211,7 +210,7 @@ test('K-44: Recent Imagery readout keeps its English layout', () => { assert.equal(declaration(swap, 'min-width'), undefined); }); -test('N-13: CCTV control buttons stay on one line in English', () => { +test('CCTV control buttons stay on one line in English', () => { assert.equal( unguardedRules(css('cctv.css'), '.cctv-controls .scene-btn').length, 0, diff --git a/src/ui/styles/recent-imagery.css b/src/ui/styles/recent-imagery.css index 78b8822e1..e04955e2f 100644 --- a/src/ui/styles/recent-imagery.css +++ b/src/ui/styles/recent-imagery.css @@ -362,10 +362,9 @@ html:not([lang='en']) color: var(--text-secondary); font-style: italic; } -/* 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. */ +/* The slot rows share the bar's columns (subgrid), so the tag column fits + the longest tag and both rows align; a long slot line (date · source · + resolution · state) takes a second line instead of losing the state. */ html:not([lang='en']) .recent-imagery-readout .ri-selection { grid-template-columns: minmax(44px, max-content) minmax(0, 1fr) 22px; column-gap: 6px; diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index e55fd88c3..287b8b842 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -163,10 +163,16 @@ outline: none; } +/* A translated scene name needs the width: the select takes its own row, + NEW / DEL share the next one. */ html:not([lang='en']) #scene-select { + flex: 1 1 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.04em; } /* A translated action longer than its button wraps inside the button instead @@ -468,15 +474,3 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { .director-sharing-dialog::backdrop { background: rgba(0, 8, 14, 0.65); } - -html:not([lang='en']) #scene-select { - font-family: var(--font-mono); - font-size: 10px; - letter-spacing: 0.04em; -} - -/* A translated scene name needs the width: the select takes its own row and - NEW / DELETE share the next one. */ -html:not([lang='en']) #scene-select { - flex: 1 1 100%; -} diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index db3db5d2e..4e72d87b8 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -216,8 +216,7 @@ button.sl-status:focus-visible { display: none; } -/* Longer translations: rather than squeeze the hint into a narrow column - beside the button, put the button on its own line when both do not fit. */ +/* When the hint and the button do not both fit, the button takes its own line. */ html:not([lang='en']) .sl-meta-row { flex-wrap: wrap; } diff --git a/src/ui/styles/voice-cost.css b/src/ui/styles/voice-cost.css index 827f026d1..128811e3b 100644 --- a/src/ui/styles/voice-cost.css +++ b/src/ui/styles/voice-cost.css @@ -83,14 +83,11 @@ display: none; } -/* The dock heading row is only as wide as the voice column, so the cost - cluster keeps its size and the state label gives way: a long translated - state (ПОДКЛЮЧЕНИЕ, CONECTANDO) ends in an ellipsis instead of pushing the - cost past the card edge. The kicker already collapses to zero width here; - taking it out of the row returns its gap to the state label. The row also - reaches left over the gutter beside the mic ring: at heading height the - ring has already curved away. English fits as it is, so this applies to - the other languages only. */ +/* The heading row is only as wide as the voice column: the cost cluster + keeps its size and a long state (ПОДКЛЮЧЕНИЕ, CONECTANDO) ends in an + ellipsis. Dropping the kicker returns its gap to the state label; the + negative margin reaches over the gutter beside the mic ring, which has + curved away at heading height. */ html:not([lang='en']) #command-dock .gev-voice-heading { gap: 0.2rem; margin-left: -0.6rem; @@ -123,10 +120,9 @@ html:not([lang='en']) #command-dock .gev-voice-cost-value { letter-spacing: 0; } -/* 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. */ +/* Live hints and the tier switch ("СТАНДАРТ — со следующей сессии") run + longer: a second line from the spare space under the readout, with no + tracking so the tier line does not need a third. */ html:not([lang='en']) #command-dock #gev-voice-control:is( diff --git a/src/ui/templates/provider-settings.html b/src/ui/templates/provider-settings.html index fbab6594a..a293799f3 100644 --- a/src/ui/templates/provider-settings.html +++ b/src/ui/templates/provider-settings.html @@ -2,9 +2,8 @@ both the chip and this dialog outright when /api/setup/status is not there to answer (prod builds, LAN visitors). Rows are rendered from that payload, so the key registry lives in src/keySetupCore.mjs alone. --> - +