From 9d01f9bfcd7a38479e3764f291cbf5f0d1ffe6e1 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 13 Oct 2026 20:03:54 +0400 Subject: [PATCH] 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 --- scripts/qa-l9-matrix.mjs | 5 +++-- src/i18n/i18n.test.mjs | 25 ++++++++++++++++++++++++ src/i18n/index.js | 8 +++++--- src/i18n/locales/es.json | 5 ++++- src/i18n/locales/ru.json | 5 ++++- src/scenes/director.js | 10 ++++------ src/scenes/recipes.js | 6 ++++-- src/ui/cctvCalibration.js | 11 ++++++++--- src/ui/cctvFrames.js | 2 +- src/ui/sceneSharing.js | 10 +++++++--- src/ui/shareRestoration.js | 2 +- src/ui/shellFeedback.js | 17 ++++++++--------- src/ui/styles/cctv.css | 37 +++++++++++++++++++++++++----------- src/ui/styles/scenes.css | 11 ++++++++--- src/ui/styles/voice-cost.css | 18 ++++++++++-------- 15 files changed, 118 insertions(+), 54 deletions(-) diff --git a/scripts/qa-l9-matrix.mjs b/scripts/qa-l9-matrix.mjs index 2a3d7328e..fdac4fd40 100644 --- a/scripts/qa-l9-matrix.mjs +++ b/scripts/qa-l9-matrix.mjs @@ -2006,8 +2006,9 @@ async function runBrowserGroup(record) { for (let i = 0; i < 20; i += 1) { // eslint-disable-next-line no-await-in-loop await new Promise((r) => setTimeout(r, 500)); - const status = document.getElementById('gev-voice-status')?.textContent?.trim() || ''; - if (status && status !== 'CONNECTING') break; + // The label is translated; the machine state is on the control. + const status = document.getElementById('gev-voice-control')?.dataset.status || ''; + if (status && status !== 'connecting') break; } let diag = null; try { diag = vc.getDiagnostics?.(); } catch { /* not started */ } diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 5d76567c5..1c0700aad 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -188,6 +188,31 @@ test('applyDocumentTranslations translates opted-in text and attributes in place 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', () => { const storage = memoryStorage(); const calls = []; diff --git a/src/i18n/index.js b/src/i18n/index.js index 281acfdcd..0cb04c995 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -212,7 +212,7 @@ export function applyDocumentTranslations(doc) { /** * Persist a locale choice, then reload preserving window.location.hash exactly * 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. */ export function persistLocaleAndReload( @@ -224,12 +224,14 @@ export function persistLocaleAndReload( } = {}, ) { const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; - writeStoredLocale(normalized, storage); + const stored = writeStoredLocale(normalized, storage); if (!location || typeof location.assign !== 'function') return false; let target = location.href; try { + // Without storage the choice only survives in the URL, so keep it there. 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; } catch { // Keep the current href. diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index c9459c31c..7f512c95d 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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.", "{state} {layers}": "{state}: {layers}", "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" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index bb32eb792..605f82c66 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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.": "Используется зарегистрированный контент или связанные медиафайлы. Эти внешние файлы не входят в архив сцены. Их исходные уведомления по-прежнему действуют.", "{state} {layers}": "{state}: {layers}", "Selected Space Mission": "Выбранная космическая миссия", - "{duration}s + {hold}s": "{duration} с + {hold} с" + "{duration}s + {hold}s": "{duration} с + {hold} с", + "Configured Source": "Настроенный источник", + "subject": "объект", + "{value}m": "{value} м" } diff --git a/src/scenes/director.js b/src/scenes/director.js index e134138a8..18ac1d501 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -507,9 +507,8 @@ export class SceneDirector { const scene = { id: uid('scene'), - title: - sceneName.trim() || - t('Scene {n}', { n: this._project.scenes.length + 1 }), + // i18n-ignore: stored English, like normalizeProject; displaySceneTitle translates it. + title: sceneName.trim() || `Scene ${this._project.scenes.length + 1}`, shots: [], }; @@ -967,9 +966,8 @@ export class SceneDirector { const shot = normalizeShot( { id: uid('shot'), - title: t('Shot {n}', { - n: scene.shots.length + 1, - }), + // i18n-ignore: stored English; displayShotTitle translates it when shown. + title: `Shot ${scene.shots.length + 1}`, durationSec: DEFAULT_SHOT_DURATION_SEC, holdSec: DEFAULT_HOLD_SEC, camera, diff --git a/src/scenes/recipes.js b/src/scenes/recipes.js index 4a330d142..29e3572ed 100644 --- a/src/scenes/recipes.js +++ b/src/scenes/recipes.js @@ -1045,8 +1045,10 @@ const BUILTIN_SHOT_TITLES = new Set([ */ export function displaySceneTitle(scene) { const builtin = SCENE_RECIPES.find((recipe) => recipe.id === scene?.id); - if (!builtin?.title) return scene?.title || ''; - return scene.title === builtin.title ? t(builtin.title) : scene.title; + if (builtin?.title && scene.title === builtin.title) return t(builtin.title); + const title = scene?.title || ''; + const numbered = /^Scene (\d+)$/.exec(title); + return numbered ? t('Scene {n}', { n: Number(numbered[1]) }) : title; } /** diff --git a/src/ui/cctvCalibration.js b/src/ui/cctvCalibration.js index b2d6dd1a2..8144edd8a 100644 --- a/src/ui/cctvCalibration.js +++ b/src/ui/cctvCalibration.js @@ -1,5 +1,4 @@ import { formatNumber, t, msg } from '../i18n/index.js'; -import { formatQuantity } from './unitLabels.js'; /** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */ const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180; @@ -73,8 +72,14 @@ function calValueText(field, value) { minimumFractionDigits: field.decimals, maximumFractionDigits: field.decimals, }; - if (field.unit === 'm') - return `${t(field.label)} ${formatQuantity(Number(value), 'm', options)}`; + if (field.unit === 'm') { + // 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), { useGrouping: false, ...options, diff --git a/src/ui/cctvFrames.js b/src/ui/cctvFrames.js index afedb64fa..7976d46c8 100644 --- a/src/ui/cctvFrames.js +++ b/src/ui/cctvFrames.js @@ -39,7 +39,7 @@ const SOURCE_MESSAGES = Object.freeze({ const SOURCE_LABELS = Object.freeze({ 'Configured source': msg('Configured source'), - 'Configured Source': msg('Configured source'), + 'Configured Source': msg('Configured Source'), 'Synthetic fallback': msg('Synthetic fallback'), }); diff --git a/src/ui/sceneSharing.js b/src/ui/sceneSharing.js index f8ce8e51a..b234eebf1 100644 --- a/src/ui/sceneSharing.js +++ b/src/ui/sceneSharing.js @@ -126,9 +126,13 @@ export function createSceneDialog(title, onClose) { const chosen = document.createElement('span'); const showChosen = () => { const count = node.files?.length || 0; - chosen.textContent = count - ? tn(count, '{count} file selected', '{count} files selected') - : t('No file chosen'); + // One file shows its name, like the browser's own picker did. + chosen.textContent = + count === 1 + ? node.files[0].name + : count + ? tn(count, '{count} file selected', '{count} files selected') + : t('No file chosen'); }; showChosen(); row.append(chooser, chosen); diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 21f50fbef..c0b98ec56 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -193,7 +193,7 @@ export class ShareRestoration { state: 'acquiring', detail: t('SHARED {subject}', { subject: String( - result.label ? t(result.label) : t('entity'), + result.label ? t(result.label) : t('subject'), ).toUpperCase(), }), persistent: true, diff --git a/src/ui/shellFeedback.js b/src/ui/shellFeedback.js index ec2d00ef0..d8a77dbc3 100644 --- a/src/ui/shellFeedback.js +++ b/src/ui/shellFeedback.js @@ -139,15 +139,14 @@ export class ShellFeedback { // 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. if (presentation.label) - if (presentation.label) - setSplitFlapText( - this._trafficSyncLabel, - localizeStatusLabel(presentation.label), - { - // Completion is a timing signal, not another animated loading stage. - immediate: !presentation.busy, - }, - ); + setSplitFlapText( + this._trafficSyncLabel, + localizeStatusLabel(presentation.label), + { + // Completion is a timing signal, not another animated loading stage. + immediate: !presentation.busy, + }, + ); // Written on every change INCLUDING the empty settled value — the reducer // clears the progress number once the sync lands, and a truthiness guard // here would strand the last "..." beside the settled label. diff --git a/src/ui/styles/cctv.css b/src/ui/styles/cctv.css index b858121ad..6c8acbdcf 100644 --- a/src/ui/styles/cctv.css +++ b/src/ui/styles/cctv.css @@ -106,26 +106,22 @@ html[lang='es'] .cctv-controls { } /* Click-to-edit effective-pose readout (viewshed/gizmo design §3d): compact - value chips replace the seven offset sliders. Tight enough that the seven - 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. */ + value chips replace the seven offset sliders. */ .cctv-cal-readout { display: flex; flex-wrap: wrap; - gap: 4px; + gap: 5px; } .cctv-cal-value { - flex: 1 1 auto; - white-space: nowrap; font-family: var(--font-mono); font-size: 9px; - letter-spacing: 0.3px; + letter-spacing: 0.7px; color: #91edff; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 7px; - padding: 4px; + padding: 4px 7px; cursor: text; transition: border-color 0.15s ease, @@ -175,9 +171,8 @@ html[lang='es'] .cctv-controls { border-radius: 7px; color: var(--text-primary); padding: 6px 8px; - font-family: var(--font-mono); - font-size: 10px; - letter-spacing: 0.04em; + font-family: var(--font-sans); + font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -389,3 +384,23 @@ html[lang='es'] .cctv-controls { color: #a8f7ff; 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; +} diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index 8e9a5f3f6..fbec5ab04 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -158,9 +158,8 @@ border-radius: 7px; color: var(--text-primary); padding: 6px 8px; - font-family: var(--font-mono); - font-size: 10px; - letter-spacing: 0.04em; + font-family: var(--font-sans); + font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -466,3 +465,9 @@ .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; +} diff --git a/src/ui/styles/voice-cost.css b/src/ui/styles/voice-cost.css index b8610d010..1c6f3da29 100644 --- a/src/ui/styles/voice-cost.css +++ b/src/ui/styles/voice-cost.css @@ -89,17 +89,18 @@ 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. */ -#command-dock .gev-voice-heading { + ring has already curved away. English fits as it is, so this applies to + the other languages only. */ +html:not([lang='en']) #command-dock .gev-voice-heading { gap: 0.2rem; 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; } -#command-dock .gev-voice-heading > #gev-voice-status { +html:not([lang='en']) #command-dock .gev-voice-heading > #gev-voice-status { flex: 0 1 auto; min-width: 0; overflow: hidden; @@ -108,24 +109,25 @@ white-space: nowrap; } -#command-dock .gev-voice-cost { +html:not([lang='en']) #command-dock .gev-voice-cost { gap: 0.16rem; padding-left: 0.1rem; } -#command-dock .gev-voice-tier-btn { +html:not([lang='en']) #command-dock .gev-voice-tier-btn { min-width: 0; padding: 0 0.2rem; } -#command-dock .gev-voice-cost-value { +html:not([lang='en']) #command-dock .gev-voice-cost-value { letter-spacing: 0; } /* Live hints ("Ask or command", "Requesting microphone") run longer in other languages; give them a second line from the spare space under the readout rather than cutting them mid-word. */ -#command-dock +html:not([lang='en']) + #command-dock #gev-voice-control:is( [data-status='connecting'], [data-status='listening'],