refactor(i18n): key messages by English text; add Russian

Replace the semantic keys with the English source text, so code reads as the
UI does and English needs no catalog: t('Save keys'), tn(count, one, other)
for plurals, msg() for text stored in data, and bare data-i18n* markup that
translates the element's own English. Other languages are one flat JSON each.

- Simplify src/i18n: drop namespaces, the configurable locale pair
  (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE) and its build defines. Locale
  comes from ?lang=, the stored choice, then browser languages.
- Add scripts/i18n.mjs (npm run i18n): extracts every message from t/tn/msg
  and markup, and fails on missing, unused, placeholder-mismatched or
  wrongly-shaped plural entries. npm test runs it via catalog.test.mjs.
- Add Russian (src/i18n/locales/ru.json, 833 messages), started from the
  community catalog in #312 and reviewed against the current English.
- Fix data-i18n placed on parents that would wipe their children (cockpit
  LEVEL and local weather cells, first-run EXPLORE MANUALLY tile), and a
  doubled degree sign in the TRACK ACQUIRED signal.
- Rewrite docs/TRANSLATORS.md for the new workflow; drop sections the earlier
  merge resurrected in docs/CURRENT-STATE.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-12 13:48:56 +04:00
1 parent dfbaeafbd8
commit 13efd411b6
112 files changed
+4419 -4856

No files matched your search

