Move Visual to the left rail and let panels sit at the screen edge

The left rail holds Data Layers, Utilities, Authoring and Visual; the right
rail holds Context, CCTV and Street Level. A button in each panel header
moves that side's panels to the screen edge, with the rail as a row above
them, and scrolling at a screen edge steps through a rail's panels. An open
right panel starts just under the HUD readouts, and Context and CCTV take
the column's full height. The right rail stays level with the left column's
top whichever left panel is open, in both layouts.

Co-authored-by: Sameh Khamis <[email protected]>
This commit is contained in:
Manjunath PaparajuandSameh Khamis authored and Sameh Khamis committed 2026-11-14 13:41:07 -08:00
1 parent f0a8b1193a
commit 7ee047961b
16 files changed
+999 -12

No files matched your search

+2
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { vClassToggle } from '../rail/classToggle.js'; import { vClassToggle } from '../rail/classToggle.js';
import UiPanelEdge from './UiPanelEdge.vue';
import UiPanelToggle from './UiPanelToggle.vue'; import UiPanelToggle from './UiPanelToggle.vue';
// Core's panel, with a name, title, slots and v-model:collapsed. It draws Core's panel markup // Core's panel, with a name, title, slots and v-model:collapsed. It draws Core's panel markup
@@ -57,6 +58,7 @@ const emit = defineEmits(['update:collapsed']);
<slot name="badge" /> <slot name="badge" />
<span class="panel-divider"></span> <span class="panel-divider"></span>
<slot name="actions" /> <slot name="actions" />
<UiPanelEdge v-if="props.collapsible" />
<UiPanelToggle <UiPanelToggle
v-if="props.collapsible" v-if="props.collapsible"
:target="props.id" :target="props.id"
+39
View File
@@ -0,0 +1,39 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { t } from '../../i18n/index.js';
import { panelEdge, panelSide, togglePanelEdge } from '../panelEdge.js';
// A panel header's edge switch: it slides this side's panels over their rail
// to the screen edge, and back. The side comes from the column the panel is in.
const button = ref(null);
const side = ref('left');
const on = computed(() => panelEdge.value[side.value]);
function toggle() {
side.value = panelSide(button.value);
togglePanelEdge(side.value);
}
function sync() {
side.value = panelSide(button.value);
}
onMounted(sync);
const icon = computed(() => (side.value === 'right' ? 'align_horizontal_right' : 'align_horizontal_left'));
const label = computed(() =>
on.value ? t('Move panels back beside the rail') : t('Move panels over the rail, to the edge'),
);
</script>
<template>
<button
ref="button"
type="button"
class="ui-panel-edge"
:aria-pressed="String(on)"
:aria-label="label"
:title="label"
@pointerenter="sync"
@focus="sync"
@click="toggle"
>
<span class="material-symbols-outlined" aria-hidden="true">{{ icon }}</span>
</button>
</template>
+7
View File
@@ -60,6 +60,7 @@ export function layoutLeftPanelRail({
stack.style.removeProperty('--left-stack-safe-bottom'); stack.style.removeProperty('--left-stack-safe-bottom');
stack.style.removeProperty('--left-stack-centered-height'); stack.style.removeProperty('--left-stack-centered-height');
stack.style.removeProperty('--panel-rail-offset'); stack.style.removeProperty('--panel-rail-offset');
stack.style.removeProperty('--panel-rail-lift');
stack.classList.remove('layout-exclusive'); stack.classList.remove('layout-exclusive');
stack.dataset.layoutMode = 'mobile'; stack.dataset.layoutMode = 'mobile';
settleSideAccordion({ panels, enabled: false, onCollapse }); settleSideAccordion({ panels, enabled: false, onCollapse });
@@ -286,6 +287,12 @@ export function layoutLeftPanelRail({
if (stack.style.getPropertyValue('--panel-rail-offset') !== railOffset) { if (stack.style.getPropertyValue('--panel-rail-offset') !== railOffset) {
stack.style.setProperty('--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'); stack.style.removeProperty('--left-stack-centered-height');
for (const panel of panels) for (const panel of panels)
panel.style.removeProperty('--left-panel-allocated-height'); panel.style.removeProperty('--left-panel-allocated-height');
+40
View File
@@ -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();
}
+8 -1
View File
@@ -189,7 +189,11 @@ export class PanelLayoutController {
const stack = this._rightPanelStack; const stack = this._rightPanelStack;
if (!stack || !this._ppToggles) return; 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'); const globalContextPanel = document.getElementById('global-context-panel');
for (const panel of [ for (const panel of [
this._cctvPanel, this._cctvPanel,
@@ -201,6 +205,9 @@ export class PanelLayoutController {
if (!panel) continue; if (!panel) continue;
stack.insertBefore(panel, globalContextPanel); 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') { if (typeof ResizeObserver !== 'undefined') {
this._rightStackResizeObserver = new ResizeObserver(() => { this._rightStackResizeObserver = new ResizeObserver(() => {
this._scheduleRightPanelLayout(); this._scheduleRightPanelLayout();
+12 -1
View File
@@ -6,9 +6,20 @@
* @returns {number} Natural height in CSS pixels. * @returns {number} Natural height in CSS pixels.
*/ */
export function measurePanelNaturalHeight(panel, getComputedStyle) { export function measurePanelNaturalHeight(panel, getComputedStyle) {
const inner = [...panel.children].find( const content = [...panel.children].filter(
(child) => !child.classList.contains('panel-glow'), (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) if (!inner)
return Math.ceil( return Math.ceil(
panel.scrollHeight || panel.getBoundingClientRect().height, panel.scrollHeight || panel.getBoundingClientRect().height,
+1 -1
View File
@@ -19,7 +19,7 @@ export const PANEL_RAIL_ITEMS = Object.freeze({
}, },
'utilities-panel': { icon: 'handyman', label: msg('UTILITIES') }, 'utilities-panel': { icon: 'handyman', label: msg('UTILITIES') },
'scene-panel': { icon: 'movie', label: msg('AUTHORING') }, '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') }, 'cctv-panel': { icon: 'videocam', label: msg('CCTV') },
'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') }, 'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') },
'news-panel': { icon: 'public', label: msg('NEWS') }, 'news-panel': { icon: 'public', label: msg('NEWS') },
+207
View File
@@ -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'),
);
};
}
+45 -7
View File
@@ -31,6 +31,20 @@ function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) {
return Number.isFinite(targetPct) ? (targetPct * viewportHeight) / 100 : top; 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. * Measure and place the right panel rail for one synchronous layout pass.
* The caller owns scheduling, obstacle selection, disclosure preferences and * The caller owns scheduling, obstacle selection, disclosure preferences and
@@ -126,9 +140,20 @@ export function layoutRightPanelRail({
viewportHeight, viewportHeight,
getComputedStyle, getComputedStyle,
); );
// Align with the left rail's buttons, which hold still while its stack moves. // Align with the left rail's buttons, which hold still while its stack moves:
const leftRailOffset = // the rail's own settled `top` inside the stack, so every left layout is
parseFloat(leftStack?.style.getPropertyValue('--panel-rail-offset')) || 0; // 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) const alignedTop = Number.isFinite(leftStackTop)
? leftStackTop + leftRailOffset ? leftStackTop + leftRailOffset
: viewportHeight * 0.26; : viewportHeight * 0.26;
@@ -206,13 +231,24 @@ export function layoutRightPanelRail({
0, 0,
) + ) +
gap * Math.max(0, measuredPanels.length - 1); 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({ const layout = resolveHudRailLayout({
viewportHeight, viewportHeight,
panelHeight: naturalHeight, panelHeight: naturalHeight,
laneLeft: stackRect.left, laneLeft: stackRect.left,
laneRight: stackRect.right, laneRight: stackRect.right,
obstacles: obstacleRects, obstacles: obstacleRects,
baseTop: alignedTop, baseTop: anyOpen ? viewportHeight * 0.04 : alignedTop,
baseBottom: viewportHeight * 0.96, baseBottom: viewportHeight * 0.96,
gap: safeGap, gap: safeGap,
align: 'start', align: 'start',
@@ -221,9 +257,11 @@ export function layoutRightPanelRail({
const { safeTop, safeBottom, maxHeight: availableHeight } = layout; const { safeTop, safeBottom, maxHeight: availableHeight } = layout;
const stabilityBand = Math.max(48, viewportHeight * 0.06); const stabilityBand = Math.max(48, viewportHeight * 0.06);
const wasFocused = stack.classList.contains('layout-focus'); const wasFocused = stack.classList.contains('layout-focus');
const shouldFocus = wasFocused const shouldFocus =
? naturalHeight >= availableHeight - stabilityBand contextOpen ||
: naturalHeight > availableHeight; (wasFocused
? naturalHeight >= availableHeight - stabilityBand
: naturalHeight > availableHeight);
const layoutTop = shouldFocus ? safeTop : layout.top; const layoutTop = shouldFocus ? safeTop : layout.top;
const collapsedHeight = visiblePanels.reduce( const collapsedHeight = visiblePanels.reduce(
(total, panel) => (total, panel) =>
+2 -2
View File
@@ -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 f = fixture();
const observed = new Set(); const observed = new Set();
const previousResizeObserver = globalThis.ResizeObserver; const previousResizeObserver = globalThis.ResizeObserver;
@@ -319,7 +319,7 @@ test('weather rail orders between CCTV and Context, observes and presents chrome
owner._initRightPanelAdaptiveLayout(); owner._initRightPanelAdaptiveLayout();
assert.deepEqual( assert.deepEqual(
stack.children.map((panel) => panel.id), 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)); assert.ok(observed.has(weather));
// Moving a panel into the rail leaves its state as it was. // Moving a panel into the rail leaves its state as it was.
+50
View File
@@ -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;
}
}
+167
View File
@@ -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);
}
+92
View File
@@ -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;
}
+102
View File
@@ -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;
}
}
+192
View File
@@ -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;
}
+33
View File
@@ -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;
}
}