From 7ee047961b7a57f920b3ef79e84c18f74ab02a11 Mon Sep 17 00:00:00 2001 From: Manjunath Paparaju Date: Fri, 13 Nov 2026 10:31:52 +0530 Subject: [PATCH 01/21] 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; + } +} From b080857c35bb1428253b85df74e17e098e26b192 Mon Sep 17 00:00:00 2001 From: Manjunath Paparaju Date: Fri, 13 Nov 2026 10:38:45 +0530 Subject: [PATCH 02/21] Make one scroll at a rail's edge step one panel A swipe is still speeding up when it first steps, and that rise in speed was read as a fresh flick, so one swipe could step two panels. A flick now counts only after the gesture has clearly slowed and a short moment after a step; a mouse wheel notch still steps each time. The rest of a gesture that has stepped is used up even when the pointer is no longer in the strip, so its coasting does not zoom the globe or scroll what opened under it. The strip is never wider than a rail's own column, a panel in either stack keeps its own scrolling, and the slide classes come off without an animation to end them (reduced motion). Co-authored-by: Sameh Khamis --- src/ui/railEdgeScroll.js | 79 +++++++++++++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 17 deletions(-) diff --git a/src/ui/railEdgeScroll.js b/src/ui/railEdgeScroll.js index 42cfc91ca..57d94a998 100644 --- a/src/ui/railEdgeScroll.js +++ b/src/ui/railEdgeScroll.js @@ -14,9 +14,18 @@ const STEP_DELTA = 40; * 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. */ +/** After a step, how long (ms) before the same motion can count as a new flick. */ +const STEP_COOLDOWN_MS = 220; +/** A wheel event this large (pixels) is a mouse wheel notch, not a trackpad. */ +const NOTCH_DELTA = 100; +/** The widest the strip gets, measured from the screen edge, whatever shape the rail takes. */ +const STRIP_MAX = 84; +/** + * Things in the strip that keep their own pointer and wheel behaviour: any + * control, and every panel in either side's stack (a panel scrolls itself). + */ const OWN_CONTROLS = - 'a, button, input, select, textarea, [role="dialog"], .ui-panel, #first-run-launcher, #cesium-credits'; + 'a, button, input, select, textarea, [role="dialog"], .ui-panel, #left-panel-stack > :not(.gev-panel-rail), #right-context-rail > :not(.gev-panel-rail), #first-run-launcher, #cesium-credits'; /** * @param {Document} [doc] @@ -32,6 +41,10 @@ export function installRailEdgeScroll(doc = globalThis.document) { let lastWheel = 0; let lastSpeed = 0; let stepped = false; + /** When the last step was taken, the fastest the gesture has been, and whether it has slowed since. */ + let steppedAt = 0; + let peakSpeed = 0; + let slowed = false; let frame = 0; const rails = () => @@ -103,10 +116,14 @@ export function installRailEdgeScroll(doc = globalThis.document) { if (doc.body.classList.contains('mobile-shell') || coarse?.matches) return null; for (const { side, rail } of rails()) { const box = rail.getBoundingClientRect(); + // From the screen edge to just past the rail, but never wider than a + // rail's own column: a rail laid out as a row across the top of its + // panels would otherwise claim most of the panel's width. + const width = doc.documentElement.clientWidth; const inStrip = side === 'left' - ? x <= box.right + STRIP_MARGIN - : x >= box.left - STRIP_MARGIN; + ? x <= Math.min(box.right + STRIP_MARGIN, STRIP_MAX) + : x >= Math.max(box.left - STRIP_MARGIN, width - STRIP_MAX); if (!inStrip) continue; if (rail.contains(target)) return rail; if (target?.closest?.(OWN_CONTROLS)) return null; @@ -151,37 +168,65 @@ export function installRailEdgeScroll(doc = globalThis.document) { 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 }, - ); + // The classes come off when the slide ends, or after a moment where + // there is no slide to end (reduced motion). + const settle = () => { + win.clearTimeout(timer); + panel.removeEventListener('animationend', settle); + panel.classList.remove('rail-switch-down', 'rail-switch-up'); + }; + const timer = win.setTimeout(settle, 600); + panel.addEventListener('animationend', settle); } follow(); } function onWheel(event) { - if (!hot || event.ctrlKey) return; + if (event.ctrlKey) return; + const now = win.performance?.now?.() ?? Date.now(); + const inGesture = now - lastWheel <= GESTURE_GAP_MS; // Recheck: the pointer may not have moved since the panels changed. - if (railAt(event.clientX, event.target) !== hot) return setHot(null); + const stillHot = Boolean(hot) && railAt(event.clientX, event.target) === hot; + if (!stillHot) { + setHot(null); + // The rest of a gesture that has already stepped is used up here, so + // its coasting does not go on to zoom the globe or scroll what opened + // under the pointer. + if (stepped && inGesture) { + event.preventDefault(); + event.stopPropagation(); + lastWheel = now; + } + return; + } 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). + // A new gesture: after a pause, after turning round, a mouse wheel notch, + // or a fresh flick while the last one is still coasting. A flick is a + // rise in speed after the gesture has clearly slowed, and not in the + // moment after a step: a swipe is still speeding up when it first steps. const turned = travel && Math.sign(event.deltaY) !== Math.sign(travel); - const flick = stepped && speed > 20 && speed > lastSpeed * 2; - if (now - lastWheel > GESTURE_GAP_MS || turned || flick) { + const cooled = now - steppedAt > STEP_COOLDOWN_MS; + const notch = stepped && cooled && speed >= NOTCH_DELTA; + const flick = stepped && cooled && slowed && speed > Math.max(8, lastSpeed * 1.8); + if (!inGesture || turned || notch || flick) { travel = 0; stepped = false; + peakSpeed = 0; + slowed = false; } lastWheel = now; lastSpeed = speed; - if (stepped) return; + peakSpeed = Math.max(peakSpeed, speed); + if (stepped) { + if (speed < peakSpeed * 0.5) slowed = true; + return; + } travel += event.deltaY; if (Math.abs(travel) < STEP_DELTA) return; stepped = true; + steppedAt = now; step(Math.sign(travel)); } From 59ffa8753544f6f0574518da8afdf47a2375bf99 Mon Sep 17 00:00:00 2001 From: Manjunath Paparaju Date: Fri, 13 Nov 2026 10:44:45 +0530 Subject: [PATCH 03/21] Keep the left rail's order when its open item is lifted out Co-authored-by: Sameh Khamis --- src/ui/railEdgeScroll.js | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/ui/railEdgeScroll.js b/src/ui/railEdgeScroll.js index 57d94a998..4dfd56df6 100644 --- a/src/ui/railEdgeScroll.js +++ b/src/ui/railEdgeScroll.js @@ -61,9 +61,25 @@ export function installRailEdgeScroll(doc = globalThis.document) { // 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(); + + // Where a rail lifts its open item out to sit beside the panel's title, the + // items left in the rail follow on from it in turn, so the next one in line + // is the one directly under it and a step brings that one up. A rail that + // keeps its open item in place keeps its own order. + function reorder(rail, on) { + const slots = [...rail.children].filter((slot) => slot.querySelector?.('.ui-rail__item')); + const at = on ? slots.findIndex((slot) => slot.contains(on)) : -1; + const lifted = at >= 0 && win.getComputedStyle(slots[at]).position === 'absolute'; + slots.forEach((slot, index) => { + const order = lifted ? String((index - at + slots.length) % slots.length) : ''; + if (slot.style.order !== order) slot.style.order = order; + }); + } + function glide(rail) { rail.classList.toggle('ui-rail--edge-hot', rail === hot); const on = rail.querySelector('.ui-rail__item[aria-pressed="true"]'); + reorder(rail, on); if (!on?.offsetWidth) { gliding.delete(rail); rail.classList.remove('ui-rail--glide'); @@ -245,8 +261,9 @@ export function installRailEdgeScroll(doc = globalThis.document) { 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'), - ); + doc.querySelectorAll('.ui-rail').forEach((rail) => { + rail.classList.remove('ui-rail--glide', 'ui-rail--glide-jump', 'ui-rail--edge-hot'); + reorder(rail, null); + }); }; } From b090937e5fda51e91461a4d6d0a0228c7cd162df Mon Sep 17 00:00:00 2001 From: Manjunath Paparaju Date: Fri, 13 Nov 2026 10:51:01 +0530 Subject: [PATCH 04/21] Keep rail icons in place and move only the highlight The left rail no longer lifts its open icon out beside the panel title or reorders the rest behind it. Every icon keeps its place, open or not, and the rail's shared highlight slides to the open one, on both rails. The rail also holds one height whichever panel is open: its offset now allows for the stack rising by a different amount for a tall panel than at rest. Co-authored-by: Sameh Khamis --- src/ui/leftPanelRail.js | 7 +++-- src/ui/panelRails.test.mjs | 17 +++++++++--- src/ui/railEdgeScroll.js | 16 ----------- src/ui/styles/panel-edge.css | 10 ------- src/ui/styles/rail-split.css | 52 +++++++++--------------------------- 5 files changed, 30 insertions(+), 72 deletions(-) diff --git a/src/ui/leftPanelRail.js b/src/ui/leftPanelRail.js index 0b3e7ee2f..c5c65378b 100644 --- a/src/ui/leftPanelRail.js +++ b/src/ui/leftPanelRail.js @@ -283,13 +283,16 @@ export function layoutLeftPanelRail({ getComputedStyle(stack) .getPropertyValue?.('--panel-stack-top-pinned') ?.trim() === '1'; - const railOffset = `${(topPinned ? 0 : safeTop - layoutTop).toFixed(1)}px`; + // The stack itself rises by the lift below, by a different amount for a + // tall panel than at rest; the rail takes up the difference. + const lift = (top) => Math.min(0, obstacleSafeTop - top); + const railOffset = `${(topPinned ? 0 : safeTop + lift(safeTop) - layoutTop - lift(layoutTop)).toFixed(1)}px`; if (stack.style.getPropertyValue('--panel-rail-offset') !== railOffset) { stack.style.setProperty('--panel-rail-offset', railOffset); } // How far the column can rise before it meets the HUD readout, so the rail // and any open panel sit just under it, whether or not a panel is open. - const railLift = `${(topPinned ? 0 : Math.min(0, obstacleSafeTop - layoutTop)).toFixed(1)}px`; + const railLift = `${(topPinned ? 0 : lift(layoutTop)).toFixed(1)}px`; if (stack.style.getPropertyValue('--panel-rail-lift') !== railLift) { stack.style.setProperty('--panel-rail-lift', railLift); } diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 40c2be20b..7edbc854a 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -359,14 +359,23 @@ test('right rail aligns to the left rail buttons while the left stack is offset' assert.equal(f.stack.dataset.safeTop, '286.7'); }); -test('left rail buttons hold still while a tall panel moves the stack', () => { +test('left rail buttons hold still whether the open panel is short or tall', () => { + // Where the rail sits: the stack's top, the stack's rise, and the rail's + // own offset inside the stack. + const railTop = (stack) => + (parseFloat(stack.style.getPropertyValue('--left-stack-safe-top')) * 900) / + 100 + + parseFloat(stack.style.getPropertyValue('--panel-rail-lift')) + + parseFloat(stack.style.getPropertyValue('--panel-rail-offset')); + const short = fixture('left'); + short.expand(short.first, 250); + short.run(); const f = fixture('left'); f.expand(f.first, 900); f.run(); assert.equal(f.stack.dataset.layoutMode, 'focus'); - assert.ok( - parseFloat(f.stack.style.getPropertyValue('--panel-rail-offset')) > 0, - ); + assert.notEqual(short.stack.dataset.layoutMode, 'focus'); + assert.ok(Math.abs(railTop(f.stack) - railTop(short.stack)) < 0.2); // A theme that pins the stack's top (Cyber) leaves nothing to offset. f.stack.computed.getPropertyValue = (name) => diff --git a/src/ui/railEdgeScroll.js b/src/ui/railEdgeScroll.js index 4dfd56df6..cf46ef02f 100644 --- a/src/ui/railEdgeScroll.js +++ b/src/ui/railEdgeScroll.js @@ -62,24 +62,9 @@ export function installRailEdgeScroll(doc = globalThis.document) { // highlight is showing is kept here, and the classes are put back each time. const gliding = new WeakSet(); - // Where a rail lifts its open item out to sit beside the panel's title, the - // items left in the rail follow on from it in turn, so the next one in line - // is the one directly under it and a step brings that one up. A rail that - // keeps its open item in place keeps its own order. - function reorder(rail, on) { - const slots = [...rail.children].filter((slot) => slot.querySelector?.('.ui-rail__item')); - const at = on ? slots.findIndex((slot) => slot.contains(on)) : -1; - const lifted = at >= 0 && win.getComputedStyle(slots[at]).position === 'absolute'; - slots.forEach((slot, index) => { - const order = lifted ? String((index - at + slots.length) % slots.length) : ''; - if (slot.style.order !== order) slot.style.order = order; - }); - } - function glide(rail) { rail.classList.toggle('ui-rail--edge-hot', rail === hot); const on = rail.querySelector('.ui-rail__item[aria-pressed="true"]'); - reorder(rail, on); if (!on?.offsetWidth) { gliding.delete(rail); rail.classList.remove('ui-rail--glide'); @@ -263,7 +248,6 @@ export function installRailEdgeScroll(doc = globalThis.document) { win.removeEventListener('resize', onResize); doc.querySelectorAll('.ui-rail').forEach((rail) => { rail.classList.remove('ui-rail--glide', 'ui-rail--glide-jump', 'ui-rail--edge-hot'); - reorder(rail, null); }); }; } diff --git a/src/ui/styles/panel-edge.css b/src/ui/styles/panel-edge.css index c9cfa4faf..79d19b6d2 100644 --- a/src/ui/styles/panel-edge.css +++ b/src/ui/styles/panel-edge.css @@ -71,16 +71,6 @@ body.panels-edge-right:not(.context-docked) .gev-panel-rail--right .ui-rail--col 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; diff --git a/src/ui/styles/rail-split.css b/src/ui/styles/rail-split.css index 1f8400dd1..d84940233 100644 --- a/src/ui/styles/rail-split.css +++ b/src/ui/styles/rail-split.css @@ -1,37 +1,5 @@ -/* 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; -} +/* The left rail's icons keep their places whether or not a panel is open; + only the highlight moves to the open one. */ /* With nothing open, the icons and their names split into two glass panels side by side: the plain icon rail, and a name panel beside it with one row @@ -179,14 +147,18 @@ 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). */ +/* The open item takes the accent colour; its highlight is the rail's shared + one, which slides to it. Without that highlight the button carries its own. */ :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; +:is(.gev-panel-rail--left, .gev-panel-rail--right) .ui-rail:not(.ui-rail--glide) .ui-rail__item.ui-rail__item--on { + background: rgba(0, 212, 255, 0.15); + border-color: rgba(0, 212, 255, 0.15); +} + +:is(.gev-panel-rail--left, .gev-panel-rail--right) .ui-rail::before { + background: rgba(0, 212, 255, 0.15); + border-color: rgba(0, 212, 255, 0.28); } From 9cc2a1811b2b7e18ff3fb4e7122e6d915a503b93 Mon Sep 17 00:00:00 2001 From: Manjunath Paparaju Date: Fri, 13 Nov 2026 10:52:37 +0530 Subject: [PATCH 05/21] Leave at least half a second between edge-scroll steps A scroll that would step a rail again within 500 ms of the last step is used up without stepping, so panels never switch back to back. Co-authored-by: Sameh Khamis --- src/ui/railEdgeScroll.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ui/railEdgeScroll.js b/src/ui/railEdgeScroll.js index cf46ef02f..c66b461ab 100644 --- a/src/ui/railEdgeScroll.js +++ b/src/ui/railEdgeScroll.js @@ -16,6 +16,12 @@ const STEP_DELTA = 40; const GESTURE_GAP_MS = 180; /** After a step, how long (ms) before the same motion can count as a new flick. */ const STEP_COOLDOWN_MS = 220; +/** + * The least time (ms) between two steps, whatever the wheel does. A motion + * that would step sooner is used up without stepping, so panels never switch + * back to back. + */ +const STEP_GAP_MS = 500; /** A wheel event this large (pixels) is a mouse wheel notch, not a trackpad. */ const NOTCH_DELTA = 100; /** The widest the strip gets, measured from the screen edge, whatever shape the rail takes. */ @@ -227,6 +233,7 @@ export function installRailEdgeScroll(doc = globalThis.document) { travel += event.deltaY; if (Math.abs(travel) < STEP_DELTA) return; stepped = true; + if (now - steppedAt < STEP_GAP_MS) return; steppedAt = now; step(Math.sign(travel)); } From 422a11d9a4998152eea6de68b02faf266d2ad30a Mon Sep 17 00:00:00 2001 From: Manjunath Paparaju Date: Fri, 13 Nov 2026 11:07:06 +0530 Subject: [PATCH 06/21] Give a rail's open icon its own island With a panel open, a rail's glass is drawn in pieces: one around the open icon and one for each run of icons above and below it, with a small break between them. The icons keep their places; the rail's icon spacing grows from 2 px to 12 px to leave room for the break. The shadow rule for the old lifted icon is removed. Co-authored-by: Sameh Khamis --- src/ui/styles/rail-split.css | 81 ++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/ui/styles/rail-split.css b/src/ui/styles/rail-split.css index d84940233..22313cc7b 100644 --- a/src/ui/styles/rail-split.css +++ b/src/ui/styles/rail-split.css @@ -162,3 +162,84 @@ background: rgba(0, 212, 255, 0.15); border-color: rgba(0, 212, 255, 0.28); } + +/* ── The open icon is its own island ── + With a panel open, the rail's glass is drawn in pieces behind its icons: + one piece for the open icon, and one for each run of icons above and below + it. The icons keep their places, open or not; the spacing between them is + what leaves room for the break. */ +:is(.gev-panel-rail--left, .gev-panel-rail--right) .ui-rail { + gap: 12px; +} + +:is(body:not(.panels-edge-left) .gev-panel-rail--left, body:not(.panels-edge-right) .gev-panel-rail--right) + .ui-rail--collapsible:not(.ui-rail--expanded):has(.ui-rail__item--on) { + background: none !important; + border-color: transparent !important; + box-shadow: none !important; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +:is(body:not(.panels-edge-left) .gev-panel-rail--left, body:not(.panels-edge-right) .gev-panel-rail--right) + .ui-rail--collapsible:not(.ui-rail--expanded):has(.ui-rail__item--on) + > .ui-tooltip::before { + position: absolute; + inset: -6px -5px; + z-index: -1; + background: rgba(16, 20, 28, 0.82); + border: solid rgba(255, 255, 255, 0.14); + border-width: 0 1px; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + content: ''; + pointer-events: none; +} + +/* The ends of the rail. */ +:is(body:not(.panels-edge-left) .gev-panel-rail--left, body:not(.panels-edge-right) .gev-panel-rail--right) + .ui-rail--collapsible:not(.ui-rail--expanded):has(.ui-rail__item--on) + > .ui-tooltip:first-child::before { + top: -5px; + border-top-width: 1px; + border-top-left-radius: 14px; + border-top-right-radius: 14px; +} + +:is(body:not(.panels-edge-left) .gev-panel-rail--left, body:not(.panels-edge-right) .gev-panel-rail--right) + .ui-rail--collapsible:not(.ui-rail--expanded):has(.ui-rail__item--on) + > .ui-tooltip:last-child::before { + bottom: -5px; + border-bottom-width: 1px; + border-bottom-left-radius: 14px; + border-bottom-right-radius: 14px; +} + +/* The runs stop short of the open icon. */ +:is(body:not(.panels-edge-left) .gev-panel-rail--left, body:not(.panels-edge-right) .gev-panel-rail--right) + .ui-rail--collapsible:not(.ui-rail--expanded):has(.ui-rail__item--on) + > .ui-tooltip:has(+ .ui-tooltip > .ui-rail__item--on)::before { + bottom: -1px; + border-bottom-width: 1px; + border-bottom-left-radius: 14px; + border-bottom-right-radius: 14px; +} + +:is(body:not(.panels-edge-left) .gev-panel-rail--left, body:not(.panels-edge-right) .gev-panel-rail--right) + .ui-rail--collapsible:not(.ui-rail--expanded):has(.ui-rail__item--on) + > .ui-tooltip:has(> .ui-rail__item--on) + .ui-tooltip::before { + top: -1px; + border-top-width: 1px; + border-top-left-radius: 14px; + border-top-right-radius: 14px; +} + +/* The open icon's own piece. */ +:is(body:not(.panels-edge-left) .gev-panel-rail--left, body:not(.panels-edge-right) .gev-panel-rail--right) + .ui-rail--collapsible:not(.ui-rail--expanded):has(.ui-rail__item--on) + > .ui-tooltip:has(> .ui-rail__item--on)::before { + inset: -4px -5px; + border-width: 1px; + border-radius: 14px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45); +} From 81f3602134744b89b63ca3594644bde92e1cab1d Mon Sep 17 00:00:00 2001 From: Manjunath Paparaju Date: Fri, 13 Nov 2026 13:03:12 +0530 Subject: [PATCH 07/21] Hold an edge-scroll step that comes too soon instead of dropping it A scroll that reached a step within 500 ms of the last one was used up without stepping, so mouse wheel notches 300 ms apart stepped only every other notch and a second swipe inside the gap did nothing. Such a step now waits and fires once the gap has passed (one held at most, skipped if the pointer has left the strip), and the held timer is cleared on teardown. A comment naming the items cockpit hides is corrected. Co-authored-by: Sameh Khamis --- src/ui/railEdgeScroll.js | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/ui/railEdgeScroll.js b/src/ui/railEdgeScroll.js index c66b461ab..7b965cda0 100644 --- a/src/ui/railEdgeScroll.js +++ b/src/ui/railEdgeScroll.js @@ -18,8 +18,8 @@ const GESTURE_GAP_MS = 180; const STEP_COOLDOWN_MS = 220; /** * The least time (ms) between two steps, whatever the wheel does. A motion - * that would step sooner is used up without stepping, so panels never switch - * back to back. + * that would step sooner waits and steps once the gap has passed (one step + * held at most), so panels never switch back to back and no step is lost. */ const STEP_GAP_MS = 500; /** A wheel event this large (pixels) is a mouse wheel notch, not a trackpad. */ @@ -49,6 +49,8 @@ export function installRailEdgeScroll(doc = globalThis.document) { let stepped = false; /** When the last step was taken, the fastest the gesture has been, and whether it has slowed since. */ let steppedAt = 0; + /** A step waiting for the gap to pass, if any. */ + let heldStep = 0; let peakSpeed = 0; let slowed = false; let frame = 0; @@ -59,7 +61,9 @@ export function installRailEdgeScroll(doc = globalThis.document) { .filter(({ rail }) => rail && rail.getBoundingClientRect().width > 0); const items = (rail) => - [...rail.querySelectorAll('.ui-rail__item')].filter((item) => !item.disabled); + // Only items that show: a theme or mode may hide some, and the wheel must + // not open those. + [...rail.querySelectorAll('.ui-rail__item')].filter((item) => !item.disabled && item.offsetWidth > 0); // ── The sliding highlight ── // Each rail draws it as a ::before under its items, placed by custom @@ -233,9 +237,22 @@ export function installRailEdgeScroll(doc = globalThis.document) { travel += event.deltaY; if (Math.abs(travel) < STEP_DELTA) return; stepped = true; - if (now - steppedAt < STEP_GAP_MS) return; - steppedAt = now; - step(Math.sign(travel)); + const direction = Math.sign(travel); + const wait = STEP_GAP_MS - (now - steppedAt); + if (wait <= 0) { + steppedAt = now; + step(direction); + return; + } + // Too soon after the last step: hold this one until the gap has passed. + if (heldStep) return; + heldStep = win.setTimeout(() => { + heldStep = 0; + // The pointer may have left the strip meanwhile; then nothing steps. + if (!hot) return; + steppedAt = win.performance?.now?.() ?? Date.now(); + step(direction); + }, wait); } const onResize = () => follow(2); @@ -246,6 +263,7 @@ export function installRailEdgeScroll(doc = globalThis.document) { follow(); return () => { + win.clearTimeout(heldStep); setHot(null); observer?.disconnect(); win.cancelAnimationFrame(frame); From 9261279639c2fb60fcdb622d69aa63c9b2919750 Mon Sep 17 00:00:00 2001 From: Manjunath Paparaju Date: Fri, 13 Nov 2026 15:42:31 +0530 Subject: [PATCH 08/21] Drop a held edge-scroll step when the motion turns or the pointer moves to the other rail Co-authored-by: Sameh Khamis --- src/ui/railEdgeScroll.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/ui/railEdgeScroll.js b/src/ui/railEdgeScroll.js index 7b965cda0..548f79668 100644 --- a/src/ui/railEdgeScroll.js +++ b/src/ui/railEdgeScroll.js @@ -222,6 +222,12 @@ export function installRailEdgeScroll(doc = globalThis.document) { const notch = stepped && cooled && speed >= NOTCH_DELTA; const flick = stepped && cooled && slowed && speed > Math.max(8, lastSpeed * 1.8); if (!inGesture || turned || notch || flick) { + // A held step belongs to the motion it came from; a reversal or a new + // flick replaces it rather than queueing behind it. + if (turned && heldStep) { + win.clearTimeout(heldStep); + heldStep = 0; + } travel = 0; stepped = false; peakSpeed = 0; @@ -246,10 +252,12 @@ export function installRailEdgeScroll(doc = globalThis.document) { } // Too soon after the last step: hold this one until the gap has passed. if (heldStep) return; + const heldRail = hot; heldStep = win.setTimeout(() => { heldStep = 0; - // The pointer may have left the strip meanwhile; then nothing steps. - if (!hot) return; + // The pointer may have left the strip, or moved to the other rail, + // meanwhile; then nothing steps. + if (!hot || hot !== heldRail) return; steppedAt = win.performance?.now?.() ?? Date.now(); step(direction); }, wait); From 129b35e47078c61e3030eaae493d09b70e87f0fc Mon Sep 17 00:00:00 2001 From: Manjunath Paparaju Date: Fri, 13 Nov 2026 16:00:22 +0530 Subject: [PATCH 09/21] Show the rail highlight at icon size when a first panel opens Opening a panel from the labelled rail folds its pills to icons over a few frames, and the highlight faded in at a pill's width and then shrank, as did the open button's own fill. The highlight now appears only once the open button is an icon again (square), its size changes at once rather than easing, and the open button carries no fill of its own. Co-authored-by: Sameh Khamis --- src/ui/railEdgeScroll.js | 5 +++++ src/ui/styles/rail-edge.css | 5 +++-- src/ui/styles/rail-split.css | 8 +++++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/ui/railEdgeScroll.js b/src/ui/railEdgeScroll.js index 548f79668..2a7a606c6 100644 --- a/src/ui/railEdgeScroll.js +++ b/src/ui/railEdgeScroll.js @@ -83,6 +83,11 @@ export function installRailEdgeScroll(doc = globalThis.document) { const railBox = rail.getBoundingClientRect(); const box = on.getBoundingClientRect(); const shown = gliding.has(rail); + // A rail opening its first panel folds its labelled pills to icons, the + // buttons shrinking over a few frames; the highlight waits until the open + // button is an icon again (square) rather than fading in at a pill's + // width and then shrinking. + if (!shown && (rail.classList.contains('ui-rail--expanded') || box.width > box.height + 2)) return; // Appearing from nothing: jump into place, then fade in. rail.classList.toggle('ui-rail--glide-jump', !shown); rail.style.setProperty('--rail-glide-x', `${box.left - railBox.left - rail.clientLeft}px`); diff --git a/src/ui/styles/rail-edge.css b/src/ui/styles/rail-edge.css index e1af40be4..cbcdb0eda 100644 --- a/src/ui/styles/rail-edge.css +++ b/src/ui/styles/rail-edge.css @@ -31,10 +31,11 @@ opacity: 0; pointer-events: none; transform: translate(var(--rail-glide-x, 0), var(--rail-glide-y, 0)); + /* Only the position slides. The size follows at once: it changes only + when the rail changes shape (its labelled pills folding to icons as a + panel opens), and a size easing there reads as a stretch. */ transition: transform 380ms cubic-bezier(0.22, 1, 0.36, 1), - 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; } diff --git a/src/ui/styles/rail-split.css b/src/ui/styles/rail-split.css index 22313cc7b..f974b0cbc 100644 --- a/src/ui/styles/rail-split.css +++ b/src/ui/styles/rail-split.css @@ -148,14 +148,16 @@ } /* The open item takes the accent colour; its highlight is the rail's shared - one, which slides to it. Without that highlight the button carries its own. */ + one, which slides to it. The button carries no fill of its own: as a rail + folds its labelled pills to icons, a fill on the shrinking button would + read as the highlight stretching. */ :is(.gev-panel-rail--left, .gev-panel-rail--right) .ui-rail__item.ui-rail__item--on { color: #00d4ff !important; } :is(.gev-panel-rail--left, .gev-panel-rail--right) .ui-rail:not(.ui-rail--glide) .ui-rail__item.ui-rail__item--on { - background: rgba(0, 212, 255, 0.15); - border-color: rgba(0, 212, 255, 0.15); + background: none; + border-color: transparent; } :is(.gev-panel-rail--left, .gev-panel-rail--right) .ui-rail::before { From 4568b9ff12af3df212a236fda204c56f0d76103b Mon Sep 17 00:00:00 2001 From: Manjunath Paparaju Date: Fri, 13 Nov 2026 16:01:15 +0530 Subject: [PATCH 10/21] Fold the side rails' labels at once When a panel opened or closed, the rails' labels eased their width, which stretched and shrank every button in the rail. On the side rails the labels now fold away and come back at once; only their fade eases. Co-authored-by: Sameh Khamis --- src/ui/styles/rail-split.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ui/styles/rail-split.css b/src/ui/styles/rail-split.css index f974b0cbc..72debe865 100644 --- a/src/ui/styles/rail-split.css +++ b/src/ui/styles/rail-split.css @@ -245,3 +245,10 @@ border-radius: 14px; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45); } + +/* The side rails fold their labels away at once when a panel opens, and back + when it closes: easing the labels' width made every button visibly + stretch and shrink. Only their fade eases. */ +:is(.gev-panel-rail--left, .gev-panel-rail--right) .ui-rail__label { + transition: opacity var(--transition-fast); +} From 4debb49fe07ef90758907a9ae8257187c03424ff Mon Sep 17 00:00:00 2001 From: Manjunath Paparaju Date: Fri, 13 Nov 2026 16:13:26 +0530 Subject: [PATCH 11/21] Place a rail's highlight again whenever the rail changes size The highlight was placed only when an item was pressed or released, the window resized, or the pointer moved through an edge strip. A rail hidden and shown again (Context docked and undocked) was left with its open item unmarked, and a rail turned from a column to a row (panels moved to the screen edge) kept its highlight where the icon had been. Each rail is now watched for size changes, and its highlight placed again when it changes. Co-authored-by: Sameh Khamis --- src/ui/railEdgeScroll.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/ui/railEdgeScroll.js b/src/ui/railEdgeScroll.js index 2a7a606c6..de3c2e39a 100644 --- a/src/ui/railEdgeScroll.js +++ b/src/ui/railEdgeScroll.js @@ -105,6 +105,7 @@ export function installRailEdgeScroll(doc = globalThis.document) { win.cancelAnimationFrame(frame); let left = frames; const tick = () => { + watchRails(); doc.querySelectorAll('.ui-rail').forEach(glide); if ((left -= 1) > 0) frame = win.requestAnimationFrame(tick); }; @@ -126,6 +127,23 @@ export function installRailEdgeScroll(doc = globalThis.document) { attributeFilter: ['aria-pressed'], }); + // A rail also changes shape with nothing pressed or released: hidden and + // shown again (Context docked and undocked), or turned from a column to a + // row (panels moved to the screen edge). Its highlight is placed again + // whenever its size changes, so it is never left where an icon was. + const sized = + typeof win.ResizeObserver === 'function' ? new win.ResizeObserver(() => follow(2)) : null; + const watched = new WeakSet(); + const watchRails = () => { + if (!sized) return; + doc.querySelectorAll('.ui-rail').forEach((rail) => { + if (watched.has(rail)) return; + watched.add(rail); + sized.observe(rail); + }); + }; + watchRails(); + // ── The edge strip ── /** The rail whose strip holds the point, unless a control there owns it. */ function railAt(x, target) { @@ -279,6 +297,7 @@ export function installRailEdgeScroll(doc = globalThis.document) { win.clearTimeout(heldStep); setHot(null); observer?.disconnect(); + sized?.disconnect(); win.cancelAnimationFrame(frame); doc.removeEventListener('pointermove', onMove); doc.removeEventListener('pointerout', onLeave); From 8da632cc2d2f099ac6e1f64c59e71954231ffd96 Mon Sep 17 00:00:00 2001 From: Manjunath Paparaju Date: Fri, 13 Nov 2026 16:55:01 +0530 Subject: [PATCH 12/21] Line the side rails up and show coordinates once in Minimal HUD - The right rail now starts level with the left one whether or not a panel is open on it. It used to rise to 4% of the screen when one was open, which in Tactical and Cyber a top-right readout pushed back down, but in Minimal (which has none) left it about 35 px above the left rail. - Minimal no longer shows its full-width coordinate strip above the corner coordinates block, which showed the same position twice; it keeps the corner block, where the other layouts have it. This changes upstream's Minimal layout. Co-authored-by: Sameh Khamis --- src/ui/rightPanelRail.js | 13 +++++++------ src/ui/styles/overlays.css | 4 +++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index e92d2267b..352ebda94 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -236,19 +236,20 @@ export function layoutRightPanelRail({ // 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'), + (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')); + // Every panel starts level with the left rail, open or not, so the two + // rails line up in every HUD layout (Minimal has no top-right readout to + // push this column down) and switching between Context, CCTV and Street + // Level does not move it. Only the two above also stretch to the bottom. const layout = resolveHudRailLayout({ viewportHeight, panelHeight: naturalHeight, laneLeft: stackRect.left, laneRight: stackRect.right, obstacles: obstacleRects, - baseTop: anyOpen ? viewportHeight * 0.04 : alignedTop, + baseTop: alignedTop, baseBottom: viewportHeight * 0.96, gap: safeGap, align: 'start', diff --git a/src/ui/styles/overlays.css b/src/ui/styles/overlays.css index 85aaafa06..b45611785 100644 --- a/src/ui/styles/overlays.css +++ b/src/ui/styles/overlays.css @@ -310,8 +310,10 @@ bottom: 12vh; } +/* Minimal keeps its coordinates in the corner block, where the other layouts + have them; the full-width strip would only repeat the same position. */ #intel-hud[data-variant='minimal'] .hud-bottom-bar { - display: flex; + display: none; bottom: calc(12vh + 4rem); } From 85415bda97522cb2d235bb1295a1c653a075e0f9 Mon Sep 17 00:00:00 2001 From: Manjunath Paparaju Date: Sat, 14 Nov 2026 13:15:07 -0800 Subject: [PATCH 13/21] Install edge scrolling and the rail styles in the shell Co-authored-by: Sameh Khamis --- index.html | 2 +- src/ui/applicationShell.js | 4 ++++ style.css | 6 ++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index f3057f742..806a8b41b 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - +
diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 1fd86824f..564578e60 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -65,6 +65,7 @@ import { useHudStore } from './stores/hud.js'; import { useDisplayStore } from './stores/display.js'; import { useLocaleStore } from './stores/locale.js'; import { useLocationStore } from './stores/location.js'; +import { installRailEdgeScroll } from './railEdgeScroll.js'; import { useCockpitStore } from './stores/cockpit.js'; import { useGlobeActionsStore } from './stores/globeActions.js'; import { useRecordingStore } from './stores/recording.js'; @@ -164,6 +165,8 @@ export class StyleManager extends ShellFacade { }); // Vue views share this runtime and its stores; dispose unmounts them. this._ui = createUiRuntime({ pinia: applicationPinia() }); + // Scrolling at a screen edge steps through that side's rail panels. + this._removeRailEdgeScroll = installRailEdgeScroll(); this._feedback = new ShellFeedback({ readLayers: () => withPlacePacks(this._dataManager?.getAll?.() || []), }); @@ -1856,6 +1859,7 @@ export class StyleManager extends ShellFacade { const { destroyTrackedReadout, destroyWorldOverlay, destroyDetection } = this.services; if (this._disposed) return; + this._removeRailEdgeScroll?.(); this._shareRestoration.destroy(); this._feedback.clearLoadingStatus(); this._disposed = true; diff --git a/style.css b/style.css index 45f4f3eff..976aa0f8d 100644 --- a/style.css +++ b/style.css @@ -45,5 +45,11 @@ @import './src/ui/styles/rtl.css'; @import './src/ui/styles/mobile-shell.css'; @import './src/ui/styles/annotations.css'; +@import './src/ui/styles/credits.css'; +@import './src/ui/styles/shell-spacing.css'; +@import './src/ui/styles/rail-edge.css'; +@import './src/ui/styles/rail-split.css'; +@import './src/ui/styles/left-tools.css'; +@import './src/ui/styles/panel-edge.css'; @import './src/ui/styles/ai-chat.css'; @import './src/ui/styles/cyber.css'; From 605bf5b564dd1968c41b698a2c9eafebdde3ff86 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 13:36:12 -0800 Subject: [PATCH 14/21] Keep the dock's Location and Visual Presets trays on desktop Nothing replaces them on this branch yet, so the dock keeps them. The credit pill and the HUD spacing built on it are left for a change of their own. Co-authored-by: Manjunath Paparaju --- src/ui/styles/credits.css | 50 ------------ src/ui/styles/left-tools.css | 130 -------------------------------- src/ui/styles/shell-spacing.css | 33 -------- style.css | 2 - 4 files changed, 215 deletions(-) delete mode 100644 src/ui/styles/credits.css delete mode 100644 src/ui/styles/shell-spacing.css diff --git a/src/ui/styles/credits.css b/src/ui/styles/credits.css deleted file mode 100644 index be4160a66..000000000 --- a/src/ui/styles/credits.css +++ /dev/null @@ -1,50 +0,0 @@ -/* ── 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 index 0ce9759a8..83da30251 100644 --- a/src/ui/styles/left-tools.css +++ b/src/ui/styles/left-tools.css @@ -1,133 +1,3 @@ -/* 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 { diff --git a/src/ui/styles/shell-spacing.css b/src/ui/styles/shell-spacing.css deleted file mode 100644 index 7e67e526d..000000000 --- a/src/ui/styles/shell-spacing.css +++ /dev/null @@ -1,33 +0,0 @@ -/* ── 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; - } -} diff --git a/style.css b/style.css index 976aa0f8d..012714e4e 100644 --- a/style.css +++ b/style.css @@ -45,8 +45,6 @@ @import './src/ui/styles/rtl.css'; @import './src/ui/styles/mobile-shell.css'; @import './src/ui/styles/annotations.css'; -@import './src/ui/styles/credits.css'; -@import './src/ui/styles/shell-spacing.css'; @import './src/ui/styles/rail-edge.css'; @import './src/ui/styles/rail-split.css'; @import './src/ui/styles/left-tools.css'; From 235fe94a896a5be0864ad771fee42964f451c2f7 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 13:36:12 -0800 Subject: [PATCH 15/21] Open Visual from the left stack in the phone's More sheet Co-authored-by: Manjunath Paparaju --- src/ui/mobileShell.js | 3 ++- src/ui/mobileShell.test.mjs | 2 +- src/ui/styles/mobile-shell.css | 6 ++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/ui/mobileShell.js b/src/ui/mobileShell.js index 65673cd55..7570e2d17 100644 --- a/src/ui/mobileShell.js +++ b/src/ui/mobileShell.js @@ -368,7 +368,8 @@ export function initMobileShell({ : sheet === 'search' ? [dock] : sheet === 'more' - ? [element('right-context-rail'), dock] + ? // Visual lives in the left stack; the dock holds the presets. + [element('left-panel-stack'), dock] : [element('right-context-rail')]; return roots.find((root) => root?.contains(target)); }; diff --git a/src/ui/mobileShell.test.mjs b/src/ui/mobileShell.test.mjs index c0e0b909f..337fde99b 100644 --- a/src/ui/mobileShell.test.mjs +++ b/src/ui/mobileShell.test.mjs @@ -623,7 +623,7 @@ test('the phone shell keeps the required attribution visible', () => { ); const openDock = rule('body.mobile-shell[data-mobile-sheet] #command-dock'); const railSheet = rule( - "body.mobile-shell[data-mobile-sheet='more'] #right-context-rail", + "body.mobile-shell[data-mobile-sheet='more'] #left-panel-stack", ); assert.ok(z(sheetCredit) > z(openDock)); assert.ok(z(sheetCredit) > z(railSheet)); diff --git a/src/ui/styles/mobile-shell.css b/src/ui/styles/mobile-shell.css index 593e10a07..b6e7875f8 100644 --- a/src/ui/styles/mobile-shell.css +++ b/src/ui/styles/mobile-shell.css @@ -129,7 +129,9 @@ body.mobile-shell #right-context-rail { body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack, body.mobile-shell[data-mobile-sheet='cameras'] #right-context-rail, body.mobile-shell[data-mobile-sheet='context'] #right-context-rail, -body.mobile-shell[data-mobile-sheet='more'] #right-context-rail { +/* Visual opens from the left rail on wider screens, so it lives in the left + stack and the More sheet is that stack showing Visual alone. */ +body.mobile-shell[data-mobile-sheet='more'] #left-panel-stack { position: fixed; top: auto; right: 0; @@ -189,7 +191,7 @@ body.mobile-shell[data-mobile-sheet='context'] body.mobile-shell[data-mobile-sheet='context'] #right-context-rail > #recent-imagery-panel:not([hidden]), -body.mobile-shell[data-mobile-sheet='more'] #right-context-rail > #pp-toggles { +body.mobile-shell[data-mobile-sheet='more'] #left-panel-stack > #pp-toggles { position: relative; inset: auto; display: block; From 9374e0228b308d40913eb772b236a1316ac27429 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 13:36:12 -0800 Subject: [PATCH 16/21] Leave the wheel alone where edge scrolling should not take it Hidden rails (clean view, recording), sideways or empty moves, dialogs and the world timeline keep their wheel; coasting after a step is swallowed for at most 800 ms; line-based wheels step a panel per notch; the strip follows where each rail is in a right-to-left language; a held step is dropped when the pointer leaves the strip; nothing runs after removal. Co-authored-by: Manjunath Paparaju --- src/ui/railEdgeScroll.js | 127 +++++++++++++++++++---- src/ui/railEdgeScroll.test.mjs | 177 +++++++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+), 22 deletions(-) create mode 100644 src/ui/railEdgeScroll.test.mjs diff --git a/src/ui/railEdgeScroll.js b/src/ui/railEdgeScroll.js index de3c2e39a..4e3d9b0a4 100644 --- a/src/ui/railEdgeScroll.js +++ b/src/ui/railEdgeScroll.js @@ -26,12 +26,20 @@ const STEP_GAP_MS = 500; const NOTCH_DELTA = 100; /** The widest the strip gets, measured from the screen edge, whatever shape the rail takes. */ const STRIP_MAX = 84; +/** + * After a step, how long (ms) the rest of that gesture is still swallowed once + * the pointer has left the strip, so trackpad coasting does not go on to zoom + * the globe. Past it, the wheel belongs to whatever is under the pointer. + */ +const COAST_MAX_MS = 800; +/** Pixels per wheel line, for mice that report lines rather than pixels. */ +const LINE_PX = 16; /** * Things in the strip that keep their own pointer and wheel behaviour: any * control, and every panel in either side's stack (a panel scrolls itself). */ const OWN_CONTROLS = - 'a, button, input, select, textarea, [role="dialog"], .ui-panel, #left-panel-stack > :not(.gev-panel-rail), #right-context-rail > :not(.gev-panel-rail), #first-run-launcher, #cesium-credits'; + 'a, button, input, select, textarea, dialog, [role="dialog"], .ui-panel, #left-panel-stack > :not(.gev-panel-rail), #right-context-rail > :not(.gev-panel-rail), #first-run-launcher, #cesium-credits, #world-timeline'; /** * @param {Document} [doc] @@ -54,16 +62,40 @@ export function installRailEdgeScroll(doc = globalThis.document) { let peakSpeed = 0; let slowed = false; let frame = 0; + /** Set once removed, so frames already queued do nothing. */ + let removed = false; + // Clean view and recording hide the rails with visibility and opacity, + // which keep their boxes, so a box alone does not mean the rail shows. + const showing = (node) => + node.getBoundingClientRect().width > 0 && + (typeof node.checkVisibility !== 'function' || + node.checkVisibility({ + visibilityProperty: true, + opacityProperty: true, + })); + + // Which screen edge a rail is on comes from where it is, not its name: in a + // right-to-left language the left stack sits on the right. const rails = () => ['left', 'right'] - .map((side) => ({ side, rail: doc.querySelector(`.ui-rail--${side}`) })) - .filter(({ rail }) => rail && rail.getBoundingClientRect().width > 0); + .map((name) => doc.querySelector(`.ui-rail--${name}`)) + .filter((rail) => rail && showing(rail)) + .map((rail) => { + const box = rail.getBoundingClientRect(); + const middle = doc.documentElement.clientWidth / 2; + return { + side: box.left + box.width / 2 < middle ? 'left' : 'right', + rail, + }; + }); const items = (rail) => // Only items that show: a theme or mode may hide some, and the wheel must // not open those. - [...rail.querySelectorAll('.ui-rail__item')].filter((item) => !item.disabled && item.offsetWidth > 0); + [...rail.querySelectorAll('.ui-rail__item')].filter( + (item) => !item.disabled && showing(item), + ); // ── The sliding highlight ── // Each rail draws it as a ::before under its items, placed by custom @@ -73,6 +105,7 @@ export function installRailEdgeScroll(doc = globalThis.document) { const gliding = new WeakSet(); function glide(rail) { + if (removed) return; rail.classList.toggle('ui-rail--edge-hot', rail === hot); const on = rail.querySelector('.ui-rail__item[aria-pressed="true"]'); if (!on?.offsetWidth) { @@ -87,16 +120,30 @@ export function installRailEdgeScroll(doc = globalThis.document) { // buttons shrinking over a few frames; the highlight waits until the open // button is an icon again (square) rather than fading in at a pill's // width and then shrinking. - if (!shown && (rail.classList.contains('ui-rail--expanded') || box.width > box.height + 2)) return; + if ( + !shown && + (rail.classList.contains('ui-rail--expanded') || + box.width > box.height + 2) + ) + return; // Appearing from nothing: jump into place, then fade in. rail.classList.toggle('ui-rail--glide-jump', !shown); - rail.style.setProperty('--rail-glide-x', `${box.left - railBox.left - rail.clientLeft}px`); - rail.style.setProperty('--rail-glide-y', `${box.top - railBox.top - rail.clientTop}px`); + rail.style.setProperty( + '--rail-glide-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')); + if (!shown) + win.requestAnimationFrame(() => + rail.classList.remove('ui-rail--glide-jump'), + ); } // Follow the rails for a few frames after any change, as their items @@ -105,6 +152,7 @@ export function installRailEdgeScroll(doc = globalThis.document) { win.cancelAnimationFrame(frame); let left = frames; const tick = () => { + if (removed) return; watchRails(); doc.querySelectorAll('.ui-rail').forEach(glide); if ((left -= 1) > 0) frame = win.requestAnimationFrame(tick); @@ -117,7 +165,11 @@ export function installRailEdgeScroll(doc = globalThis.document) { ? 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'))) + if ( + records.some((record) => + record.target.classList?.contains('ui-rail__item'), + ) + ) win.requestAnimationFrame(() => follow()); }) : null; @@ -132,7 +184,9 @@ export function installRailEdgeScroll(doc = globalThis.document) { // row (panels moved to the screen edge). Its highlight is placed again // whenever its size changes, so it is never left where an icon was. const sized = - typeof win.ResizeObserver === 'function' ? new win.ResizeObserver(() => follow(2)) : null; + typeof win.ResizeObserver === 'function' + ? new win.ResizeObserver(() => follow(2)) + : null; const watched = new WeakSet(); const watchRails = () => { if (!sized) return; @@ -147,7 +201,8 @@ export function installRailEdgeScroll(doc = globalThis.document) { // ── 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; + if (doc.body.classList.contains('mobile-shell') || coarse?.matches) + return null; for (const { side, rail } of rails()) { const box = rail.getBoundingClientRect(); // From the screen edge to just past the rail, but never wider than a @@ -168,6 +223,12 @@ export function installRailEdgeScroll(doc = globalThis.document) { function setHot(rail) { if (rail === hot) return; + // A held step belongs to the strip it was made in; leaving drops it, so + // coming back later does not set it off with no wheel turned. + if (heldStep) { + win.clearTimeout(heldStep); + heldStep = 0; + } hot?.classList.remove('ui-rail--edge-hot'); hot = rail; travel = 0; @@ -186,7 +247,9 @@ export function installRailEdgeScroll(doc = globalThis.document) { function step(direction) { const list = items(hot); if (!list.length) return; - const current = list.findIndex((item) => item.getAttribute('aria-pressed') === 'true'); + const current = list.findIndex( + (item) => item.getAttribute('aria-pressed') === 'true', + ); // Past either end, carry on from the other. const nextIndex = current < 0 @@ -201,7 +264,9 @@ export function installRailEdgeScroll(doc = globalThis.document) { 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.classList.add( + direction > 0 ? 'rail-switch-down' : 'rail-switch-up', + ); // The classes come off when the slide ends, or after a moment where // there is no slide to end (reduced motion). const settle = () => { @@ -216,17 +281,20 @@ export function installRailEdgeScroll(doc = globalThis.document) { } function onWheel(event) { - if (event.ctrlKey) return; + // Pinch zoom, and sideways or empty moves (trackpads send them mid-swipe), + // are left alone: they neither step nor turn a gesture round. + if (event.ctrlKey || !event.deltaY) return; const now = win.performance?.now?.() ?? Date.now(); const inGesture = now - lastWheel <= GESTURE_GAP_MS; // Recheck: the pointer may not have moved since the panels changed. - const stillHot = Boolean(hot) && railAt(event.clientX, event.target) === hot; + const stillHot = + Boolean(hot) && railAt(event.clientX, event.target) === hot; if (!stillHot) { setHot(null); // The rest of a gesture that has already stepped is used up here, so // its coasting does not go on to zoom the globe or scroll what opened // under the pointer. - if (stepped && inGesture) { + if (stepped && inGesture && now - steppedAt <= COAST_MAX_MS) { event.preventDefault(); event.stopPropagation(); lastWheel = now; @@ -235,15 +303,25 @@ export function installRailEdgeScroll(doc = globalThis.document) { } event.preventDefault(); event.stopPropagation(); - const speed = Math.abs(event.deltaY); + // Line- and page-based wheels report far smaller numbers than pixels. + const deltaY = + event.deltaMode === 1 + ? event.deltaY * LINE_PX + : event.deltaMode === 2 + ? event.deltaY * win.innerHeight + : event.deltaY; + const speed = Math.abs(deltaY); // A new gesture: after a pause, after turning round, a mouse wheel notch, // or a fresh flick while the last one is still coasting. A flick is a // rise in speed after the gesture has clearly slowed, and not in the // moment after a step: a swipe is still speeding up when it first steps. - const turned = travel && Math.sign(event.deltaY) !== Math.sign(travel); + const turned = travel && Math.sign(deltaY) !== Math.sign(travel); const cooled = now - steppedAt > STEP_COOLDOWN_MS; - const notch = stepped && cooled && speed >= NOTCH_DELTA; - const flick = stepped && cooled && slowed && speed > Math.max(8, lastSpeed * 1.8); + // Every event from a line- or page-based wheel is a notch. + const notch = + stepped && cooled && (speed >= NOTCH_DELTA || event.deltaMode > 0); + const flick = + stepped && cooled && slowed && speed > Math.max(8, lastSpeed * 1.8); if (!inGesture || turned || notch || flick) { // A held step belongs to the motion it came from; a reversal or a new // flick replaces it rather than queueing behind it. @@ -263,7 +341,7 @@ export function installRailEdgeScroll(doc = globalThis.document) { if (speed < peakSpeed * 0.5) slowed = true; return; } - travel += event.deltaY; + travel += deltaY; if (Math.abs(travel) < STEP_DELTA) return; stepped = true; const direction = Math.sign(travel); @@ -294,6 +372,7 @@ export function installRailEdgeScroll(doc = globalThis.document) { follow(); return () => { + removed = true; win.clearTimeout(heldStep); setHot(null); observer?.disconnect(); @@ -304,7 +383,11 @@ export function installRailEdgeScroll(doc = globalThis.document) { 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'); + rail.classList.remove( + 'ui-rail--glide', + 'ui-rail--glide-jump', + 'ui-rail--edge-hot', + ); }); }; } diff --git a/src/ui/railEdgeScroll.test.mjs b/src/ui/railEdgeScroll.test.mjs new file mode 100644 index 000000000..a16964ebe --- /dev/null +++ b/src/ui/railEdgeScroll.test.mjs @@ -0,0 +1,177 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Window } from 'happy-dom'; +import { installRailEdgeScroll } from './railEdgeScroll.js'; + +// A page with a left and a right rail of three items each, laid out by hand: +// happy-dom does no layout, so boxes and the clock are stubbed. +function page({ rtl = false } = {}) { + const window = new Window({ width: 1600, height: 900 }); + const doc = window.document; + let now = 1000; + const timers = new Map(); + let nextTimer = 1; + window.performance.now = () => now; + window.setTimeout = (fn, ms) => { + const id = nextTimer++; + timers.set(id, { fn, at: now + ms }); + return id; + }; + window.clearTimeout = (id) => timers.delete(id); + window.requestAnimationFrame = () => 0; + window.cancelAnimationFrame = () => {}; + Object.defineProperty(doc.documentElement, 'clientWidth', { value: 1600 }); + const box = (el, left, top, width, height) => { + el.getBoundingClientRect = () => ({ + left, + top, + width, + height, + right: left + width, + bottom: top + height, + }); + Object.defineProperty(el, 'offsetWidth', { get: () => width }); + }; + const clicks = []; + const rail = (name, left) => { + const el = doc.createElement('div'); + el.className = `ui-rail ui-rail--${name}`; + box(el, left, 200, 40, 120); + for (let i = 0; i < 3; i++) { + const item = doc.createElement('button'); + item.className = 'ui-rail__item'; + item.dataset.railItem = `${name}-${i}`; + item.setAttribute('aria-pressed', i === 0 ? 'true' : 'false'); + box(item, left + 4, 204 + i * 40, 32, 32); + item.addEventListener('click', () => { + clicks.push(item.dataset.railItem); + for (const other of el.children) + other.setAttribute('aria-pressed', String(other === item)); + }); + el.append(item); + } + doc.body.append(el); + return el; + }; + // In a right-to-left language the left stack's rail is on the right edge. + const left = rail('left', rtl ? 1550 : 10); + const right = rail('right', rtl ? 10 : 1550); + const remove = installRailEdgeScroll(doc); + const wheel = (x, deltaY, { deltaMode = 0, target = doc.body } = {}) => { + // happy-dom leaves clientX off these events, so it is set by hand. + const at = (event) => Object.defineProperty(event, 'clientX', { value: x }); + target.dispatchEvent( + at(new window.PointerEvent('pointermove', { bubbles: true })), + ); + const event = at( + new window.WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY, + deltaMode, + }), + ); + target.dispatchEvent(event); + return event; + }; + const advance = (ms) => { + now += ms; + for (const [id, timer] of [...timers]) { + if (timer.at > now) continue; + timers.delete(id); + timer.fn(); + } + }; + return { window, doc, left, right, clicks, wheel, advance, remove }; +} + +test('a wheel in the left strip steps that rail and keeps the globe from zooming', () => { + const p = page(); + const event = p.wheel(20, 120); + assert.equal(event.defaultPrevented, true); + assert.deepEqual(p.clicks, ['left-1']); + p.remove(); +}); + +test('a line-based mouse wheel steps one panel per notch', () => { + const p = page(); + p.wheel(20, 3, { deltaMode: 1 }); + p.advance(600); + p.wheel(20, 3, { deltaMode: 1 }); + assert.deepEqual(p.clicks, ['left-1', 'left-2']); + p.remove(); +}); + +test('a rail hidden by clean view or recording leaves the wheel to the globe', () => { + const p = page(); + p.left.checkVisibility = () => false; + const event = p.wheel(20, 120); + assert.equal(event.defaultPrevented, false); + assert.deepEqual(p.clicks, []); + p.remove(); +}); + +test('right to left: the wheel at each screen edge steps the rail on that edge', () => { + const p = page({ rtl: true }); + p.wheel(1580, 120); + p.advance(600); + p.wheel(20, 120); + assert.deepEqual(p.clicks, ['left-1', 'right-1']); + p.remove(); +}); + +test('after a step, the wheel goes back to the page once the coasting is over', () => { + const p = page(); + p.wheel(20, 120); + // The pointer moves off the strip while the gesture still coasts. + let event; + for (let i = 0; i < 20; i++) { + p.advance(100); + event = p.wheel(800, 30); + } + assert.equal(event.defaultPrevented, false); + assert.deepEqual(p.clicks, ['left-1']); + p.remove(); +}); + +test('a held step is dropped when the pointer leaves the strip, even if it comes back', () => { + const p = page(); + p.wheel(20, 120); + p.advance(250); + p.wheel(20, -120); // turns round: a second step, held for the gap + p.wheel(800, 0); // leaves the strip + p.wheel(20, 0); // and returns before the gap ends + p.advance(600); + assert.deepEqual(p.clicks, ['left-1']); + p.remove(); +}); + +test('removing it stops edge scrolling', () => { + const p = page(); + p.remove(); + const event = p.wheel(20, 120); + assert.equal(event.defaultPrevented, false); + assert.deepEqual(p.clicks, []); +}); + +test('a sideways move mid-swipe neither turns the gesture round nor steps again', () => { + const p = page(); + p.wheel(20, 60); + p.advance(50); + const sideways = p.wheel(20, 0); + p.advance(50); + p.wheel(20, 40); // the same swipe coasting on + assert.equal(sideways.defaultPrevented, false); + assert.deepEqual(p.clicks, ['left-1']); + p.remove(); +}); + +test('an open dialog near the edge keeps its own wheel', () => { + const p = page(); + const dialog = p.doc.createElement('dialog'); + p.doc.body.append(dialog); + const event = p.wheel(20, 120, { target: dialog }); + assert.equal(event.defaultPrevented, false); + assert.deepEqual(p.clicks, []); + p.remove(); +}); From d0b614d88ee5ad849d75d8ee4d9706ddff47c95a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 13:36:12 -0800 Subject: [PATCH 17/21] Keep edge mode to the desktop shell and mirror it in right-to-left Phones and Cockpit keep their own layouts and do not show the switch. The rails' name panels and edge mode run toward the middle in a right-to-left language, and the switch reads its side again after the layout moves it. Co-authored-by: Manjunath Paparaju --- src/ui/kit/UiPanelEdge.vue | 17 ++++++++++---- src/ui/panelEdge.js | 4 +++- src/ui/styles/panel-edge.css | 38 +++++++++++++++++++++++-------- src/ui/styles/rtl.css | 44 ++++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 15 deletions(-) diff --git a/src/ui/kit/UiPanelEdge.vue b/src/ui/kit/UiPanelEdge.vue index 5827e914f..bb1ec1f9c 100644 --- a/src/ui/kit/UiPanelEdge.vue +++ b/src/ui/kit/UiPanelEdge.vue @@ -15,10 +15,19 @@ function toggle() { function sync() { side.value = panelSide(button.value); } -onMounted(sync); -const icon = computed(() => (side.value === 'right' ? 'align_horizontal_right' : 'align_horizontal_left')); +// The layout moves some panels to the right column after they mount, so the +// side is read again once that has happened. +onMounted(() => { + sync(); + globalThis.requestAnimationFrame?.(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'), + on.value + ? t('Move panels back beside the rail') + : t('Move panels over the rail, to the edge'), ); @@ -26,7 +35,7 @@ const label = computed(() =>