+11 -11
View File
@@ -14,14 +14,14 @@ export function createVoiceControl({ reset = false } = {}) {
root.dataset.speaker = 'idle';
root.innerHTML = `
<div class="gev-voice-heading">
<div class="gev-voice-kicker">${t('setup.voice.kicker.agent')}</div>
<div id="gev-voice-status">${t('setup.voice.status.idle')}</div>
<div class="gev-voice-kicker">${t('AI AGENT')}</div>
<div id="gev-voice-status">${t('OFF')}</div>
<div class="gev-voice-cost">
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="${t('setup.voice.tier.buttonTitle')}">STD</button>
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="${t('setup.voice.cost.buttonTitle')}">~$0.00</span>
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="${t('Voice model tier — applies next session')}">STD</button>
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="${t('Estimated session cost')}">~$0.00</span>
</div>
</div>
<button id="gev-voice-button" type="button" aria-label="${t('setup.voice.button.ariaLabel')}" aria-describedby="gev-voice-help">
<button id="gev-voice-button" type="button" aria-label="${t('Voice control — activate to toggle voice; hold Space to speak')}" aria-describedby="gev-voice-help">
<span class="gev-mic-orbit"><img src="/mic.svg" alt="" /></span>
<span class="gev-mic-label">ON/OFF</span>
</button>
@@ -29,11 +29,11 @@ export function createVoiceControl({ reset = false } = {}) {
${Array.from({ length: 15 }, (_, index) => `<span style="--bar:${index}"></span>`).join('')}
</div>
<div class="gev-voice-readout">
<div id="gev-voice-detail">${t('setup.voice.detail.standby')}</div>
<div id="gev-voice-detail">${t('VOICE STANDBY')}</div>
</div>
<div id="gev-voice-help" class="gev-voice-help-tray" role="tooltip">
<span class="gev-voice-help-kicker">${t('setup.voice.kicker.control')}</span>
<span class="gev-voice-help-detail">${t('setup.voice.hint.default')}</span>
<span class="gev-voice-help-kicker">${t('VOICE CONTROL')}</span>
<span class="gev-voice-help-detail">${t('Hold Space to speak · tap Space to activate focused controls')}</span>
</div>
<section id="gev-voice-card" class="gev-voice-card" hidden aria-label="Voice assistant">
<div class="gev-voice-card-header">
@@ -59,11 +59,11 @@ export function createVoiceControl({ reset = false } = {}) {
</section>
<div class="gev-voice-error-tray" role="alert" aria-live="assertive">
<div class="gev-voice-error-header">
<span>${t('setup.voice.error.trayTitle')}</span>
<button class="gev-voice-error-dismiss" type="button">${t('setup.voice.error.dismiss')}</button>
<span>${t('VOICE SYSTEM ERROR')}</span>
<button class="gev-voice-error-dismiss" type="button">${t('DISMISS')}</button>
</div>
<div id="gev-voice-error-detail"></div>
<div class="gev-voice-error-hint">${t('setup.voice.error.hint')}</div>
<div class="gev-voice-error-hint">${t('Check microphone permission and network access, then try again.')}</div>
</div>
`;
const commandDock = document.getElementById('command-dock');
+5 -5
View File
@@ -61,7 +61,7 @@ export class RealtimeConnection {
this.input.pushToTalkKeyHeld = pushToTalkKeyHeld;
this.input.spaceKeyHeld = spaceKeyHeld;
if (!window.RTCPeerConnection || !navigator.mediaDevices?.getUserMedia) {
this.setStatus('error', t('setup.voice.detail.microphoneUnavailable'));
this.setStatus('error', t('WebRTC microphone support unavailable'));
return;
}
@@ -80,7 +80,7 @@ export class RealtimeConnection {
// — this is what "applies next session" means.
this.cost.prepareSession();
this.syncCostUi();
this.setStatus('connecting', t('setup.voice.detail.requestingMicrophone'));
this.setStatus('connecting', t('Requesting microphone'));
this.debugLog('session.starting', {
epoch,
tier: this.cost.voiceTier,
@@ -181,9 +181,9 @@ export class RealtimeConnection {
if (!ownsChannel()) return;
const detail = this.input.pushToTalkMode
? this.input.pushToTalkKeyHeld
? t('setup.voice.detail.releaseSpaceSend')
: t('setup.voice.detail.holdSpaceTalk')
: t('setup.voice.detail.askOrCommand');
? t('Release Space to send')
: t('Hold Space to talk')
: t('Ask or command');
this.setStatus('listening', detail);
this.debugLog('data_channel.open', {
connection: this.connectionDiagnostics(dataChannel),
+12 -14
View File
@@ -55,16 +55,16 @@ export {
import { createRealtimeBackend } from './realtimeBackend.js';
import { t } from '../i18n/index.js';
import { t, msg } from '../i18n/index.js';
// Status enum VALUES are machine identifiers (dataset.status, CSS hooks);
// only the LABEL rendered from them localizes, resolved at write time via t().
const STATUS_LABEL_KEYS = Object.freeze({
idle: 'setup.voice.status.idle',
connecting: 'setup.voice.status.connecting',
listening: 'setup.voice.status.listening',
executing: 'setup.voice.status.executing',
error: 'setup.voice.status.error',
idle: msg('OFF'),
connecting: msg('CONNECTING'),
listening: msg('LISTENING'),
executing: msg('EXECUTING'),
error: msg('ERROR'),
});
/** Compose voice state owners and coordinate ordered session startup/teardown. */
@@ -416,7 +416,7 @@ export class GevRealtimeController extends RealtimeFacade {
this.ui.root.remove();
}
if (!preserveStatus && !removeUi) {
this.setStatus('idle', t('setup.voice.detail.voiceOff'));
this.setStatus('idle', t('Voice off'));
}
this.setRadioVoiceDucking(false);
if (removeUi) this.emitSessionEvent({ type: 'disposed' });
@@ -442,22 +442,20 @@ export class GevRealtimeController extends RealtimeFacade {
const resolvedDetail =
status === 'listening' && this.pushToTalkMode
? this.pushToTalkKeyHeld
? t('setup.voice.detail.releaseSpaceSend')
: t('setup.voice.detail.holdSpaceTalk')
? t('Release Space to send')
: t('Hold Space to talk')
: detail;
const primaryDetail =
status === 'error'
? t('setup.voice.detail.unavailable')
? t('VOICE UNAVAILABLE')
: resolvedDetail ||
(status === 'idle'
? t('setup.voice.detail.standby')
: t('setup.voice.detail.active'));
(status === 'idle' ? t('VOICE STANDBY') : t('VOICE ACTIVE'));
this.ui.detail.textContent = primaryDetail;
this.ui.detail.title = primaryDetail;
if (this.ui.errorDetail) {
this.ui.errorDetail.textContent =
status === 'error'
? resolvedDetail || t('setup.voice.error.sessionStart')
? resolvedDetail || t('Voice session could not be started.')
: '';
}
if (status === 'idle' || status === 'connecting' || status === 'error') {
+19 -13
View File
@@ -57,14 +57,17 @@ export class RealtimeCost {
const pendingId = resolveVoiceModel(pendingTier).id;
this.ui.tierButton.title =
this.isActive() && state.modelId !== pendingId
? t('setup.voice.tierNextSession', {
? t('Next session: {pendingId} — this session stays on {modelId}', {
pendingId,
modelId: state.modelId,
})
: t('setup.voice.tierSwitchHint', {
pendingId,
target: isMini ? 'standard' : 'mini',
});
: t(
'Voice model: {pendingId} — click to switch to {target}; applies next session',
{
pendingId,
target: isMini ? 'standard' : 'mini',
},
);
}
if (this.ui?.costValue) {
const liveAuth = this.sessionCloudVoiceAuth || this.cloudVoiceAuth;
@@ -85,12 +88,15 @@ export class RealtimeCost {
this.ui.costValue.textContent = state.display;
this.ui.costValue.dataset.level = state.level;
this.ui.costValue.title =
t('setup.voice.costTooltip', {
modelId: state.modelId,
responses: state.responses,
warn: formatCostUsd(state.warnUsd),
cap: formatCostUsd(state.capUsd),
}) + (state.note ? ` ${state.note}` : '');
t(
'Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.',
{
modelId: state.modelId,
responses: state.responses,
warn: formatCostUsd(state.warnUsd),
cap: formatCostUsd(state.capUsd),
},
) + (state.note ? ` ${state.note}` : '');
}
}
@@ -133,7 +139,7 @@ export class RealtimeCost {
if (this.isActive() && this.ui?.detail) {
this.setStatus(
this.status,
t('setup.voice.tier.appliesNextSession', {
t('{tier} applies next session', {
tier: this.voiceTier.toUpperCase(),
}),
);
@@ -252,7 +258,7 @@ export class RealtimeCost {
} finally {
this.setStatus(
'idle',
t('setup.voice.status.sessionCostCap', { cost: state.display }),
t('Session ended — cost cap {cost}', { cost: state.display }),
);
this.syncCostUi();
}
+2 -5
View File
@@ -119,10 +119,7 @@ export class RealtimeInput {
this.ui.root.dataset.pushToTalk = 'held';
this.setMicrophoneEnabled(true);
if (this.status === 'listening')
this.setStatus(
'listening',
t('setup.voice.detail.releaseSpaceSend'),
);
this.setStatus('listening', t('Release Space to send'));
} else {
this.start({ pushToTalk: true });
// start() performs a controlled stop() before connecting. Restore the
@@ -198,7 +195,7 @@ export class RealtimeInput {
if (!this.pushToTalkMode) return;
this.setMicrophoneEnabled(false);
if (this.status === 'listening')
this.setStatus('listening', t('setup.voice.detail.holdSpaceTalk'));
this.setStatus('listening', t('Hold Space to talk'));
else this.updateVoiceButtonLabel();
}
+2 -2
View File
@@ -152,8 +152,8 @@ export function resolveVoiceVisualizerSpeaker(
*/
export function resolveVoiceControlHint(pushToTalkMode, pushToTalkKeyHeld) {
return pushToTalkMode && pushToTalkKeyHeld
? t('setup.voice.detail.releaseSpaceSend')
: t('setup.voice.hint.default');
? t('Release Space to send')
: t('Hold Space to speak · tap Space to activate focused controls');
}
/**
+1 -1
View File
@@ -259,7 +259,7 @@ export class RealtimeRadio {
if (radioHandoff.result?.ok || radioHandoff.cancelled || !stillCurrent)
return;
if (this.dc?.readyState === 'open' && !this.userTurnPending) {
this.setStatus('listening', t('setup.voice.detail.radioDidNotStart'));
this.setStatus('listening', t('Radio did not start'));
this.queueResponseCreate(
'Say exactly one short correction: “The Radio station could not start. Voice is still on.”',
);
+4 -4
View File
@@ -278,7 +278,7 @@ export class RealtimeTurns {
eventId: payload.event_id,
activeResponseMessage: payload.error?.message || null,
});
this.setStatus('listening', t('setup.voice.detail.askOrCommand'));
this.setStatus('listening', t('Ask or command'));
return;
}
// A conversation.item.delete for a stale viewport screenshot can land
@@ -415,7 +415,7 @@ export class RealtimeTurns {
return;
}
this.setStatus('executing', t('setup.voice.detail.runningCommand'));
this.setStatus('executing', t('Running command'));
this.pruneProcessedCalls();
let sentOutput = false;
let lastResult = null;
@@ -679,7 +679,7 @@ export class RealtimeTurns {
),
);
}
this.setStatus('listening', t('setup.voice.detail.askOrCommand'));
this.setStatus('listening', t('Ask or command'));
}
/**
@@ -998,7 +998,7 @@ export class RealtimeTurns {
// connection is still live. Recover to listening so the user can retry
// (mirrors the transient-blip philosophy, H8).
if (this.dc?.readyState === 'open') {
this.setStatus('listening', t('setup.voice.detail.askOrCommand'));
this.setStatus('listening', t('Ask or command'));
}
}
if (!this.radio.pendingRadioPlaybackResult) {