diff --git a/src/data/bhoteKoshiEmbeddedMedia.js b/src/data/bhoteKoshiEmbeddedMedia.js index 1ea0ccec9..85e7ebed6 100644 --- a/src/data/bhoteKoshiEmbeddedMedia.js +++ b/src/data/bhoteKoshiEmbeddedMedia.js @@ -569,7 +569,8 @@ function anchorOnScreen(viewer, anchor, root) { return { x, y, width: rootBounds.width, height: rootBounds.height }; } -function placeCard(card, leader, anchor) { +/** Place a media card beside its map anchor, in the lane between the panels. */ +export function placeCard(card, leader, anchor) { const margin = 18; const gap = 42; const width = card.offsetWidth || 392; @@ -580,22 +581,22 @@ function placeCard(card, leader, anchor) { let laneRight = anchor.width - margin; let laneBottom = anchor.height - margin; if (rootBounds && documentRef?.querySelector) { - const leftPanel = documentRef - .querySelector('#scene-panel') - ?.getBoundingClientRect?.(); - const rightRail = documentRef - .querySelector('#right-context-rail') - ?.getBoundingClientRect?.(); const commandDock = documentRef .querySelector('#command-dock') ?.getBoundingClientRect?.(); - if (leftPanel?.width > 0) - laneLeft = Math.max(laneLeft, leftPanel.right - rootBounds.left + margin); - if (rightRail?.width > 0) - laneRight = Math.min( - laneRight, - rightRail.left - rootBounds.left - margin, - ); + // The scene panel and the context rail swap sides in right-to-left + // languages: each narrows the lane from the side it sits on. + for (const selector of ['#scene-panel', '#right-context-rail']) { + const panel = documentRef + .querySelector(selector) + ?.getBoundingClientRect?.(); + if (!(panel?.width > 0)) continue; + const start = panel.left - rootBounds.left; + const end = panel.right - rootBounds.left; + if (start + end < rootBounds.width) + laneLeft = Math.max(laneLeft, end + margin); + else laneRight = Math.min(laneRight, start - margin); + } if (commandDock?.height > 0) laneBottom = Math.min( laneBottom, diff --git a/src/data/bhoteKoshiEmbeddedMedia.test.mjs b/src/data/bhoteKoshiEmbeddedMedia.test.mjs index 6829adca5..94318dc29 100644 --- a/src/data/bhoteKoshiEmbeddedMedia.test.mjs +++ b/src/data/bhoteKoshiEmbeddedMedia.test.mjs @@ -6,6 +6,7 @@ import { createBhoteKoshiEmbeddedMedia, createTrimmedYouTubePlayback, embeddedMediaFrameUrl, + placeCard, publicEmbeddedMediaUrl, resolveEmbeddedMediaSource, } from './bhoteKoshiEmbeddedMedia.js'; @@ -394,3 +395,25 @@ test('pausing cancels a mounted Facebook preload and rejects its late ready call assert.deepEqual(playerCalls, []); media.destroy(); }); + +test('a media card stays between the panels when right-to-left swaps their sides', () => { + const rect = (left, right, top = 0, bottom = 900) => ({ left, right, top, bottom, width: right - left, height: bottom - top }); + // Arabic: the scene panel sits on the right, the context rail on the left. + const panels = { + '#scene-panel': rect(1040, 1420), + '#right-context-rail': rect(20, 340), + '#command-dock': rect(500, 940, 820, 880), + }; + const card = { + offsetWidth: 392, + offsetHeight: 360, + parentElement: { getBoundingClientRect: () => rect(0, 1440) }, + ownerDocument: { querySelector: (selector) => ({ getBoundingClientRect: () => panels[selector] }) }, + classList: { contains: () => false }, + style: {}, + }; + placeCard(card, { querySelector: () => null }, { x: 700, y: 400, width: 1440, height: 900 }); + const left = parseFloat(card.style.left); + assert.ok(left >= 340 + 18, `card at ${left}px lands on the context rail`); + assert.ok(left + 392 <= 1040 - 18, `card at ${left}px lands on the scene panel`); +}); diff --git a/src/data/geoFileImport.js b/src/data/geoFileImport.js index 6e379bb39..2d444092e 100644 --- a/src/data/geoFileImport.js +++ b/src/data/geoFileImport.js @@ -65,7 +65,8 @@ const KML_REMOVED_ELEMENTS = new Set([ /** * An error whose message is meant for the person who chose the file. The * message stays English; `template` and `values`, when given, let the UI show - * it in the active language. + * it in the active language. Numbers stay numbers in `values`: the UI formats + * them in that language's numerals. */ export class GeoImportError extends Error { constructor(message, template = message, values = {}) { @@ -259,7 +260,7 @@ function overLimitError({ over, features }) { return new GeoImportError( `That file creates more than ${MAX_IMPORT_ENTITIES.toLocaleString()} map objects (the limit).`, msg('That file creates more than {limit} map objects (the limit).'), - { limit: MAX_IMPORT_ENTITIES.toLocaleString() }, + { limit: MAX_IMPORT_ENTITIES }, ); if (over === 'links') return new GeoImportError( @@ -267,13 +268,13 @@ function overLimitError({ over, features }) { msg( 'That file has more than {limit} style definitions and image links after cleanup (the limit).', ), - { limit: MAX_KML_LINKS.toLocaleString() }, + { limit: MAX_KML_LINKS }, ); if (over === 'features') return new GeoImportError( `That file has more than ${MAX_IMPORT_ENTITIES.toLocaleString()} features (the limit).`, msg('That file has more than {limit} features (the limit).'), - { limit: MAX_IMPORT_ENTITIES.toLocaleString() }, + { limit: MAX_IMPORT_ENTITIES }, ); const limit = MAX_IMPORT_POSITIONS.toLocaleString(); if (features) @@ -282,12 +283,12 @@ function overLimitError({ over, features }) { msg( 'That file has more than {limit} points (the limit) across {features} features.', ), - { limit, features: features.toLocaleString() }, + { limit: MAX_IMPORT_POSITIONS, features }, ); return new GeoImportError( `That file has more than ${limit} points (the limit).`, msg('That file has more than {limit} points (the limit).'), - { limit }, + { limit: MAX_IMPORT_POSITIONS }, ); } @@ -1466,8 +1467,8 @@ export async function loadGeoFile( `That file has ${entityCount.toLocaleString()} map objects; the limit is ${MAX_IMPORT_ENTITIES.toLocaleString()}.`, msg('That file has {count} map objects; the limit is {limit}.'), { - count: entityCount.toLocaleString(), - limit: MAX_IMPORT_ENTITIES.toLocaleString(), + count: entityCount, + limit: MAX_IMPORT_ENTITIES, }, ); } diff --git a/src/i18n/ar-display.test.mjs b/src/i18n/ar-display.test.mjs index 5ba81277b..f58d1cee2 100644 --- a/src/i18n/ar-display.test.mjs +++ b/src/i18n/ar-display.test.mjs @@ -18,6 +18,7 @@ import { import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js'; import { formatCockpitDistance } from '../ui/cockpitPresentation.js'; import { READOUT_WORDS } from '../layers/recentImagery/display.js'; +import { createPresentation as createCctvPresentation } from '../layers/cctv/presentation.js'; import { formatQuantity, formatScaleLabel, @@ -160,3 +161,46 @@ test('Arabic uses all six plural forms and spaced HUD units', () => { setLocale(previous); } }); + +test('Arabic keeps each CCTV summary code beside its own value', () => { + const camera = { + id: 'cam-1', + city: 'Austin', + name: 'Congress Ave', + headingDeg: 68, + fovDeg: 44, + rangeM: 120, + feedType: 'image', + }; + const active = { camera }; + const presentation = createCctvPresentation({ + state: { + _records: [active], + _healthById: new Map(), + _showProjection: true, + _coverageMode: 'sector', + }, + parts: { + selection: { getActiveRecord: () => active }, + model: { + sectorAreaKm2: () => 0.01, + currentViewContext: () => 'street:1', + }, + geometry: { coverageNeighborCount: () => 0 }, + calibration: { deriveCalBadge: () => 'auto' }, + }, + }); + const previous = getLocale(); + try { + setLocale('ar'); + const parts = presentation.buildSummaryText().split(' · '); + // Each part is its own isolate: «HDG 68° · FOV 44°» cannot form one + // left-to-right run that hands 44° to the HDG side. + for (const part of parts) assert.match(part, /^\u2068[\s\S]*\u2069$/); + assert.ok(parts.includes('\u2068FOV \u206844\u2069°\u2069'), parts.join(' | ')); + setLocale('en'); + assert.doesNotMatch(presentation.buildSummaryText(), /[\u2068\u2069]/); + } finally { + setLocale(previous); + } +}); diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js index 0d684eba5..beb78fb6c 100644 --- a/src/layers/cctv/presentation.js +++ b/src/layers/cctv/presentation.js @@ -1,5 +1,5 @@ import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; -import { t, translateKnown } from '../../i18n/index.js'; +import { isolate, t, translateKnown } from '../../i18n/index.js'; import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js'; import { headingHudToken, isHeadingEstimated } from './headingConfidence.js'; import { @@ -60,6 +60,8 @@ export function createPresentation({ const health = layerState._healthById.get(active.camera.id) || null; const calBadge = parts.calibration.deriveCalBadge(active.camera); + // Each part is isolated: in right-to-left text a code keeps its own value + // (FOV 44°) instead of trading it with its neighbour. return [ t('{city} CCTV', { city: active.camera.city.toUpperCase(), @@ -107,6 +109,7 @@ export function createPresentation({ : null, ] .filter(Boolean) + .map(isolate) .join(' · '); } diff --git a/src/layers/launches/panel.js b/src/layers/launches/panel.js index 95eeede6f..39f052588 100644 --- a/src/layers/launches/panel.js +++ b/src/layers/launches/panel.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { t } from '../../i18n/index.js'; +import { formatNumber, isolate, t } from '../../i18n/index.js'; import { MISSION_CLOSE_VIEW_RANGE_M, MISSION_GLOBE_VIEW_RANGE_M, @@ -232,7 +232,7 @@ export function createPanel({ state: layerState, services, parts, source }) { ? payload.operator : null, Number.isFinite(payload.massKg) - ? `${payload.massKg.toLocaleString()} KG` + ? `${formatNumber(payload.massKg)} KG` : null, ] .filter(Boolean) @@ -260,10 +260,10 @@ export function createPanel({ state: layerState, services, parts, source }) { ? launch.launchSite : t('UNAVAILABLE')); const position = endpoint - ? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${accuracyLabel(endpoint.accuracy)}` + ? `${isolate(`${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)}`)} · ${accuracyLabel(endpoint.accuracy)}` : stage.downrangeKm > 0 ? t('{value} KM DOWNRANGE', { - value: stage.downrangeKm.toLocaleString(), + value: formatNumber(stage.downrangeKm), }) : t('POSITION UNAVAILABLE'); const stageDetail = [ @@ -394,7 +394,7 @@ export function createPanel({ state: layerState, services, parts, source }) { '[data-mission-distance]', Number.isFinite(altitudeM) ? t('{value} KM', { - value: Math.max(0, altitudeM / 1000).toLocaleString(undefined, { + value: formatNumber(Math.max(0, altitudeM / 1000), { maximumFractionDigits: 0, }), }) @@ -407,14 +407,14 @@ export function createPanel({ state: layerState, services, parts, source }) { '[data-mission-speed]', Number.isFinite(speedMps) ? t('{value} KM/S', { - value: (speedMps / 1000).toLocaleString(undefined, { + value: formatNumber(speedMps / 1000, { minimumFractionDigits: 2, maximumFractionDigits: 2, }), }) : null, Number.isFinite(speedMps) - ? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h` + ? `${formatNumber(speedMps * 3.6, { maximumFractionDigits: 0 })} km/h` : '', ); } diff --git a/src/scenes/recipes.js b/src/scenes/recipes.js index d1f0fe548..84001f73d 100644 --- a/src/scenes/recipes.js +++ b/src/scenes/recipes.js @@ -1061,15 +1061,15 @@ export function displaySceneTitle(scene) { * (`sourcePackId`), so a typed title that matches a built-in one stays as * typed. Without `shot`, the title alone decides. * @param {string} title - * @param {{sourcePackId?: string}} [shot] - * @param {{id?: string}} [scene] The shot's scene, for projects saved before - * shots recorded their source pack. + * @param {{id?: string, sourcePackId?: string}} [shot] + * @param {{id?: string, appliedShotPacks?: object[]}} [scene] The shot's + * scene, for projects saved before shots recorded their source pack. * @returns {string} */ export function displayShotTitle(title, shot = undefined, scene = undefined) { const text = typeof title === 'string' ? title : ''; const fromRecipe = shot - ? Boolean(shot.sourcePackId) || recipeDeclaresShotTitle(scene, text) + ? Boolean(shot.sourcePackId) || recipeDeclaresShotTitle(scene, shot, text) : true; if (fromRecipe && BUILTIN_SHOT_TITLES.has(text)) return t(text); const numbered = /^Shot ([1-9]\d*)$/.exec(text); @@ -1078,21 +1078,19 @@ export function displayShotTitle(title, shot = undefined, scene = undefined) { /** * Whether the built-in recipe behind a scene declares a shot with this title: - * its own keyframes, or the bootstrap shots of a shot pack applied to the - * scene (those shots carry no sourcePackId; the scene's pack marker vouches - * for them). + * its own keyframes, or a bootstrap shot of a shot pack applied to the scene. + * Those shots carry no sourcePackId; the pack marker binds the title to that + * very shot, so another shot typed with the same title stays as typed. */ -function recipeDeclaresShotTitle(scene, title) { - const declares = (cameraPath) => - Boolean(cameraPath?.some((keyframe) => keyframe?.title === title)); +function recipeDeclaresShotTitle(scene, shot, title) { const recipe = scene?.id ? getSceneRecipeById(scene.id) : null; - if (declares(recipe?.cameraPath)) return true; + if (recipe?.cameraPath?.some((keyframe) => keyframe?.title === title)) + return true; const packs = Array.isArray(scene?.appliedShotPacks) ? scene.appliedShotPacks : []; - return packs.some((marker) => - declares( - getSceneAppendRecipeById(marker?.id)?.legacySceneBootstrap?.cameraPath, - ), + return ( + Boolean(shot?.id) && + packs.some((marker) => marker?.shotBindings?.[title] === shot.id) ); } diff --git a/src/scenes/sceneTitleDisplay.test.mjs b/src/scenes/sceneTitleDisplay.test.mjs index 096c087cc..12d5cea89 100644 --- a/src/scenes/sceneTitleDisplay.test.mjs +++ b/src/scenes/sceneTitleDisplay.test.mjs @@ -92,10 +92,16 @@ test('bootstrap shots of an applied shot pack localize their built-in titles', ( const title = 'Global Incident Context'; const shot = { id: 'shot-1', title }; // A legacy scene upgraded in place: its bootstrap shots have no - // sourcePackId, but the scene carries the pack marker. + // sourcePackId, but the scene's pack marker binds the title to the shot. const upgraded = { id: 'scene-legacy', - appliedShotPacks: [{ id: 'bhote-koshi-nepal-evidence-pack', version: 12 }], + appliedShotPacks: [ + { + id: 'bhote-koshi-nepal-evidence-pack', + version: 12, + shotBindings: { [title]: 'shot-1' }, + }, + ], }; assert.equal( displayShotTitle(title, shot, upgraded), diff --git a/src/scenes/shotTitleProvenance.test.mjs b/src/scenes/shotTitleProvenance.test.mjs new file mode 100644 index 000000000..ada35a56f --- /dev/null +++ b/src/scenes/shotTitleProvenance.test.mjs @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { displayShotTitle } from './recipes.js'; +import { setLocale } from '../i18n/index.js'; + +test('a typed shot title in a user scene with an applied pack stays as typed', () => { + setLocale('ru'); + const title = 'Global Incident Context'; + // The user's own shot (not from the pack: no sourcePackId) in a user scene + // that later received the Nepal evidence pack. + const ownShot = { id: 'user-shot-7', title }; + const scene = { + id: 'scene-user-123', + appliedShotPacks: [{ id: 'bhote-koshi-nepal-evidence-pack', version: 12 }], + }; + assert.equal(displayShotTitle(title, ownShot, scene), title); +}); diff --git a/src/ui/geoFileImport.js b/src/ui/geoFileImport.js index 8785f24fd..1936b569a 100644 --- a/src/ui/geoFileImport.js +++ b/src/ui/geoFileImport.js @@ -18,6 +18,16 @@ import { flyToDataSource } from './flyToDataSource.js'; // One color per file, in import order, from the whiteboard palette. const COLORS = ['#39d0ff', '#ffb547', '#5dff9f', '#ff6b6b', '#8be9ff']; +/** An import error's values, with its counts and limits in local numerals. */ +function localizedValues(values) { + return Object.fromEntries( + Object.entries(values || {}).map(([name, value]) => [ + name, + typeof value === 'number' ? formatNumber(value) : value, + ]), + ); +} + /** * Wire the Import control. * @param {{viewer: object, load?: Function, document?: Document}} deps @@ -303,7 +313,10 @@ export function initGeoFileImport({ ok: false, error: error instanceof GeoImportError || error?.name === 'GeoImportError' - ? t(error.template || error.message, error.values) + ? t( + error.template || error.message, + localizedValues(error.values), + ) : t('That file could not be read.'), }); continue; diff --git a/src/ui/geoFileImport.test.mjs b/src/ui/geoFileImport.test.mjs index eb4bdfa99..8e74430bd 100644 --- a/src/ui/geoFileImport.test.mjs +++ b/src/ui/geoFileImport.test.mjs @@ -729,3 +729,21 @@ test('the map-object count in a row title keeps digit grouping', async () => { assert.match(row.children[1].title, /12,345 map objects/); t.tool.destroy(); }); + +test('a refusal shows its counts in the language’s numerals, not the browser’s', async () => { + const t = setup({ + load: async () => { + throw new GeoImportError( + 'That file has 60,000 map objects; the limit is 50,000.', + 'That file has {count} map objects; the limit is {limit}.', + { count: 60000, limit: 50000 }, + ); + }, + }); + const [out] = await t.tool.importFiles([file('huge.kml')]); + assert.equal( + out.error, + 'That file has 60,000 map objects; the limit is 50,000.', + ); + t.tool.destroy(); +}); diff --git a/src/ui/rtlLayout.test.mjs b/src/ui/rtlLayout.test.mjs new file mode 100644 index 000000000..74a556dd1 --- /dev/null +++ b/src/ui/rtlLayout.test.mjs @@ -0,0 +1,98 @@ +// Right-to-left layout pins: chrome that mirrors in Arabic must mirror on +// both sides of a pair, or one half lands on the other or off the screen. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const sheet = (name) => + readFileSync(new URL(`./styles/${name}`, import.meta.url), 'utf8'); + +/** The declarations of the first rule whose selector is exactly `selector`. */ +function ruleBody(css, selector) { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = new RegExp(`(?:^|\\n)\\s*${escaped}\\s*\\{([^}]*)\\}`).exec( + css, + ); + assert.ok(match, `no rule for ${selector}`); + return match[1]; +} + +test('the dock trays open from their own wing in right-to-left', () => { + const rtl = sheet('rtl.css'); + const location = ruleBody( + rtl, + "html[dir='rtl'] #command-dock #location-bar .dock-popover-content", + ); + assert.match( + location, + /right: calc\(\(var\(--dock-unified-width\) - var\(--dock-location-open\)\) \/ 2\);/, + ); + assert.match(location, /left: auto;/); + assert.match(location, /transform-origin: bottom right;/); + const presets = ruleBody( + rtl, + "html[dir='rtl'] #command-dock #control-panel .dock-popover-content", + ); + assert.match( + presets, + /left: calc\(\(var\(--dock-unified-width\) - var\(--dock-presets-open\)\) \/ 2\);/, + ); + assert.match(presets, /right: auto;/); + assert.match(presets, /transform-origin: bottom left;/); + // The narrow band re-centres both trays on the mobile tray width. + const narrow = /@media \(max-width: 900px\) \{([\s\S]*?)\n\}/.exec(rtl); + assert.ok(narrow, 'no max-width:900px block in rtl.css'); + assert.match( + narrow[1], + /#location-bar \.dock-popover-content \{\s*right: calc\(\s*\(var\(--dock-unified-width\) - var\(--dock-popover-mobile-width\)\) \/ 2\s*\);/, + ); + assert.match( + narrow[1], + /#control-panel \.dock-popover-content \{\s*left: calc\(\s*\(var\(--dock-unified-width\) - var\(--dock-popover-mobile-width\)\) \/ 2\s*\);/, + ); +}); + +test('the Cyber cockpit keeps Data Layers on the left edge in every language', () => { + const body = ruleBody( + sheet('cyber.css'), + ":root[data-ui-theme='cyber'] body.cockpit-mode #left-panel-stack", + ); + // inset-inline-start would become right: 30px in Arabic and beat + // rtl.css's reset, landing the stack on the utility controls. + assert.doesNotMatch(body, /inset-inline-start/); + assert.match(body, /left: 30px;/); + assert.match(body, /right: auto;/); +}); + +test('the compact Radio window opens toward the screen in right-to-left', () => { + const layers = ruleBody(sheet('layers.css'), '.context-radio-mini'); + assert.match(layers, /inset-inline-end: -38px;/); + assert.doesNotMatch(layers, /(?:^|\s)right:/); + const responsive = /\.context-radio-mini \{([^}]*)\}/.exec( + sheet('responsive.css'), + ); + assert.ok(responsive); + assert.match(responsive[1], /inset-inline-end: -38px;/); + assert.doesNotMatch(responsive[1], /(?:^|\s)right:/); +}); + +test('phone top-bar actions mirror with the logo', () => { + const mobile = sheet('mobile-shell.css'); + const actions = ruleBody(mobile, 'body.mobile-shell #top-center-actions'); + assert.match(actions, /inset-inline-start: auto;/); + assert.match(actions, /inset-inline-end: 12px;/); + assert.doesNotMatch(actions, /(?:^|\s)right:/); + const corner = ruleBody(mobile, 'body.mobile-shell #corner-actions'); + assert.match(corner, /inset-inline-end: 12px;/); + assert.doesNotMatch(corner, /(?:^|\s)right:/); +}); + +test('the Space Missions counter reads as a number pair in right-to-left', () => { + const counters = + /html\[dir='rtl'\]\s*:is\(([^)]*\[data-role='beat-index'\][^)]*)\)\s*\{([^}]*)\}/.exec( + sheet('rtl.css'), + ); + assert.ok(counters, 'no left-to-right counter rule in rtl.css'); + assert.match(counters[1], /\[data-mission-index\]/); + assert.match(counters[2], /direction: ltr;/); +}); diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index 448f3f733..2efc4e7fd 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -2721,7 +2721,10 @@ :root[data-ui-theme='cyber'] body.cockpit-mode #left-panel-stack { top: clamp(164px, 19vh, 204px) !important; bottom: auto; - inset-inline-start: 30px; + /* The cockpit keeps its physical sides in every language (rtl.css): the + utility controls hold the right edge. */ + right: auto; + left: 30px; width: var(--cyber-panel-width); padding-inline-start: 0; border-inline-start: 0; diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index dfc5a0905..dfce25753 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -1059,7 +1059,7 @@ position: absolute; z-index: 5; top: calc(100% + 20px); - right: -38px; + inset-inline-end: -38px; display: none; width: 244px; box-sizing: border-box; diff --git a/src/ui/styles/mobile-shell.css b/src/ui/styles/mobile-shell.css index d5deb067c..4c0d6edc4 100644 --- a/src/ui/styles/mobile-shell.css +++ b/src/ui/styles/mobile-shell.css @@ -84,7 +84,7 @@ body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) { background: rgba(0, 0, 0, 0.18); } -/* ── Top bar: logo left, globe actions right ───────────────────────────── */ +/* ── Top bar: logo at the start, globe actions at the end ──────────────── */ body.mobile-shell #title-bar { top: calc(var(--mobile-top-inset) + 10px); inset-inline-start: 14px; @@ -101,7 +101,8 @@ body.mobile-shell #title-bar .subtitle { body.mobile-shell #top-center-actions { top: calc(var(--mobile-top-inset) + 10px); - right: 12px; + inset-inline-start: auto; + inset-inline-end: 12px; } /* ── HUD: one compact readout under the top bar ────────────────────────── */ @@ -401,7 +402,7 @@ body.mobile-shell:not(.cockpit-mode) #view-switcher { body.mobile-shell #corner-actions { top: calc(var(--mobile-top-inset) + 60px); - right: 12px; + inset-inline-end: 12px; bottom: auto; } diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css index 3b97c60ee..5bc8f7507 100644 --- a/src/ui/styles/responsive.css +++ b/src/ui/styles/responsive.css @@ -229,7 +229,7 @@ width: 100%; } .context-radio-mini { - right: -38px; + inset-inline-end: -38px; width: min(244px, calc(100vw - 32px)); } diff --git a/src/ui/styles/rtl.css b/src/ui/styles/rtl.css index c8463037b..ec71bb73d 100644 --- a/src/ui/styles/rtl.css +++ b/src/ui/styles/rtl.css @@ -77,6 +77,37 @@ html:lang(ar) :is(#intel-hud, #cockpit-hud) { } } +/* The dock's wings swap sides (Location on the right, Visual presets on the + left), so each slide-up tray is anchored from the wing's outer edge and + centred on the whole dock from there, as it is in left-to-right. */ +html[dir='rtl'] #command-dock #location-bar .dock-popover-content { + right: calc((var(--dock-unified-width) - var(--dock-location-open)) / 2); + left: auto; + transform-origin: bottom right; +} +html[dir='rtl'] #command-dock #control-panel .dock-popover-content { + right: auto; + left: calc((var(--dock-unified-width) - var(--dock-presets-open)) / 2); + transform-origin: bottom left; +} +@media (max-width: 900px) { + html[dir='rtl'] #command-dock #location-bar .dock-popover-content { + right: calc( + (var(--dock-unified-width) - var(--dock-popover-mobile-width)) / 2 + ); + } + html[dir='rtl'] #command-dock #control-panel .dock-popover-content { + left: calc( + (var(--dock-unified-width) - var(--dock-popover-mobile-width)) / 2 + ); + } +} +/* The tray pin sits on the tray's top end corner: top-left here. */ +html[dir='rtl'] #command-dock :is(#location-bar, #control-panel) .dock-pin-btn { + right: auto; + left: -1rem; +} + /* The right rail sits on the left: its expand/collapse arrows point the other way, and the start window's "go" arrows point along the reading direction. */ @@ -165,7 +196,8 @@ html[dir='rtl'] .gev-anno-text { html[dir='rtl'] :is( #bhote-koshi-event-panel [data-role='beat-index'], - #cockpit-brief-position + #cockpit-brief-position, + [data-mission-index] ) { direction: ltr; unicode-bidi: isolate;