Merge pull request #1735 from bilawalsidhu/manjunath/rail-panels

Side rails: Visual on the left rail, edge mode and edge scrolling
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-14 14:11:08 -08:00
commit 7451c9b372
43 files changed
+1574 -76

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
- Move Visual to the left rail, after Data Layers, Utilities and Authoring, and
lead the right column with Context; an open Context or CCTV panel takes the
column's full height. A switch in each panel header moves that side's panels
over their rail to the screen edge, and the scroll wheel at a screen edge
steps through that side's panels (manjunath22466, samehkhamis, #1735).
+30
View File
@@ -627,6 +627,36 @@ panel that holds its row, and `set_panel_open` reaches it as
`utilities-panel` or by "utilities", "draw", "drawing" or "import". On a phone `utilities-panel` or by "utilities", "draw", "drawing" or "import". On a phone
it sits collapsed in the Layers sheet under Data Layers. it sits collapsed in the Layers sheet under Data Layers.
## Side rails and the screen edge — November 14, 2026
Visual (`#pp-toggles`, the display settings, rail item `palette`) is the last
panel of the left column, after Data Layers, Utilities and Authoring, and
opens from the left rail like them (`PanelLayoutController`, falling back to
the right column in a host without a left stack). Context leads the right
column, followed by CCTV, Street Level and the other right panels. An open
Context or CCTV panel takes the column's full height; the other right panels
keep the content-sized layout and its hysteresis band. With nothing open, a
rail's icons and their names show as two glass pieces side by side; once a
panel opens, the icons stay in place and a highlight slides to the open one.
Each collapsible panel header has an edge switch (`UiPanelEdge.vue`,
`src/ui/panelEdge.js`): it moves that side's panels over their rail to the
screen edge, with the rail as a row of icons above the open panel, and back.
The choice is per side, kept in `localStorage` (`gev.panelEdge`), and applies
on the desktop shell only: phones and Cockpit keep their own layouts and do
not show the switch.
Scrolling the wheel at a screen edge (`src/ui/railEdgeScroll.js`, from the
edge to just past the rail, at most 84 px) steps that side's rail to its next
or previous panel instead of zooming the globe, wrapping round at either end.
One gesture is one step, trackpad coasting included, with at least half a
second between steps; a step that comes too soon is held until then and is
dropped if the pointer leaves the strip. Panels, controls, dialogs and the
world timeline in the strip keep their own wheel. The strip is off on phones
and touch screens, while the rails are hidden (clean view, recording), and it
follows the rails' sides in a right-to-left language. On a phone, Visual is in
the More sheet with the dock's Visual Presets, as before.
## God's Eye View in conversations — October 2, 2026 ## God's Eye View in conversations — October 2, 2026
Tool answers that can be shown in God's Eye View include a view: camera, layers, Tool answers that can be shown in God's Eye View include a view: camera, layers,
+1 -1
View File
@@ -26,7 +26,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source 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 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. --> a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,delete,directions_boat,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop,zoom_in_map" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,align_horizontal_left,align_horizontal_right,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,delete,directions_boat,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,palette,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop,zoom_in_map" rel="stylesheet" />
</head> </head>
<body> <body>
<div class="gev-view" data-gev-view="scene-chrome"></div> <div class="gev-view" data-gev-view="scene-chrome"></div>
+9 -1
View File
@@ -1449,5 +1449,13 @@
"src/data/fleetModelInstances.test.mjs", "src/data/fleetModelInstances.test.mjs",
"src/testSupport/fleetGlb.mjs", "src/testSupport/fleetGlb.mjs",
"src/testSupport/fleetModelFakes.mjs", "src/testSupport/fleetModelFakes.mjs",
"scripts/measure-fleet-trail-anchors.mjs" "scripts/measure-fleet-trail-anchors.mjs",
"src/ui/panelEdge.js",
"src/ui/railEdgeScroll.js",
"src/ui/railEdgeScroll.test.mjs",
"src/ui/kit/UiPanelEdge.vue",
"src/ui/styles/rail-edge.css",
"src/ui/styles/rail-split.css",
"src/ui/styles/left-tools.css",
"src/ui/styles/panel-edge.css"
] ]
+4
View File
@@ -154,6 +154,8 @@
"src/ui/kit/UiSegmented.vue", "src/ui/kit/UiSegmented.vue",
"src/ui/rail/RailPanel.vue", "src/ui/rail/RailPanel.vue",
"src/ui/kit/UiPanel.vue", "src/ui/kit/UiPanel.vue",
"src/ui/kit/UiPanelEdge.vue",
"src/ui/panelEdge.js",
"src/ui/kit/UiPanelToggle.vue", "src/ui/kit/UiPanelToggle.vue",
"src/ui/stores/panelChrome.js", "src/ui/stores/panelChrome.js",
"src/ui/rail/PanelCollapseButton.vue", "src/ui/rail/PanelCollapseButton.vue",
@@ -1136,6 +1138,7 @@
"src/styles/surveillance.js", "src/styles/surveillance.js",
"src/styles/thermal.js", "src/styles/thermal.js",
"src/ui/applicationShell.js", "src/ui/applicationShell.js",
"src/ui/railEdgeScroll.js",
"src/ui/localePicker.js", "src/ui/localePicker.js",
"src/ui/cyberSonarControls.js", "src/ui/cyberSonarControls.js",
"src/ui/locationNavigation.js", "src/ui/locationNavigation.js",
@@ -3087,6 +3090,7 @@
"src/styles/surveillance.js", "src/styles/surveillance.js",
"src/styles/thermal.js", "src/styles/thermal.js",
"src/ui/applicationShell.js", "src/ui/applicationShell.js",
"src/ui/railEdgeScroll.js",
"src/ui/mobileShell.js", "src/ui/mobileShell.js",
"src/exclusiveSurfaces.js", "src/exclusiveSurfaces.js",
"src/ui/localePicker.js", "src/ui/localePicker.js",
+127 -36
View File
@@ -4,18 +4,31 @@ import { _selectContextMode } from './ui/contextTransactions.js';
import { _initGlobalContextPanel } from './ui/contextBindings.js'; import { _initGlobalContextPanel } from './ui/contextBindings.js';
import { clearSelectedLayers } from './ui/contextActions.js'; import { clearSelectedLayers } from './ui/contextActions.js';
import { readFileSync as readRadioSource } from 'node:fs'; import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); const radioBindings = readRadioSource(
const contextView = readRadioSource(new URL('./ui/views/ContextView.vue', import.meta.url), 'utf8'); new URL('./ui/radioBindings.js', import.meta.url),
'utf8',
);
const contextView = readRadioSource(
new URL('./ui/views/ContextView.vue', import.meta.url),
'utf8',
);
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const html = await renderApplicationHtml(
readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
);
const css = readStylesheet(new URL('../style.css', import.meta.url)); const css = readStylesheet(new URL('../style.css', import.meta.url));
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => { test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) { for (const id of [
const button = html.match(new RegExp(`<button id="${id}"[\\s\\S]*?</button>`)); 'global-context-flights-btn',
'global-context-missions-btn',
]) {
const button = html.match(
new RegExp(`<button id="${id}"[\\s\\S]*?</button>`),
);
assert.ok(button, `${id} is missing`); assert.ok(button, `${id} is missing`);
assert.match(button[0], /role="tab"/); assert.match(button[0], /role="tab"/);
assert.match(button[0], /tabindex="0"/); assert.match(button[0], /tabindex="0"/);
@@ -23,7 +36,10 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
// The view keeps both tabs' tabindex static and never disables them; the // The view keeps both tabs' tabindex static and never disables them; the
// transition itself is covered over happy-dom in ui/contextPanel.test.mjs. // transition itself is covered over happy-dom in ui/contextPanel.test.mjs.
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) { for (const id of [
'global-context-flights-btn',
'global-context-missions-btn',
]) {
const tag = contextView.match(new RegExp(`<button id="${id}"[^>]*>`))?.[0]; const tag = contextView.match(new RegExp(`<button id="${id}"[^>]*>`))?.[0];
assert.ok(tag, `${id} is missing from the view`); assert.ok(tag, `${id} is missing from the view`);
assert.doesNotMatch(tag, /:tabindex|:disabled|\sdisabled/); assert.doesNotMatch(tag, /:tabindex|:disabled|\sdisabled/);
@@ -34,9 +50,22 @@ test('Context activation and Clear All never native-disable tabs and guard repea
const init = _initGlobalContextPanel.toString(); const init = _initGlobalContextPanel.toString();
const select = _selectContextMode.toString(); const select = _selectContextMode.toString();
const clear = clearSelectedLayers.toString(); const clear = clearSelectedLayers.toString();
assert.equal((init.match(/if \(\s*this\.destroyed\s*\|\|\s*this\._contextModeChanging\s*\|\|\s*this\._clearSelectedLayersPromise\s*\)\s*return;/g) || []).length, 2); assert.equal(
assert.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/); (
assert.doesNotMatch(clear, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/); init.match(
/if \(\s*this\.destroyed\s*\|\|\s*this\._contextModeChanging\s*\|\|\s*this\._clearSelectedLayersPromise\s*\)\s*return;/g,
) || []
).length,
2,
);
assert.doesNotMatch(
select,
/_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/,
);
assert.doesNotMatch(
clear,
/_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/,
);
}); });
test('Context tablist retains Left, Right, Home, and End keyboard navigation', () => { test('Context tablist retains Left, Right, Home, and End keyboard navigation', () => {
@@ -45,40 +74,62 @@ test('Context tablist retains Left, Right, Home, and End keyboard navigation', (
assert.match(initSource, /event\.key === 'ArrowLeft'/); assert.match(initSource, /event\.key === 'ArrowLeft'/);
assert.match(initSource, /event\.key === 'Home'/); assert.match(initSource, /event\.key === 'Home'/);
assert.match(initSource, /event\.key === 'End'/); assert.match(initSource, /event\.key === 'End'/);
assert.match(initSource, /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/); assert.match(
initSource,
/contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/,
);
assert.match(initSource, /contextTabs\[nextIndex\]\.click\(\)/); assert.match(initSource, /contextTabs\[nextIndex\]\.click\(\)/);
}); });
test('Context tabs draw a visible keyboard-focus outline including active tabs', () => { test('Context tabs draw a visible keyboard-focus outline including active tabs', () => {
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)]; const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
const focusRule = rules.find(([, selector, body]) => ( const focusRule = rules.find(
selector.trim().endsWith('.context-mode-button:focus-visible') ([, selector, body]) =>
&& /outline:\s*2px solid var\(--text-primary\)/.test(body) selector.trim().endsWith('.context-mode-button:focus-visible') &&
)); /outline:\s*2px solid var\(--text-primary\)/.test(body),
assert.ok(focusRule, 'Context focus-visible rule must draw a two-pixel outline'); );
assert.ok(
focusRule,
'Context focus-visible rule must draw a two-pixel outline',
);
assert.match(focusRule[2], /outline-offset:\s*-3px/); assert.match(focusRule[2], /outline-offset:\s*-3px/);
const activeRule = rules.find(([, selector]) => selector.trim().endsWith('.context-mode-button.active')); const activeRule = rules.find(([, selector]) =>
selector.trim().endsWith('.context-mode-button.active'),
);
assert.ok(activeRule, 'Context active-state rule is missing'); assert.ok(activeRule, 'Context active-state rule is missing');
assert.doesNotMatch(activeRule[2], /outline:\s*none/); assert.doesNotMatch(activeRule[2], /outline:\s*none/);
}); });
test('Context async action buttons remain focused while busy', () => { test('Context async action buttons remain focused while busy', () => {
const init = _initGlobalContextPanel.toString(); const init = _initGlobalContextPanel.toString();
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this._storeToken = this.store?.connect?.(')); const radio = radioBindings.slice(
const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8'); radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
radioBindings.indexOf('this._storeToken = this.store?.connect?.('),
);
const radioPanel = readFileSync(
new URL('./ui/views/panels/RadioPanel.vue', import.meta.url),
'utf8',
);
const clear = clearSelectedLayers.toString(); const clear = clearSelectedLayers.toString();
assert.match(init, /if \(this\._installationsSearchBusy\) return;/); assert.match(init, /if \(this\._installationsSearchBusy\) return;/);
assert.doesNotMatch(init, /\.disabled\s*=\s*true/); assert.doesNotMatch(init, /\.disabled\s*=\s*true/);
const search = contextView.match(/<button id="installations-search-btn"[^>]*>/)?.[0]; const search = contextView.match(
/<button id="installations-search-btn"[^>]*>/,
)?.[0];
assert.ok(search, 'nearby sites search is missing'); assert.ok(search, 'nearby sites search is missing');
assert.doesNotMatch(search, /:disabled|\sdisabled/); assert.doesNotMatch(search, /:disabled|\sdisabled/);
assert.match(search, /:aria-busy=/); assert.match(search, /:aria-busy=/);
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/); assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/); assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
const cockpitRadio = readFileSync(new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url), 'utf8'); const cockpitRadio = readFileSync(
const cockpitEnable = cockpitRadio.match(/<UiButton id="cockpit-radio-enable-btn"[^>]*>/)?.[0]; new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url),
'utf8',
);
const cockpitEnable = cockpitRadio.match(
/<UiButton id="cockpit-radio-enable-btn"[^>]*>/,
)?.[0];
assert.ok(cockpitEnable, 'Cockpit Radio Enable button is missing'); assert.ok(cockpitEnable, 'Cockpit Radio Enable button is missing');
assert.doesNotMatch(cockpitEnable, /disabled(?!=")|:disabled/); assert.doesNotMatch(cockpitEnable, /disabled(?!=")|:disabled/);
assert.match(cockpitEnable, /:aria-busy=/); assert.match(cockpitEnable, /:aria-busy=/);
@@ -89,44 +140,84 @@ test('Context async action buttons remain focused while busy', () => {
assert.match(enable, /:aria-busy=/); assert.match(enable, /:aria-busy=/);
assert.match(clear, /this\.setClearBusy\(true\)/); assert.match(clear, /this\.setClearBusy\(true\)/);
// The globe actions view marks the clear button busy, never disabled. // The globe actions view marks the clear button busy, never disabled.
const globeActions = readFileSync(new URL('./ui/views/panels/GlobeActions.vue', import.meta.url), 'utf8'); const globeActions = readFileSync(
const clearButton = globeActions.match(/<button id="clear-selected-layers"[^>]*>/)?.[0]; new URL('./ui/views/panels/GlobeActions.vue', import.meta.url),
'utf8',
);
const clearButton = globeActions.match(
/<button id="clear-selected-layers"[^>]*>/,
)?.[0];
assert.ok(clearButton, 'Clear selected layers button is missing'); assert.ok(clearButton, 'Clear selected layers button is missing');
assert.doesNotMatch(clearButton, /disabled(?!=")|:disabled=/); assert.doesNotMatch(clearButton, /disabled(?!=")|:disabled=/);
assert.match(clearButton, /:aria-busy=/); assert.match(clearButton, /:aria-busy=/);
}); });
test('Context header holds only its title and collapse control', () => { test('Context header holds only its title, edge switch and collapse control', () => {
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/); const panel = html.match(
/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/,
);
assert.ok(panel, 'Global Context panel is missing'); assert.ok(panel, 'Global Context panel is missing');
const header = panel[0].match(/<div class="ui-panel__head panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/); const header = panel[0].match(
/<div class="ui-panel__head panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/,
);
assert.ok(header, 'Global Context header is missing'); assert.ok(header, 'Global Context header is missing');
// The kit's own ui-* classes carry no Core styles. // The kit's own ui-* classes carry no Core styles.
const tags = [...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g)].map(([, tag, cls]) => const tags = [
`${tag}.${cls.split(' ').filter((name) => !/^ui-/.test(name)).join(' ')}`); ...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g),
assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']); ].map(
([, tag, cls]) =>
`${tag}.${cls
.split(' ')
.filter((name) => !/^ui-/.test(name))
.join(' ')}`,
);
assert.deepEqual(tags, [
'span.panel-title',
'span.panel-divider',
'button.panel-edge-btn',
'span.material-symbols-outlined',
'button.panel-collapse-btn',
]);
}); });
test('Context reads as one segmented control with legible type and no halo', () => { test('Context reads as one segmented control with legible type and no halo', () => {
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)]; const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
const clean = (selector) => selector.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim(); const clean = (selector) =>
const rule = (selector) => rules.find(([, s]) => clean(s) === selector)?.[2] || ''; selector
const px = (body, prop) => Number(body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0); .replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\s+/g, ' ')
.trim();
const rule = (selector) =>
rules.find(([, s]) => clean(s) === selector)?.[2] || '';
const px = (body, prop) =>
Number(
body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0,
);
assert.match(rule('#global-context-panel .panel-glow'), /display:\s*none/); assert.match(rule('#global-context-panel .panel-glow'), /display:\s*none/);
const modes = rule('.global-context-modes'); const modes = rule('.global-context-modes');
assert.match(modes, /border:\s*1px solid/); assert.match(modes, /border:\s*1px solid/);
assert.match(modes, /padding:\s*2px/); assert.match(modes, /padding:\s*2px/);
assert.ok(px(rule('.context-mode-button'), 'font') >= 10, 'mode labels must stay at least 10px'); assert.ok(
px(rule('.context-mode-button'), 'font') >= 10,
'mode labels must stay at least 10px',
);
assert.doesNotMatch(rule('.context-mode-button.active'), /box-shadow/); assert.doesNotMatch(rule('.context-mode-button.active'), /box-shadow/);
const standby = rule('.context-mode-standby'); const standby = rule('.context-mode-standby');
assert.ok(px(standby, 'font') >= 10, 'standby guidance must stay at least 10px'); assert.ok(
px(standby, 'font') >= 10,
'standby guidance must stay at least 10px',
);
assert.doesNotMatch(standby, /dashed/); assert.doesNotMatch(standby, /dashed/);
assert.ok(px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10); assert.ok(
px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10,
);
// The shared rail title colour (brighter than --text-secondary) keeps the // The shared rail title colour (brighter than --text-secondary) keeps the
// open Context title legible; no Context-only rule dims it again. // open Context title legible; no Context-only rule dims it again.
assert.equal( assert.equal(
rule(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"), rule(
":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title",
),
'', '',
); );
}); });
+3 -1
View File
@@ -3967,5 +3967,7 @@
"Cable car": "ترام كابلي", "Cable car": "ترام كابلي",
"Monorail": "قطار أحادي السكة", "Monorail": "قطار أحادي السكة",
"Funicular": "قطار جبلي مائل", "Funicular": "قطار جبلي مائل",
"Gondola": "تلفريك" "Gondola": "تلفريك",
"Move panels back beside the rail": "إعادة اللوحات إلى جانب الشريط",
"Move panels over the rail, to the edge": "نقل اللوحات فوق الشريط إلى الحافة"
} }
+3 -1
View File
@@ -3619,5 +3619,7 @@
"Cable car": "Kabelstraßenbahn", "Cable car": "Kabelstraßenbahn",
"Monorail": "Einschienenbahn", "Monorail": "Einschienenbahn",
"Funicular": "Standseilbahn", "Funicular": "Standseilbahn",
"Gondola": "Gondelbahn" "Gondola": "Gondelbahn",
"Move panels back beside the rail": "Bedienfelder zurück neben die Leiste",
"Move panels over the rail, to the edge": "Bedienfelder über die Leiste an den Rand verschieben"
} }
+3 -1
View File
@@ -3706,5 +3706,7 @@
"Cable car": "Tranvía de cable", "Cable car": "Tranvía de cable",
"Monorail": "Monorraíl", "Monorail": "Monorraíl",
"Funicular": "Funicular", "Funicular": "Funicular",
"Gondola": "Góndola" "Gondola": "Góndola",
"Move panels back beside the rail": "Devolver los paneles junto a la barra",
"Move panels over the rail, to the edge": "Mover los paneles sobre la barra, hasta el borde"
} }
+3 -1
View File
@@ -3706,5 +3706,7 @@
"Cable car": "Tramway à câble", "Cable car": "Tramway à câble",
"Monorail": "Monorail", "Monorail": "Monorail",
"Funicular": "Funiculaire", "Funicular": "Funiculaire",
"Gondola": "Télécabine" "Gondola": "Télécabine",
"Move panels back beside the rail": "Remettre les panneaux à côté de la barre",
"Move panels over the rail, to the edge": "Déplacer les panneaux par-dessus la barre, jusqu’au bord"
} }
+3 -1
View File
@@ -3706,5 +3706,7 @@
"Cable car": "חשמלית כבלים", "Cable car": "חשמלית כבלים",
"Monorail": "מונורייל", "Monorail": "מונורייל",
"Funicular": "פוניקולר", "Funicular": "פוניקולר",
"Gondola": "רכבל" "Gondola": "רכבל",
"Move panels back beside the rail": "החזרת הלוחות לצד הסרגל",
"Move panels over the rail, to the edge": "הזזת הלוחות מעל הסרגל, אל הקצה"
} }
+3 -1
View File
@@ -3706,5 +3706,7 @@
"Cable car": "Tram a fune", "Cable car": "Tram a fune",
"Monorail": "Monorotaia", "Monorail": "Monorotaia",
"Funicular": "Funicolare", "Funicular": "Funicolare",
"Gondola": "Cabinovia" "Gondola": "Cabinovia",
"Move panels back beside the rail": "Riporta i pannelli accanto alla barra",
"Move panels over the rail, to the edge": "Sposta i pannelli sopra la barra, fino al bordo"
} }
+3 -1
View File
@@ -3532,5 +3532,7 @@
"Cable car": "ケーブルトラム", "Cable car": "ケーブルトラム",
"Monorail": "モノレール", "Monorail": "モノレール",
"Funicular": "ケーブルカー", "Funicular": "ケーブルカー",
"Gondola": "ゴンドラ" "Gondola": "ゴンドラ",
"Move panels back beside the rail": "パネルをレールの横に戻す",
"Move panels over the rail, to the edge": "パネルをレールの上、画面端へ移動"
} }
+3 -1
View File
@@ -3706,5 +3706,7 @@
"Cable car": "Bonde a cabo", "Cable car": "Bonde a cabo",
"Monorail": "Monotrilho", "Monorail": "Monotrilho",
"Funicular": "Funicular", "Funicular": "Funicular",
"Gondola": "Teleférico" "Gondola": "Teleférico",
"Move panels back beside the rail": "Voltar os painéis para junto da barra",
"Move panels over the rail, to the edge": "Mover os painéis sobre a barra, até a borda"
} }
+3 -1
View File
@@ -3793,5 +3793,7 @@
"Cable car": "Канатный трамвай", "Cable car": "Канатный трамвай",
"Monorail": "Монорельс", "Monorail": "Монорельс",
"Funicular": "Фуникулёр", "Funicular": "Фуникулёр",
"Gondola": "Канатная дорога" "Gondola": "Канатная дорога",
"Move panels back beside the rail": "Вернуть панели к боковой панели",
"Move panels over the rail, to the edge": "Переместить панели поверх боковой панели к краю"
} }
+3 -1
View File
@@ -3619,5 +3619,7 @@
"Cable car": "Kabelspårvagn", "Cable car": "Kabelspårvagn",
"Monorail": "Enskensbana", "Monorail": "Enskensbana",
"Funicular": "Bergbana", "Funicular": "Bergbana",
"Gondola": "Kabinbana" "Gondola": "Kabinbana",
"Move panels back beside the rail": "Flytta tillbaka panelerna bredvid listen",
"Move panels over the rail, to the edge": "Flytta panelerna över listen, till kanten"
} }
+3 -1
View File
@@ -3793,5 +3793,7 @@
"Cable car": "Канатний трамвай", "Cable car": "Канатний трамвай",
"Monorail": "Монорейка", "Monorail": "Монорейка",
"Funicular": "Фунікулер", "Funicular": "Фунікулер",
"Gondola": "Канатна дорога" "Gondola": "Канатна дорога",
"Move panels back beside the rail": "Повернути панелі до бічної смуги",
"Move panels over the rail, to the edge": "Перемістити панелі поверх бічної смуги до краю"
} }
+3 -1
View File
@@ -3532,5 +3532,7 @@
"Cable car": "缆索电车", "Cable car": "缆索电车",
"Monorail": "单轨列车", "Monorail": "单轨列车",
"Funicular": "缆索铁路", "Funicular": "缆索铁路",
"Gondola": "缆车" "Gondola": "缆车",
"Move panels back beside the rail": "将面板移回侧栏旁",
"Move panels over the rail, to the edge": "将面板移到侧栏上方,贴靠边缘"
} }
+3 -1
View File
@@ -3532,5 +3532,7 @@
"Cable car": "纜索電車", "Cable car": "纜索電車",
"Monorail": "單軌列車", "Monorail": "單軌列車",
"Funicular": "纜索鐵路", "Funicular": "纜索鐵路",
"Gondola": "纜車" "Gondola": "纜車",
"Move panels back beside the rail": "將面板移回側欄旁",
"Move panels over the rail, to the edge": "將面板移到側欄上方,貼齊邊緣"
} }
+4
View File
@@ -65,6 +65,7 @@ import { useHudStore } from './stores/hud.js';
import { useDisplayStore } from './stores/display.js'; import { useDisplayStore } from './stores/display.js';
import { useLocaleStore } from './stores/locale.js'; import { useLocaleStore } from './stores/locale.js';
import { useLocationStore } from './stores/location.js'; import { useLocationStore } from './stores/location.js';
import { installRailEdgeScroll } from './railEdgeScroll.js';
import { useCockpitStore } from './stores/cockpit.js'; import { useCockpitStore } from './stores/cockpit.js';
import { useGlobeActionsStore } from './stores/globeActions.js'; import { useGlobeActionsStore } from './stores/globeActions.js';
import { useRecordingStore } from './stores/recording.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. // Vue views share this runtime and its stores; dispose unmounts them.
this._ui = createUiRuntime({ pinia: applicationPinia() }); this._ui = createUiRuntime({ pinia: applicationPinia() });
// Scrolling at a screen edge steps through that side's rail panels.
this._removeRailEdgeScroll = installRailEdgeScroll();
this._feedback = new ShellFeedback({ this._feedback = new ShellFeedback({
readLayers: () => withPlacePacks(this._dataManager?.getAll?.() || []), readLayers: () => withPlacePacks(this._dataManager?.getAll?.() || []),
}); });
@@ -1856,6 +1859,7 @@ export class StyleManager extends ShellFacade {
const { destroyTrackedReadout, destroyWorldOverlay, destroyDetection } = const { destroyTrackedReadout, destroyWorldOverlay, destroyDetection } =
this.services; this.services;
if (this._disposed) return; if (this._disposed) return;
this._removeRailEdgeScroll?.();
this._shareRestoration.destroy(); this._shareRestoration.destroy();
this._feedback.clearLoadingStatus(); this._feedback.clearLoadingStatus();
this._disposed = true; this._disposed = true;
+2
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { vClassToggle } from '../rail/classToggle.js'; import { vClassToggle } from '../rail/classToggle.js';
import UiPanelEdge from './UiPanelEdge.vue';
import UiPanelToggle from './UiPanelToggle.vue'; import UiPanelToggle from './UiPanelToggle.vue';
// Core's panel, with a name, title, slots and v-model:collapsed. It draws Core's panel markup // Core's panel, with a name, title, slots and v-model:collapsed. It draws Core's panel markup
@@ -57,6 +58,7 @@ const emit = defineEmits(['update:collapsed']);
<slot name="badge" /> <slot name="badge" />
<span class="panel-divider"></span> <span class="panel-divider"></span>
<slot name="actions" /> <slot name="actions" />
<UiPanelEdge v-if="props.collapsible" />
<UiPanelToggle <UiPanelToggle
v-if="props.collapsible" v-if="props.collapsible"
:target="props.id" :target="props.id"
+48
View File
@@ -0,0 +1,48 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { t } from '../../i18n/index.js';
import { panelEdge, panelSide, togglePanelEdge } from '../panelEdge.js';
// A panel header's edge switch: it slides this side's panels over their rail
// to the screen edge, and back. The side comes from the column the panel is in.
const button = ref(null);
const side = ref('left');
const on = computed(() => panelEdge.value[side.value]);
function toggle() {
side.value = panelSide(button.value);
togglePanelEdge(side.value);
}
function sync() {
side.value = panelSide(button.value);
}
// 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'),
);
</script>
<template>
<button
ref="button"
type="button"
class="ui-panel-edge panel-edge-btn"
:aria-pressed="String(on)"
:aria-label="label"
:title="label"
@pointerenter="sync"
@focus="sync"
@click="toggle"
>
<span class="material-symbols-outlined" aria-hidden="true">{{ icon }}</span>
</button>
</template>
+11 -1
View File
@@ -60,6 +60,7 @@ export function layoutLeftPanelRail({
stack.style.removeProperty('--left-stack-safe-bottom'); stack.style.removeProperty('--left-stack-safe-bottom');
stack.style.removeProperty('--left-stack-centered-height'); stack.style.removeProperty('--left-stack-centered-height');
stack.style.removeProperty('--panel-rail-offset'); stack.style.removeProperty('--panel-rail-offset');
stack.style.removeProperty('--panel-rail-lift');
stack.classList.remove('layout-exclusive'); stack.classList.remove('layout-exclusive');
stack.dataset.layoutMode = 'mobile'; stack.dataset.layoutMode = 'mobile';
settleSideAccordion({ panels, enabled: false, onCollapse }); settleSideAccordion({ panels, enabled: false, onCollapse });
@@ -282,10 +283,19 @@ export function layoutLeftPanelRail({
getComputedStyle(stack) getComputedStyle(stack)
.getPropertyValue?.('--panel-stack-top-pinned') .getPropertyValue?.('--panel-stack-top-pinned')
?.trim() === '1'; ?.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) { if (stack.style.getPropertyValue('--panel-rail-offset') !== railOffset) {
stack.style.setProperty('--panel-rail-offset', railOffset); stack.style.setProperty('--panel-rail-offset', railOffset);
} }
// How far the column can rise before it meets the HUD readout, so the rail
// and any open panel sit just under it, whether or not a panel is open.
const railLift = `${(topPinned ? 0 : lift(layoutTop)).toFixed(1)}px`;
if (stack.style.getPropertyValue('--panel-rail-lift') !== railLift) {
stack.style.setProperty('--panel-rail-lift', railLift);
}
stack.style.removeProperty('--left-stack-centered-height'); stack.style.removeProperty('--left-stack-centered-height');
for (const panel of panels) for (const panel of panels)
panel.style.removeProperty('--left-panel-allocated-height'); panel.style.removeProperty('--left-panel-allocated-height');
+2 -1
View File
@@ -368,7 +368,8 @@ export function initMobileShell({
: sheet === 'search' : sheet === 'search'
? [dock] ? [dock]
: sheet === 'more' : 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')]; : [element('right-context-rail')];
return roots.find((root) => root?.contains(target)); return roots.find((root) => root?.contains(target));
}; };
+1 -1
View File
@@ -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 openDock = rule('body.mobile-shell[data-mobile-sheet] #command-dock');
const railSheet = rule( 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(openDock));
assert.ok(z(sheetCredit) > z(railSheet)); assert.ok(z(sheetCredit) > z(railSheet));
+1
View File
@@ -141,6 +141,7 @@ test("UiPanel draws Core's panel markup and asks for the other state", async (t)
'demo-badge', 'demo-badge',
'panel-divider', 'panel-divider',
'demo-action', 'demo-action',
'panel-edge-btn',
'panel-collapse-btn', 'panel-collapse-btn',
], ],
); );
+42
View File
@@ -0,0 +1,42 @@
import { ref } from 'vue';
// Panels can slide over their rail to the screen edge, one setting per side:
// the left column moves onto the left rail, the right column onto the right
// rail. Each panel header has the switch (UiPanelEdge); the choice is kept
// for the next visit and shows as a body class the stylesheet reads.
const KEY = 'gev.panelEdge';
function read() {
try {
const saved = JSON.parse(globalThis.localStorage?.getItem(KEY) || '{}');
return { left: saved.left === true, right: saved.right === true };
} catch {
return { left: false, right: false };
}
}
export const panelEdge = ref(read());
function apply() {
const body = globalThis.document?.body;
if (!body) return;
body.classList.toggle('panels-edge-left', panelEdge.value.left);
body.classList.toggle('panels-edge-right', panelEdge.value.right);
}
apply();
/** Which side a panel element sits on, from the column it is in. */
export function panelSide(element) {
return element?.closest?.('#right-context-rail, #cockpit-utility-controls')
? 'right'
: 'left';
}
export function togglePanelEdge(side) {
panelEdge.value = { ...panelEdge.value, [side]: !panelEdge.value[side] };
try {
globalThis.localStorage?.setItem(KEY, JSON.stringify(panelEdge.value));
} catch {
// Without storage the choice lasts for this visit.
}
apply();
}
+8 -1
View File
@@ -189,7 +189,11 @@ export class PanelLayoutController {
const stack = this._rightPanelStack; const stack = this._rightPanelStack;
if (!stack || !this._ppToggles) return; if (!stack || !this._ppToggles) return;
stack.prepend(this._ppToggles); // Visual (presets and display settings) is the last panel of the left
// column, after the other tools, and opens from the left rail like them.
if (typeof this._leftPanelStack?.append === 'function')
this._leftPanelStack.append(this._ppToggles);
else stack.prepend(this._ppToggles);
const globalContextPanel = document.getElementById('global-context-panel'); const globalContextPanel = document.getElementById('global-context-panel');
for (const panel of [ for (const panel of [
this._cctvPanel, this._cctvPanel,
@@ -201,6 +205,9 @@ export class PanelLayoutController {
if (!panel) continue; if (!panel) continue;
stack.insertBefore(panel, globalContextPanel); stack.insertBefore(panel, globalContextPanel);
} }
// Context leads the right column: it is the panel for whatever is picked,
// and the camera panels follow it.
if (globalContextPanel) stack.prepend(globalContextPanel);
if (typeof ResizeObserver !== 'undefined') { if (typeof ResizeObserver !== 'undefined') {
this._rightStackResizeObserver = new ResizeObserver(() => { this._rightStackResizeObserver = new ResizeObserver(() => {
this._scheduleRightPanelLayout(); this._scheduleRightPanelLayout();
+12 -1
View File
@@ -6,9 +6,20 @@
* @returns {number} Natural height in CSS pixels. * @returns {number} Natural height in CSS pixels.
*/ */
export function measurePanelNaturalHeight(panel, getComputedStyle) { export function measurePanelNaturalHeight(panel, getComputedStyle) {
const inner = [...panel.children].find( const content = [...panel.children].filter(
(child) => !child.classList.contains('panel-glow'), (child) => !child.classList.contains('panel-glow'),
); );
// A panel built as a header row and a body (Visual) scrolls as a whole, so
// its full scroll height is its natural height.
if (content.length > 1) {
const panelStyle = getComputedStyle(panel);
return Math.ceil(
panel.scrollHeight +
(parseFloat(panelStyle.borderTopWidth) || 0) +
(parseFloat(panelStyle.borderBottomWidth) || 0),
);
}
const inner = content[0];
if (!inner) if (!inner)
return Math.ceil( return Math.ceil(
panel.scrollHeight || panel.getBoundingClientRect().height, panel.scrollHeight || panel.getBoundingClientRect().height,
+1 -1
View File
@@ -19,7 +19,7 @@ export const PANEL_RAIL_ITEMS = Object.freeze({
}, },
'utilities-panel': { icon: 'handyman', label: msg('UTILITIES') }, 'utilities-panel': { icon: 'handyman', label: msg('UTILITIES') },
'scene-panel': { icon: 'movie', label: msg('AUTHORING') }, 'scene-panel': { icon: 'movie', label: msg('AUTHORING') },
'pp-toggles': { icon: 'tune', label: msg('DISPLAY') }, 'pp-toggles': { icon: 'palette', label: msg('VISUAL') },
'cctv-panel': { icon: 'videocam', label: msg('CCTV') }, 'cctv-panel': { icon: 'videocam', label: msg('CCTV') },
'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') }, 'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') },
'news-panel': { icon: 'public', label: msg('NEWS') }, 'news-panel': { icon: 'public', label: msg('NEWS') },
+33 -8
View File
@@ -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'); 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'); const f = fixture('left');
f.expand(f.first, 900); f.expand(f.first, 900);
f.run(); f.run();
assert.equal(f.stack.dataset.layoutMode, 'focus'); assert.equal(f.stack.dataset.layoutMode, 'focus');
assert.ok( assert.notEqual(short.stack.dataset.layoutMode, 'focus');
parseFloat(f.stack.style.getPropertyValue('--panel-rail-offset')) > 0, 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. // A theme that pins the stack's top (Cyber) leaves nothing to offset.
f.stack.computed.getPropertyValue = (name) => f.stack.computed.getPropertyValue = (name) =>
@@ -447,13 +456,17 @@ test('natural height includes visible content, margins and wrapper chrome, exclu
// Outside measurement, CCTV is 391 px in focus and 629 px in normal mode; // Outside measurement, CCTV is 391 px in focus and 629 px in normal mode;
// Removing only the allocation leaves focus CSS active: its next reading is // Removing only the allocation leaves focus CSS active: its next reading is
// 422 px, so the old decision exits focus and then re-enters on the 629 px read. // 422 px, so the old decision exits focus and then re-enters on the 629 px read.
function thrashingRightRail({ focused = false, weatherOpen = false } = {}) { function thrashingRightRail({
focused = false,
weatherOpen = false,
tallId = 'cctv-panel',
} = {}) {
const f = fixture('right'); const f = fixture('right');
f.options.windowRef.innerHeight = 920; f.options.windowRef.innerHeight = 920;
f.options.leftStack.rect.top = 439.6; f.options.leftStack.rect.top = 439.6;
f.stack.computed.rowGap = '8px'; f.stack.computed.rowGap = '8px';
const display = element('pp-toggles', { collapsed: true }); const display = element('pp-toggles', { collapsed: true });
const cctv = element('cctv-panel', { height: 629 }); const cctv = element(tallId, { height: 629 });
const weather = element('weather-panel', { const weather = element('weather-panel', {
height: 444, height: 444,
collapsed: !weatherOpen, collapsed: !weatherOpen,
@@ -524,8 +537,10 @@ for (const focused of [false, true]) {
}); });
} }
// The hysteresis applies to panels that size to their content; CCTV and
// Context take the column's full height whenever they are open.
test('right rail keeps focus through 30 px content growth inside the hysteresis band', () => { test('right rail keeps focus through 30 px content growth inside the hysteresis band', () => {
const f = thrashingRightRail(); const f = thrashingRightRail({ tallId: 'news-panel' });
f.run(); f.run();
// Header + gap contribute 52 px: need moves 420 -> 450 -> 420. // Header + gap contribute 52 px: need moves 420 -> 450 -> 420.
for (const height of [368, 398, 368, 398, 368]) { for (const height of [368, 398, 368, 398, 368]) {
@@ -542,7 +557,7 @@ test('right rail keeps focus through 30 px content growth inside the hysteresis
}); });
test('right rail enters focus only above available height and leaves below the full deadband', () => { test('right rail enters focus only above available height and leaves below the full deadband', () => {
const f = thrashingRightRail(); const f = thrashingRightRail({ tallId: 'news-panel' });
f.options.windowRef.innerHeight = 1000; f.options.windowRef.innerHeight = 1000;
f.options.leftStack.rect.top = 500; f.options.leftStack.rect.top = 500;
f.cctv.intrinsicHeight = 408; // Need equals available (460 px). f.cctv.intrinsicHeight = 408; // Need equals available (460 px).
@@ -559,6 +574,16 @@ test('right rail enters focus only above available height and leaves below the f
assert.equal(f.stack.classList.contains('layout-focus'), false); assert.equal(f.stack.classList.contains('layout-focus'), false);
}); });
test('an open CCTV panel takes the full column however short its content', () => {
const f = thrashingRightRail();
f.cctv.intrinsicHeight = 200;
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), true);
f.cctv.classList.add('collapsed');
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), false);
});
test('right rail settles a second tall panel through the accordion in one pass', () => { test('right rail settles a second tall panel through the accordion in one pass', () => {
const f = thrashingRightRail({ weatherOpen: true }); const f = thrashingRightRail({ weatherOpen: true });
const modes = []; const modes = [];
+393
View File
@@ -0,0 +1,393 @@
// 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;
/** 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 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. */
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, 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]
* @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;
/** 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;
/** 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((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 && showing(item),
);
// ── 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) {
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) {
gliding.delete(rail);
rail.classList.remove('ui-rail--glide');
return;
}
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`,
);
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 = () => {
if (removed) return;
watchRails();
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'],
});
// 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) {
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 <= 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;
return rail;
}
return null;
}
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;
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',
);
// 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) {
// 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;
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 && now - steppedAt <= COAST_MAX_MS) {
event.preventDefault();
event.stopPropagation();
lastWheel = now;
}
return;
}
event.preventDefault();
event.stopPropagation();
// 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(deltaY) !== Math.sign(travel);
const cooled = now - steppedAt > STEP_COOLDOWN_MS;
// 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.
if (turned && heldStep) {
win.clearTimeout(heldStep);
heldStep = 0;
}
travel = 0;
stepped = false;
peakSpeed = 0;
slowed = false;
}
lastWheel = now;
lastSpeed = speed;
peakSpeed = Math.max(peakSpeed, speed);
if (stepped) {
if (speed < peakSpeed * 0.5) slowed = true;
return;
}
travel += deltaY;
if (Math.abs(travel) < STEP_DELTA) return;
stepped = true;
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;
const heldRail = hot;
heldStep = win.setTimeout(() => {
heldStep = 0;
// 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);
}
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 () => {
removed = true;
win.clearTimeout(heldStep);
setHot(null);
observer?.disconnect();
sized?.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',
);
});
};
}
+177
View File
@@ -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();
});
+45 -6
View File
@@ -31,6 +31,20 @@ function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) {
return Number.isFinite(targetPct) ? (targetPct * viewportHeight) / 100 : top; return Number.isFinite(targetPct) ? (targetPct * viewportHeight) / 100 : top;
} }
/**
* An element's CSS `top` in pixels where it will settle: the end of a running
* `top` transition, else its computed value. NaN when it cannot be read.
*/
function settledCssTop(element, getComputedStyle) {
if (!element) return NaN;
const transition = element
.getAnimations?.()
.find((animation) => animation.transitionProperty === 'top');
const target = parseFloat(transition?.effect?.getKeyframes?.().at(-1)?.top);
if (Number.isFinite(target)) return target;
return parseFloat(getComputedStyle(element).top);
}
/** /**
* Measure and place the right panel rail for one synchronous layout pass. * Measure and place the right panel rail for one synchronous layout pass.
* The caller owns scheduling, obstacle selection, disclosure preferences and * The caller owns scheduling, obstacle selection, disclosure preferences and
@@ -126,9 +140,20 @@ export function layoutRightPanelRail({
viewportHeight, viewportHeight,
getComputedStyle, getComputedStyle,
); );
// Align with the left rail's buttons, which hold still while its stack moves. // Align with the left rail's buttons, which hold still while its stack moves:
const leftRailOffset = // the rail's own settled `top` inside the stack, so every left layout is
parseFloat(leftStack?.style.getPropertyValue('--panel-rail-offset')) || 0; // followed (a layout may pin the rail to the stack's top whatever the
// offset says). The stack's offset is the fallback when there is no rail.
const leftRailInset = settledCssTop(
leftStack?.querySelector?.('.gev-panel-rail') ?? null,
getComputedStyle,
);
// A rail that sits lower in its stack has only made room above itself for
// the open panel's own icon, which stays at the stack's top: the column's
// top has not moved, so that inset is not followed.
const leftRailOffset = Number.isFinite(leftRailInset)
? Math.min(0, leftRailInset)
: parseFloat(leftStack?.style.getPropertyValue('--panel-rail-offset')) || 0;
const alignedTop = Number.isFinite(leftStackTop) const alignedTop = Number.isFinite(leftStackTop)
? leftStackTop + leftRailOffset ? leftStackTop + leftRailOffset
: viewportHeight * 0.26; : viewportHeight * 0.26;
@@ -206,6 +231,18 @@ export function layoutRightPanelRail({
0, 0,
) + ) +
gap * Math.max(0, measuredPanels.length - 1); gap * Math.max(0, measuredPanels.length - 1);
// An open Context panel takes the column's full height: up to just under
// the HUD readouts at the top of the screen, down to the usual bottom.
// CCTV does the same, so its feed and controls have the room.
const FULL_HEIGHT = new Set(['global-context-panel', 'cctv-panel']);
const contextOpen = panels.some(
(panel) =>
FULL_HEIGHT.has(panel.id) && !panel.classList.contains('collapsed'),
);
// 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({ const layout = resolveHudRailLayout({
viewportHeight, viewportHeight,
panelHeight: naturalHeight, panelHeight: naturalHeight,
@@ -221,9 +258,11 @@ export function layoutRightPanelRail({
const { safeTop, safeBottom, maxHeight: availableHeight } = layout; const { safeTop, safeBottom, maxHeight: availableHeight } = layout;
const stabilityBand = Math.max(48, viewportHeight * 0.06); const stabilityBand = Math.max(48, viewportHeight * 0.06);
const wasFocused = stack.classList.contains('layout-focus'); const wasFocused = stack.classList.contains('layout-focus');
const shouldFocus = wasFocused const shouldFocus =
? naturalHeight >= availableHeight - stabilityBand contextOpen ||
: naturalHeight > availableHeight; (wasFocused
? naturalHeight >= availableHeight - stabilityBand
: naturalHeight > availableHeight);
const layoutTop = shouldFocus ? safeTop : layout.top; const layoutTop = shouldFocus ? safeTop : layout.top;
const collapsedHeight = visiblePanels.reduce( const collapsedHeight = visiblePanels.reduce(
(total, panel) => (total, panel) =>
+2 -2
View File
@@ -259,7 +259,7 @@ test('recording restores the original HUD after repeated entry and revokes retai
} }
}); });
test('weather rail orders between CCTV and Context, observes and presents chrome', (t) => { test('Context leads the right rail, weather follows CCTV, observes and presents chrome', (t) => {
const f = fixture(); const f = fixture();
const observed = new Set(); const observed = new Set();
const previousResizeObserver = globalThis.ResizeObserver; const previousResizeObserver = globalThis.ResizeObserver;
@@ -319,7 +319,7 @@ test('weather rail orders between CCTV and Context, observes and presents chrome
owner._initRightPanelAdaptiveLayout(); owner._initRightPanelAdaptiveLayout();
assert.deepEqual( assert.deepEqual(
stack.children.map((panel) => panel.id), stack.children.map((panel) => panel.id),
['pp-toggles', 'cctv-panel', 'weather-panel', 'global-context-panel'], ['global-context-panel', 'pp-toggles', 'cctv-panel', 'weather-panel'],
); );
assert.ok(observed.has(weather)); assert.ok(observed.has(weather));
// Moving a panel into the rail leaves its state as it was. // Moving a panel into the rail leaves its state as it was.
+37
View File
@@ -0,0 +1,37 @@
/* 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);
}
+4 -2
View File
@@ -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='layers'] #left-panel-stack,
body.mobile-shell[data-mobile-sheet='cameras'] #right-context-rail, 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='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; position: fixed;
top: auto; top: auto;
right: 0; right: 0;
@@ -189,7 +191,7 @@ body.mobile-shell[data-mobile-sheet='context']
body.mobile-shell[data-mobile-sheet='context'] body.mobile-shell[data-mobile-sheet='context']
#right-context-rail #right-context-rail
> #recent-imagery-panel:not([hidden]), > #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; position: relative;
inset: auto; inset: auto;
display: block; display: block;
+3 -1
View File
@@ -310,8 +310,10 @@
bottom: 12vh; 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 { #intel-hud[data-variant='minimal'] .hud-bottom-bar {
display: flex; display: none;
bottom: calc(12vh + 4rem); bottom: calc(12vh + 4rem);
} }
+100
View File
@@ -0,0 +1,100 @@
/* ── 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(.mobile-shell):not(.cockpit-mode)
#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:not(.mobile-shell):not(.cockpit-mode)
#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,
body.cockpit-mode .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:not(.mobile-shell):not(.cockpit-mode)
.gev-panel-rail--left:has(.ui-rail--collapsible:not(.ui-rail--expanded)),
body.panels-edge-right:not(.mobile-shell):not(.cockpit-mode)
.gev-panel-rail--right:has(.ui-rail--collapsible:not(.ui-rail--expanded)) {
top: 0 !important;
z-index: 3;
}
body.panels-edge-left:not(.mobile-shell):not(.cockpit-mode)
.gev-panel-rail--left
.ui-rail--collapsible:not(.ui-rail--expanded),
body.panels-edge-right:not(.mobile-shell):not(.cockpit-mode)
.gev-panel-rail--right
.ui-rail--collapsible:not(.ui-rail--expanded) {
flex-direction: row;
width: max-content;
gap: 4px;
}
body.panels-edge-left:not(.mobile-shell):not(.cockpit-mode)
#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(.mobile-shell):not(.cockpit-mode)
#right-context-rail
> :not(.gev-panel-rail):not(.collapsed) {
transform: translate(var(--panel-rail-clearance, 48px), var(--edge-rail-row));
max-height: calc(100% - var(--edge-rail-row)) !important;
}
+103
View File
@@ -0,0 +1,103 @@
/* ── 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));
/* 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),
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;
}
}
+282
View File
@@ -0,0 +1,282 @@
/* 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
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 takes the accent colour; its highlight is the rail's shared
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: none;
border-color: transparent;
}
: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);
}
/* ── 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);
}
/* 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);
}
+44
View File
@@ -242,3 +242,47 @@ html[dir='rtl'] select.gev-field--compact {
html[dir='rtl'] .rail-card-scale { html[dir='rtl'] .rail-card-scale {
direction: ltr; direction: ltr;
} }
/* ── Side rails, mirrored ──────────────────────────────────────────────
The left stack sits on the right edge, so the left rail's name panel
opens to its left and the right rail's to its right, each toward the
middle of the screen, as they do in left-to-right. */
html[dir='rtl'] .gev-panel-rail--left .ui-rail--expanded::after {
right: calc(100% + 6px);
left: auto;
}
html[dir='rtl'] .gev-panel-rail--left .ui-rail--expanded .ui-rail__label {
right: calc(100% + 24px);
left: auto;
}
html[dir='rtl'] .gev-panel-rail--right .ui-rail--expanded::after {
right: auto;
left: calc(100% + 6px);
}
html[dir='rtl'] .gev-panel-rail--right .ui-rail--expanded .ui-rail__label {
right: auto;
left: calc(100% + 24px);
}
/* Panels moved to the screen edge slide over their rail toward that edge. */
html[dir='rtl']
body.panels-edge-left:not(.mobile-shell):not(.cockpit-mode)
#left-panel-stack
> :not(.gev-panel-rail) {
transform: translateX(var(--panel-rail-clearance, 48px));
}
html[dir='rtl']
body.panels-edge-left:not(.mobile-shell):not(.cockpit-mode)
#left-panel-stack
> :not(.gev-panel-rail):not(.collapsed) {
transform: translate(var(--panel-rail-clearance, 48px), var(--edge-rail-row));
}
html[dir='rtl']
body.panels-edge-right:not(.mobile-shell):not(.cockpit-mode)
#right-context-rail
> :not(.gev-panel-rail) {
transform: translate(
calc(-1 * var(--panel-rail-clearance, 48px)),
var(--edge-rail-row)
);
}
+4
View File
@@ -45,5 +45,9 @@
@import './src/ui/styles/rtl.css'; @import './src/ui/styles/rtl.css';
@import './src/ui/styles/mobile-shell.css'; @import './src/ui/styles/mobile-shell.css';
@import './src/ui/styles/annotations.css'; @import './src/ui/styles/annotations.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/ai-chat.css';
@import './src/ui/styles/cyber.css'; @import './src/ui/styles/cyber.css';