([\s\S]*?)<\/div>\s*
- `${tag}.${cls.split(' ').filter((name) => !/^ui-/.test(name)).join(' ')}`);
- assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']);
+ const tags = [
+ ...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g),
+ ].map(
+ ([, tag, cls]) =>
+ `${tag}.${cls
+ .split(' ')
+ .filter((name) => !/^ui-/.test(name))
+ .join(' ')}`,
+ );
+ assert.deepEqual(tags, [
+ 'span.panel-title',
+ 'span.panel-divider',
+ 'button.panel-edge-btn',
+ 'span.material-symbols-outlined',
+ 'button.panel-collapse-btn',
+ ]);
});
test('Context reads as one segmented control with legible type and no halo', () => {
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
- const clean = (selector) => selector.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim();
- const rule = (selector) => rules.find(([, s]) => clean(s) === selector)?.[2] || '';
- const px = (body, prop) => Number(body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0);
+ const clean = (selector) =>
+ selector
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/\s+/g, ' ')
+ .trim();
+ const rule = (selector) =>
+ rules.find(([, s]) => clean(s) === selector)?.[2] || '';
+ const px = (body, prop) =>
+ Number(
+ body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0,
+ );
assert.match(rule('#global-context-panel .panel-glow'), /display:\s*none/);
const modes = rule('.global-context-modes');
assert.match(modes, /border:\s*1px solid/);
assert.match(modes, /padding:\s*2px/);
- assert.ok(px(rule('.context-mode-button'), 'font') >= 10, 'mode labels must stay at least 10px');
+ assert.ok(
+ px(rule('.context-mode-button'), 'font') >= 10,
+ 'mode labels must stay at least 10px',
+ );
assert.doesNotMatch(rule('.context-mode-button.active'), /box-shadow/);
const standby = rule('.context-mode-standby');
- assert.ok(px(standby, 'font') >= 10, 'standby guidance must stay at least 10px');
+ assert.ok(
+ px(standby, 'font') >= 10,
+ 'standby guidance must stay at least 10px',
+ );
assert.doesNotMatch(standby, /dashed/);
- assert.ok(px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10);
+ assert.ok(
+ px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10,
+ );
// The shared rail title colour (brighter than --text-secondary) keeps the
// open Context title legible; no Context-only rule dims it again.
assert.equal(
- rule(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"),
+ rule(
+ ":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title",
+ ),
'',
);
});
diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json
index ebdc0ef99..e882d6f29 100644
--- a/src/i18n/locales/ar.json
+++ b/src/i18n/locales/ar.json
@@ -3967,5 +3967,7 @@
"Cable car": "ترام كابلي",
"Monorail": "قطار أحادي السكة",
"Funicular": "قطار جبلي مائل",
- "Gondola": "تلفريك"
+ "Gondola": "تلفريك",
+ "Move panels back beside the rail": "إعادة اللوحات إلى جانب الشريط",
+ "Move panels over the rail, to the edge": "نقل اللوحات فوق الشريط إلى الحافة"
}
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index c1bf7dd2a..4dbecb4d1 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -3619,5 +3619,7 @@
"Cable car": "Kabelstraßenbahn",
"Monorail": "Einschienenbahn",
"Funicular": "Standseilbahn",
- "Gondola": "Gondelbahn"
+ "Gondola": "Gondelbahn",
+ "Move panels back beside the rail": "Bedienfelder zurück neben die Leiste",
+ "Move panels over the rail, to the edge": "Bedienfelder über die Leiste an den Rand verschieben"
}
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index 1ba3bc810..f63dae030 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -3706,5 +3706,7 @@
"Cable car": "Tranvía de cable",
"Monorail": "Monorraíl",
"Funicular": "Funicular",
- "Gondola": "Góndola"
+ "Gondola": "Góndola",
+ "Move panels back beside the rail": "Devolver los paneles junto a la barra",
+ "Move panels over the rail, to the edge": "Mover los paneles sobre la barra, hasta el borde"
}
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index cb82ee62b..8f1996cde 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -3706,5 +3706,7 @@
"Cable car": "Tramway à câble",
"Monorail": "Monorail",
"Funicular": "Funiculaire",
- "Gondola": "Télécabine"
+ "Gondola": "Télécabine",
+ "Move panels back beside the rail": "Remettre les panneaux à côté de la barre",
+ "Move panels over the rail, to the edge": "Déplacer les panneaux par-dessus la barre, jusqu’au bord"
}
diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json
index 80943335a..c848be0bd 100644
--- a/src/i18n/locales/he.json
+++ b/src/i18n/locales/he.json
@@ -3706,5 +3706,7 @@
"Cable car": "חשמלית כבלים",
"Monorail": "מונורייל",
"Funicular": "פוניקולר",
- "Gondola": "רכבל"
+ "Gondola": "רכבל",
+ "Move panels back beside the rail": "החזרת הלוחות לצד הסרגל",
+ "Move panels over the rail, to the edge": "הזזת הלוחות מעל הסרגל, אל הקצה"
}
diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json
index d68693f31..d93a963a5 100644
--- a/src/i18n/locales/it.json
+++ b/src/i18n/locales/it.json
@@ -3706,5 +3706,7 @@
"Cable car": "Tram a fune",
"Monorail": "Monorotaia",
"Funicular": "Funicolare",
- "Gondola": "Cabinovia"
+ "Gondola": "Cabinovia",
+ "Move panels back beside the rail": "Riporta i pannelli accanto alla barra",
+ "Move panels over the rail, to the edge": "Sposta i pannelli sopra la barra, fino al bordo"
}
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 6c36c71ce..3fb7ad023 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -3532,5 +3532,7 @@
"Cable car": "ケーブルトラム",
"Monorail": "モノレール",
"Funicular": "ケーブルカー",
- "Gondola": "ゴンドラ"
+ "Gondola": "ゴンドラ",
+ "Move panels back beside the rail": "パネルをレールの横に戻す",
+ "Move panels over the rail, to the edge": "パネルをレールの上、画面端へ移動"
}
diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json
index 8864f6d22..a42bf0448 100644
--- a/src/i18n/locales/pt.json
+++ b/src/i18n/locales/pt.json
@@ -3706,5 +3706,7 @@
"Cable car": "Bonde a cabo",
"Monorail": "Monotrilho",
"Funicular": "Funicular",
- "Gondola": "Teleférico"
+ "Gondola": "Teleférico",
+ "Move panels back beside the rail": "Voltar os painéis para junto da barra",
+ "Move panels over the rail, to the edge": "Mover os painéis sobre a barra, até a borda"
}
diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json
index 37c6db315..96efc6e5e 100644
--- a/src/i18n/locales/ru.json
+++ b/src/i18n/locales/ru.json
@@ -3793,5 +3793,7 @@
"Cable car": "Канатный трамвай",
"Monorail": "Монорельс",
"Funicular": "Фуникулёр",
- "Gondola": "Канатная дорога"
+ "Gondola": "Канатная дорога",
+ "Move panels back beside the rail": "Вернуть панели к боковой панели",
+ "Move panels over the rail, to the edge": "Переместить панели поверх боковой панели к краю"
}
diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json
index b1c9887e3..63304ba65 100644
--- a/src/i18n/locales/sv.json
+++ b/src/i18n/locales/sv.json
@@ -3619,5 +3619,7 @@
"Cable car": "Kabelspårvagn",
"Monorail": "Enskensbana",
"Funicular": "Bergbana",
- "Gondola": "Kabinbana"
+ "Gondola": "Kabinbana",
+ "Move panels back beside the rail": "Flytta tillbaka panelerna bredvid listen",
+ "Move panels over the rail, to the edge": "Flytta panelerna över listen, till kanten"
}
diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json
index f59df5624..47db81c3e 100644
--- a/src/i18n/locales/uk.json
+++ b/src/i18n/locales/uk.json
@@ -3793,5 +3793,7 @@
"Cable car": "Канатний трамвай",
"Monorail": "Монорейка",
"Funicular": "Фунікулер",
- "Gondola": "Канатна дорога"
+ "Gondola": "Канатна дорога",
+ "Move panels back beside the rail": "Повернути панелі до бічної смуги",
+ "Move panels over the rail, to the edge": "Перемістити панелі поверх бічної смуги до краю"
}
diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json
index 2c4b5e9df..511b2f4ae 100644
--- a/src/i18n/locales/zh-CN.json
+++ b/src/i18n/locales/zh-CN.json
@@ -3532,5 +3532,7 @@
"Cable car": "缆索电车",
"Monorail": "单轨列车",
"Funicular": "缆索铁路",
- "Gondola": "缆车"
+ "Gondola": "缆车",
+ "Move panels back beside the rail": "将面板移回侧栏旁",
+ "Move panels over the rail, to the edge": "将面板移到侧栏上方,贴靠边缘"
}
diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json
index c9f352964..385b18064 100644
--- a/src/i18n/locales/zh-Hant.json
+++ b/src/i18n/locales/zh-Hant.json
@@ -3532,5 +3532,7 @@
"Cable car": "纜索電車",
"Monorail": "單軌列車",
"Funicular": "纜索鐵路",
- "Gondola": "纜車"
+ "Gondola": "纜車",
+ "Move panels back beside the rail": "將面板移回側欄旁",
+ "Move panels over the rail, to the edge": "將面板移到側欄上方,貼齊邊緣"
}
diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js
index 1fd86824f..564578e60 100644
--- a/src/ui/applicationShell.js
+++ b/src/ui/applicationShell.js
@@ -65,6 +65,7 @@ import { useHudStore } from './stores/hud.js';
import { useDisplayStore } from './stores/display.js';
import { useLocaleStore } from './stores/locale.js';
import { useLocationStore } from './stores/location.js';
+import { installRailEdgeScroll } from './railEdgeScroll.js';
import { useCockpitStore } from './stores/cockpit.js';
import { useGlobeActionsStore } from './stores/globeActions.js';
import { useRecordingStore } from './stores/recording.js';
@@ -164,6 +165,8 @@ export class StyleManager extends ShellFacade {
});
// Vue views share this runtime and its stores; dispose unmounts them.
this._ui = createUiRuntime({ pinia: applicationPinia() });
+ // Scrolling at a screen edge steps through that side's rail panels.
+ this._removeRailEdgeScroll = installRailEdgeScroll();
this._feedback = new ShellFeedback({
readLayers: () => withPlacePacks(this._dataManager?.getAll?.() || []),
});
@@ -1856,6 +1859,7 @@ export class StyleManager extends ShellFacade {
const { destroyTrackedReadout, destroyWorldOverlay, destroyDetection } =
this.services;
if (this._disposed) return;
+ this._removeRailEdgeScroll?.();
this._shareRestoration.destroy();
this._feedback.clearLoadingStatus();
this._disposed = true;
diff --git a/src/ui/kit/UiPanel.vue b/src/ui/kit/UiPanel.vue
index 5421fc699..f16adc385 100644
--- a/src/ui/kit/UiPanel.vue
+++ b/src/ui/kit/UiPanel.vue
@@ -1,5 +1,6 @@
+
+
+
+ {{ icon }}
+
+
diff --git a/src/ui/leftPanelRail.js b/src/ui/leftPanelRail.js
index ca11a8149..c5c65378b 100644
--- a/src/ui/leftPanelRail.js
+++ b/src/ui/leftPanelRail.js
@@ -60,6 +60,7 @@ export function layoutLeftPanelRail({
stack.style.removeProperty('--left-stack-safe-bottom');
stack.style.removeProperty('--left-stack-centered-height');
stack.style.removeProperty('--panel-rail-offset');
+ stack.style.removeProperty('--panel-rail-lift');
stack.classList.remove('layout-exclusive');
stack.dataset.layoutMode = 'mobile';
settleSideAccordion({ panels, enabled: false, onCollapse });
@@ -282,10 +283,19 @@ export function layoutLeftPanelRail({
getComputedStyle(stack)
.getPropertyValue?.('--panel-stack-top-pinned')
?.trim() === '1';
- const railOffset = `${(topPinned ? 0 : safeTop - layoutTop).toFixed(1)}px`;
+ // The stack itself rises by the lift below, by a different amount for a
+ // tall panel than at rest; the rail takes up the difference.
+ const lift = (top) => Math.min(0, obstacleSafeTop - top);
+ const railOffset = `${(topPinned ? 0 : safeTop + lift(safeTop) - layoutTop - lift(layoutTop)).toFixed(1)}px`;
if (stack.style.getPropertyValue('--panel-rail-offset') !== railOffset) {
stack.style.setProperty('--panel-rail-offset', railOffset);
}
+ // How far the column can rise before it meets the HUD readout, so the rail
+ // and any open panel sit just under it, whether or not a panel is open.
+ const railLift = `${(topPinned ? 0 : lift(layoutTop)).toFixed(1)}px`;
+ if (stack.style.getPropertyValue('--panel-rail-lift') !== railLift) {
+ stack.style.setProperty('--panel-rail-lift', railLift);
+ }
stack.style.removeProperty('--left-stack-centered-height');
for (const panel of panels)
panel.style.removeProperty('--left-panel-allocated-height');
diff --git a/src/ui/mobileShell.js b/src/ui/mobileShell.js
index 65673cd55..7570e2d17 100644
--- a/src/ui/mobileShell.js
+++ b/src/ui/mobileShell.js
@@ -368,7 +368,8 @@ export function initMobileShell({
: sheet === 'search'
? [dock]
: sheet === 'more'
- ? [element('right-context-rail'), dock]
+ ? // Visual lives in the left stack; the dock holds the presets.
+ [element('left-panel-stack'), dock]
: [element('right-context-rail')];
return roots.find((root) => root?.contains(target));
};
diff --git a/src/ui/mobileShell.test.mjs b/src/ui/mobileShell.test.mjs
index c0e0b909f..337fde99b 100644
--- a/src/ui/mobileShell.test.mjs
+++ b/src/ui/mobileShell.test.mjs
@@ -623,7 +623,7 @@ test('the phone shell keeps the required attribution visible', () => {
);
const openDock = rule('body.mobile-shell[data-mobile-sheet] #command-dock');
const railSheet = rule(
- "body.mobile-shell[data-mobile-sheet='more'] #right-context-rail",
+ "body.mobile-shell[data-mobile-sheet='more'] #left-panel-stack",
);
assert.ok(z(sheetCredit) > z(openDock));
assert.ok(z(sheetCredit) > z(railSheet));
diff --git a/src/ui/panelChromeButtons.test.mjs b/src/ui/panelChromeButtons.test.mjs
index 543a77730..ae3a212c0 100644
--- a/src/ui/panelChromeButtons.test.mjs
+++ b/src/ui/panelChromeButtons.test.mjs
@@ -141,6 +141,7 @@ test("UiPanel draws Core's panel markup and asks for the other state", async (t)
'demo-badge',
'panel-divider',
'demo-action',
+ 'panel-edge-btn',
'panel-collapse-btn',
],
);
diff --git a/src/ui/panelEdge.js b/src/ui/panelEdge.js
new file mode 100644
index 000000000..85b9d353f
--- /dev/null
+++ b/src/ui/panelEdge.js
@@ -0,0 +1,42 @@
+import { ref } from 'vue';
+
+// Panels can slide over their rail to the screen edge, one setting per side:
+// the left column moves onto the left rail, the right column onto the right
+// rail. Each panel header has the switch (UiPanelEdge); the choice is kept
+// for the next visit and shows as a body class the stylesheet reads.
+const KEY = 'gev.panelEdge';
+function read() {
+ try {
+ const saved = JSON.parse(globalThis.localStorage?.getItem(KEY) || '{}');
+ return { left: saved.left === true, right: saved.right === true };
+ } catch {
+ return { left: false, right: false };
+ }
+}
+
+export const panelEdge = ref(read());
+
+function apply() {
+ const body = globalThis.document?.body;
+ if (!body) return;
+ body.classList.toggle('panels-edge-left', panelEdge.value.left);
+ body.classList.toggle('panels-edge-right', panelEdge.value.right);
+}
+apply();
+
+/** Which side a panel element sits on, from the column it is in. */
+export function panelSide(element) {
+ return element?.closest?.('#right-context-rail, #cockpit-utility-controls')
+ ? 'right'
+ : 'left';
+}
+
+export function togglePanelEdge(side) {
+ panelEdge.value = { ...panelEdge.value, [side]: !panelEdge.value[side] };
+ try {
+ globalThis.localStorage?.setItem(KEY, JSON.stringify(panelEdge.value));
+ } catch {
+ // Without storage the choice lasts for this visit.
+ }
+ apply();
+}
diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js
index d5de96647..22242c54f 100644
--- a/src/ui/panelLayoutController.js
+++ b/src/ui/panelLayoutController.js
@@ -189,7 +189,11 @@ export class PanelLayoutController {
const stack = this._rightPanelStack;
if (!stack || !this._ppToggles) return;
- stack.prepend(this._ppToggles);
+ // Visual (presets and display settings) is the last panel of the left
+ // column, after the other tools, and opens from the left rail like them.
+ if (typeof this._leftPanelStack?.append === 'function')
+ this._leftPanelStack.append(this._ppToggles);
+ else stack.prepend(this._ppToggles);
const globalContextPanel = document.getElementById('global-context-panel');
for (const panel of [
this._cctvPanel,
@@ -201,6 +205,9 @@ export class PanelLayoutController {
if (!panel) continue;
stack.insertBefore(panel, globalContextPanel);
}
+ // Context leads the right column: it is the panel for whatever is picked,
+ // and the camera panels follow it.
+ if (globalContextPanel) stack.prepend(globalContextPanel);
if (typeof ResizeObserver !== 'undefined') {
this._rightStackResizeObserver = new ResizeObserver(() => {
this._scheduleRightPanelLayout();
diff --git a/src/ui/panelMeasurement.js b/src/ui/panelMeasurement.js
index dcd9de464..86be6f95c 100644
--- a/src/ui/panelMeasurement.js
+++ b/src/ui/panelMeasurement.js
@@ -6,9 +6,20 @@
* @returns {number} Natural height in CSS pixels.
*/
export function measurePanelNaturalHeight(panel, getComputedStyle) {
- const inner = [...panel.children].find(
+ const content = [...panel.children].filter(
(child) => !child.classList.contains('panel-glow'),
);
+ // A panel built as a header row and a body (Visual) scrolls as a whole, so
+ // its full scroll height is its natural height.
+ if (content.length > 1) {
+ const panelStyle = getComputedStyle(panel);
+ return Math.ceil(
+ panel.scrollHeight +
+ (parseFloat(panelStyle.borderTopWidth) || 0) +
+ (parseFloat(panelStyle.borderBottomWidth) || 0),
+ );
+ }
+ const inner = content[0];
if (!inner)
return Math.ceil(
panel.scrollHeight || panel.getBoundingClientRect().height,
diff --git a/src/ui/panelRailCatalog.js b/src/ui/panelRailCatalog.js
index dbda77dcd..6e0b0a876 100644
--- a/src/ui/panelRailCatalog.js
+++ b/src/ui/panelRailCatalog.js
@@ -19,7 +19,7 @@ export const PANEL_RAIL_ITEMS = Object.freeze({
},
'utilities-panel': { icon: 'handyman', label: msg('UTILITIES') },
'scene-panel': { icon: 'movie', label: msg('AUTHORING') },
- 'pp-toggles': { icon: 'tune', label: msg('DISPLAY') },
+ 'pp-toggles': { icon: 'palette', label: msg('VISUAL') },
'cctv-panel': { icon: 'videocam', label: msg('CCTV') },
'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') },
'news-panel': { icon: 'public', label: msg('NEWS') },
diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs
index 40c2be20b..ce1c82446 100644
--- a/src/ui/panelRails.test.mjs
+++ b/src/ui/panelRails.test.mjs
@@ -359,14 +359,23 @@ test('right rail aligns to the left rail buttons while the left stack is offset'
assert.equal(f.stack.dataset.safeTop, '286.7');
});
-test('left rail buttons hold still while a tall panel moves the stack', () => {
+test('left rail buttons hold still whether the open panel is short or tall', () => {
+ // Where the rail sits: the stack's top, the stack's rise, and the rail's
+ // own offset inside the stack.
+ const railTop = (stack) =>
+ (parseFloat(stack.style.getPropertyValue('--left-stack-safe-top')) * 900) /
+ 100 +
+ parseFloat(stack.style.getPropertyValue('--panel-rail-lift')) +
+ parseFloat(stack.style.getPropertyValue('--panel-rail-offset'));
+ const short = fixture('left');
+ short.expand(short.first, 250);
+ short.run();
const f = fixture('left');
f.expand(f.first, 900);
f.run();
assert.equal(f.stack.dataset.layoutMode, 'focus');
- assert.ok(
- parseFloat(f.stack.style.getPropertyValue('--panel-rail-offset')) > 0,
- );
+ assert.notEqual(short.stack.dataset.layoutMode, 'focus');
+ assert.ok(Math.abs(railTop(f.stack) - railTop(short.stack)) < 0.2);
// A theme that pins the stack's top (Cyber) leaves nothing to offset.
f.stack.computed.getPropertyValue = (name) =>
@@ -447,13 +456,17 @@ test('natural height includes visible content, margins and wrapper chrome, exclu
// Outside measurement, CCTV is 391 px in focus and 629 px in normal mode;
// Removing only the allocation leaves focus CSS active: its next reading is
// 422 px, so the old decision exits focus and then re-enters on the 629 px read.
-function thrashingRightRail({ focused = false, weatherOpen = false } = {}) {
+function thrashingRightRail({
+ focused = false,
+ weatherOpen = false,
+ tallId = 'cctv-panel',
+} = {}) {
const f = fixture('right');
f.options.windowRef.innerHeight = 920;
f.options.leftStack.rect.top = 439.6;
f.stack.computed.rowGap = '8px';
const display = element('pp-toggles', { collapsed: true });
- const cctv = element('cctv-panel', { height: 629 });
+ const cctv = element(tallId, { height: 629 });
const weather = element('weather-panel', {
height: 444,
collapsed: !weatherOpen,
@@ -524,8 +537,10 @@ for (const focused of [false, true]) {
});
}
+// The hysteresis applies to panels that size to their content; CCTV and
+// Context take the column's full height whenever they are open.
test('right rail keeps focus through 30 px content growth inside the hysteresis band', () => {
- const f = thrashingRightRail();
+ const f = thrashingRightRail({ tallId: 'news-panel' });
f.run();
// Header + gap contribute 52 px: need moves 420 -> 450 -> 420.
for (const height of [368, 398, 368, 398, 368]) {
@@ -542,7 +557,7 @@ test('right rail keeps focus through 30 px content growth inside the hysteresis
});
test('right rail enters focus only above available height and leaves below the full deadband', () => {
- const f = thrashingRightRail();
+ const f = thrashingRightRail({ tallId: 'news-panel' });
f.options.windowRef.innerHeight = 1000;
f.options.leftStack.rect.top = 500;
f.cctv.intrinsicHeight = 408; // Need equals available (460 px).
@@ -559,6 +574,16 @@ test('right rail enters focus only above available height and leaves below the f
assert.equal(f.stack.classList.contains('layout-focus'), false);
});
+test('an open CCTV panel takes the full column however short its content', () => {
+ const f = thrashingRightRail();
+ f.cctv.intrinsicHeight = 200;
+ f.run();
+ assert.equal(f.stack.classList.contains('layout-focus'), true);
+ f.cctv.classList.add('collapsed');
+ f.run();
+ assert.equal(f.stack.classList.contains('layout-focus'), false);
+});
+
test('right rail settles a second tall panel through the accordion in one pass', () => {
const f = thrashingRightRail({ weatherOpen: true });
const modes = [];
diff --git a/src/ui/railEdgeScroll.js b/src/ui/railEdgeScroll.js
new file mode 100644
index 000000000..4e3d9b0a4
--- /dev/null
+++ b/src/ui/railEdgeScroll.js
@@ -0,0 +1,393 @@
+// Edge scrolling for the side rails. While the pointer is in a screen edge's
+// strip (from the edge to just past its rail), that rail's border lights up,
+// and the scroll wheel steps to the next or previous panel on that side
+// instead of zooming the globe, wrapping round at either end. The open item's
+// highlight slides between items however the panel changes, and a panel
+// opened by the wheel slides in from the direction of travel.
+
+/** How far past the rail the strip reaches, in pixels. */
+const STRIP_MARGIN = 16;
+/** Wheel travel (pixels) that counts as one step. */
+const STEP_DELTA = 40;
+/**
+ * A pause this long (ms) with no wheel events ends a scroll gesture. One
+ * gesture moves one step, however long it runs, trackpad coasting included.
+ */
+const GESTURE_GAP_MS = 180;
+/** After a step, how long (ms) before the same motion can count as a new flick. */
+const STEP_COOLDOWN_MS = 220;
+/**
+ * The least time (ms) between two steps, whatever the wheel does. A motion
+ * that would step sooner waits and steps once the gap has passed (one step
+ * held at most), so panels never switch back to back and no step is lost.
+ */
+const STEP_GAP_MS = 500;
+/** A wheel event this large (pixels) is a mouse wheel notch, not a trackpad. */
+const NOTCH_DELTA = 100;
+/** The widest the strip gets, measured from the screen edge, whatever shape the rail takes. */
+const STRIP_MAX = 84;
+/**
+ * After a step, how long (ms) the rest of that gesture is still swallowed once
+ * the pointer has left the strip, so trackpad coasting does not go on to zoom
+ * the globe. Past it, the wheel belongs to whatever is under the pointer.
+ */
+const COAST_MAX_MS = 800;
+/** Pixels per wheel line, for mice that report lines rather than pixels. */
+const LINE_PX = 16;
+/**
+ * Things in the strip that keep their own pointer and wheel behaviour: any
+ * control, and every panel in either side's stack (a panel scrolls itself).
+ */
+const OWN_CONTROLS =
+ 'a, button, input, select, textarea, dialog, [role="dialog"], .ui-panel, #left-panel-stack > :not(.gev-panel-rail), #right-context-rail > :not(.gev-panel-rail), #first-run-launcher, #cesium-credits, #world-timeline';
+
+/**
+ * @param {Document} [doc]
+ * @returns {() => void} removes the listeners and observers
+ */
+export function installRailEdgeScroll(doc = globalThis.document) {
+ const win = doc?.defaultView;
+ if (!doc || !win) return () => {};
+ const coarse = win.matchMedia?.('(pointer: coarse)');
+
+ let hot = null;
+ let travel = 0;
+ let lastWheel = 0;
+ let lastSpeed = 0;
+ let stepped = false;
+ /** When the last step was taken, the fastest the gesture has been, and whether it has slowed since. */
+ let steppedAt = 0;
+ /** A step waiting for the gap to pass, if any. */
+ let heldStep = 0;
+ let peakSpeed = 0;
+ let slowed = false;
+ let frame = 0;
+ /** Set once removed, so frames already queued do nothing. */
+ let removed = false;
+
+ // Clean view and recording hide the rails with visibility and opacity,
+ // which keep their boxes, so a box alone does not mean the rail shows.
+ const showing = (node) =>
+ node.getBoundingClientRect().width > 0 &&
+ (typeof node.checkVisibility !== 'function' ||
+ node.checkVisibility({
+ visibilityProperty: true,
+ opacityProperty: true,
+ }));
+
+ // Which screen edge a rail is on comes from where it is, not its name: in a
+ // right-to-left language the left stack sits on the right.
+ const rails = () =>
+ ['left', 'right']
+ .map((name) => doc.querySelector(`.ui-rail--${name}`))
+ .filter((rail) => rail && showing(rail))
+ .map((rail) => {
+ const box = rail.getBoundingClientRect();
+ const middle = doc.documentElement.clientWidth / 2;
+ return {
+ side: box.left + box.width / 2 < middle ? 'left' : 'right',
+ rail,
+ };
+ });
+
+ const items = (rail) =>
+ // Only items that show: a theme or mode may hide some, and the wheel must
+ // not open those.
+ [...rail.querySelectorAll('.ui-rail__item')].filter(
+ (item) => !item.disabled && showing(item),
+ );
+
+ // ── The sliding highlight ──
+ // Each rail draws it as a ::before under its items, placed by custom
+ // properties; the open item's own background steps aside while it shows.
+ // Vue rewrites a rail's class list when it folds its labels, so whether the
+ // highlight is showing is kept here, and the classes are put back each time.
+ const gliding = new WeakSet();
+
+ function glide(rail) {
+ if (removed) return;
+ rail.classList.toggle('ui-rail--edge-hot', rail === hot);
+ const on = rail.querySelector('.ui-rail__item[aria-pressed="true"]');
+ if (!on?.offsetWidth) {
+ gliding.delete(rail);
+ rail.classList.remove('ui-rail--glide');
+ return;
+ }
+ const railBox = rail.getBoundingClientRect();
+ const box = on.getBoundingClientRect();
+ const shown = gliding.has(rail);
+ // A rail opening its first panel folds its labelled pills to icons, the
+ // buttons shrinking over a few frames; the highlight waits until the open
+ // button is an icon again (square) rather than fading in at a pill's
+ // width and then shrinking.
+ if (
+ !shown &&
+ (rail.classList.contains('ui-rail--expanded') ||
+ box.width > box.height + 2)
+ )
+ return;
+ // Appearing from nothing: jump into place, then fade in.
+ rail.classList.toggle('ui-rail--glide-jump', !shown);
+ rail.style.setProperty(
+ '--rail-glide-x',
+ `${box.left - railBox.left - rail.clientLeft}px`,
+ );
+ rail.style.setProperty(
+ '--rail-glide-y',
+ `${box.top - railBox.top - rail.clientTop}px`,
+ );
+ rail.style.setProperty('--rail-glide-w', `${box.width}px`);
+ rail.style.setProperty('--rail-glide-h', `${box.height}px`);
+ rail.classList.add('ui-rail--glide');
+ gliding.add(rail);
+ if (!shown)
+ win.requestAnimationFrame(() =>
+ rail.classList.remove('ui-rail--glide-jump'),
+ );
+ }
+
+ // Follow the rails for a few frames after any change, as their items
+ // resize while labels fold away.
+ function follow(frames = 24) {
+ win.cancelAnimationFrame(frame);
+ let left = frames;
+ const tick = () => {
+ if (removed) return;
+ watchRails();
+ doc.querySelectorAll('.ui-rail').forEach(glide);
+ if ((left -= 1) > 0) frame = win.requestAnimationFrame(tick);
+ };
+ tick();
+ }
+
+ const observer =
+ typeof win.MutationObserver === 'function'
+ ? new win.MutationObserver((records) => {
+ // A rail item opened or closed. Nothing here writes aria-pressed,
+ // and the work waits for the next frame, so this never feeds itself.
+ if (
+ records.some((record) =>
+ record.target.classList?.contains('ui-rail__item'),
+ )
+ )
+ win.requestAnimationFrame(() => follow());
+ })
+ : null;
+ observer?.observe(doc.body, {
+ subtree: true,
+ attributes: true,
+ attributeFilter: ['aria-pressed'],
+ });
+
+ // A rail also changes shape with nothing pressed or released: hidden and
+ // shown again (Context docked and undocked), or turned from a column to a
+ // row (panels moved to the screen edge). Its highlight is placed again
+ // whenever its size changes, so it is never left where an icon was.
+ const sized =
+ typeof win.ResizeObserver === 'function'
+ ? new win.ResizeObserver(() => follow(2))
+ : null;
+ const watched = new WeakSet();
+ const watchRails = () => {
+ if (!sized) return;
+ doc.querySelectorAll('.ui-rail').forEach((rail) => {
+ if (watched.has(rail)) return;
+ watched.add(rail);
+ sized.observe(rail);
+ });
+ };
+ watchRails();
+
+ // ── The edge strip ──
+ /** The rail whose strip holds the point, unless a control there owns it. */
+ function railAt(x, target) {
+ if (doc.body.classList.contains('mobile-shell') || coarse?.matches)
+ return null;
+ for (const { side, rail } of rails()) {
+ const box = rail.getBoundingClientRect();
+ // From the screen edge to just past the rail, but never wider than a
+ // rail's own column: a rail laid out as a row across the top of its
+ // panels would otherwise claim most of the panel's width.
+ const width = doc.documentElement.clientWidth;
+ const inStrip =
+ side === 'left'
+ ? x <= Math.min(box.right + STRIP_MARGIN, STRIP_MAX)
+ : x >= Math.max(box.left - STRIP_MARGIN, width - STRIP_MAX);
+ if (!inStrip) continue;
+ if (rail.contains(target)) return rail;
+ if (target?.closest?.(OWN_CONTROLS)) return null;
+ return rail;
+ }
+ return null;
+ }
+
+ function setHot(rail) {
+ if (rail === hot) return;
+ // A held step belongs to the strip it was made in; leaving drops it, so
+ // coming back later does not set it off with no wheel turned.
+ if (heldStep) {
+ win.clearTimeout(heldStep);
+ heldStep = 0;
+ }
+ hot?.classList.remove('ui-rail--edge-hot');
+ hot = rail;
+ travel = 0;
+ hot?.classList.add('ui-rail--edge-hot');
+ follow(2);
+ }
+
+ function onMove(event) {
+ setHot(railAt(event.clientX, event.target));
+ }
+
+ function onLeave(event) {
+ if (!event.relatedTarget) setHot(null);
+ }
+
+ function step(direction) {
+ const list = items(hot);
+ if (!list.length) return;
+ const current = list.findIndex(
+ (item) => item.getAttribute('aria-pressed') === 'true',
+ );
+ // Past either end, carry on from the other.
+ const nextIndex =
+ current < 0
+ ? direction > 0
+ ? 0
+ : list.length - 1
+ : (current + direction + list.length) % list.length;
+ if (nextIndex === current) return;
+ const target = list[nextIndex];
+ target.click();
+ const panel = doc.getElementById(target.dataset.railItem);
+ if (panel) {
+ panel.classList.remove('rail-switch-down', 'rail-switch-up');
+ void panel.offsetWidth;
+ panel.classList.add(
+ direction > 0 ? 'rail-switch-down' : 'rail-switch-up',
+ );
+ // The classes come off when the slide ends, or after a moment where
+ // there is no slide to end (reduced motion).
+ const settle = () => {
+ win.clearTimeout(timer);
+ panel.removeEventListener('animationend', settle);
+ panel.classList.remove('rail-switch-down', 'rail-switch-up');
+ };
+ const timer = win.setTimeout(settle, 600);
+ panel.addEventListener('animationend', settle);
+ }
+ follow();
+ }
+
+ function onWheel(event) {
+ // Pinch zoom, and sideways or empty moves (trackpads send them mid-swipe),
+ // are left alone: they neither step nor turn a gesture round.
+ if (event.ctrlKey || !event.deltaY) return;
+ const now = win.performance?.now?.() ?? Date.now();
+ const inGesture = now - lastWheel <= GESTURE_GAP_MS;
+ // Recheck: the pointer may not have moved since the panels changed.
+ const stillHot =
+ Boolean(hot) && railAt(event.clientX, event.target) === hot;
+ if (!stillHot) {
+ setHot(null);
+ // The rest of a gesture that has already stepped is used up here, so
+ // its coasting does not go on to zoom the globe or scroll what opened
+ // under the pointer.
+ if (stepped && inGesture && now - steppedAt <= COAST_MAX_MS) {
+ event.preventDefault();
+ event.stopPropagation();
+ lastWheel = now;
+ }
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ // Line- and page-based wheels report far smaller numbers than pixels.
+ const deltaY =
+ event.deltaMode === 1
+ ? event.deltaY * LINE_PX
+ : event.deltaMode === 2
+ ? event.deltaY * win.innerHeight
+ : event.deltaY;
+ const speed = Math.abs(deltaY);
+ // A new gesture: after a pause, after turning round, a mouse wheel notch,
+ // or a fresh flick while the last one is still coasting. A flick is a
+ // rise in speed after the gesture has clearly slowed, and not in the
+ // moment after a step: a swipe is still speeding up when it first steps.
+ const turned = travel && Math.sign(deltaY) !== Math.sign(travel);
+ const cooled = now - steppedAt > STEP_COOLDOWN_MS;
+ // Every event from a line- or page-based wheel is a notch.
+ const notch =
+ stepped && cooled && (speed >= NOTCH_DELTA || event.deltaMode > 0);
+ const flick =
+ stepped && cooled && slowed && speed > Math.max(8, lastSpeed * 1.8);
+ if (!inGesture || turned || notch || flick) {
+ // A held step belongs to the motion it came from; a reversal or a new
+ // flick replaces it rather than queueing behind it.
+ if (turned && heldStep) {
+ win.clearTimeout(heldStep);
+ heldStep = 0;
+ }
+ travel = 0;
+ stepped = false;
+ peakSpeed = 0;
+ slowed = false;
+ }
+ lastWheel = now;
+ lastSpeed = speed;
+ peakSpeed = Math.max(peakSpeed, speed);
+ if (stepped) {
+ if (speed < peakSpeed * 0.5) slowed = true;
+ return;
+ }
+ travel += deltaY;
+ if (Math.abs(travel) < STEP_DELTA) return;
+ stepped = true;
+ const direction = Math.sign(travel);
+ const wait = STEP_GAP_MS - (now - steppedAt);
+ if (wait <= 0) {
+ steppedAt = now;
+ step(direction);
+ return;
+ }
+ // Too soon after the last step: hold this one until the gap has passed.
+ if (heldStep) return;
+ const heldRail = hot;
+ heldStep = win.setTimeout(() => {
+ heldStep = 0;
+ // The pointer may have left the strip, or moved to the other rail,
+ // meanwhile; then nothing steps.
+ if (!hot || hot !== heldRail) return;
+ steppedAt = win.performance?.now?.() ?? Date.now();
+ step(direction);
+ }, wait);
+ }
+
+ const onResize = () => follow(2);
+ doc.addEventListener('pointermove', onMove, { passive: true });
+ doc.addEventListener('pointerout', onLeave, { passive: true });
+ win.addEventListener('wheel', onWheel, { capture: true, passive: false });
+ win.addEventListener('resize', onResize, { passive: true });
+ follow();
+
+ return () => {
+ removed = true;
+ win.clearTimeout(heldStep);
+ setHot(null);
+ observer?.disconnect();
+ sized?.disconnect();
+ win.cancelAnimationFrame(frame);
+ doc.removeEventListener('pointermove', onMove);
+ doc.removeEventListener('pointerout', onLeave);
+ win.removeEventListener('wheel', onWheel, { capture: true });
+ win.removeEventListener('resize', onResize);
+ doc.querySelectorAll('.ui-rail').forEach((rail) => {
+ rail.classList.remove(
+ 'ui-rail--glide',
+ 'ui-rail--glide-jump',
+ 'ui-rail--edge-hot',
+ );
+ });
+ };
+}
diff --git a/src/ui/railEdgeScroll.test.mjs b/src/ui/railEdgeScroll.test.mjs
new file mode 100644
index 000000000..a16964ebe
--- /dev/null
+++ b/src/ui/railEdgeScroll.test.mjs
@@ -0,0 +1,177 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { Window } from 'happy-dom';
+import { installRailEdgeScroll } from './railEdgeScroll.js';
+
+// A page with a left and a right rail of three items each, laid out by hand:
+// happy-dom does no layout, so boxes and the clock are stubbed.
+function page({ rtl = false } = {}) {
+ const window = new Window({ width: 1600, height: 900 });
+ const doc = window.document;
+ let now = 1000;
+ const timers = new Map();
+ let nextTimer = 1;
+ window.performance.now = () => now;
+ window.setTimeout = (fn, ms) => {
+ const id = nextTimer++;
+ timers.set(id, { fn, at: now + ms });
+ return id;
+ };
+ window.clearTimeout = (id) => timers.delete(id);
+ window.requestAnimationFrame = () => 0;
+ window.cancelAnimationFrame = () => {};
+ Object.defineProperty(doc.documentElement, 'clientWidth', { value: 1600 });
+ const box = (el, left, top, width, height) => {
+ el.getBoundingClientRect = () => ({
+ left,
+ top,
+ width,
+ height,
+ right: left + width,
+ bottom: top + height,
+ });
+ Object.defineProperty(el, 'offsetWidth', { get: () => width });
+ };
+ const clicks = [];
+ const rail = (name, left) => {
+ const el = doc.createElement('div');
+ el.className = `ui-rail ui-rail--${name}`;
+ box(el, left, 200, 40, 120);
+ for (let i = 0; i < 3; i++) {
+ const item = doc.createElement('button');
+ item.className = 'ui-rail__item';
+ item.dataset.railItem = `${name}-${i}`;
+ item.setAttribute('aria-pressed', i === 0 ? 'true' : 'false');
+ box(item, left + 4, 204 + i * 40, 32, 32);
+ item.addEventListener('click', () => {
+ clicks.push(item.dataset.railItem);
+ for (const other of el.children)
+ other.setAttribute('aria-pressed', String(other === item));
+ });
+ el.append(item);
+ }
+ doc.body.append(el);
+ return el;
+ };
+ // In a right-to-left language the left stack's rail is on the right edge.
+ const left = rail('left', rtl ? 1550 : 10);
+ const right = rail('right', rtl ? 10 : 1550);
+ const remove = installRailEdgeScroll(doc);
+ const wheel = (x, deltaY, { deltaMode = 0, target = doc.body } = {}) => {
+ // happy-dom leaves clientX off these events, so it is set by hand.
+ const at = (event) => Object.defineProperty(event, 'clientX', { value: x });
+ target.dispatchEvent(
+ at(new window.PointerEvent('pointermove', { bubbles: true })),
+ );
+ const event = at(
+ new window.WheelEvent('wheel', {
+ bubbles: true,
+ cancelable: true,
+ deltaY,
+ deltaMode,
+ }),
+ );
+ target.dispatchEvent(event);
+ return event;
+ };
+ const advance = (ms) => {
+ now += ms;
+ for (const [id, timer] of [...timers]) {
+ if (timer.at > now) continue;
+ timers.delete(id);
+ timer.fn();
+ }
+ };
+ return { window, doc, left, right, clicks, wheel, advance, remove };
+}
+
+test('a wheel in the left strip steps that rail and keeps the globe from zooming', () => {
+ const p = page();
+ const event = p.wheel(20, 120);
+ assert.equal(event.defaultPrevented, true);
+ assert.deepEqual(p.clicks, ['left-1']);
+ p.remove();
+});
+
+test('a line-based mouse wheel steps one panel per notch', () => {
+ const p = page();
+ p.wheel(20, 3, { deltaMode: 1 });
+ p.advance(600);
+ p.wheel(20, 3, { deltaMode: 1 });
+ assert.deepEqual(p.clicks, ['left-1', 'left-2']);
+ p.remove();
+});
+
+test('a rail hidden by clean view or recording leaves the wheel to the globe', () => {
+ const p = page();
+ p.left.checkVisibility = () => false;
+ const event = p.wheel(20, 120);
+ assert.equal(event.defaultPrevented, false);
+ assert.deepEqual(p.clicks, []);
+ p.remove();
+});
+
+test('right to left: the wheel at each screen edge steps the rail on that edge', () => {
+ const p = page({ rtl: true });
+ p.wheel(1580, 120);
+ p.advance(600);
+ p.wheel(20, 120);
+ assert.deepEqual(p.clicks, ['left-1', 'right-1']);
+ p.remove();
+});
+
+test('after a step, the wheel goes back to the page once the coasting is over', () => {
+ const p = page();
+ p.wheel(20, 120);
+ // The pointer moves off the strip while the gesture still coasts.
+ let event;
+ for (let i = 0; i < 20; i++) {
+ p.advance(100);
+ event = p.wheel(800, 30);
+ }
+ assert.equal(event.defaultPrevented, false);
+ assert.deepEqual(p.clicks, ['left-1']);
+ p.remove();
+});
+
+test('a held step is dropped when the pointer leaves the strip, even if it comes back', () => {
+ const p = page();
+ p.wheel(20, 120);
+ p.advance(250);
+ p.wheel(20, -120); // turns round: a second step, held for the gap
+ p.wheel(800, 0); // leaves the strip
+ p.wheel(20, 0); // and returns before the gap ends
+ p.advance(600);
+ assert.deepEqual(p.clicks, ['left-1']);
+ p.remove();
+});
+
+test('removing it stops edge scrolling', () => {
+ const p = page();
+ p.remove();
+ const event = p.wheel(20, 120);
+ assert.equal(event.defaultPrevented, false);
+ assert.deepEqual(p.clicks, []);
+});
+
+test('a sideways move mid-swipe neither turns the gesture round nor steps again', () => {
+ const p = page();
+ p.wheel(20, 60);
+ p.advance(50);
+ const sideways = p.wheel(20, 0);
+ p.advance(50);
+ p.wheel(20, 40); // the same swipe coasting on
+ assert.equal(sideways.defaultPrevented, false);
+ assert.deepEqual(p.clicks, ['left-1']);
+ p.remove();
+});
+
+test('an open dialog near the edge keeps its own wheel', () => {
+ const p = page();
+ const dialog = p.doc.createElement('dialog');
+ p.doc.body.append(dialog);
+ const event = p.wheel(20, 120, { target: dialog });
+ assert.equal(event.defaultPrevented, false);
+ assert.deepEqual(p.clicks, []);
+ p.remove();
+});
diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js
index 44043fb01..352ebda94 100644
--- a/src/ui/rightPanelRail.js
+++ b/src/ui/rightPanelRail.js
@@ -31,6 +31,20 @@ function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) {
return Number.isFinite(targetPct) ? (targetPct * viewportHeight) / 100 : top;
}
+/**
+ * An element's CSS `top` in pixels where it will settle: the end of a running
+ * `top` transition, else its computed value. NaN when it cannot be read.
+ */
+function settledCssTop(element, getComputedStyle) {
+ if (!element) return NaN;
+ const transition = element
+ .getAnimations?.()
+ .find((animation) => animation.transitionProperty === 'top');
+ const target = parseFloat(transition?.effect?.getKeyframes?.().at(-1)?.top);
+ if (Number.isFinite(target)) return target;
+ return parseFloat(getComputedStyle(element).top);
+}
+
/**
* Measure and place the right panel rail for one synchronous layout pass.
* The caller owns scheduling, obstacle selection, disclosure preferences and
@@ -126,9 +140,20 @@ export function layoutRightPanelRail({
viewportHeight,
getComputedStyle,
);
- // Align with the left rail's buttons, which hold still while its stack moves.
- const leftRailOffset =
- parseFloat(leftStack?.style.getPropertyValue('--panel-rail-offset')) || 0;
+ // Align with the left rail's buttons, which hold still while its stack moves:
+ // the rail's own settled `top` inside the stack, so every left layout is
+ // followed (a layout may pin the rail to the stack's top whatever the
+ // offset says). The stack's offset is the fallback when there is no rail.
+ const leftRailInset = settledCssTop(
+ leftStack?.querySelector?.('.gev-panel-rail') ?? null,
+ getComputedStyle,
+ );
+ // A rail that sits lower in its stack has only made room above itself for
+ // the open panel's own icon, which stays at the stack's top: the column's
+ // top has not moved, so that inset is not followed.
+ const leftRailOffset = Number.isFinite(leftRailInset)
+ ? Math.min(0, leftRailInset)
+ : parseFloat(leftStack?.style.getPropertyValue('--panel-rail-offset')) || 0;
const alignedTop = Number.isFinite(leftStackTop)
? leftStackTop + leftRailOffset
: viewportHeight * 0.26;
@@ -206,6 +231,18 @@ export function layoutRightPanelRail({
0,
) +
gap * Math.max(0, measuredPanels.length - 1);
+ // An open Context panel takes the column's full height: up to just under
+ // the HUD readouts at the top of the screen, down to the usual bottom.
+ // CCTV does the same, so its feed and controls have the room.
+ const FULL_HEIGHT = new Set(['global-context-panel', 'cctv-panel']);
+ const contextOpen = panels.some(
+ (panel) =>
+ FULL_HEIGHT.has(panel.id) && !panel.classList.contains('collapsed'),
+ );
+ // Every panel starts level with the left rail, open or not, so the two
+ // rails line up in every HUD layout (Minimal has no top-right readout to
+ // push this column down) and switching between Context, CCTV and Street
+ // Level does not move it. Only the two above also stretch to the bottom.
const layout = resolveHudRailLayout({
viewportHeight,
panelHeight: naturalHeight,
@@ -221,9 +258,11 @@ export function layoutRightPanelRail({
const { safeTop, safeBottom, maxHeight: availableHeight } = layout;
const stabilityBand = Math.max(48, viewportHeight * 0.06);
const wasFocused = stack.classList.contains('layout-focus');
- const shouldFocus = wasFocused
- ? naturalHeight >= availableHeight - stabilityBand
- : naturalHeight > availableHeight;
+ const shouldFocus =
+ contextOpen ||
+ (wasFocused
+ ? naturalHeight >= availableHeight - stabilityBand
+ : naturalHeight > availableHeight);
const layoutTop = shouldFocus ? safeTop : layout.top;
const collapsedHeight = visiblePanels.reduce(
(total, panel) =>
diff --git a/src/ui/shellLifecycle.test.mjs b/src/ui/shellLifecycle.test.mjs
index 2c8bc8ee7..bcefb345e 100644
--- a/src/ui/shellLifecycle.test.mjs
+++ b/src/ui/shellLifecycle.test.mjs
@@ -259,7 +259,7 @@ test('recording restores the original HUD after repeated entry and revokes retai
}
});
-test('weather rail orders between CCTV and Context, observes and presents chrome', (t) => {
+test('Context leads the right rail, weather follows CCTV, observes and presents chrome', (t) => {
const f = fixture();
const observed = new Set();
const previousResizeObserver = globalThis.ResizeObserver;
@@ -319,7 +319,7 @@ test('weather rail orders between CCTV and Context, observes and presents chrome
owner._initRightPanelAdaptiveLayout();
assert.deepEqual(
stack.children.map((panel) => panel.id),
- ['pp-toggles', 'cctv-panel', 'weather-panel', 'global-context-panel'],
+ ['global-context-panel', 'pp-toggles', 'cctv-panel', 'weather-panel'],
);
assert.ok(observed.has(weather));
// Moving a panel into the rail leaves its state as it was.
diff --git a/src/ui/styles/left-tools.css b/src/ui/styles/left-tools.css
new file mode 100644
index 000000000..83da30251
--- /dev/null
+++ b/src/ui/styles/left-tools.css
@@ -0,0 +1,37 @@
+/* Visual in the left column: it takes clicks (the column itself lets them
+ through to the globe) and stays inside the column, scrolling as a whole. */
+#left-panel-stack > #pp-toggles {
+ position: relative;
+ inset: auto;
+ flex: 0 1 var(--left-panel-allocated-height, auto);
+ min-height: 0;
+ max-height: var(--left-panel-allocated-height, 100%);
+ overflow-y: auto;
+ pointer-events: auto;
+}
+
+#left-panel-stack > #pp-toggles.collapsed {
+ display: none;
+}
+
+/* ── Expanded panel width ───────────────────────────────────────────────
+ Data Layers opens 15px wider than it used to (280 → 295px), and that is
+ the narrowest any side panel opens to; wider ones (Context, CCTV,
+ Authoring) keep their own width. */
+:root {
+ --side-panel-min-expanded-width: 295px;
+}
+
+#data-panel,
+#utilities-panel {
+ --panel-expanded-width: var(--side-panel-min-expanded-width);
+}
+
+#left-panel-stack > #pp-toggles:not(.collapsed) {
+ --pp-expanded-width: var(--side-panel-min-expanded-width);
+ width: var(--side-panel-min-expanded-width);
+}
+
+:is(#left-panel-stack, #right-context-rail) > [data-panel-id]:not(.collapsed) {
+ min-width: var(--side-panel-min-expanded-width);
+}
diff --git a/src/ui/styles/mobile-shell.css b/src/ui/styles/mobile-shell.css
index 593e10a07..b6e7875f8 100644
--- a/src/ui/styles/mobile-shell.css
+++ b/src/ui/styles/mobile-shell.css
@@ -129,7 +129,9 @@ body.mobile-shell #right-context-rail {
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack,
body.mobile-shell[data-mobile-sheet='cameras'] #right-context-rail,
body.mobile-shell[data-mobile-sheet='context'] #right-context-rail,
-body.mobile-shell[data-mobile-sheet='more'] #right-context-rail {
+/* Visual opens from the left rail on wider screens, so it lives in the left
+ stack and the More sheet is that stack showing Visual alone. */
+body.mobile-shell[data-mobile-sheet='more'] #left-panel-stack {
position: fixed;
top: auto;
right: 0;
@@ -189,7 +191,7 @@ body.mobile-shell[data-mobile-sheet='context']
body.mobile-shell[data-mobile-sheet='context']
#right-context-rail
> #recent-imagery-panel:not([hidden]),
-body.mobile-shell[data-mobile-sheet='more'] #right-context-rail > #pp-toggles {
+body.mobile-shell[data-mobile-sheet='more'] #left-panel-stack > #pp-toggles {
position: relative;
inset: auto;
display: block;
diff --git a/src/ui/styles/overlays.css b/src/ui/styles/overlays.css
index 85aaafa06..b45611785 100644
--- a/src/ui/styles/overlays.css
+++ b/src/ui/styles/overlays.css
@@ -310,8 +310,10 @@
bottom: 12vh;
}
+/* Minimal keeps its coordinates in the corner block, where the other layouts
+ have them; the full-width strip would only repeat the same position. */
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
- display: flex;
+ display: none;
bottom: calc(12vh + 4rem);
}
diff --git a/src/ui/styles/panel-edge.css b/src/ui/styles/panel-edge.css
new file mode 100644
index 000000000..5903dd140
--- /dev/null
+++ b/src/ui/styles/panel-edge.css
@@ -0,0 +1,100 @@
+/* ── Panels over the rail ───────────────────────────────────────────────
+ Each panel header's edge switch (UiPanelEdge) slides that side's open
+ panels over their rail to the screen edge; the rail stays underneath. */
+.ui-panel-edge {
+ display: grid;
+ flex: none;
+ place-items: center;
+ width: 22px;
+ height: 22px;
+ padding: 0;
+ color: rgba(206, 232, 239, 0.6);
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 6px;
+ cursor: pointer;
+}
+
+.ui-panel-edge .material-symbols-outlined {
+ font-size: 14px;
+}
+
+.ui-panel-edge:hover,
+.ui-panel-edge:focus-visible {
+ color: var(--text-primary, #e8eaed);
+ border-color: rgba(255, 255, 255, 0.24);
+}
+
+.ui-panel-edge[aria-pressed='true'] {
+ color: #36dcff;
+ background: rgba(54, 220, 255, 0.12);
+ border-color: rgba(54, 220, 255, 0.45);
+}
+
+/* Collapsed panels hide the switch; it only means something once open. */
+.panel-collapsible.collapsed .ui-panel-edge {
+ display: none;
+}
+
+/* Right: open panels move right by the rail's width and sit over it. */
+body.panels-edge-right:not(.mobile-shell):not(.cockpit-mode)
+ #right-context-rail
+ > :not(.gev-panel-rail) {
+ z-index: 2;
+ transform: translateX(var(--panel-rail-clearance, 48px));
+}
+
+/* Left: the same, moving left. */
+body.panels-edge-left:not(.mobile-shell):not(.cockpit-mode)
+ #left-panel-stack
+ > :not(.gev-panel-rail) {
+ z-index: 2;
+ transform: translateX(calc(-1 * var(--panel-rail-clearance, 48px)));
+}
+
+body.mobile-shell .ui-panel-edge,
+body.cockpit-mode .ui-panel-edge {
+ display: none;
+}
+
+/* ── Edge mode with a panel open: the rail's icons move to a row along the
+ top of the panel, and the panel steps down to make room. ── */
+:root {
+ --edge-rail-row: 50px;
+}
+
+body.panels-edge-left:not(.mobile-shell):not(.cockpit-mode)
+ .gev-panel-rail--left:has(.ui-rail--collapsible:not(.ui-rail--expanded)),
+body.panels-edge-right:not(.mobile-shell):not(.cockpit-mode)
+ .gev-panel-rail--right:has(.ui-rail--collapsible:not(.ui-rail--expanded)) {
+ top: 0 !important;
+ z-index: 3;
+}
+
+body.panels-edge-left:not(.mobile-shell):not(.cockpit-mode)
+ .gev-panel-rail--left
+ .ui-rail--collapsible:not(.ui-rail--expanded),
+body.panels-edge-right:not(.mobile-shell):not(.cockpit-mode)
+ .gev-panel-rail--right
+ .ui-rail--collapsible:not(.ui-rail--expanded) {
+ flex-direction: row;
+ width: max-content;
+ gap: 4px;
+}
+
+body.panels-edge-left:not(.mobile-shell):not(.cockpit-mode)
+ #left-panel-stack
+ > :not(.gev-panel-rail):not(.collapsed) {
+ transform: translate(
+ calc(-1 * var(--panel-rail-clearance, 48px)),
+ var(--edge-rail-row)
+ );
+ max-height: calc(100% - var(--edge-rail-row)) !important;
+}
+
+body.panels-edge-right:not(.mobile-shell):not(.cockpit-mode)
+ #right-context-rail
+ > :not(.gev-panel-rail):not(.collapsed) {
+ transform: translate(var(--panel-rail-clearance, 48px), var(--edge-rail-row));
+ max-height: calc(100% - var(--edge-rail-row)) !important;
+}
diff --git a/src/ui/styles/rail-edge.css b/src/ui/styles/rail-edge.css
new file mode 100644
index 000000000..cbcdb0eda
--- /dev/null
+++ b/src/ui/styles/rail-edge.css
@@ -0,0 +1,103 @@
+/* ── Side rails: sliding highlight and edge scrolling ──────────────────
+ The open item's highlight is drawn once per rail, under the icons, and
+ slides from item to item when the panel changes. With the pointer in a
+ screen edge's strip, the rail's border lights up a little to say the wheel
+ now steps through its panels; the panel that opens slides in from the
+ direction of travel. */
+.ui-rail {
+ position: relative;
+ transition:
+ border-color 240ms ease,
+ border-radius var(--transition-fast);
+}
+
+.ui-rail.ui-rail--edge-hot {
+ border-color: rgba(48, 194, 224, 0.4);
+}
+
+/* The highlight itself, matching an open item's own look. */
+.ui-rail::before {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 0;
+ width: var(--rail-glide-w, 32px);
+ height: var(--rail-glide-h, 32px);
+ background: var(--accent-dim);
+ border: 1px solid var(--accent-dim);
+ border-radius: var(--btn-radius);
+ box-sizing: border-box;
+ content: '';
+ opacity: 0;
+ pointer-events: none;
+ transform: translate(var(--rail-glide-x, 0), var(--rail-glide-y, 0));
+ /* Only the position slides. The size follows at once: it changes only
+ when the rail changes shape (its labelled pills folding to icons as a
+ panel opens), and a size easing there reads as a stretch. */
+ transition:
+ transform 380ms cubic-bezier(0.22, 1, 0.36, 1),
+ border-radius var(--transition-fast),
+ opacity 200ms ease;
+}
+
+.ui-rail--expanded::before {
+ border-radius: 16px;
+}
+
+.ui-rail.ui-rail--glide::before {
+ opacity: 1;
+}
+
+.ui-rail.ui-rail--glide-jump::before {
+ transition: opacity 200ms ease;
+}
+
+/* While the shared highlight shows, the items stay clear and sit above it;
+ the open item keeps its accent colour, which eases in as it arrives. */
+.ui-rail--glide .ui-rail__item {
+ z-index: 1;
+ transition:
+ color 300ms ease,
+ background var(--transition-fast),
+ border-color var(--transition-fast),
+ border-radius var(--transition-fast);
+}
+
+.ui-rail--glide .ui-rail__item--on,
+.ui-rail--glide .ui-rail__item--on:hover:not(:disabled) {
+ background: transparent;
+ border-color: transparent;
+}
+
+.rail-switch-down {
+ animation: rail-switch-down 340ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.rail-switch-up {
+ animation: rail-switch-up 340ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+@keyframes rail-switch-down {
+ from {
+ opacity: 0;
+ translate: 0 16px;
+ }
+}
+
+@keyframes rail-switch-up {
+ from {
+ opacity: 0;
+ translate: 0 -16px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ui-rail::before {
+ transition: opacity 200ms ease;
+ }
+
+ .rail-switch-down,
+ .rail-switch-up {
+ animation: none;
+ }
+}
diff --git a/src/ui/styles/rail-split.css b/src/ui/styles/rail-split.css
new file mode 100644
index 000000000..5599180f1
--- /dev/null
+++ b/src/ui/styles/rail-split.css
@@ -0,0 +1,282 @@
+/* The left rail's icons keep their places whether or not a panel is open;
+ only the highlight moves to the open one. */
+
+/* With nothing open, the icons and their names split into two glass panels
+ side by side: the plain icon rail, and a name panel beside it with one row
+ per icon. A name is part of its button, so clicking it opens that panel. */
+.gev-panel-rail--left .ui-rail--expanded {
+ position: relative;
+ border-radius: calc(var(--btn-radius) + 4px);
+}
+
+.gev-panel-rail--left .ui-rail--expanded .ui-rail__item {
+ width: 32px;
+ border-radius: var(--btn-radius);
+}
+
+.gev-panel-rail--left .ui-rail--expanded::after {
+ content: '';
+ position: absolute;
+ top: -1px;
+ bottom: -1px;
+ left: calc(100% + 6px);
+ width: 170px;
+ border: 1px solid var(--glass-border);
+ border-radius: calc(var(--btn-radius) + 4px);
+ background: var(--glass-bg);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ pointer-events: none;
+}
+
+.gev-panel-rail--left .ui-rail--expanded .ui-rail__label {
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ bottom: 0;
+ /* Past the rail's padding and border, the 6px gap, and the name panel's
+ border and inner padding. */
+ left: calc(100% + 24px);
+ display: flex;
+ align-items: center;
+ max-width: 150px;
+ margin: 0;
+}
+
+.gev-panel-rail--left .ui-rail--expanded .ui-rail__item:hover .ui-rail__label {
+ color: var(--text-primary);
+}
+
+/* The column rises to sit just under the HUD readout instead of leaving a
+ gap above it, with or without a panel open, so opening one doesn't move
+ the rail down. */
+#left-panel-stack {
+ top: calc(
+ var(--left-stack-safe-top, var(--left-stack-top)) +
+ var(--panel-rail-lift, 0px)
+ );
+}
+
+/* The right rail splits the same way with nothing open, mirrored: the icon
+ rail stays on the screen edge and the name panel sits to its left. */
+.gev-panel-rail--right .ui-rail--expanded {
+ position: relative;
+ border-radius: calc(var(--btn-radius) + 4px);
+}
+
+.gev-panel-rail--right .ui-rail--expanded .ui-rail__item {
+ width: 32px;
+ border-radius: var(--btn-radius);
+}
+
+.gev-panel-rail--right .ui-rail--expanded::after {
+ content: '';
+ position: absolute;
+ top: -1px;
+ bottom: -1px;
+ right: calc(100% + 6px);
+ width: 170px;
+ border: 1px solid var(--glass-border);
+ border-radius: calc(var(--btn-radius) + 4px);
+ background: var(--glass-bg);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ pointer-events: none;
+}
+
+.gev-panel-rail--right .ui-rail--expanded .ui-rail__label {
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ bottom: 0;
+ /* From the button's left edge back past the rail's padding and border,
+ the 6px gap and the 170px name panel, to the panel's inner padding. */
+ left: -168px;
+ display: flex;
+ align-items: center;
+ max-width: 150px;
+ margin: 0;
+}
+
+.gev-panel-rail--right .ui-rail--expanded .ui-rail__item:hover .ui-rail__label {
+ color: var(--text-primary);
+}
+
+/* In clean view the screen-edge gutters stay empty: the HUD's vertical edge
+ readouts (ONA / COLL on the left, BAND / BITS / LVL on the right) are not
+ shown. Cyber lays its HUD out differently and keeps them. */
+:root:not([data-ui-theme='cyber']) body.ui-clean-view #intel-hud .hud-edge {
+ display: none !important;
+}
+
+/* The bottom-right readout (GSD / ALT / AIS) has one more line than the
+ bottom-left one (MGRS / coordinates); it drops by that line so the two
+ corners start level. */
+#intel-hud[data-variant='tactical'] .hud-bottom-right {
+ bottom: calc(2vh + 6.5rem - 26px);
+}
+
+@media (min-width: 1200px) {
+ #intel-hud[data-variant='tactical'] .hud-bottom-right {
+ bottom: calc(4.2rem - 26px);
+ }
+}
+
+/* ── Contrast: rails and name panels read clearly over dark space as well
+ as bright imagery. ── */
+:is(.gev-panel-rail--left, .gev-panel-rail--right) .ui-rail {
+ background: rgba(16, 20, 28, 0.82) !important;
+ border-color: rgba(255, 255, 255, 0.14) !important;
+ box-shadow:
+ 0 6px 20px rgba(0, 0, 0, 0.45),
+ inset 0 1px 0 rgba(255, 255, 255, 0.05);
+}
+
+:is(.gev-panel-rail--left, .gev-panel-rail--right) .ui-rail--expanded::after {
+ background: rgba(16, 20, 28, 0.82) !important;
+ border-color: rgba(255, 255, 255, 0.14) !important;
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
+}
+
+:is(.gev-panel-rail--left, .gev-panel-rail--right) .ui-rail__item {
+ color: rgba(225, 236, 242, 0.88);
+}
+
+:is(.gev-panel-rail--left, .gev-panel-rail--right) .ui-rail__label {
+ color: rgba(225, 236, 242, 0.82) !important;
+}
+
+:is(.gev-panel-rail--left, .gev-panel-rail--right) .ui-rail__item:hover {
+ color: #fff;
+}
+
+/* The open item takes the accent colour; its highlight is the rail's shared
+ one, which slides to it. The button carries no fill of its own: as a rail
+ folds its labelled pills to icons, a fill on the shrinking button would
+ read as the highlight stretching. */
+:is(.gev-panel-rail--left, .gev-panel-rail--right)
+ .ui-rail__item.ui-rail__item--on {
+ color: #00d4ff !important;
+}
+
+:is(.gev-panel-rail--left, .gev-panel-rail--right)
+ .ui-rail:not(.ui-rail--glide)
+ .ui-rail__item.ui-rail__item--on {
+ background: none;
+ border-color: transparent;
+}
+
+:is(.gev-panel-rail--left, .gev-panel-rail--right) .ui-rail::before {
+ background: rgba(0, 212, 255, 0.15);
+ border-color: rgba(0, 212, 255, 0.28);
+}
+
+/* ── The open icon is its own island ──
+ With a panel open, the rail's glass is drawn in pieces behind its icons:
+ one piece for the open icon, and one for each run of icons above and below
+ it. The icons keep their places, open or not; the spacing between them is
+ what leaves room for the break. */
+:is(.gev-panel-rail--left, .gev-panel-rail--right) .ui-rail {
+ gap: 12px;
+}
+
+:is(
+ body:not(.panels-edge-left) .gev-panel-rail--left,
+ body:not(.panels-edge-right) .gev-panel-rail--right
+ )
+ .ui-rail--collapsible:not(.ui-rail--expanded):has(.ui-rail__item--on) {
+ background: none !important;
+ border-color: transparent !important;
+ box-shadow: none !important;
+ backdrop-filter: none;
+ -webkit-backdrop-filter: none;
+}
+
+:is(
+ body:not(.panels-edge-left) .gev-panel-rail--left,
+ body:not(.panels-edge-right) .gev-panel-rail--right
+ )
+ .ui-rail--collapsible:not(.ui-rail--expanded):has(.ui-rail__item--on)
+ > .ui-tooltip::before {
+ position: absolute;
+ inset: -6px -5px;
+ z-index: -1;
+ background: rgba(16, 20, 28, 0.82);
+ border: solid rgba(255, 255, 255, 0.14);
+ border-width: 0 1px;
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ content: '';
+ pointer-events: none;
+}
+
+/* The ends of the rail. */
+:is(
+ body:not(.panels-edge-left) .gev-panel-rail--left,
+ body:not(.panels-edge-right) .gev-panel-rail--right
+ )
+ .ui-rail--collapsible:not(.ui-rail--expanded):has(.ui-rail__item--on)
+ > .ui-tooltip:first-child::before {
+ top: -5px;
+ border-top-width: 1px;
+ border-top-left-radius: 14px;
+ border-top-right-radius: 14px;
+}
+
+:is(
+ body:not(.panels-edge-left) .gev-panel-rail--left,
+ body:not(.panels-edge-right) .gev-panel-rail--right
+ )
+ .ui-rail--collapsible:not(.ui-rail--expanded):has(.ui-rail__item--on)
+ > .ui-tooltip:last-child::before {
+ bottom: -5px;
+ border-bottom-width: 1px;
+ border-bottom-left-radius: 14px;
+ border-bottom-right-radius: 14px;
+}
+
+/* The runs stop short of the open icon. */
+:is(
+ body:not(.panels-edge-left) .gev-panel-rail--left,
+ body:not(.panels-edge-right) .gev-panel-rail--right
+ )
+ .ui-rail--collapsible:not(.ui-rail--expanded):has(.ui-rail__item--on)
+ > .ui-tooltip:has(+ .ui-tooltip > .ui-rail__item--on)::before {
+ bottom: -1px;
+ border-bottom-width: 1px;
+ border-bottom-left-radius: 14px;
+ border-bottom-right-radius: 14px;
+}
+
+:is(
+ body:not(.panels-edge-left) .gev-panel-rail--left,
+ body:not(.panels-edge-right) .gev-panel-rail--right
+ )
+ .ui-rail--collapsible:not(.ui-rail--expanded):has(.ui-rail__item--on)
+ > .ui-tooltip:has(> .ui-rail__item--on)
+ + .ui-tooltip::before {
+ top: -1px;
+ border-top-width: 1px;
+ border-top-left-radius: 14px;
+ border-top-right-radius: 14px;
+}
+
+/* The open icon's own piece. */
+:is(
+ body:not(.panels-edge-left) .gev-panel-rail--left,
+ body:not(.panels-edge-right) .gev-panel-rail--right
+ )
+ .ui-rail--collapsible:not(.ui-rail--expanded):has(.ui-rail__item--on)
+ > .ui-tooltip:has(> .ui-rail__item--on)::before {
+ inset: -4px -5px;
+ border-width: 1px;
+ border-radius: 14px;
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
+}
+
+/* The side rails fold their labels away at once when a panel opens, and back
+ when it closes: easing the labels' width made every button visibly
+ stretch and shrink. Only their fade eases. */
+:is(.gev-panel-rail--left, .gev-panel-rail--right) .ui-rail__label {
+ transition: opacity var(--transition-fast);
+}
diff --git a/src/ui/styles/rtl.css b/src/ui/styles/rtl.css
index fa0179b70..7b596b1ae 100644
--- a/src/ui/styles/rtl.css
+++ b/src/ui/styles/rtl.css
@@ -242,3 +242,47 @@ html[dir='rtl'] select.gev-field--compact {
html[dir='rtl'] .rail-card-scale {
direction: ltr;
}
+
+/* ── Side rails, mirrored ──────────────────────────────────────────────
+ The left stack sits on the right edge, so the left rail's name panel
+ opens to its left and the right rail's to its right, each toward the
+ middle of the screen, as they do in left-to-right. */
+html[dir='rtl'] .gev-panel-rail--left .ui-rail--expanded::after {
+ right: calc(100% + 6px);
+ left: auto;
+}
+html[dir='rtl'] .gev-panel-rail--left .ui-rail--expanded .ui-rail__label {
+ right: calc(100% + 24px);
+ left: auto;
+}
+html[dir='rtl'] .gev-panel-rail--right .ui-rail--expanded::after {
+ right: auto;
+ left: calc(100% + 6px);
+}
+html[dir='rtl'] .gev-panel-rail--right .ui-rail--expanded .ui-rail__label {
+ right: auto;
+ left: calc(100% + 24px);
+}
+
+/* Panels moved to the screen edge slide over their rail toward that edge. */
+html[dir='rtl']
+ body.panels-edge-left:not(.mobile-shell):not(.cockpit-mode)
+ #left-panel-stack
+ > :not(.gev-panel-rail) {
+ transform: translateX(var(--panel-rail-clearance, 48px));
+}
+html[dir='rtl']
+ body.panels-edge-left:not(.mobile-shell):not(.cockpit-mode)
+ #left-panel-stack
+ > :not(.gev-panel-rail):not(.collapsed) {
+ transform: translate(var(--panel-rail-clearance, 48px), var(--edge-rail-row));
+}
+html[dir='rtl']
+ body.panels-edge-right:not(.mobile-shell):not(.cockpit-mode)
+ #right-context-rail
+ > :not(.gev-panel-rail) {
+ transform: translate(
+ calc(-1 * var(--panel-rail-clearance, 48px)),
+ var(--edge-rail-row)
+ );
+}
diff --git a/style.css b/style.css
index 45f4f3eff..012714e4e 100644
--- a/style.css
+++ b/style.css
@@ -45,5 +45,9 @@
@import './src/ui/styles/rtl.css';
@import './src/ui/styles/mobile-shell.css';
@import './src/ui/styles/annotations.css';
+@import './src/ui/styles/rail-edge.css';
+@import './src/ui/styles/rail-split.css';
+@import './src/ui/styles/left-tools.css';
+@import './src/ui/styles/panel-edge.css';
@import './src/ui/styles/ai-chat.css';
@import './src/ui/styles/cyber.css';