i18n: fix review findings and polish ru/es strings

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

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

Catalogs: es/ru 2535 messages each, complete.

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

No files matched your search

+64 -22
View File
@@ -281,22 +281,16 @@
border-top: 3px solid #6be8ff;
}
/* The title takes the full card width and up to two lines, so a translated
title keeps its place name; the link sits beside the status below it. */
.bhote-embedded-callout footer strong {
grid-column: 1 / -1;
display: -webkit-box;
overflow: hidden;
font-size: 11px;
line-height: 1.25;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-overflow: ellipsis;
white-space: nowrap;
}
.bhote-embedded-callout footer small {
grid-column: 1;
align-self: center;
color: rgba(151, 211, 224, 0.7);
font-size: 8px;
line-height: 1.35;
@@ -304,14 +298,34 @@
.bhote-embedded-callout footer a {
grid-column: 2;
grid-row: 2;
grid-row: 1 / span 2;
align-self: center;
white-space: nowrap;
color: #6be8ff;
font-size: 8px;
text-decoration: none;
}
/* The title takes the full card width and up to two lines, so a translated
title keeps its place name; the link sits beside the status below it. */
html:not([lang='en']) .bhote-embedded-callout footer strong {
grid-column: 1 / -1;
display: -webkit-box;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
html:not([lang='en']) .bhote-embedded-callout footer small {
align-self: center;
}
html:not([lang='en']) .bhote-embedded-callout footer a {
grid-row: 2;
white-space: nowrap;
}
.bhote-embedded-callout footer a:hover,
.bhote-embedded-callout footer a:focus-visible {
text-decoration: underline;
@@ -423,8 +437,9 @@ html:not([lang='en']) .bhote-event-section-head span:last-child {
color: rgba(174, 208, 216, 0.62);
}
/* Keep "16 OCT 2021" together so the year never wraps alone. */
.bhote-event-date {
/* Keep a translated date ("16 OCT 2021") together so the year never wraps
alone. */
html:not([lang='en']) .bhote-event-date {
white-space: nowrap;
}
@@ -458,7 +473,7 @@ html:not([lang='en']) .bhote-event-section-head span:last-child {
align-content: center;
column-gap: 9px;
min-width: 0;
padding: 5px 10px;
padding: 7px 10px;
border: 1px solid rgba(88, 196, 218, 0.18);
background: rgba(6, 19, 27, 0.72);
}
@@ -470,26 +485,45 @@ html:not([lang='en']) .bhote-event-section-head span:last-child {
line-height: 1.25;
}
/* Up to two lines: a translated beat title keeps its subject. Two title lines
and one meta line still fit the 54px arrow buttons, so the controls stay put. */
.bhote-event-story-current strong {
display: -webkit-box;
overflow: hidden;
color: var(--text-primary);
font-size: 11px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.bhote-event-story-current small {
grid-column: 1 / -1;
margin-top: 4px;
overflow: hidden;
color: rgba(116, 216, 235, 0.7);
text-overflow: ellipsis;
white-space: nowrap;
}
html:not([lang='en']) .bhote-event-story-current {
padding: 5px 10px;
}
/* Up to two lines: a translated beat title keeps its subject. Two title lines
and one meta line still fit the 54px arrow buttons, so the controls stay put. */
html:not([lang='en']) .bhote-event-story-current strong {
display: -webkit-box;
text-overflow: clip;
white-space: normal;
overflow-wrap: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
/* Phase / time / sources run long in translation: allow a second line. */
.bhote-event-story-current small {
html:not([lang='en']) .bhote-event-story-current small {
display: -webkit-box;
grid-column: 1 / -1;
margin-top: 3px;
overflow: hidden;
color: rgba(116, 216, 235, 0.7);
text-overflow: clip;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
@@ -514,14 +548,22 @@ html:not([lang='en']) .bhote-event-section-head span:last-child {
cursor: pointer;
}
/* One line in English; a longer translation wraps to a second line. */
.bhote-event-actions button {
min-width: 0;
min-height: 30px;
padding: 3px 5px;
padding: 0 5px;
overflow: hidden;
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A longer translation wraps to a second line instead of being cut. */
html:not([lang='en']) .bhote-event-actions button {
padding: 3px 5px;
line-height: 1.2;
text-overflow: clip;
white-space: normal;
overflow-wrap: break-word;
}
+8 -4
View File
@@ -279,12 +279,12 @@ html[lang='es'] .cctv-controls {
animation: cctv-frame-shimmer 1.4s linear infinite;
}
/* "Acquiring" caption only when no previous frame is showing underneath */
.cctv-frame-wrap.loading:not(.has-frame)::before {
content: 'ACQUIRING FRAME';
/* "Acquiring" caption only when no previous frame is showing underneath.
The text lives in the markup (data-i18n) so it is translated. */
.cctv-frame-caption {
display: none;
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
@@ -294,6 +294,10 @@ html[lang='es'] .cctv-controls {
pointer-events: none;
}
.cctv-frame-wrap.loading:not(.has-frame) .cctv-frame-caption {
display: flex;
}
@keyframes cctv-frame-shimmer {
to {
transform: translateX(100%);
+23 -10
View File
@@ -575,7 +575,9 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed))
text-align: center;
vertical-align: -0.08em;
}
.cockpit-roll-token.is-separator {
/* Grouping and decimal separators (a space or comma in Spanish and Russian)
get a narrow cell; English keeps the full 0.56em cell. */
html:not([lang='en']) .cockpit-roll-token.is-separator {
min-width: 0.3em;
}
.cockpit-roll-token.is-rolling {
@@ -1508,9 +1510,11 @@ body.cockpit-mode #view-switcher button {
.cockpit-signal-header > div {
display: grid;
gap: 2px;
}
html:not([lang='en']) .cockpit-signal-header > div {
min-width: 0;
}
.cockpit-signal-kicker {
html:not([lang='en']) .cockpit-signal-kicker {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1543,7 +1547,7 @@ body.cockpit-mode #view-switcher button {
}
/* A longer translated state label widens the cycle button; the other
controls keep their size instead of being squeezed. */
.cockpit-brief-actions > * {
html:not([lang='en']) .cockpit-brief-actions > * {
flex-shrink: 0;
}
.cockpit-brief-actions button {
@@ -1568,12 +1572,15 @@ body.cockpit-mode #view-switcher button {
font-size: 14px;
}
#cockpit-brief-auto {
width: auto;
min-width: 50px;
padding: 0 4px;
width: 50px;
color: rgba(244, 251, 255, 0.42);
font: 700 6px/1 var(--font-mono);
letter-spacing: 0.05em;
}
html:not([lang='en']) #cockpit-brief-auto {
width: auto;
min-width: 50px;
padding: 0 4px;
white-space: nowrap;
}
#cockpit-brief-auto[aria-pressed='true'] {
@@ -1880,18 +1887,24 @@ body.cockpit-mode #view-switcher button {
letter-spacing: 0.1em;
}
.cockpit-signal-footer > span {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cockpit-brief-tabs {
display: flex;
flex: 0 0 auto;
gap: 3px;
}
.cockpit-brief-tabs button {
/* Translated tab names are longer: the tabs keep their words whole and the
footer text beside them gives way. */
html:not([lang='en']) .cockpit-signal-footer > span {
flex: 1 1 auto;
min-width: 0;
}
html:not([lang='en']) .cockpit-brief-tabs {
flex: 0 0 auto;
}
html:not([lang='en']) .cockpit-brief-tabs button {
white-space: nowrap;
}
.cockpit-brief-tabs button {
+31 -14
View File
@@ -240,8 +240,6 @@ html:not([lang='en'])
}
.panel-collapse-btn {
/* A long translated title must not squeeze the toggle into a sliver. */
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 6px;
@@ -258,6 +256,11 @@ html:not([lang='en'])
color var(--transition-fast);
}
/* A long translated title must not squeeze the toggle into a sliver. */
html:not([lang='en']) .panel-collapse-btn {
flex-shrink: 0;
}
.panel-collapse-btn:hover {
border-color: var(--glass-border-hover);
color: var(--text-primary);
@@ -787,8 +790,9 @@ html:not([lang='en'])
/* One label column across the Display rows: --pp-label-chars is the longest
label of the active language (set by applicationShell), and the labels are
monospace, so every slider track starts at the same x whatever the
language. 0.125em is the 1px letter-spacing at 8px. */
.pp-slider-row > .pp-slider-mini-label {
language. 0.125em is the 1px letter-spacing at 8px. English keeps its
own label widths. */
html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
flex: 0 0 auto;
min-width: calc(var(--pp-label-chars, 0) * (1ch + 0.125em));
}
@@ -989,9 +993,9 @@ html:not([lang='en'])
#draw-mode-row {
min-width: 184px;
}
/* Equal thirds with room around the label, so a long shape name never runs
edge to edge in its segment. */
#draw-mode-row .pp-mode-btn {
/* Equal thirds with room around the label, so a long translated shape name
never runs edge to edge in its segment. */
html:not([lang='en']) #draw-mode-row .pp-mode-btn {
padding: 0 4px;
}
#draw-label-row {
@@ -1015,9 +1019,11 @@ html:not([lang='en'])
.pp-text-input:focus {
border-color: var(--accent);
}
/* Sized to its longest colour name so a translated one is never clipped;
74px keeps the English width. */
.draw-color-select {
flex: 0 0 74px;
}
/* Sized to its longest colour name so a translated one is never clipped. */
html:not([lang='en']) .draw-color-select {
flex: 0 0 auto;
min-width: 74px;
}
@@ -1339,20 +1345,31 @@ body.ui-clean-view #scene-runtime.active {
);
}
/* One label column for every parameter, as wide as the longest label in the
active language, so all slider tracks start and end at the same x. */
#param-sliders {
.param-slider-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.param-slider-row:last-child {
margin-bottom: 0;
}
/* One label column for every parameter, as wide as the longest translated
label, so all slider tracks start and end at the same x. */
html:not([lang='en']) #param-sliders {
display: grid;
grid-template-columns: max-content minmax(0, 1fr) max-content;
align-items: center;
gap: 10px;
}
.param-slider-row {
html:not([lang='en']) .param-slider-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
margin-bottom: 0;
}
.param-label {
+212
View File
@@ -0,0 +1,212 @@
// K-44 (+ refront-opus-1): layout rules written for long translations must not change the
// English rendering ("English renders as before"). Every rule below is read
// from the stylesheet without a `html:not([lang='en'])` guard, so it applies
// to English too; the expected values are the ones on main (9beadbda).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const css = (name) => readFileSync(join(here, name), 'utf8');
/** Declarations of every rule whose (unguarded) selector list is `selector`. */
function unguardedRules(source, selector) {
const norm = (s) => s.replace(/\s+/g, ' ').trim();
const want = norm(selector);
const out = [];
const re = /([^{}]+)\{([^{}]*)\}/g;
let m;
while ((m = re.exec(source))) {
// The captured "selector" may carry the tail of a preceding block
// (comments, an @media prelude); keep what follows the last `}` or `*/`.
const raw = m[1].split(/\*\/|\}/).pop();
const sel = norm(raw);
if (sel === want && !sel.includes("html:not([lang='en'])")) out.push(m[2]);
}
return out;
}
function declaration(block, prop) {
const m = block.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`));
return m ? m[1].replace(/\s+/g, ' ').trim() : undefined;
}
test('K-44: layer status chip keeps its English width (82px)', () => {
const rules = unguardedRules(
css('radio.css'),
`.data-toggle-btn.feed-loading, .data-toggle-btn.feed-degraded,
.data-toggle-btn.feed-stale, .data-toggle-btn.feed-partial,
.data-toggle-btn.feed-fallback, .data-toggle-btn.feed-unavailable`,
);
assert.equal(rules.length, 1);
assert.equal(declaration(rules[0], 'min-width'), '82px');
});
test('K-44: Directions steps keep one line with an ellipsis in English', () => {
const [rule] = unguardedRules(css('radio.css'), '.data-row-list-text');
assert.ok(rule);
assert.equal(declaration(rule, 'white-space'), 'nowrap');
assert.equal(declaration(rule, 'text-overflow'), 'ellipsis');
});
test('K-44: count-line layout is not reachable from an unguarded selector', () => {
const source = css('radio.css');
assert.equal(
unguardedRules(source, '.data-toggle-row--count-line .data-count').length,
0,
);
});
test('K-44: weather status line keeps its fixed English height', () => {
const [rule] = unguardedRules(
css('weather.css'),
".weather-card [data-line-id='status']",
);
assert.ok(rule);
assert.equal(declaration(rule, 'height'), '1.45em');
});
test('K-44: cockpit number separators keep the English cell width', () => {
assert.equal(
unguardedRules(css('cockpit.css'), '.cockpit-roll-token.is-separator')
.length,
0,
);
});
test('K-44: Bhote Koshi callout keeps its English footer layout', () => {
const source = css('bhote-koshi.css');
const [strong] = unguardedRules(
source,
'.bhote-embedded-callout footer strong',
);
assert.equal(declaration(strong, 'white-space'), 'nowrap');
const [link] = unguardedRules(source, '.bhote-embedded-callout footer a');
assert.equal(declaration(link, 'grid-row'), '1 / span 2');
});
test('K-44: SCENES actions stay on one line in English (refront-opus-1)', () => {
assert.equal(
unguardedRules(css('scenes.css'), '#scene-panel .scene-controls .scene-btn')
.length,
0,
);
const [select] = unguardedRules(css('scenes.css'), '#scene-select');
assert.equal(declaration(select, 'white-space'), undefined);
});
test('K-44: Display and parameter rows keep their English layout', () => {
const source = css('controls.css');
assert.equal(
unguardedRules(source, '.pp-slider-row > .pp-slider-mini-label').length,
0,
);
assert.equal(unguardedRules(source, '#param-sliders').length, 0);
const [row] = unguardedRules(source, '.param-slider-row');
assert.equal(declaration(row, 'display'), 'flex');
const [color] = unguardedRules(source, '.draw-color-select');
assert.equal(declaration(color, 'flex'), '0 0 74px');
});
test('K-44: weather setting rows keep the 56px English label column', () => {
const source = css('weather.css');
assert.equal(unguardedRules(source, '.rail-card-settings').length, 0);
const [row] = unguardedRules(source, '.rail-card-setting');
assert.equal(
declaration(row, 'grid-template-columns'),
'56px minmax(0, 1fr)',
);
});
test('K-44: cockpit brief controls keep their English sizes', () => {
const source = css('cockpit.css');
const [auto] = unguardedRules(source, '#cockpit-brief-auto');
assert.equal(declaration(auto, 'width'), '50px');
assert.equal(unguardedRules(source, '.cockpit-brief-actions > *').length, 0);
});
test('K-44: the count-line class is set only outside English', async () => {
const { setLocale } = await import('../../i18n/index.js');
const { LayerPanel } = await import('../layerPanel.js');
const classes = () => {
const toggled = new Map();
const node = () => ({
textContent: '',
classList: { toggle: (name, on) => toggled.set(name, on) },
appendChild() {},
});
LayerPanel.prototype._syncCount.call(LayerPanel.prototype, node(), node(), {
countLabel: '693 nearby · 93 on screen',
});
return toggled.get('data-toggle-row--count-line');
};
globalThis.document ??= { createElement: () => ({}) };
try {
setLocale('en');
assert.equal(classes(), false);
setLocale('es');
assert.equal(classes(), true);
} finally {
setLocale('en');
}
});
test('K-44: Recent Imagery readout keeps its English layout', () => {
const source = css('recent-imagery.css');
const one = (selector) => {
const rules = unguardedRules(source, selector);
assert.equal(rules.length, 1, selector);
return rules[0];
};
// The SHOW chip shares its row with the reserved B half.
assert.equal(
unguardedRules(
source,
".recent-imagery-readout:not([data-mode='ab']) .ri-card-chips .ri-chip[data-slot='a']",
).length,
0,
);
assert.equal(
unguardedRules(
source,
".recent-imagery-readout:not([data-mode='ab']) .ri-card-chips .ri-chip[data-slot='b']",
).length,
0,
);
// Slot rows: their own fixed 44px tag column, no subgrid.
const selection = one('.recent-imagery-readout .ri-selection');
assert.equal(declaration(selection, 'grid-template-columns'), undefined);
assert.equal(declaration(selection, 'column-gap'), undefined);
assert.equal(
declaration(one('.recent-imagery-readout .ri-mode'), 'grid-column'),
undefined,
);
const slot = one('.recent-imagery-readout .ri-slot');
assert.equal(
declaration(slot, 'grid-template-columns'),
'44px minmax(0, 1fr) 22px',
);
assert.equal(declaration(slot, 'grid-column'), undefined);
assert.equal(declaration(slot, 'height'), '20px');
assert.equal(
declaration(one('.recent-imagery-readout .ri-slot-tag'), 'padding'),
undefined,
);
// Opacity / SWAP / export row: fixed English columns.
assert.equal(
declaration(
one('.recent-imagery-readout .ri-controls'),
'grid-template-columns',
),
'76px minmax(0, 1fr) 40px 128px',
);
const label = one('.recent-imagery-readout .ri-opacity-label');
assert.equal(declaration(label, 'flex-wrap'), undefined);
assert.equal(declaration(label, 'gap'), '4px');
assert.equal(declaration(label, 'line-height'), undefined);
const swap = one('.recent-imagery-readout .ri-swap');
assert.equal(declaration(swap, 'width'), '40px');
assert.equal(declaration(swap, 'min-width'), undefined);
});
+83 -43
View File
@@ -250,12 +250,8 @@
gap: 5px;
}
.radio-transport button {
min-width: 0;
min-height: 30px;
padding: 6px 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid rgba(65, 220, 231, 0.28);
border-radius: 5px;
color: #8feaf2;
@@ -264,6 +260,13 @@
letter-spacing: 0.06em;
cursor: pointer;
}
/* A longer translated action stays on one line, cut with an ellipsis. */
html:not([lang='en']) .radio-transport button {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.radio-transport button:hover:not(:disabled),
.radio-transport button:focus-visible:not(:disabled) {
border-color: #65f7ff;
@@ -614,14 +617,10 @@
gap: 8px;
}
/* The name takes the room the status column leaves; a long word wraps inside
it instead of pushing the toggle out of line. */
.data-toggle-left {
display: flex;
flex: 1 1 auto;
align-items: center;
gap: 8px;
min-width: 0;
}
.data-icon {
@@ -629,37 +628,16 @@
line-height: 1;
}
/* A fixed icon box, so every name (and the meta line under it, 26px in)
starts at the same x whatever the glyph's width. */
.data-toggle-left .data-icon {
flex: none;
width: 18px;
text-align: center;
}
.data-name {
min-width: 0;
font-family: var(--font-sans);
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
letter-spacing: 0.3px;
overflow-wrap: anywhere;
}
/* Translated layer names are longer: break between words, and hyphenate a
word that still does not fit instead of splitting it anywhere. */
html:not([lang='en']) .data-name {
overflow-wrap: break-word;
hyphens: auto;
/* No two-letter pieces such as "es-paciales": a word hyphenates only when
at least four letters stay on each side. */
hyphenate-limit-chars: 8 4 4;
}
.data-toggle-right {
display: flex;
flex: 0 1 auto;
align-items: center;
gap: 10px;
}
@@ -669,34 +647,68 @@ html:not([lang='en']) .data-name {
font-size: 11px;
letter-spacing: 0.5px;
color: var(--text-secondary);
min-width: 30px;
text-align: right;
}
/* Longer translations below; English keeps the layout above. */
/* The name takes the room the status column leaves; a long word wraps inside
it instead of pushing the toggle out of line. */
html:not([lang='en']) .data-toggle-left {
flex: 1 1 auto;
min-width: 0;
}
/* A fixed icon box, so every name (and the meta line under it, 26px in)
starts at the same x whatever the glyph's width. */
html:not([lang='en']) .data-toggle-left .data-icon {
flex: none;
width: 18px;
text-align: center;
}
/* Translated layer names are longer: break between words, and hyphenate a
word that still does not fit instead of splitting it anywhere. */
html:not([lang='en']) .data-name {
min-width: 0;
overflow-wrap: break-word;
hyphens: auto;
/* No two-letter pieces such as "es-paciales": a word hyphenates only when
at least four letters stay on each side. */
hyphenate-limit-chars: 8 4 4;
}
html:not([lang='en']) .data-count {
min-width: auto;
text-wrap: balance;
}
/* Row text pieces that must not break inside: a hyphenated code (ADS-B) and
the space before a " · " separator (see setRowText in layerPanel.js). */
.data-keep {
html:not([lang='en']) .data-keep {
white-space: nowrap;
}
/* A count phrase ("24 nearby · 2 on screen") takes its own line under the
name instead of squeezing it beside the toggle. */
.data-toggle-row--count-line .data-toggle-top {
name instead of squeezing it beside the toggle. layerPanel.js sets the
class only outside English. */
html:not([lang='en']) .data-toggle-row--count-line .data-toggle-top {
flex-wrap: wrap;
row-gap: 2px;
}
.data-toggle-row--count-line .data-toggle-right {
html:not([lang='en']) .data-toggle-row--count-line .data-toggle-right {
display: contents;
}
/* A zero basis, so a long name wraps beside the toggle instead of pushing the
toggle onto the count's line. */
.data-toggle-row--count-line .data-toggle-left {
html:not([lang='en']) .data-toggle-row--count-line .data-toggle-left {
flex-basis: 0;
}
.data-toggle-row--count-line .data-count {
html:not([lang='en']) .data-toggle-row--count-line .data-count {
order: 1;
flex: 1 0 100%;
padding-left: 26px;
@@ -706,7 +718,8 @@ html:not([lang='en']) .data-name {
/* The "—" placeholder gives way to a wide status chip, so the name keeps the
room it needs. */
.data-toggle-right:has(
html:not([lang='en'])
.data-toggle-right:has(
.data-toggle-btn:is(
.transitioning,
.feed-loading,
@@ -739,9 +752,12 @@ html:not([lang='en']) .data-name {
min-width var(--transition-fast),
letter-spacing var(--transition-fast);
outline: none;
flex: none;
min-width: 38px;
text-align: center;
}
html:not([lang='en']) .data-toggle-btn {
flex: none;
white-space: nowrap;
}
@@ -751,18 +767,30 @@ html:not([lang='en']) .data-name {
outline-offset: -3px;
}
/* Status chips up to eight characters share one width; a longer word grows
its own chip rather than widening every chip and squeezing every name. */
.data-toggle-btn.feed-loading,
.data-toggle-btn.feed-degraded,
.data-toggle-btn.feed-stale,
.data-toggle-btn.feed-partial,
.data-toggle-btn.feed-fallback,
.data-toggle-btn.feed-unavailable {
min-width: 72px;
min-width: 82px;
letter-spacing: 0.8px;
}
/* Status chips up to eight characters share one width; a longer word grows
its own chip rather than widening every chip and squeezing every name. */
html:not([lang='en'])
.data-toggle-btn:is(
.feed-loading,
.feed-degraded,
.feed-stale,
.feed-partial,
.feed-fallback,
.feed-unavailable
) {
min-width: 72px;
}
.data-toggle-btn:hover {
border-color: var(--glass-border-hover);
background: rgba(255, 255, 255, 0.06);
@@ -964,15 +992,27 @@ html:not([lang='en']) .data-name {
.data-row-list-lead {
min-width: 34px;
flex: none;
white-space: nowrap;
opacity: 0.85;
}
/* The whole instruction wraps under itself rather than losing its tail:
"Turn right onto …" says nothing, and Russian or Spanish run longer. */
.data-row-list-text {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A translated instruction wraps under itself rather than losing its tail:
"Gira a la derecha hacia …" says nothing. */
html:not([lang='en']) .data-row-list-lead {
white-space: nowrap;
}
html:not([lang='en']) .data-row-list-text {
min-width: 0;
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
+45 -24
View File
@@ -293,34 +293,32 @@
height: 18px;
font-weight: 700;
}
/* Outside A / B there is one chip (SHOW): it takes the whole row, so a long
translation is not cut to a stub. */
.recent-imagery-readout:not([data-mode='ab'])
/* Outside A / B there is one chip (SHOW): in a translation it takes the
whole row, so a long label is not cut to a stub. English keeps the
reserved B half. */
html:not([lang='en'])
.recent-imagery-readout:not([data-mode='ab'])
.ri-card-chips
.ri-chip[data-slot='a'] {
grid-column: 1 / -1;
}
.recent-imagery-readout:not([data-mode='ab'])
html:not([lang='en'])
.recent-imagery-readout:not([data-mode='ab'])
.ri-card-chips
.ri-chip[data-slot='b'] {
display: none;
}
/* 4. The selection bar: mode, then two rows that are always there. The slot
rows share the bar's columns (subgrid), so the tag column fits the longest
tag in either row and both rows stay aligned. */
/* 4. The selection bar: mode, then two rows that are always there. */
.recent-imagery-readout .ri-selection {
display: grid;
grid-template-columns: minmax(44px, max-content) minmax(0, 1fr) 22px;
grid-template-rows: 20px 20px 20px;
column-gap: 6px;
row-gap: 4px;
padding: 5px;
border: 1px solid var(--weather-card-border);
border-radius: 6px;
}
.recent-imagery-readout .ri-mode {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
@@ -329,16 +327,14 @@
height: 20px;
}
.recent-imagery-readout .ri-slot {
grid-column: 1 / -1;
display: grid;
grid-template-columns: subgrid;
grid-template-columns: 44px minmax(0, 1fr) 22px;
align-items: center;
column-gap: 6px;
height: 20px;
}
.recent-imagery-readout .ri-slot-tag {
height: 16px;
padding: 0 3px;
line-height: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 3px;
@@ -366,12 +362,26 @@
color: var(--text-secondary);
font-style: italic;
}
/* A translated slot line (date · source · resolution · state) is longer:
/* In a translation the slot rows share the bar's columns (subgrid), so the
tag column fits the longest tag in either row and both rows stay aligned.
A translated slot line (date · source · resolution · state) is longer:
let it take a second line rather than cut off the state at the end. */
html:not([lang='en']) .recent-imagery-readout .ri-selection {
grid-template-columns: minmax(44px, max-content) minmax(0, 1fr) 22px;
column-gap: 6px;
}
html:not([lang='en']) .recent-imagery-readout .ri-mode {
grid-column: 1 / -1;
}
html:not([lang='en']) .recent-imagery-readout .ri-slot {
grid-column: 1 / -1;
grid-template-columns: subgrid;
height: auto;
min-height: 20px;
}
html:not([lang='en']) .recent-imagery-readout .ri-slot-tag {
padding: 0 3px;
}
html:not([lang='en']) .recent-imagery-readout .ri-slot-value {
white-space: normal;
}
@@ -383,28 +393,35 @@ html:not([lang='en']) .recent-imagery-readout .ri-slot-value {
}
/* 5. Opacity (both images), SWAP and export, one row. SWAP keeps its slot
while no swipe is live. A label longer than its 76px column widens it to
the label's longest word, and the value wraps under the label. */
while no swipe is live. */
.recent-imagery-readout .ri-controls {
display: grid;
grid-template-columns:
minmax(76px, min-content) minmax(0, 1fr) minmax(40px, max-content)
128px;
grid-template-columns: 76px minmax(0, 1fr) 40px 128px;
align-items: center;
column-gap: 6px;
height: 22px;
}
.recent-imagery-readout .ri-opacity-label {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-content: center;
column-gap: 4px;
gap: 4px;
font-size: 8px;
line-height: 10px;
letter-spacing: 1px;
white-space: nowrap;
}
/* In a translation a label longer than its 76px column widens it to the
label's longest word, and the value wraps under the label. */
html:not([lang='en']) .recent-imagery-readout .ri-controls {
grid-template-columns:
minmax(76px, min-content) minmax(0, 1fr) minmax(40px, max-content)
128px;
}
html:not([lang='en']) .recent-imagery-readout .ri-opacity-label {
flex-wrap: wrap;
align-content: center;
row-gap: 0;
line-height: 10px;
}
.recent-imagery-readout .ri-opacity-value {
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
@@ -419,9 +436,13 @@ html:not([lang='en']) .recent-imagery-readout .ri-slot-value {
opacity: 0.35;
}
.recent-imagery-readout .ri-swap {
min-width: 40px;
width: 40px;
padding: 0 2px;
}
html:not([lang='en']) .recent-imagery-readout .ri-swap {
width: auto;
min-width: 40px;
}
.recent-imagery-readout .ri-exports .rail-card-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
+9 -6
View File
@@ -160,16 +160,19 @@
padding: 6px 8px;
font-family: var(--font-sans);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
outline: none;
}
html:not([lang='en']) #scene-select {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A translated action longer than its button wraps inside the button instead
of running past it. English labels fit on one line, so the English layout
is unchanged. */
#scene-panel .scene-controls .scene-btn {
of running past it. English keeps its one-line buttons ("EXPORT PRESETS"
included). */
html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
white-space: normal;
overflow-wrap: break-word;
}
+5
View File
@@ -123,6 +123,11 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* Translated retry details ("cámaras ALPR · reintento en 53 s") run longer
than the English one; English keeps its 220px. */
html:not([lang='en']) #global-loading-detail {
max-width: min(300px, 46vw);
}
#global-loading-status:is(
[data-state='complete'],
[data-state='cancelled'],
+3
View File
@@ -231,6 +231,7 @@
font-family: var(--font-sans);
font-size: 0.78rem;
font-weight: 600;
overflow-wrap: break-word;
}
.gev-voice-card-chips {
@@ -271,6 +272,8 @@
font-size: 0.66rem;
line-height: 1.4;
color: rgba(223, 245, 250, 0.82);
/* A whole error line or a long headline wraps inside the card. */
overflow-wrap: break-word;
}
.gev-voice-card-referents {
+5 -3
View File
@@ -123,9 +123,10 @@ html:not([lang='en']) #command-dock .gev-voice-cost-value {
letter-spacing: 0;
}
/* Live hints ("Ask or command", "Requesting microphone") run longer in other
languages; give them a second line from the spare space under the readout
rather than cutting them mid-word. */
/* Live hints ("Ask or command", "Requesting microphone") and the tier switch
("СТАНДАРТ — со следующей сессии") run longer in other languages; give them
a second line from the spare space under the readout, without the tracking
that would push the tier line onto a third, rather than cutting them. */
html:not([lang='en'])
#command-dock
#gev-voice-control:is(
@@ -139,6 +140,7 @@ html:not([lang='en'])
-webkit-line-clamp: 2;
line-clamp: 2;
line-height: 1.15;
letter-spacing: 0;
white-space: normal;
overflow-wrap: anywhere;
}
+21 -10
View File
@@ -147,12 +147,18 @@
.rail-card-line {
white-space: pre-line;
}
/* One line stays reserved so the card does not jump when a status appears;
a long (or translated) status wraps instead of being cut. */
.weather-card [data-line-id='status'] {
min-height: 1.45em;
height: 1.45em;
overflow-y: auto;
color: var(--accent);
}
/* One line stays reserved so the card does not jump when a status appears;
a translated status runs longer and wraps instead of being cut. */
html:not([lang='en']) .weather-card [data-line-id='status'] {
height: auto;
min-height: 1.45em;
overflow-y: visible;
}
.rail-card-ramp {
position: relative;
height: 4px;
@@ -259,10 +265,12 @@ body.recording-mode .gev-wind-inspection-marker {
top: 8px;
left: 0;
transform: translateX(-50%);
white-space: nowrap;
font: 9px/1.45 var(--font-mono);
color: var(--text-dim);
}
html:not([lang='en']) .rail-card-zero::after {
white-space: nowrap;
}
.weather-observed-group {
border: 1px solid var(--weather-card-border);
@@ -313,21 +321,24 @@ body.recording-mode .gev-wind-inspection-marker {
.rail-card-compact.status {
color: var(--accent);
}
.rail-card-setting {
display: grid;
grid-template-columns: 56px minmax(0, 1fr);
align-items: baseline;
gap: 6px;
margin-top: 6px;
}
/* One label column per card, as wide as its longest label: chips line up
across rows, and a long translated label never runs under its chips. */
.rail-card-settings {
html:not([lang='en']) .rail-card-settings {
display: grid;
grid-template-columns: minmax(56px, max-content) minmax(0, 1fr);
column-gap: 6px;
}
.rail-card-setting {
display: grid;
html:not([lang='en']) .rail-card-setting {
grid-column: 1 / -1;
grid-template-columns: minmax(56px, max-content) minmax(0, 1fr);
grid-template-columns: subgrid;
align-items: baseline;
gap: 6px;
margin-top: 6px;
}
.rail-card-setting-label {
color: var(--text-dim);