mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
i18n(ar): address code review findings
Mirror the dock trays and their pin, the compact Radio window and the phone top-bar actions in right-to-left; keep the Cyber cockpit's Data Layers stack on the left edge. Isolate the Space Missions counter, landing coordinates and CCTV summary parts, format their numbers with the locale's numerals, decide pack shot titles by the marker binding, and place Bhote Koshi media cards between the panels on either side. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8c291d8a0c
commit
04481ef202
17 files changed
+313
-55
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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(' · ');
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
: '',
|
||||
);
|
||||
}
|
||||
|
||||
+13
-15
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+14
-1
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;/);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
width: 100%;
|
||||
}
|
||||
.context-radio-mini {
|
||||
right: -38px;
|
||||
inset-inline-end: -38px;
|
||||
width: min(244px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
|
||||
+33
-1
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user