From 7ee047961b7a57f920b3ef79e84c18f74ab02a11 Mon Sep 17 00:00:00 2001 From: Manjunath Paparaju Date: Fri, 13 Nov 2026 10:31:52 +0530 Subject: [PATCH] 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 --- src/ui/kit/UiPanel.vue | 2 + src/ui/kit/UiPanelEdge.vue | 39 ++++++ src/ui/leftPanelRail.js | 7 ++ src/ui/panelEdge.js | 40 ++++++ src/ui/panelLayoutController.js | 9 +- src/ui/panelMeasurement.js | 13 +- src/ui/panelRailCatalog.js | 2 +- src/ui/railEdgeScroll.js | 207 ++++++++++++++++++++++++++++++++ src/ui/rightPanelRail.js | 52 ++++++-- src/ui/shellLifecycle.test.mjs | 4 +- src/ui/styles/credits.css | 50 ++++++++ src/ui/styles/left-tools.css | 167 ++++++++++++++++++++++++++ src/ui/styles/panel-edge.css | 92 ++++++++++++++ src/ui/styles/rail-edge.css | 102 ++++++++++++++++ src/ui/styles/rail-split.css | 192 +++++++++++++++++++++++++++++ src/ui/styles/shell-spacing.css | 33 +++++ 16 files changed, 999 insertions(+), 12 deletions(-) create mode 100644 src/ui/kit/UiPanelEdge.vue create mode 100644 src/ui/panelEdge.js create mode 100644 src/ui/railEdgeScroll.js create mode 100644 src/ui/styles/credits.css create mode 100644 src/ui/styles/left-tools.css create mode 100644 src/ui/styles/panel-edge.css create mode 100644 src/ui/styles/rail-edge.css create mode 100644 src/ui/styles/rail-split.css create mode 100644 src/ui/styles/shell-spacing.css 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; + } +}