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 @@
+
+
+
+
diff --git a/src/ui/leftPanelRail.js b/src/ui/leftPanelRail.js
index ca11a8149..0b3e7ee2f 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 });
@@ -286,6 +287,12 @@ export function layoutLeftPanelRail({
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 : Math.min(0, obstacleSafeTop - 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/panelEdge.js b/src/ui/panelEdge.js
new file mode 100644
index 000000000..a3cb7c750
--- /dev/null
+++ b/src/ui/panelEdge.js
@@ -0,0 +1,40 @@
+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/railEdgeScroll.js b/src/ui/railEdgeScroll.js
new file mode 100644
index 000000000..42cfc91ca
--- /dev/null
+++ b/src/ui/railEdgeScroll.js
@@ -0,0 +1,207 @@
+// 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;
+/** Things in the strip that keep their own pointer and wheel behaviour. */
+const OWN_CONTROLS =
+ 'a, button, input, select, textarea, [role="dialog"], .ui-panel, #first-run-launcher, #cesium-credits';
+
+/**
+ * @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;
+ let frame = 0;
+
+ const rails = () =>
+ ['left', 'right']
+ .map((side) => ({ side, rail: doc.querySelector(`.ui-rail--${side}`) }))
+ .filter(({ rail }) => rail && rail.getBoundingClientRect().width > 0);
+
+ const items = (rail) =>
+ [...rail.querySelectorAll('.ui-rail__item')].filter((item) => !item.disabled);
+
+ // ── 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) {
+ 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);
+ // 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 = () => {
+ 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'],
+ });
+
+ // ── 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();
+ const inStrip =
+ side === 'left'
+ ? x <= box.right + STRIP_MARGIN
+ : x >= box.left - STRIP_MARGIN;
+ 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;
+ 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');
+ panel.addEventListener(
+ 'animationend',
+ () => panel.classList.remove('rail-switch-down', 'rail-switch-up'),
+ { once: true },
+ );
+ }
+ follow();
+ }
+
+ function onWheel(event) {
+ if (!hot || event.ctrlKey) return;
+ // Recheck: the pointer may not have moved since the panels changed.
+ if (railAt(event.clientX, event.target) !== hot) return setHot(null);
+ event.preventDefault();
+ event.stopPropagation();
+ const now = win.performance?.now?.() ?? Date.now();
+ const speed = Math.abs(event.deltaY);
+ // A new gesture: after a pause, after turning round, or a fresh flick
+ // while the last one is still coasting (the speed jumps back up).
+ const turned = travel && Math.sign(event.deltaY) !== Math.sign(travel);
+ const flick = stepped && speed > 20 && speed > lastSpeed * 2;
+ if (now - lastWheel > GESTURE_GAP_MS || turned || flick) {
+ travel = 0;
+ stepped = false;
+ }
+ lastWheel = now;
+ lastSpeed = speed;
+ if (stepped) return;
+ travel += event.deltaY;
+ if (Math.abs(travel) < STEP_DELTA) return;
+ stepped = true;
+ step(Math.sign(travel));
+ }
+
+ 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 () => {
+ setHot(null);
+ observer?.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/rightPanelRail.js b/src/ui/rightPanelRail.js
index 44043fb01..e92d2267b 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,13 +231,24 @@ 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'),
+ );
+ // Any open panel starts from that same raised top, so switching between
+ // Context, CCTV and Street Level does not move the column. Only the two
+ // above also stretch to the bottom.
+ const anyOpen = panels.some((panel) => !panel.classList.contains('collapsed'));
const layout = resolveHudRailLayout({
viewportHeight,
panelHeight: naturalHeight,
laneLeft: stackRect.left,
laneRight: stackRect.right,
obstacles: obstacleRects,
- baseTop: alignedTop,
+ baseTop: anyOpen ? viewportHeight * 0.04 : alignedTop,
baseBottom: viewportHeight * 0.96,
gap: safeGap,
align: 'start',
@@ -221,9 +257,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/credits.css b/src/ui/styles/credits.css
new file mode 100644
index 000000000..be4160a66
--- /dev/null
+++ b/src/ui/styles/credits.css
@@ -0,0 +1,50 @@
+/* ── Map attribution ───────────────────────────────────────────────────
+ The required Cesium / imagery credits sit in a small glass pill. On wide
+ screens it drops to the bottom-left corner, clear of the HUD's coordinate
+ readout and left of the command dock; narrower screens keep the raised
+ position the dock rules give it. Attribution must stay visible. */
+body:not(.ui-clean-view):not(.recording-mode) #cesium-credits,
+body.ui-clean-view #cesium-credits,
+body.recording-mode #cesium-credits {
+ display: inline-flex;
+ align-items: center;
+ height: auto !important;
+ padding: 0.3rem 0.75rem 0.3rem 0.45rem;
+ background: var(--glass-bg);
+ border: 1px solid var(--glass-border);
+ border-radius: 999px;
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
+ backdrop-filter: blur(20px) saturate(1.3);
+ -webkit-backdrop-filter: blur(20px) saturate(1.3);
+ text-shadow: none;
+}
+
+#cesium-credits .cesium-widget-credits {
+ position: static !important;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: auto !important;
+ height: auto !important;
+ padding: 0 !important;
+}
+
+#cesium-credits img {
+ height: 18px;
+ vertical-align: middle !important;
+}
+
+@media (min-width: 1200px) {
+ body:not(.ui-clean-view):not(.recording-mode) #cesium-credits,
+ body:not(.ui-clean-view):not(.recording-mode):has(
+ #intel-hud[data-variant='minimal'].active
+ )
+ #cesium-credits {
+ right: auto;
+ bottom: 1.1rem;
+ left: 1.5rem;
+ /* Never reach under the centred command dock (about 30rem wide). */
+ max-width: calc(50vw - 17rem);
+ overflow: hidden;
+ }
+}
diff --git a/src/ui/styles/left-tools.css b/src/ui/styles/left-tools.css
new file mode 100644
index 000000000..0ce9759a8
--- /dev/null
+++ b/src/ui/styles/left-tools.css
@@ -0,0 +1,167 @@
+/* On desktop and tablet the omnibox finds places and Visual (left rail)
+ holds the presets, so the command dock keeps only voice. Phones keep the
+ trays in their sheets. */
+body:not(.mobile-shell) #command-dock > #control-panel,
+body:not(.mobile-shell) #command-dock > #location-bar {
+ display: none;
+}
+
+/* ── Visual: presets and map source at the top of the panel ─────────── */
+.visual-presets {
+ display: grid;
+ gap: 8px;
+ padding: 2px 0 12px;
+ margin-bottom: 10px;
+ border-bottom: 1px solid var(--glass-border);
+}
+
+.visual-presets__heading {
+ margin: 4px 0 0;
+ color: var(--text-secondary);
+ font: 600 10px/1.2 var(--font-mono);
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.visual-presets__heading--row {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.visual-presets__status {
+ color: var(--accent);
+}
+
+.visual-presets__status.warn {
+ color: #ff9f43;
+}
+
+.visual-presets__grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+}
+
+.visual-presets__style {
+ position: relative;
+ display: grid;
+ justify-items: center;
+ gap: 4px;
+ padding: 9px 4px 7px;
+ color: var(--text-secondary);
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--btn-radius);
+ cursor: pointer;
+}
+
+.visual-presets__style:hover {
+ color: var(--text-primary);
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.visual-presets__style.active {
+ color: var(--accent);
+ background: var(--accent-dim);
+ border-color: var(--accent-dim);
+}
+
+.visual-presets__icon {
+ font-size: 15px;
+ line-height: 1;
+}
+
+.visual-presets__label {
+ font: 500 11px/1 var(--font-sans);
+}
+
+.visual-presets__key {
+ color: var(--text-secondary);
+ font: 9px/1 var(--font-mono);
+ opacity: 0.6;
+}
+
+.visual-presets__key {
+ position: absolute;
+ top: 4px;
+ right: 5px;
+}
+
+.visual-presets__chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+}
+
+.visual-presets__chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 5px 9px;
+ color: var(--text-secondary);
+ font: 500 11px/1.2 var(--font-sans);
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--glass-border);
+ border-radius: 999px;
+ cursor: pointer;
+}
+
+.visual-presets__chip small {
+ font-size: 9px;
+ opacity: 0.6;
+}
+
+.visual-presets__chip:hover {
+ color: var(--text-primary);
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.visual-presets__chip.active {
+ color: var(--accent);
+ background: var(--accent-dim);
+ border-color: var(--accent-dim);
+}
+
+.visual-presets__chip.unavailable {
+ opacity: 0.45;
+ cursor: default;
+}
+
+/* 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/panel-edge.css b/src/ui/styles/panel-edge.css
new file mode 100644
index 000000000..c9cfa4faf
--- /dev/null
+++ b/src/ui/styles/panel-edge.css
@@ -0,0 +1,92 @@
+/* ── 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(.context-docked) #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 #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 {
+ 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 .gev-panel-rail--left:has(.ui-rail--collapsible:not(.ui-rail--expanded)),
+body.panels-edge-right:not(.context-docked) .gev-panel-rail--right:has(.ui-rail--collapsible:not(.ui-rail--expanded)) {
+ top: 0 !important;
+ z-index: 3;
+}
+
+body.panels-edge-left .gev-panel-rail--left .ui-rail--collapsible:not(.ui-rail--expanded),
+body.panels-edge-right:not(.context-docked) .gev-panel-rail--right .ui-rail--collapsible:not(.ui-rail--expanded) {
+ flex-direction: row;
+ width: max-content;
+ gap: 4px;
+}
+
+/* The open panel's icon joins the row instead of floating above it. */
+body.panels-edge-left .gev-panel-rail--left .ui-rail--collapsible:not(.ui-rail--expanded) .ui-tooltip {
+ position: static !important;
+ padding: 0 !important;
+ border: 0 !important;
+ background: none !important;
+ backdrop-filter: none !important;
+ -webkit-backdrop-filter: none !important;
+}
+
+body.panels-edge-left #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(.context-docked) #right-context-rail > :not(.gev-panel-rail) {
+ 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..e1af40be4
--- /dev/null
+++ b/src/ui/styles/rail-edge.css
@@ -0,0 +1,102 @@
+/* ── 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));
+ transition:
+ transform 380ms cubic-bezier(0.22, 1, 0.36, 1),
+ width 380ms cubic-bezier(0.22, 1, 0.36, 1),
+ height 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..1f8400dd1
--- /dev/null
+++ b/src/ui/styles/rail-split.css
@@ -0,0 +1,192 @@
+/* Experiment: with a left panel open, its rail icon leaves the rail and sits
+ level with the panel's title, as the panel's own tab. The other icons stay
+ in the rail underneath it and close the gap. With nothing open the rail keeps its
+ labelled pills. */
+.gev-panel-rail--left:has(.ui-rail--collapsible:not(.ui-rail--expanded) .ui-rail__item--on) {
+ /* The lifted chip (42px) centres on the panel title (26px into the
+ stack); the rail starts 6px below the chip. */
+ top: 53px;
+}
+
+.gev-panel-rail--left .ui-rail--collapsible:not(.ui-rail--expanded) {
+ position: relative;
+}
+
+.gev-panel-rail--left
+ .ui-rail--collapsible:not(.ui-rail--expanded)
+ .ui-tooltip:has(> .ui-rail__item--on) {
+ position: absolute;
+ bottom: calc(100% + 6px);
+ left: -1px;
+ padding: 4px;
+ 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);
+}
+
+.gev-panel-rail--left
+ .ui-rail--collapsible:not(.ui-rail--expanded)
+ .ui-tooltip:has(> .ui-rail__item--on)
+ .ui-rail__item {
+ width: 32px;
+}
+
+/* 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's highlight sits on the button itself, so it shows wherever
+ the button is (the lifted chip, the edge row). */
+:is(.gev-panel-rail--left, .gev-panel-rail--right) .ui-rail__item.ui-rail__item--on {
+ color: #00d4ff !important;
+ background: rgba(0, 212, 255, 0.15) !important;
+ border-color: rgba(0, 212, 255, 0.15) !important;
+}
+
+:is(.gev-panel-rail--left, .gev-panel-rail--right) .ui-rail.ui-rail--glide::before {
+ display: none !important;
+}
diff --git a/src/ui/styles/shell-spacing.css b/src/ui/styles/shell-spacing.css
new file mode 100644
index 000000000..7e67e526d
--- /dev/null
+++ b/src/ui/styles/shell-spacing.css
@@ -0,0 +1,33 @@
+/* ── Shell spacing on wide screens ─────────────────────────────────────
+ With the map credits in the bottom-left corner pill, the tactical HUD's
+ corner readouts no longer need to stand clear of them, so they close the
+ empty bands: the top readouts sit just under the title block and the
+ bottom readouts just above the credits and the key chip. The side panels
+ lay themselves out around the HUD, so they gain the space. The first-run
+ card spans the same band. */
+@media (min-width: 1200px) {
+ #intel-hud[data-variant='tactical'] .hud-top-left,
+ #intel-hud[data-variant='tactical'] .hud-top-right {
+ top: 102px;
+ }
+
+ #intel-hud[data-variant='tactical'] .hud-bottom-left,
+ #intel-hud[data-variant='tactical'] .hud-bottom-right {
+ bottom: 4.2rem;
+ }
+
+ #first-run-launcher {
+ top: 102px;
+ max-height: calc(100vh - 102px - 4.2rem);
+ max-height: calc(100dvh - 102px - 4.2rem);
+ }
+}
+
+/* The active style already shows top right ("ACTIVE STYLE · NORMAL"), so the
+ HUD's own style line is a duplicate wherever that indicator is shown. Phones
+ hide the indicator (responsive.css), so they keep the HUD line. */
+@media (min-width: 721px) {
+ body:not(.mobile-shell) #intel-hud #hud-mode {
+ display: none;
+ }
+}