i18n(ar): mirror the layout right to left

- The title, style indicator and both rails swap sides in RTL through logical
  properties (inset-inline-*, margin-inline-*, border-inline-start); LTR
  computes the same values. The Display/Context rail keeps its physical
  `right` for the credit-clearance model and swaps in rtl.css above 720px.
- rtl.css: no letter-spacing on Arabic (it tears joined letters apart);
  Noto Sans Arabic leads the Arabic font stacks so words are not set in a
  fixed-pitch fallback; the intel HUD and cockpit instruments stay a
  left-to-right instrument overlay; media transport and the radio dial stay
  left to right; feed headlines and station names take their own direction;
  the moved rail's arrows flip.
- isolate(): a quantity in RTL text (2.7 كم, -18.0°) is wrapped in
  first-strong isolates so its digits do not join a neighbouring Latin run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-28 13:28:23 +04:00
1 parent 0b928b6810
commit 06370d8676
15 files changed
+183 -39

No files matched your search

+1 -1
View File
@@ -21,7 +21,7 @@
<link rel="stylesheet" href="/style.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&family=Noto+Sans+Arabic:wght@400;500;600;700&display=swap" rel="stylesheet" />
<!-- Icon font subset to the glyphs this app renders. Keep `icon_names` in sync
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
+9 -1
View File
@@ -10,6 +10,7 @@ import {
import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js';
import { formatCockpitDistance } from '../ui/cockpitPresentation.js';
import { READOUT_WORDS } from '../layers/recentImagery/display.js';
import { formatQuantity } from '../ui/unitLabels.js';
test('Arabic resolves from URL, saved preference and browser language', () => {
assert.equal(
@@ -88,13 +89,20 @@ test('Arabic uses all six plural forms and spaced HUD units', () => {
],
);
assert.equal(hudQuantity(2069, 'km', 1), '2069.0 كم');
assert.equal(formatCockpitDistance(14000), '14 كم');
// Isolated, so the number keeps its unit next to Latin words.
assert.equal(formatCockpitDistance(14000), '\u206814\u00a0كم\u2069');
assert.equal(
`Sentinel-2 · ${formatQuantity(30, 'm')}`,
'Sentinel-2 · \u206830\u00a0م\u2069',
);
assert.equal(
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'), 'حفظ المفاتيح');
setLocale('ru');
assert.equal(formatCockpitDistance(14000), '14 КМ');
} finally {
setLocale(previous);
}
+10
View File
@@ -14,6 +14,7 @@
import {
DEFAULT_LOCALE,
intlLocale,
isRtlLocale,
normalizeLocale,
writeStoredLocale,
} from './locale.js';
@@ -43,6 +44,15 @@ export function getLocale() {
return currentLocale;
}
/**
* A value inserted into right-to-left text as one unit: `30 م` next to
* `Sentinel-2` would otherwise lend its digits to the Latin run and leave the
* unit stranded. First-strong isolates (U+2068…U+2069) in RTL locales only.
*/
export function isolate(text) {
return isRtlLocale(currentLocale) ? `\u2068${text}\u2069` : String(text);
}
/**
* Switch the active locale. Pure state change: call applyDocumentLanguage /
* applyDocumentTranslations to reflect it in the DOM.
+5
View File
@@ -30,6 +30,11 @@ export const LOCALE_NAMES = Object.freeze({
/** Right-to-left locales. */
const RTL_LOCALES = Object.freeze(['ar']);
/** Whether a shipped locale reads right to left. */
export function isRtlLocale(locale) {
return RTL_LOCALES.includes(normalizeLocale(locale));
}
/**
* Tags for Intl formatters where the locale code alone is not enough. Arabic
* keeps Western digits (0–9) like its catalog: ICU picks Arabic-Indic digits
+4 -3
View File
@@ -1,4 +1,4 @@
import { formatNumber, t, msg } from '../i18n/index.js';
import { formatNumber, isolate, msg, t } from '../i18n/index.js';
import { createNumberInput } from './formField.js';
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
@@ -79,13 +79,14 @@ function calValueText(field, value) {
useGrouping: false,
...options,
});
return `${t(field.label)} ${t('{value}m', { value: metres })}`;
return `${t(field.label)} ${isolate(t('{value}m', { value: metres }))}`;
}
const number = formatNumber(Number(value), {
useGrouping: false,
...options,
});
return `${t(field.label)} ${number}${field.unit}`;
// Isolated so a minus sign stays on its number in right-to-left text.
return `${t(field.label)} ${isolate(`${number}${field.unit}`)}`;
}
export function _activeCctvCameraId() {
+2 -2
View File
@@ -550,7 +550,7 @@ test('Cyber side panels share one width and one framed surface material', () =>
);
assert.match(
cyberStyles,
/#right-context-rail\s+:is\(\s*\.cctv-panel-inner,\s*\.weather-panel-inner,\s*\.recent-imagery-panel-inner,\s*\.street-level-panel-inner,\s*\.global-context-panel-inner\s*\) \{\s*padding-right: 8px;/,
/#right-context-rail\s+:is\(\s*\.cctv-panel-inner,\s*\.weather-panel-inner,\s*\.recent-imagery-panel-inner,\s*\.street-level-panel-inner,\s*\.global-context-panel-inner\s*\) \{\s*padding-inline-end: 8px;/,
);
assert.match(
cyberStyles,
@@ -765,7 +765,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
assert.match(cyberStyles, /mask-image: linear-gradient\(/);
assert.match(cyberStyles, /flex: 1 1 auto;/);
assert.match(cyberStyles, /#left-panel-stack:has/);
assert.match(cyberStyles, /left: 54px;/);
assert.match(cyberStyles, /inset-inline-start: 54px;/);
assert.match(cyberStyles, /right: 62px;/);
assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem\);/);
assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/);
+10 -9
View File
@@ -1081,7 +1081,7 @@ body.cockpit-mode #view-switcher button {
gap: 3px 8px;
min-width: 0;
padding: 6px 9px;
border-left: 1px solid
border-inline-start: 1px solid
color-mix(in srgb, var(--cockpit-accent) 40%, transparent);
background: rgba(0, 7, 12, 0.34);
}
@@ -1107,7 +1107,7 @@ body.cockpit-mode #view-switcher button {
white-space: nowrap;
}
.cockpit-context-cohorts > div.unknown {
border-left-color: rgba(213, 168, 99, 0.58);
border-inline-start-color: rgba(213, 168, 99, 0.58);
}
.cockpit-context-cohorts > div.unknown strong {
color: #d5a863;
@@ -1626,8 +1626,9 @@ html:not([lang='en']) #cockpit-brief-auto {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
gap: 8px;
padding: 7px 7px 7px 9px;
border-left: 1px solid rgba(244, 251, 255, 0.2);
padding-block: 7px;
padding-inline: 9px 7px;
border-inline-start: 1px solid rgba(244, 251, 255, 0.2);
background: rgba(0, 7, 12, 0.3);
}
.cockpit-signal-list li.actionable {
@@ -1637,7 +1638,7 @@ html:not([lang='en']) #cockpit-brief-auto {
}
.cockpit-signal-list li.actionable:hover,
.cockpit-signal-list li.actionable:focus-within {
border-left-color: var(--cockpit-accent);
border-inline-start-color: var(--cockpit-accent);
background: color-mix(
in srgb,
var(--cockpit-accent) 10%,
@@ -1646,16 +1647,16 @@ html:not([lang='en']) #cockpit-brief-auto {
}
.cockpit-signal-list li.track,
.cockpit-signal-list li.current {
border-left-color: var(--cockpit-accent);
border-inline-start-color: var(--cockpit-accent);
}
.cockpit-signal-list li.nearby {
border-left-color: #fff;
border-inline-start-color: #fff;
}
.cockpit-signal-list li.military {
border-left-color: #d5a863;
border-inline-start-color: #d5a863;
}
.cockpit-signal-list li.warning {
border-left-color: #d5a863;
border-inline-start-color: #d5a863;
}
.cockpit-signal-list time {
color: rgba(244, 251, 255, 0.38);
+7 -7
View File
@@ -247,7 +247,7 @@
.street-level-panel-inner,
.global-context-panel-inner
) {
padding-right: 8px;
padding-inline-end: 8px;
}
/* Collapsed right-rail panels otherwise inherit different legacy gutters:
@@ -1653,10 +1653,10 @@
:root[data-ui-theme='cyber'] #left-panel-stack {
top: var(--cyber-left-rail-top) !important;
bottom: calc(2vh + 13rem);
left: 54px;
inset-inline-start: 54px;
width: 300px;
padding-left: 10px;
border-left: 1px solid rgba(196, 84, 84, 0.72);
padding-inline-start: 10px;
border-inline-start: 1px solid rgba(196, 84, 84, 0.72);
}
:root[data-ui-theme='cyber']
@@ -2721,10 +2721,10 @@
:root[data-ui-theme='cyber'] body.cockpit-mode #left-panel-stack {
top: clamp(164px, 19vh, 204px) !important;
bottom: auto;
left: 30px;
inset-inline-start: 30px;
width: var(--cyber-panel-width);
padding-left: 0;
border-left: 0;
padding-inline-start: 0;
border-inline-start: 0;
}
:root[data-ui-theme='cyber'] body.cockpit-mode #left-panel-stack > #data-panel {
+4 -4
View File
@@ -492,7 +492,7 @@ body.cockpit-mode #cockpit-cloud-effects.active {
#title-bar {
position: fixed;
top: 32px;
left: 36px;
inset-inline-start: 36px;
z-index: 100;
pointer-events: none;
user-select: none;
@@ -501,7 +501,7 @@ body.cockpit-mode #cockpit-cloud-effects.active {
#title-bar .title-glow {
position: absolute;
top: -20px;
left: -20px;
inset-inline-start: -20px;
width: 200px;
height: 80px;
background: radial-gradient(ellipse, var(--accent-dim) 0%, transparent 70%);
@@ -626,11 +626,11 @@ body.cockpit-mode #cockpit-cloud-effects.active {
#style-indicator {
position: fixed;
top: 32px;
right: 36px;
inset-inline-end: 36px;
z-index: 100;
pointer-events: none;
user-select: none;
text-align: right;
text-align: end;
}
#style-indicator .indicator-label {
+6 -6
View File
@@ -3,7 +3,7 @@
position: fixed;
top: var(--left-stack-safe-top, var(--left-stack-top));
bottom: var(--left-stack-safe-bottom, 4vh);
left: var(--left-stack-x);
inset-inline-start: var(--left-stack-x);
z-index: 100;
width: 360px;
display: flex;
@@ -197,11 +197,11 @@
#right-context-rail #weather-panel.collapsed,
#right-context-rail #recent-imagery-panel.collapsed {
width: var(--right-display-collapsed-width);
margin-left: auto;
margin-inline-start: auto;
}
#right-context-rail > #pp-toggles.collapsed {
width: var(--right-display-collapsed-width);
margin-left: auto;
margin-inline-start: auto;
}
#right-context-rail > #pp-toggles.collapsed .pp-header-row {
width: 100%;
@@ -240,7 +240,7 @@
width: var(--right-context-collapsed-width);
/* Keep the compact Context tab on the rail edge rather than leaving it
stranded at the rail's expanded-width origin. */
margin-left: auto;
margin-inline-start: auto;
}
#right-context-rail
#global-context-panel.collapsed
@@ -385,7 +385,7 @@
gap: 10px;
padding: 1px 3px 7px;
border-bottom: 1px solid rgba(34, 230, 230, 0.14);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
}
#space-mission-panel-host .space-mission-roster-header > div {
display: grid;
@@ -431,7 +431,7 @@
border-radius: 4px;
color: #d8ffff;
background: rgba(42, 113, 171, 0.045);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
text-align: left;
cursor: pointer;
transition:
+1 -1
View File
@@ -87,7 +87,7 @@ body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) {
/* ── Top bar: logo left, globe actions right ───────────────────────────── */
body.mobile-shell #title-bar {
top: calc(var(--mobile-top-inset) + 10px);
left: 14px;
inset-inline-start: 14px;
}
body.mobile-shell #title-bar .title-logo {
+3 -3
View File
@@ -25,7 +25,7 @@
#title-bar {
top: 20px;
left: 20px;
inset-inline-start: 20px;
}
#top-center-actions {
@@ -55,7 +55,7 @@
#title-bar .frame-rate-readout {
position: fixed;
top: 52px;
left: 20px;
inset-inline-start: 20px;
margin: 0;
font-size: 10px;
line-height: 12px;
@@ -262,7 +262,7 @@
/* 16px keeps the Radio broadcast waves (15px above the logo) on-screen. */
#title-bar {
top: 16px;
left: 16px;
inset-inline-start: 16px;
}
#title-bar h1 > span:last-child,
+118
View File
@@ -0,0 +1,118 @@
/* Right-to-left languages (Arabic). The chrome mirrors through logical
properties in the component sheets (inset-inline-*, margin-inline-*); this
sheet holds only what logical properties cannot express. */
/* Arabic letters join: letter-spacing tears every word into separate glyphs.
Latin marked translate="no" and the intel HUD's Latin telemetry keep their
tracking; the HUD's translated lines are released below. */
html:lang(ar)
body
:not(
#intel-hud,
#intel-hud *,
[translate='no'],
[translate='no'] *,
:has([translate='no'])
) {
letter-spacing: 0 !important;
}
/* The intel HUD is an instrument overlay: corners, brackets, coordinates and
sensor codes keep the English geometry. Its translated lines still read
right to left inside that frame. */
html[dir='rtl'] #intel-hud {
direction: ltr;
}
html[dir='rtl'] #intel-hud :is(.hud-mode, .hud-summary-label, .hud-summary) {
unicode-bidi: plaintext;
text-align: left;
letter-spacing: 0 !important;
}
/* Font stacks: JetBrains Mono and Inter have no Arabic glyphs, and the
generic monospace fallback is a fixed-pitch Arabic face that spaces every
letter apart. Noto Sans Arabic (loaded with the other Google fonts; its
files download only when the page shows Arabic) leads the mono stack, so
the spaces between Arabic words come from it too and not from the
fixed-pitch Latin face. The intel and cockpit HUDs keep JetBrains Mono for
their Latin telemetry and column-aligned digits. */
html:lang(ar) {
--font-mono:
'Noto Sans Arabic', 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Geeza Pro',
Tahoma, monospace;
--font-sans:
'Inter', 'Noto Sans Arabic', -apple-system, BlinkMacSystemFont,
'Geeza Pro', Tahoma, sans-serif;
}
html:lang(ar) :is(#intel-hud, #cockpit-hud) {
--font-mono:
'JetBrains Mono', 'SF Mono', 'Fira Code', 'Noto Sans Arabic', 'Geeza Pro',
Tahoma, monospace;
}
/* The Display/Context rail moves to the left edge. It keeps a physical
`right` in layers.css because the attribution-clearance model
(creditAttribution.test.mjs) reads that rule; the swap only applies above
the 720px band, where the rail is not edge-to-edge. */
@media (min-width: 721px) {
html[dir='rtl'] #right-context-rail {
right: auto;
left: var(--right-rail-x);
}
}
@media (min-width: 961px) and (min-height: 650px) {
html[dir='rtl'][data-ui-theme='cyber'] #right-context-rail {
right: auto;
left: 62px;
}
}
/* The right rail sits on the left: its expand/collapse arrows point the
other way. */
html[dir='rtl'] #right-context-rail .panel-collapse-btn {
transform: 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. */
html[dir='rtl'] body.cockpit-mode #left-panel-stack {
left: var(--left-stack-x);
right: auto;
}
html[dir='rtl']
#cockpit-hud
:is(
.cockpit-instrument-row,
.cockpit-compass,
.cockpit-pitch-rail,
.cockpit-altitude-rim,
.cockpit-roll-arc,
.cockpit-position-readout,
.cockpit-vision-controls
) {
direction: ltr;
}
/* Feed text (news headlines, station names) arrives in its own language:
each line takes its direction from its first letter, so an English headline
reads and aligns left to right instead of moving its punctuation. */
html[dir='rtl']
:is(
.cockpit-news-list li *,
#radio-station-name,
#radio-station-tags,
#context-radio-mini-station,
.cockpit-radio-station
) {
unicode-bidi: plaintext;
}
/* Physical controls keep their left-to-right sense: media transport
(previous / play / next) and the radio dial. */
html[dir='rtl']
:is(.radio-transport, .context-radio-mini-transport, .radio-tuner) {
direction: ltr;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { formatNumber, msg, t } from '../i18n/index.js';
import { formatNumber, isolate, msg, t } from '../i18n/index.js';
// Unit ids stay English in data, params and AI-tool readings; only the label
// shown on the page is translated. dBZ and °C read the same in every language.
@@ -30,7 +30,7 @@ export function unitLabel(unit) {
export function formatQuantity(value, unit, options) {
const label = unitLabel(unit);
const number = formatNumber(value, { useGrouping: false, ...options });
return label ? `${number}\u00a0${label}` : number;
return label ? isolate(`${number}\u00a0${label}`) : number;
}
/**
+1
View File
@@ -33,5 +33,6 @@
@import './src/ui/styles/panel-surfaces.css';
@import './src/ui/styles/segmented-control.css';
@import './src/ui/styles/cjk.css';
@import './src/ui/styles/rtl.css';
@import './src/ui/styles/mobile-shell.css';
@import './src/ui/styles/cyber.css';