i18n(ar): keep values whole in right-to-left text

Fixes from the Arabic screenshot review:

- t() isolates every interpolated value in RTL locales (U+2068…U+2069), so
  a time (10/28 09:00 UTC), a code or an English name keeps its order inside
  an Arabic sentence; coordinates (30.326° ش) are isolated too. No-op in
  left-to-right languages.
- The cockpit position readout reads right to left again now that its
  coordinates are isolated; gauges, tapes and roll counters stay LTR.
- The start window's arrows point along the reading direction; FM seek and
  the glyph-only mini transport stay left to right, the worded PREV / PLAY /
  NEXT row follows the page; the Bhote Koshi clock stays a number.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-28 14:27:03 +04:00
1 parent 33985138ef
commit 4c3e366211
5 files changed
+49 -23

No files matched your search

+10 -2
View File
@@ -71,13 +71,16 @@ test('Arabic formats with Western digits whatever ICU defaults to', () => {
}
});
/** The text without the bidi isolates t() puts around values in RTL. */
const plain = (text) => text.replace(/[\u2068\u2069]/g, '');
test('Arabic uses all six plural forms and spaced HUD units', () => {
const previous = getLocale();
try {
setLocale('ar');
assert.deepEqual(
[0, 1, 2, 3, 11, 100].map((n) =>
tn(n, '{count} SOURCE', '{count} SOURCES'),
plain(tn(n, '{count} SOURCE', '{count} SOURCES')),
),
[
'0 مصدر',
@@ -96,11 +99,16 @@ test('Arabic uses all six plural forms and spaced HUD units', () => {
'Sentinel-2 · \u206830\u00a0م\u2069',
);
assert.equal(
LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 }),
plain(LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 })),
'القطاع 21.33ش 77.50غ',
);
assert.equal(READOUT_WORDS.daysAgo(9), 'قبل 9 أيام');
assert.equal(t('SAVE KEYS'), 'حفظ المفاتيح');
// Each value is isolated, so a time keeps its order inside Arabic text.
assert.equal(
t('Issued: {time}', { time: '10/28 00:00 UTC' }),
'صدر: \u206810/28 00:00 UTC\u2069',
);
setLocale('ru');
assert.equal(formatCockpitDistance(14000), '14 КМ');
} finally {
+7 -3
View File
@@ -81,9 +81,13 @@ const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g;
* stays visible instead of silently vanishing. */
function interpolate(pattern, values) {
if (!values) return pattern;
return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) =>
Object.hasOwn(values, name) ? String(values[name]) : literal,
);
// In a right-to-left page each value is isolated: a time, a code or an
// English name keeps its own order instead of mixing with the sentence.
return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) => {
if (!Object.hasOwn(values, name)) return literal;
const value = String(values[name]);
return value === '' ? value : isolate(value);
});
}
/** A catalog string with something to show; an empty one counts as missing. */
+2 -2
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { formatNumber, t } from '../../i18n/index.js';
import { formatNumber, isolate, t } from '../../i18n/index.js';
import {
satelliteShortName,
fireDetectionKey,
@@ -513,7 +513,7 @@ export function createModel({
lon >= 0
? t('{value}°E', { value: amount(lon) })
: t('{value}°W', { value: amount(lon) });
return `${latPart} ${lonPart}`;
return `${isolate(latPart)} ${isolate(lonPart)}`;
}
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
+8 -7
View File
@@ -1,5 +1,5 @@
/** Pure formatting and fixed presentation constants used by Cockpit components. */
import { formatNumber, getLocale, t, msg } from '../i18n/index.js';
import { formatNumber, getLocale, isolate, t, msg } from '../i18n/index.js';
import { formatQuantity } from './unitLabels.js';
export const COCKPIT_HEADING_SLEW_DPS = 28;
@@ -124,18 +124,19 @@ export function formatCockpitCoordinate(value, positive, negative) {
maximumFractionDigits: 3,
});
const code = value >= 0 ? positive : negative;
// Not «30,203°С»: after a degree sign «С» reads as Celsius.
// Not «30,203°С»: after a degree sign «С» reads as Celsius. Isolated, so
// an Arabic hemisphere letter does not pull the next number into its run.
switch (code) {
case 'N':
return t('{value}°N', { value: amount });
return isolate(t('{value}°N', { value: amount }));
case 'S':
return t('{value}°S', { value: amount });
return isolate(t('{value}°S', { value: amount }));
case 'E':
return t('{value}°E', { value: amount });
return isolate(t('{value}°E', { value: amount }));
case 'W':
return t('{value}°W', { value: amount });
return isolate(t('{value}°W', { value: amount }));
default:
return `${amount}°${compassPointLabel(code)}`;
return isolate(`${amount}°${compassPointLabel(code)}`);
}
}
+22 -9
View File
@@ -68,16 +68,22 @@ html:lang(ar) :is(#intel-hud, #cockpit-hud) {
}
/* The right rail sits on the left: its expand/collapse arrows point the
other way. */
html[dir='rtl'] #right-context-rail .panel-collapse-btn {
other way, and the start window's "go" arrows point along the reading
direction. */
html[dir='rtl'] #right-context-rail .panel-collapse-btn,
html[dir='rtl'] .first-run-arrow {
transform: scaleX(-1);
}
html[dir='rtl'] .first-run-choices button:hover .first-run-arrow,
html[dir='rtl'] .first-run-choices button:focus-visible .first-run-arrow {
transform: translateX(-2px) scaleX(-1);
}
/* The cockpit is an aircraft panel, not page chrome: its windows keep their
physical sides, so the data-layer stack goes back to the left edge instead
of landing on the utility controls. Gauges, tapes, the roll counters and the
position readout stay left to right (speed left, altitude right, -10 not
10-); the cards keep reading right to left. */
of landing on the utility controls. Gauges, tapes and the roll counters stay
left to right (speed left, altitude right, -10 not 10-); the cards keep
reading right to left. */
html[dir='rtl'] body.cockpit-mode #left-panel-stack {
left: var(--left-stack-x);
right: auto;
@@ -90,7 +96,6 @@ html[dir='rtl']
.cockpit-pitch-rail,
.cockpit-altitude-rim,
.cockpit-roll-arc,
.cockpit-position-readout,
.cockpit-vision-controls
) {
direction: ltr;
@@ -110,9 +115,17 @@ html[dir='rtl']
unicode-bidi: plaintext;
}
/* Physical controls keep their left-to-right sense: media transport
(previous / play / next) and the radio dial. */
/* Clock readouts (~0:05 / 5:19) are numbers, not prose. */
html[dir='rtl'] #bhote-koshi-event-panel [data-role='time'] {
direction: ltr;
unicode-bidi: isolate;
}
/* Physical controls keep their left-to-right sense: the glyph-only media
transport (◀◀ ▶ ▶▶), FM seek down/up and the radio dial. The worded
PREV / PLAY / NEXT row follows the reading direction like any other
button row. */
html[dir='rtl']
:is(.radio-transport, .context-radio-mini-transport, .radio-tuner) {
:is(.context-radio-mini-transport, .sdr-seek-row, .radio-tuner) {
direction: ltr;
}