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; + } +}