mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
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:
43 files changed
+1574
-76
No files matched your search
@@ -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).
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Tool answers that can be shown in God's Eye View include a view: camera, layers,
|
||||
|
||||
+1
-1
@@ -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. -->
|
||||
<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>
|
||||
<body>
|
||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||
|
||||
@@ -1449,5 +1449,13 @@
|
||||
"src/data/fleetModelInstances.test.mjs",
|
||||
"src/testSupport/fleetGlb.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"
|
||||
]
|
||||
@@ -154,6 +154,8 @@
|
||||
"src/ui/kit/UiSegmented.vue",
|
||||
"src/ui/rail/RailPanel.vue",
|
||||
"src/ui/kit/UiPanel.vue",
|
||||
"src/ui/kit/UiPanelEdge.vue",
|
||||
"src/ui/panelEdge.js",
|
||||
"src/ui/kit/UiPanelToggle.vue",
|
||||
"src/ui/stores/panelChrome.js",
|
||||
"src/ui/rail/PanelCollapseButton.vue",
|
||||
@@ -1136,6 +1138,7 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/railEdgeScroll.js",
|
||||
"src/ui/localePicker.js",
|
||||
"src/ui/cyberSonarControls.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
@@ -3087,6 +3090,7 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/railEdgeScroll.js",
|
||||
"src/ui/mobileShell.js",
|
||||
"src/exclusiveSurfaces.js",
|
||||
"src/ui/localePicker.js",
|
||||
|
||||
+127
-36
@@ -4,18 +4,31 @@ import { _selectContextMode } from './ui/contextTransactions.js';
|
||||
import { _initGlobalContextPanel } from './ui/contextBindings.js';
|
||||
import { clearSelectedLayers } from './ui/contextActions.js';
|
||||
import { readFileSync as readRadioSource } from 'node:fs';
|
||||
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const contextView = readRadioSource(new URL('./ui/views/ContextView.vue', import.meta.url), 'utf8');
|
||||
const radioBindings = readRadioSource(
|
||||
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 { test } from 'node:test';
|
||||
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));
|
||||
|
||||
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
|
||||
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
|
||||
const button = html.match(new RegExp(`<button id="${id}"[\\s\\S]*?</button>`));
|
||||
for (const id of [
|
||||
'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.match(button[0], /role="tab"/);
|
||||
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
|
||||
// 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];
|
||||
assert.ok(tag, `${id} is missing from the view`);
|
||||
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 select = _selectContextMode.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.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
|
||||
assert.doesNotMatch(clear, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
|
||||
assert.equal(
|
||||
(
|
||||
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', () => {
|
||||
@@ -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 === 'Home'/);
|
||||
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\(\)/);
|
||||
});
|
||||
|
||||
test('Context tabs draw a visible keyboard-focus outline including active tabs', () => {
|
||||
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
|
||||
const focusRule = rules.find(([, selector, 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');
|
||||
const focusRule = rules.find(
|
||||
([, selector, 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.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.doesNotMatch(activeRule[2], /outline:\s*none/);
|
||||
});
|
||||
|
||||
test('Context async action buttons remain focused while busy', () => {
|
||||
const init = _initGlobalContextPanel.toString();
|
||||
const radio = radioBindings.slice(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 radio = radioBindings.slice(
|
||||
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();
|
||||
|
||||
assert.match(init, /if \(this\._installationsSearchBusy\) return;/);
|
||||
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.doesNotMatch(search, /:disabled|\sdisabled/);
|
||||
assert.match(search, /:aria-busy=/);
|
||||
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
|
||||
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
|
||||
const cockpitRadio = readFileSync(new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url), 'utf8');
|
||||
const cockpitEnable = cockpitRadio.match(/<UiButton id="cockpit-radio-enable-btn"[^>]*>/)?.[0];
|
||||
const cockpitRadio = readFileSync(
|
||||
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.doesNotMatch(cockpitEnable, /disabled(?!=")|:disabled/);
|
||||
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(clear, /this\.setClearBusy\(true\)/);
|
||||
// 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 clearButton = globeActions.match(/<button id="clear-selected-layers"[^>]*>/)?.[0];
|
||||
const globeActions = readFileSync(
|
||||
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.doesNotMatch(clearButton, /disabled(?!=")|:disabled=/);
|
||||
assert.match(clearButton, /:aria-busy=/);
|
||||
});
|
||||
|
||||
test('Context header holds only its title and collapse control', () => {
|
||||
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/);
|
||||
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[ "]/,
|
||||
);
|
||||
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');
|
||||
// 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]) =>
|
||||
`${tag}.${cls.split(' ').filter((name) => !/^ui-/.test(name)).join(' ')}`);
|
||||
assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']);
|
||||
const tags = [
|
||||
...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g),
|
||||
].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', () => {
|
||||
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
|
||||
const clean = (selector) => selector.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);
|
||||
const clean = (selector) =>
|
||||
selector
|
||||
.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/);
|
||||
const modes = rule('.global-context-modes');
|
||||
assert.match(modes, /border:\s*1px solid/);
|
||||
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/);
|
||||
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.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
|
||||
// open Context title legible; no Context-only rule dims it again.
|
||||
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",
|
||||
),
|
||||
'',
|
||||
);
|
||||
});
|
||||
@@ -3967,5 +3967,7 @@
|
||||
"Cable car": "ترام كابلي",
|
||||
"Monorail": "قطار أحادي السكة",
|
||||
"Funicular": "قطار جبلي مائل",
|
||||
"Gondola": "تلفريك"
|
||||
"Gondola": "تلفريك",
|
||||
"Move panels back beside the rail": "إعادة اللوحات إلى جانب الشريط",
|
||||
"Move panels over the rail, to the edge": "نقل اللوحات فوق الشريط إلى الحافة"
|
||||
}
|
||||
@@ -3619,5 +3619,7 @@
|
||||
"Cable car": "Kabelstraßenbahn",
|
||||
"Monorail": "Einschienenbahn",
|
||||
"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"
|
||||
}
|
||||
@@ -3706,5 +3706,7 @@
|
||||
"Cable car": "Tranvía de cable",
|
||||
"Monorail": "Monorraíl",
|
||||
"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"
|
||||
}
|
||||
@@ -3706,5 +3706,7 @@
|
||||
"Cable car": "Tramway à câble",
|
||||
"Monorail": "Monorail",
|
||||
"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"
|
||||
}
|
||||
@@ -3706,5 +3706,7 @@
|
||||
"Cable car": "חשמלית כבלים",
|
||||
"Monorail": "מונורייל",
|
||||
"Funicular": "פוניקולר",
|
||||
"Gondola": "רכבל"
|
||||
"Gondola": "רכבל",
|
||||
"Move panels back beside the rail": "החזרת הלוחות לצד הסרגל",
|
||||
"Move panels over the rail, to the edge": "הזזת הלוחות מעל הסרגל, אל הקצה"
|
||||
}
|
||||
@@ -3706,5 +3706,7 @@
|
||||
"Cable car": "Tram a fune",
|
||||
"Monorail": "Monorotaia",
|
||||
"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"
|
||||
}
|
||||
@@ -3532,5 +3532,7 @@
|
||||
"Cable car": "ケーブルトラム",
|
||||
"Monorail": "モノレール",
|
||||
"Funicular": "ケーブルカー",
|
||||
"Gondola": "ゴンドラ"
|
||||
"Gondola": "ゴンドラ",
|
||||
"Move panels back beside the rail": "パネルをレールの横に戻す",
|
||||
"Move panels over the rail, to the edge": "パネルをレールの上、画面端へ移動"
|
||||
}
|
||||
@@ -3706,5 +3706,7 @@
|
||||
"Cable car": "Bonde a cabo",
|
||||
"Monorail": "Monotrilho",
|
||||
"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"
|
||||
}
|
||||
@@ -3793,5 +3793,7 @@
|
||||
"Cable car": "Канатный трамвай",
|
||||
"Monorail": "Монорельс",
|
||||
"Funicular": "Фуникулёр",
|
||||
"Gondola": "Канатная дорога"
|
||||
"Gondola": "Канатная дорога",
|
||||
"Move panels back beside the rail": "Вернуть панели к боковой панели",
|
||||
"Move panels over the rail, to the edge": "Переместить панели поверх боковой панели к краю"
|
||||
}
|
||||
@@ -3619,5 +3619,7 @@
|
||||
"Cable car": "Kabelspårvagn",
|
||||
"Monorail": "Enskensbana",
|
||||
"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"
|
||||
}
|
||||
@@ -3793,5 +3793,7 @@
|
||||
"Cable car": "Канатний трамвай",
|
||||
"Monorail": "Монорейка",
|
||||
"Funicular": "Фунікулер",
|
||||
"Gondola": "Канатна дорога"
|
||||
"Gondola": "Канатна дорога",
|
||||
"Move panels back beside the rail": "Повернути панелі до бічної смуги",
|
||||
"Move panels over the rail, to the edge": "Перемістити панелі поверх бічної смуги до краю"
|
||||
}
|
||||
@@ -3532,5 +3532,7 @@
|
||||
"Cable car": "缆索电车",
|
||||
"Monorail": "单轨列车",
|
||||
"Funicular": "缆索铁路",
|
||||
"Gondola": "缆车"
|
||||
"Gondola": "缆车",
|
||||
"Move panels back beside the rail": "将面板移回侧栏旁",
|
||||
"Move panels over the rail, to the edge": "将面板移到侧栏上方,贴靠边缘"
|
||||
}
|
||||
@@ -3532,5 +3532,7 @@
|
||||
"Cable car": "纜索電車",
|
||||
"Monorail": "單軌列車",
|
||||
"Funicular": "纜索鐵路",
|
||||
"Gondola": "纜車"
|
||||
"Gondola": "纜車",
|
||||
"Move panels back beside the rail": "將面板移回側欄旁",
|
||||
"Move panels over the rail, to the edge": "將面板移到側欄上方,貼齊邊緣"
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { vClassToggle } from '../rail/classToggle.js';
|
||||
import UiPanelEdge from './UiPanelEdge.vue';
|
||||
import UiPanelToggle from './UiPanelToggle.vue';
|
||||
|
||||
// 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" />
|
||||
<span class="panel-divider"></span>
|
||||
<slot name="actions" />
|
||||
<UiPanelEdge v-if="props.collapsible" />
|
||||
<UiPanelToggle
|
||||
v-if="props.collapsible"
|
||||
:target="props.id"
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
@@ -282,10 +283,19 @@ 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 : 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');
|
||||
for (const panel of panels)
|
||||
panel.style.removeProperty('--left-panel-allocated-height');
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -141,6 +141,7 @@ test("UiPanel draws Core's panel markup and asks for the other state", async (t)
|
||||
'demo-badge',
|
||||
'panel-divider',
|
||||
'demo-action',
|
||||
'panel-edge-btn',
|
||||
'panel-collapse-btn',
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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) =>
|
||||
@@ -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;
|
||||
// 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.
|
||||
function thrashingRightRail({ focused = false, weatherOpen = false } = {}) {
|
||||
function thrashingRightRail({
|
||||
focused = false,
|
||||
weatherOpen = false,
|
||||
tallId = 'cctv-panel',
|
||||
} = {}) {
|
||||
const f = fixture('right');
|
||||
f.options.windowRef.innerHeight = 920;
|
||||
f.options.leftStack.rect.top = 439.6;
|
||||
f.stack.computed.rowGap = '8px';
|
||||
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', {
|
||||
height: 444,
|
||||
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', () => {
|
||||
const f = thrashingRightRail();
|
||||
const f = thrashingRightRail({ tallId: 'news-panel' });
|
||||
f.run();
|
||||
// Header + gap contribute 52 px: need moves 420 -> 450 -> 420.
|
||||
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', () => {
|
||||
const f = thrashingRightRail();
|
||||
const f = thrashingRightRail({ tallId: 'news-panel' });
|
||||
f.options.windowRef.innerHeight = 1000;
|
||||
f.options.leftStack.rect.top = 500;
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const f = thrashingRightRail({ weatherOpen: true });
|
||||
const modes = [];
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,6 +231,18 @@ 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'),
|
||||
);
|
||||
// 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,
|
||||
@@ -221,9 +258,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) =>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -242,3 +242,47 @@ html[dir='rtl'] select.gev-field--compact {
|
||||
html[dir='rtl'] .rail-card-scale {
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -45,5 +45,9 @@
|
||||
@import './src/ui/styles/rtl.css';
|
||||
@import './src/ui/styles/mobile-shell.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/cyber.css';
|
||||
Reference in new issue
Block a user