fix(i18n): keep English pixel-identical and stored data English

From the English-parity and breakage review against main:
- English wording back to main: "Configured Source", the "SHARED SUBJECT"
  fallback, CCTV calibration "RANGE 120m".
- Voice header and CCTV calibration chip/select tweaks for long labels now
  apply to non-English only (html:not([lang='en'])); English matches main.
  The scene select font change is scoped the same way.
- New scenes and shots store their default title in English ("Scene 3",
  "Shot 2") and translate it when shown, so a title captured in one language
  does not leak into projects, storage or shared JSON.
- Choosing a language with storage blocked keeps it in ?lang instead of
  silently doing nothing.
- One picked import file shows its name again.
- qa-l9-matrix reads the voice state from data-status, not the label.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-13 20:03:54 +04:00
1 parent a28e29e05e
commit 9d01f9bfcd
15 files changed
+118 -54

No files matched your search

+3 -2
View File
@@ -2006,8 +2006,9 @@ async function runBrowserGroup(record) {
for (let i = 0; i < 20; i += 1) { for (let i = 0; i < 20; i += 1) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
const status = document.getElementById('gev-voice-status')?.textContent?.trim() || ''; // The label is translated; the machine state is on the control.
if (status && status !== 'CONNECTING') break; const status = document.getElementById('gev-voice-control')?.dataset.status || '';
if (status && status !== 'connecting') break;
} }
let diag = null; let diag = null;
try { diag = vc.getDiagnostics?.(); } catch { /* not started */ } try { diag = vc.getDiagnostics?.(); } catch { /* not started */ }
+25
View File
@@ -188,6 +188,31 @@ test('applyDocumentTranslations translates opted-in text and attributes in place
assert.equal(unknown.textContent, 'No such message'); assert.equal(unknown.textContent, 'No such message');
}); });
test('persistLocaleAndReload keeps the choice in ?lang when storage is blocked', () => {
const calls = [];
const location = {
href: 'https://gev.local/?x=1#scene=a',
assign: (url) => calls.push(['assign', url]),
reload: () => calls.push(['reload']),
};
const history = {
replaceState: (_s, _t, url) => calls.push(['replace', url]),
};
const storage = {
setItem() {
throw new Error('blocked');
},
};
assert.equal(
persistLocaleAndReload('ru', { location, history, storage }),
true,
);
assert.deepEqual(calls, [
['replace', 'https://gev.local/?x=1&lang=ru#scene=a'],
['reload'],
]);
});
test('persistLocaleAndReload stores the choice, keeps the hash, strips ?lang', () => { test('persistLocaleAndReload stores the choice, keeps the hash, strips ?lang', () => {
const storage = memoryStorage(); const storage = memoryStorage();
const calls = []; const calls = [];
+5 -3
View File
@@ -212,7 +212,7 @@ export function applyDocumentTranslations(doc) {
/** /**
* Persist a locale choice, then reload preserving window.location.hash exactly * Persist a locale choice, then reload preserving window.location.hash exactly
* and stripping ?lang from the search string, so the override is one-shot. * and stripping ?lang from the search string, so the override is one-shot.
* Storage is best-effort: where it is blocked the choice will not survive. * Where storage is blocked, ?lang carries the choice instead.
* @returns {boolean} true when a reload was triggered. * @returns {boolean} true when a reload was triggered.
*/ */
export function persistLocaleAndReload( export function persistLocaleAndReload(
@@ -224,12 +224,14 @@ export function persistLocaleAndReload(
} = {}, } = {},
) { ) {
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
writeStoredLocale(normalized, storage); const stored = writeStoredLocale(normalized, storage);
if (!location || typeof location.assign !== 'function') return false; if (!location || typeof location.assign !== 'function') return false;
let target = location.href; let target = location.href;
try { try {
// Without storage the choice only survives in the URL, so keep it there.
const url = new URL(location.href); const url = new URL(location.href);
url.searchParams.delete('lang'); if (stored) url.searchParams.delete('lang');
else url.searchParams.set('lang', normalized);
target = url.href; target = url.href;
} catch { } catch {
// Keep the current href. // Keep the current href.
+4 -1
View File
@@ -2025,5 +2025,8 @@
"Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Usa contenido registrado o medios vinculados. Esos archivos externos no se incluyen en un paquete de escena. Sus avisos originales siguen vigentes.", "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Usa contenido registrado o medios vinculados. Esos archivos externos no se incluyen en un paquete de escena. Sus avisos originales siguen vigentes.",
"{state} {layers}": "{state}: {layers}", "{state} {layers}": "{state}: {layers}",
"Selected Space Mission": "Misión espacial seleccionada", "Selected Space Mission": "Misión espacial seleccionada",
"{duration}s + {hold}s": "{duration} s + {hold} s" "{duration}s + {hold}s": "{duration} s + {hold} s",
"Configured Source": "Fuente configurada",
"subject": "sujeto",
"{value}m": "{value} m"
} }
+4 -1
View File
@@ -2057,5 +2057,8 @@
"Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Используется зарегистрированный контент или связанные медиафайлы. Эти внешние файлы не входят в архив сцены. Их исходные уведомления по-прежнему действуют.", "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Используется зарегистрированный контент или связанные медиафайлы. Эти внешние файлы не входят в архив сцены. Их исходные уведомления по-прежнему действуют.",
"{state} {layers}": "{state}: {layers}", "{state} {layers}": "{state}: {layers}",
"Selected Space Mission": "Выбранная космическая миссия", "Selected Space Mission": "Выбранная космическая миссия",
"{duration}s + {hold}s": "{duration} с + {hold} с" "{duration}s + {hold}s": "{duration} с + {hold} с",
"Configured Source": "Настроенный источник",
"subject": "объект",
"{value}m": "{value} м"
} }
+4 -6
View File
@@ -507,9 +507,8 @@ export class SceneDirector {
const scene = { const scene = {
id: uid('scene'), id: uid('scene'),
title: // i18n-ignore: stored English, like normalizeProject; displaySceneTitle translates it.
sceneName.trim() || title: sceneName.trim() || `Scene ${this._project.scenes.length + 1}`,
t('Scene {n}', { n: this._project.scenes.length + 1 }),
shots: [], shots: [],
}; };
@@ -967,9 +966,8 @@ export class SceneDirector {
const shot = normalizeShot( const shot = normalizeShot(
{ {
id: uid('shot'), id: uid('shot'),
title: t('Shot {n}', { // i18n-ignore: stored English; displayShotTitle translates it when shown.
n: scene.shots.length + 1, title: `Shot ${scene.shots.length + 1}`,
}),
durationSec: DEFAULT_SHOT_DURATION_SEC, durationSec: DEFAULT_SHOT_DURATION_SEC,
holdSec: DEFAULT_HOLD_SEC, holdSec: DEFAULT_HOLD_SEC,
camera, camera,
+4 -2
View File
@@ -1045,8 +1045,10 @@ const BUILTIN_SHOT_TITLES = new Set([
*/ */
export function displaySceneTitle(scene) { export function displaySceneTitle(scene) {
const builtin = SCENE_RECIPES.find((recipe) => recipe.id === scene?.id); const builtin = SCENE_RECIPES.find((recipe) => recipe.id === scene?.id);
if (!builtin?.title) return scene?.title || ''; if (builtin?.title && scene.title === builtin.title) return t(builtin.title);
return scene.title === builtin.title ? t(builtin.title) : scene.title; const title = scene?.title || '';
const numbered = /^Scene (\d+)$/.exec(title);
return numbered ? t('Scene {n}', { n: Number(numbered[1]) }) : title;
} }
/** /**
+8 -3
View File
@@ -1,5 +1,4 @@
import { formatNumber, t, msg } from '../i18n/index.js'; import { formatNumber, t, msg } from '../i18n/index.js';
import { formatQuantity } from './unitLabels.js';
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */ /** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180; const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
@@ -73,8 +72,14 @@ function calValueText(field, value) {
minimumFractionDigits: field.decimals, minimumFractionDigits: field.decimals,
maximumFractionDigits: field.decimals, maximumFractionDigits: field.decimals,
}; };
if (field.unit === 'm') if (field.unit === 'm') {
return `${t(field.label)} ${formatQuantity(Number(value), 'm', options)}`; // English keeps its compact "RANGE 120m"; other languages space the unit.
const metres = formatNumber(Number(value), {
useGrouping: false,
...options,
});
return `${t(field.label)} ${t('{value}m', { value: metres })}`;
}
const number = formatNumber(Number(value), { const number = formatNumber(Number(value), {
useGrouping: false, useGrouping: false,
...options, ...options,
+1 -1
View File
@@ -39,7 +39,7 @@ const SOURCE_MESSAGES = Object.freeze({
const SOURCE_LABELS = Object.freeze({ const SOURCE_LABELS = Object.freeze({
'Configured source': msg('Configured source'), 'Configured source': msg('Configured source'),
'Configured Source': msg('Configured source'), 'Configured Source': msg('Configured Source'),
'Synthetic fallback': msg('Synthetic fallback'), 'Synthetic fallback': msg('Synthetic fallback'),
}); });
+7 -3
View File
@@ -126,9 +126,13 @@ export function createSceneDialog(title, onClose) {
const chosen = document.createElement('span'); const chosen = document.createElement('span');
const showChosen = () => { const showChosen = () => {
const count = node.files?.length || 0; const count = node.files?.length || 0;
chosen.textContent = count // One file shows its name, like the browser's own picker did.
? tn(count, '{count} file selected', '{count} files selected') chosen.textContent =
: t('No file chosen'); count === 1
? node.files[0].name
: count
? tn(count, '{count} file selected', '{count} files selected')
: t('No file chosen');
}; };
showChosen(); showChosen();
row.append(chooser, chosen); row.append(chooser, chosen);
+1 -1
View File
@@ -193,7 +193,7 @@ export class ShareRestoration {
state: 'acquiring', state: 'acquiring',
detail: t('SHARED {subject}', { detail: t('SHARED {subject}', {
subject: String( subject: String(
result.label ? t(result.label) : t('entity'), result.label ? t(result.label) : t('subject'),
).toUpperCase(), ).toUpperCase(),
}), }),
persistent: true, persistent: true,
+8 -9
View File
@@ -139,15 +139,14 @@ export class ShellFeedback {
// the flap keeps textContent equal to the settled label throughout, so // the flap keeps textContent equal to the settled label throughout, so
// this stays a no-op on the repeat ticks exactly as it did before. // this stays a no-op on the repeat ticks exactly as it did before.
if (presentation.label) if (presentation.label)
if (presentation.label) setSplitFlapText(
setSplitFlapText( this._trafficSyncLabel,
this._trafficSyncLabel, localizeStatusLabel(presentation.label),
localizeStatusLabel(presentation.label), {
{ // Completion is a timing signal, not another animated loading stage.
// Completion is a timing signal, not another animated loading stage. immediate: !presentation.busy,
immediate: !presentation.busy, },
}, );
);
// Written on every change INCLUDING the empty settled value — the reducer // Written on every change INCLUDING the empty settled value — the reducer
// clears the progress number once the sync lands, and a truthiness guard // clears the progress number once the sync lands, and a truthiness guard
// here would strand the last "..." beside the settled label. // here would strand the last "..." beside the settled label.
+26 -11
View File
@@ -106,26 +106,22 @@ html[lang='es'] .cctv-controls {
} }
/* Click-to-edit effective-pose readout (viewshed/gizmo design §3d): compact /* Click-to-edit effective-pose readout (viewshed/gizmo design §3d): compact
value chips replace the seven offset sliders. Tight enough that the seven value chips replace the seven offset sliders. */
chips take two rows in a long-label language too, which keeps SAVE / RESET
in view; chips grow to fill each row, and a value never wraps inside one. */
.cctv-cal-readout { .cctv-cal-readout {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 5px;
} }
.cctv-cal-value { .cctv-cal-value {
flex: 1 1 auto;
white-space: nowrap;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 9px; font-size: 9px;
letter-spacing: 0.3px; letter-spacing: 0.7px;
color: #91edff; color: #91edff;
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 7px; border-radius: 7px;
padding: 4px; padding: 4px 7px;
cursor: text; cursor: text;
transition: transition:
border-color 0.15s ease, border-color 0.15s ease,
@@ -175,9 +171,8 @@ html[lang='es'] .cctv-controls {
border-radius: 7px; border-radius: 7px;
color: var(--text-primary); color: var(--text-primary);
padding: 6px 8px; padding: 6px 8px;
font-family: var(--font-mono); font-family: var(--font-sans);
font-size: 10px; font-size: 12px;
letter-spacing: 0.04em;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -389,3 +384,23 @@ html[lang='es'] .cctv-controls {
color: #a8f7ff; color: #a8f7ff;
text-shadow: 0 0 10px rgba(0, 212, 255, 0.18); 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. */
html:not([lang='en']) .cctv-cal-readout {
gap: 4px;
}
html:not([lang='en']) .cctv-cal-value {
flex: 1 1 auto;
white-space: nowrap;
letter-spacing: 0.3px;
padding: 4px;
}
html:not([lang='en']) #cctv-camera-select {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.04em;
}
+8 -3
View File
@@ -158,9 +158,8 @@
border-radius: 7px; border-radius: 7px;
color: var(--text-primary); color: var(--text-primary);
padding: 6px 8px; padding: 6px 8px;
font-family: var(--font-mono); font-family: var(--font-sans);
font-size: 10px; font-size: 12px;
letter-spacing: 0.04em;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -466,3 +465,9 @@
.director-sharing-dialog::backdrop { .director-sharing-dialog::backdrop {
background: rgba(0, 8, 14, 0.65); 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;
}
+10 -8
View File
@@ -89,17 +89,18 @@
cost past the card edge. The kicker already collapses to zero width here; 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 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 reaches left over the gutter beside the mic ring: at heading height the
ring has already curved away. */ ring has already curved away. English fits as it is, so this applies to
#command-dock .gev-voice-heading { the other languages only. */
html:not([lang='en']) #command-dock .gev-voice-heading {
gap: 0.2rem; gap: 0.2rem;
margin-left: -0.6rem; margin-left: -0.6rem;
} }
#command-dock .gev-voice-heading > .gev-voice-kicker { html:not([lang='en']) #command-dock .gev-voice-heading > .gev-voice-kicker {
display: none; display: none;
} }
#command-dock .gev-voice-heading > #gev-voice-status { html:not([lang='en']) #command-dock .gev-voice-heading > #gev-voice-status {
flex: 0 1 auto; flex: 0 1 auto;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -108,24 +109,25 @@
white-space: nowrap; white-space: nowrap;
} }
#command-dock .gev-voice-cost { html:not([lang='en']) #command-dock .gev-voice-cost {
gap: 0.16rem; gap: 0.16rem;
padding-left: 0.1rem; padding-left: 0.1rem;
} }
#command-dock .gev-voice-tier-btn { html:not([lang='en']) #command-dock .gev-voice-tier-btn {
min-width: 0; min-width: 0;
padding: 0 0.2rem; padding: 0 0.2rem;
} }
#command-dock .gev-voice-cost-value { html:not([lang='en']) #command-dock .gev-voice-cost-value {
letter-spacing: 0; letter-spacing: 0;
} }
/* Live hints ("Ask or command", "Requesting microphone") run longer in other /* Live hints ("Ask or command", "Requesting microphone") run longer in other
languages; give them a second line from the spare space under the readout languages; give them a second line from the spare space under the readout
rather than cutting them mid-word. */ rather than cutting them mid-word. */
#command-dock html:not([lang='en'])
#command-dock
#gev-voice-control:is( #gev-voice-control:is(
[data-status='connecting'], [data-status='connecting'],
[data-status='listening'], [data-status='listening'],