mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
0b928b6810
commit
06370d8676
15 files changed
+183
-39
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Vendored
+4
-4
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user