mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge pull request #1325 from bilawalsidhu/feat/phone-drawer
feat(ui): phone layout with a bottom tab bar and one panel sheet at a time
This commit is contained in:
22 files changed
+1525
-23
No files matched your search
@@ -0,0 +1,4 @@
|
||||
- Add a phone layout for viewports 720px and narrower: the globe stays full
|
||||
screen and a bottom tab bar (Layers, Cameras, Context, Search, More) opens one
|
||||
panel sheet at a time, keeping attribution visible and scene STOP reachable.
|
||||
Adapted from the phone tab-shell in #203 by blearymoth (samehkhamis, #1325).
|
||||
@@ -1379,6 +1379,32 @@ The right rail skips panels hidden by other CSS (computed `display: none`), but
|
||||
keeps the collapsed panels its own exclusive presentation hides, so automatic
|
||||
collapse can still be undone.
|
||||
|
||||
## Phone layout
|
||||
|
||||
At 720px and narrower, `src/ui/mobileShell.js` keeps the globe full screen and
|
||||
shows a bottom tab bar: Layers (Data Layers, with Scenes collapsed below),
|
||||
Cameras (CCTV and Street Level), Context (Global Context, plus Weather and
|
||||
Recent Imagery when shown), Search (Location) and More (Visual Presets and Map
|
||||
Source above Display). A tab opens one bottom sheet; the same tab, the backdrop
|
||||
or Escape closes it, and focus moves into the sheet and back to its tab. Panels
|
||||
are not moved in the DOM: `styles/mobile-shell.css` presents the rails and dock
|
||||
as the sheet, and the dock's Location and Presets trays render inline. The voice
|
||||
control floats above the tab bar while no sheet is open.
|
||||
|
||||
Sheets borrow panels. Opening one expands its panels without persisting them or
|
||||
claiming a user choice, and share links keep reporting each borrowed panel's
|
||||
state from before the sheet; closing puts back what opening changed, while a
|
||||
panel the user changed inside the sheet stays as they left it. Escape closes the
|
||||
sheet before a panel's own Escape handling, and the dock's hover auto-close does
|
||||
not apply inside a sheet.
|
||||
Cockpit, recording and clean view close the sheet and hide the tab bar; scene
|
||||
playback keeps both so its STOP control in the Scenes panel stays reachable. The
|
||||
right rail neither hides collapsed siblings nor applies Cyber's one-panel
|
||||
accordion at these widths, since the sheet chooses what shows. The Cesium and Google attribution stays visible above the
|
||||
voice control, and above an open sheet. The page draws under the notch and
|
||||
system bars (`viewport-fit=cover`) and the shell's chrome uses safe-area insets.
|
||||
Wider viewports are unchanged, and crossing to them closes any open sheet.
|
||||
|
||||
## Surface keyboard lifecycle
|
||||
|
||||
Surface Tab cycling excludes controls with a negative tabindex, native disabled
|
||||
|
||||
+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,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers_clear,light_mode,local_fire_department,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,skip_next,skip_previous,upload_file,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||
<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,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,search,skip_next,skip_previous,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- gev:template scene-chrome -->
|
||||
|
||||
@@ -651,6 +651,8 @@
|
||||
"exports": ["./ui/shell"],
|
||||
"modules": [
|
||||
"src/i18n/marker.js",
|
||||
"src/ui/mobileShell.js",
|
||||
"src/exclusiveSurfaces.js",
|
||||
"src/ui/placeNames.js",
|
||||
"src/hudLayouts.js",
|
||||
"src/hudLayoutPolicy.js",
|
||||
@@ -1905,6 +1907,7 @@
|
||||
"src/data/vesselLabels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/firstRunExperience.js",
|
||||
"src/exclusiveSurfaces.js",
|
||||
"src/hud.js",
|
||||
"src/hudLocality.js",
|
||||
"src/hudLocalityFormat.js",
|
||||
@@ -2192,6 +2195,8 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/mobileShell.js",
|
||||
"src/exclusiveSurfaces.js",
|
||||
"src/ui/localePicker.js",
|
||||
"src/ui/cyberSonarControls.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
|
||||
@@ -370,6 +370,9 @@ try {
|
||||
await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' });
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
// Phones present CCTV in the Cameras tab's sheet.
|
||||
await page.click('#mobile-tab-bar [data-mobile-sheet="cameras"]');
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' });
|
||||
check(
|
||||
'narrow camera panel remains within the viewport',
|
||||
|
||||
@@ -156,14 +156,50 @@ const pressTabs = async (count, backwards = false) => {
|
||||
// The row scrolls smoothly when native Tab brings an edge button into view.
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
};
|
||||
// Phones (≤720px) present the dock's Presets inline in the More tab's sheet.
|
||||
// It opens from the keyboard, as a keyboard user would, so the focus it moves
|
||||
// into the sheet shows a ring.
|
||||
const openPhoneMoreSheet = async () => {
|
||||
const tab = '#mobile-tab-bar [data-mobile-sheet="more"]';
|
||||
if (await page.evaluate(() => document.body.dataset.mobileSheet === 'more')) {
|
||||
await page.focus(tab);
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await page.focus(tab);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
||||
}));
|
||||
};
|
||||
|
||||
// Media-query changes reach the page on a rendering step; wait until the
|
||||
// phone shell agrees with the viewport before measuring.
|
||||
const waitForShellSettled = () => page.waitForFunction(() => (
|
||||
document.body.classList.contains('mobile-shell')
|
||||
=== matchMedia('(max-width: 720px)').matches
|
||||
), { timeout: 10_000, polling: 100 });
|
||||
|
||||
// The dock slides back into place when the layout leaves the phone shell.
|
||||
const waitForDockSettled = () => page.waitForFunction(() => (
|
||||
!document.getElementById('command-dock').getAnimations({ subtree: true })
|
||||
.some((animation) => animation.playState === 'running')
|
||||
), { timeout: 5_000 });
|
||||
|
||||
const checkResponsiveStyleFocus = async (width) => {
|
||||
// The disclosure is only the starting boundary. Every style target below is
|
||||
// reached by actual Tab events, never by focusing the style under test.
|
||||
const selectedMapBefore = await page.evaluate(() => (
|
||||
window.__godsEyeView.styleManager.mapStackController.getActiveId()
|
||||
));
|
||||
await page.focus('#control-panel-toggle');
|
||||
await pressTabs(2); // disclosure -> pin -> Normal
|
||||
if (width <= 720) {
|
||||
// Phones show Presets in the More tab's sheet, which focuses its first
|
||||
// visible control (Normal) when it opens; the tray toggle and pin are
|
||||
// desktop chrome there.
|
||||
await openPhoneMoreSheet();
|
||||
} else {
|
||||
await page.focus('#control-panel-toggle');
|
||||
await pressTabs(2); // disclosure -> pin -> Normal
|
||||
}
|
||||
for (const [index, style] of ['normal', 'thermal', 'snow'].entries()) {
|
||||
if (index) await pressTabs(3);
|
||||
const state = await styleFocusMetrics();
|
||||
@@ -215,7 +251,16 @@ const layerFocusState = async (id) => ({
|
||||
...await page.evaluate((layerId) => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`);
|
||||
const list = document.getElementById('data-toggles');
|
||||
// The list scrolls on its own on desktop; in a phone sheet the sheet
|
||||
// scrolls instead. Measure whichever container actually scrolls.
|
||||
let list = document.getElementById('data-toggles');
|
||||
for (let node = list; node; node = node.parentElement) {
|
||||
const overflow = getComputedStyle(node).overflowY;
|
||||
if ((overflow === 'auto' || overflow === 'scroll') && node.scrollHeight > node.clientHeight) {
|
||||
list = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
lifecycle: manager.getLayerLifecycleState(layerId),
|
||||
feedState: button?.dataset.feedState, label: button?.textContent.trim(),
|
||||
@@ -1146,6 +1191,8 @@ try {
|
||||
|
||||
await page.setViewport({ width: 620, height: 900, deviceScaleFactor: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
await waitForShellSettled();
|
||||
await openPhoneMoreSheet();
|
||||
const at620 = await trayMetrics();
|
||||
check(
|
||||
'620 px tray uses two rows without clipping',
|
||||
@@ -1179,7 +1226,13 @@ try {
|
||||
await page.screenshot({ path: path.join(shotsDir, '480-open.png') });
|
||||
await checkResponsiveStyleFocus(480);
|
||||
|
||||
// Pinning and the compact wing are the narrow desktop dock (721–900 px);
|
||||
// phones use the tab bar instead.
|
||||
await page.setViewport({ width: 800, height: 900, deviceScaleFactor: 1 });
|
||||
await waitForShellSettled();
|
||||
await waitForDockSettled();
|
||||
await page.click('.dock-pin-btn[data-pin-target="control-panel"]');
|
||||
await waitForDockSettled();
|
||||
await page.click('#control-panel-toggle');
|
||||
const cdp = await page.createCDPSession();
|
||||
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
|
||||
@@ -1374,6 +1427,27 @@ try {
|
||||
|
||||
for (const width of [1000, 620, 480]) {
|
||||
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
|
||||
await waitForShellSettled();
|
||||
if (width <= 720) {
|
||||
// Phones present Location as the Search tab's sheet: the keyboard opens
|
||||
// it onto the field, and Escape returns focus to the tab.
|
||||
const searchTab = '#mobile-tab-bar [data-mobile-sheet="search"]';
|
||||
await page.focus(searchTab);
|
||||
await page.keyboard.press('Enter');
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const field = await focusMetrics('#location-search');
|
||||
check(`Search: ${width} px keyboard opens the sheet on the location field`,
|
||||
field.focusedId === 'location-search' && field.visible && field.insideViewport,
|
||||
JSON.stringify(field));
|
||||
await page.screenshot({ path: path.join(shotsDir, `${width}-search-open.png`) });
|
||||
await page.keyboard.press('Escape');
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const tab = await focusMetrics(searchTab);
|
||||
const closedSheet = await page.evaluate(() => !document.body.dataset.mobileSheet);
|
||||
check(`Search: ${width} px Escape closes the sheet with a visible ring on its tab`,
|
||||
closedSheet && hasVisibleControlFocus(tab), JSON.stringify({ closedSheet, tab }));
|
||||
continue;
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('control-panel', true, { persist: false, syncShare: false });
|
||||
@@ -1489,6 +1563,12 @@ try {
|
||||
if (dataSetup.ready) {
|
||||
for (const width of [1000, 620, 480]) {
|
||||
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
|
||||
await waitForShellSettled();
|
||||
if (width <= 720 && !(await page.evaluate(() => document.body.dataset.mobileSheet === 'layers'))) {
|
||||
// Phones present Data Layers in the Layers tab's sheet.
|
||||
await page.focus('#mobile-tab-bar [data-mobile-sheet="layers"]');
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }));
|
||||
// Resizing schedules rail placement on animation frames. Wait for that
|
||||
// pass and its CSS transitions before comparing a focus rectangle with
|
||||
@@ -1549,6 +1629,7 @@ try {
|
||||
)));
|
||||
|
||||
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
|
||||
await waitForShellSettled();
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }));
|
||||
await page.focus('#data-panel .panel-collapse-btn');
|
||||
const transitionTab = await tabTo(`[data-layer-id="${dataSetup.transitionId}"] .data-toggle-btn`);
|
||||
|
||||
@@ -329,6 +329,9 @@ try {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.screenshot({ path: path.join(shots, 'desktop.png') });
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
// Phones present Scenes in the Layers tab's sheet.
|
||||
await page.click('#mobile-tab-bar [data-mobile-sheet="layers"]');
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await page.screenshot({ path: path.join(shots, 'narrow.png') });
|
||||
check(
|
||||
'Scene controls stay inside the narrow viewport',
|
||||
|
||||
@@ -245,6 +245,9 @@ function ownBoxEntries() {
|
||||
const compound = lastCompound(part);
|
||||
if (!ELEMENT_KEYS.some((key) => compound.includes(key))) continue;
|
||||
if (compound.includes('::')) continue; // a pseudo-element is its own box
|
||||
// The phone shell is its own layout; mobileShell.test.mjs pins its
|
||||
// attribution clearance.
|
||||
if (part.startsWith('body.mobile-shell')) continue;
|
||||
entries.push({ rule, part });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Body classes that mean "another surface owns the screen": cockpit, scene
|
||||
* playback, recording and clean view. First run yields to them and the phone
|
||||
* shell closes its sheets for them.
|
||||
*/
|
||||
export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([
|
||||
'cockpit-mode',
|
||||
'scene-playback-mode',
|
||||
'recording-mode',
|
||||
'ui-clean-view',
|
||||
]);
|
||||
@@ -22,6 +22,7 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
||||
// is enthusiasm, not "never show me this again".
|
||||
|
||||
import { t, msg } from './i18n/index.js';
|
||||
import { EXCLUSIVE_SURFACE_CLASSES } from './exclusiveSurfaces.js';
|
||||
|
||||
/** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */
|
||||
export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1';
|
||||
@@ -305,12 +306,7 @@ export async function runFirstRunChoice(
|
||||
* hides the launcher in CSS, and the observer below turns that into a yield —
|
||||
* see the ESC ARBITRATION note on initFirstRunExperience.
|
||||
*/
|
||||
export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([
|
||||
'cockpit-mode',
|
||||
'scene-playback-mode',
|
||||
'recording-mode',
|
||||
'ui-clean-view',
|
||||
]);
|
||||
export { EXCLUSIVE_SURFACE_CLASSES };
|
||||
|
||||
/**
|
||||
* Is some other surface currently claiming the screen?
|
||||
|
||||
@@ -247,6 +247,10 @@ test('no storage is touched from a default parameter position', () => {
|
||||
|
||||
test('the JS and CSS lists of screen-claiming surfaces stay in step', () => {
|
||||
const module = fs.readFileSync(
|
||||
new URL('./exclusiveSurfaces.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const firstRun = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
@@ -279,6 +283,8 @@ test('the JS and CSS lists of screen-claiming surfaces stay in step', () => {
|
||||
module,
|
||||
/export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/,
|
||||
);
|
||||
// First run keeps exporting the one shared list (the phone shell reads it too).
|
||||
assert.match(firstRun, /export \{ EXCLUSIVE_SURFACE_CLASSES \};/);
|
||||
});
|
||||
|
||||
test('exclusiveSurfaceActive reads the live body classes', () => {
|
||||
|
||||
@@ -2943,5 +2943,9 @@
|
||||
"hemisphere|E": "E",
|
||||
"hemisphere|W": "O",
|
||||
"OK": "OK",
|
||||
"{category} ({count})": "{category} ({count})"
|
||||
"{category} ({count})": "{category} ({count})",
|
||||
"Layers": "Capas",
|
||||
"Search": "Buscar",
|
||||
"More": "Más",
|
||||
"Panels": "Paneles"
|
||||
}
|
||||
@@ -3010,5 +3010,9 @@
|
||||
"hemisphere|E": "В",
|
||||
"hemisphere|W": "З",
|
||||
"OK": "НОРМА",
|
||||
"{category} ({count})": "{category} ({count})"
|
||||
"{category} ({count})": "{category} ({count})",
|
||||
"Layers": "Слои",
|
||||
"Search": "Поиск",
|
||||
"More": "Ещё",
|
||||
"Panels": "Панели"
|
||||
}
|
||||
@@ -2809,5 +2809,9 @@
|
||||
"Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Gemini 语音会话有时长限制。会话到期后,请重新启动语音以开始新对话。",
|
||||
"Show the street-level view full screen (Esc shrinks it)": "全屏显示街景视图(按 Esc 缩小)",
|
||||
"OK": "正常",
|
||||
"{category} ({count})": "{category}({count})"
|
||||
"{category} ({count})": "{category}({count})",
|
||||
"Layers": "图层",
|
||||
"Search": "搜索",
|
||||
"More": "更多",
|
||||
"Panels": "面板"
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { ShellFacade } from './shellFacade.js';
|
||||
import { AircraftDisplay } from './aircraftDisplay.js';
|
||||
import { LayerBindings } from './layerBindings.js';
|
||||
import { PanelChrome } from './panelChrome.js';
|
||||
import { initMobileShell } from './mobileShell.js';
|
||||
import { VisualSettings } from './visualSettings.js';
|
||||
import { NavigationController } from './navigationController.js';
|
||||
import { ShareRestoration } from './shareRestoration.js';
|
||||
@@ -124,6 +125,8 @@ export class StyleManager extends ShellFacade {
|
||||
readInitialShare: () => this._initialShareState,
|
||||
readScrollRestoreOwner: () => this._displayPortalScrollRestoreOwner,
|
||||
readDisplayScrollTop: () => this._standardDisplayScrollTop,
|
||||
readSharedCollapsed: (panelId) =>
|
||||
this._mobileShell?.sharedCollapsed(panelId),
|
||||
});
|
||||
this._feedback = new ShellFeedback({
|
||||
readLayers: () => this._dataManager?.getAll?.() || [],
|
||||
@@ -587,6 +590,7 @@ export class StyleManager extends ShellFacade {
|
||||
this._initStreetLevelPanel();
|
||||
this._initGlobalContextPanel();
|
||||
this._initLocationBar();
|
||||
this._initMobileShell();
|
||||
this._initShareButton();
|
||||
this._initCameraOrientationControls();
|
||||
this._initClearSelectedLayersButton();
|
||||
@@ -848,6 +852,19 @@ export class StyleManager extends ShellFacade {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone layout: a bottom tab bar presenting one panel sheet at a time
|
||||
* (src/ui/mobileShell.js). Inactive above 720px.
|
||||
* @returns {void}
|
||||
*/
|
||||
_initMobileShell() {
|
||||
this._mobileShell = initMobileShell({
|
||||
setPanelCollapsed: (panelId, collapsed, options) =>
|
||||
this._panelChrome.setPanelCollapsed(panelId, collapsed, options),
|
||||
onSheetsClosed: () => this._syncShareState(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the owner-approved map stack chip row from the matching controller
|
||||
* entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable,
|
||||
@@ -1707,6 +1724,7 @@ export class StyleManager extends ShellFacade {
|
||||
this._localePicker?.destroy();
|
||||
this._localePicker = null;
|
||||
this._recording.destroy();
|
||||
this._mobileShell?.destroy();
|
||||
this._panelChrome.destroy();
|
||||
this._feedback.destroy();
|
||||
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Phone shell for narrow viewports (≤720px). The globe stays full screen and a
|
||||
* bottom tab bar opens one sheet at a time; each sheet shows existing panels,
|
||||
* which stay where they are in the rails and dock while CSS presents them
|
||||
* (styles/mobile-shell.css). Desktop never sees the class or the sheet state.
|
||||
*
|
||||
* Sheets borrow panels: the panels a sheet expands are put back exactly as
|
||||
* they were when the sheets close, and nothing a sheet does is persisted or
|
||||
* treated as the user's own panel choice.
|
||||
*
|
||||
* Adapted from the phone tab-shell proposed in public PR #203 by blearymoth.
|
||||
*/
|
||||
|
||||
import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js';
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
export const MOBILE_SHELL_MEDIA_QUERY = '(max-width: 720px)';
|
||||
export const MOBILE_SHELL_CLASS = 'mobile-shell';
|
||||
export const MOBILE_SHEET_ATTR = 'data-mobile-sheet';
|
||||
|
||||
/**
|
||||
* Panels each sheet expands. A sheet also shows its rail's other panels
|
||||
* collapsed (Scenes under Layers), per styles/mobile-shell.css.
|
||||
*/
|
||||
export const MOBILE_SHEET_PANELS = Object.freeze({
|
||||
layers: Object.freeze(['data-panel']),
|
||||
cameras: Object.freeze(['cctv-panel', 'street-level-panel']),
|
||||
context: Object.freeze(['global-context-panel']),
|
||||
search: Object.freeze(['location-bar']),
|
||||
more: Object.freeze(['control-panel', 'pp-toggles']),
|
||||
});
|
||||
|
||||
export const MOBILE_TABS = Object.freeze([
|
||||
Object.freeze({ sheet: 'layers', label: msg('Layers'), icon: 'layers' }),
|
||||
Object.freeze({ sheet: 'cameras', label: msg('Cameras'), icon: 'videocam' }),
|
||||
Object.freeze({ sheet: 'context', label: msg('Context'), icon: 'radar' }),
|
||||
Object.freeze({ sheet: 'search', label: msg('Search'), icon: 'search' }),
|
||||
Object.freeze({ sheet: 'more', label: msg('More'), icon: 'more_horiz' }),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Surfaces that own the screen close the open sheet and keep sheets closed.
|
||||
* Scene playback is the exception: its STOP control lives in the Scenes panel
|
||||
* (Layers sheet), and a phone has no Escape key to stop it otherwise.
|
||||
*/
|
||||
const SHEET_BLOCKING_CLASSES = Object.freeze(
|
||||
EXCLUSIVE_SURFACE_CLASSES.filter((name) => name !== 'scene-playback-mode'),
|
||||
);
|
||||
|
||||
/** Every panel any sheet borrows; the dock trays close each other. */
|
||||
const BORROWED_PANELS = Object.freeze([
|
||||
...new Set(Object.values(MOBILE_SHEET_PANELS).flat()),
|
||||
]);
|
||||
|
||||
const BORROW_OPTIONS = Object.freeze({ persist: false, syncShare: false });
|
||||
|
||||
/**
|
||||
* @param {string} panelId
|
||||
* @returns {string|null} The sheet that presents `panelId`.
|
||||
*/
|
||||
export function sheetForPanelId(panelId) {
|
||||
for (const [sheet, ids] of Object.entries(MOBILE_SHEET_PANELS)) {
|
||||
if (ids.includes(panelId)) return sheet;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet after a tab press: the same tab closes its sheet, another opens.
|
||||
* @param {string|null} current
|
||||
* @param {string|null} pressed
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function nextSheet(current, pressed) {
|
||||
if (!pressed || !Object.hasOwn(MOBILE_SHEET_PANELS, pressed)) return null;
|
||||
return current === pressed ? null : pressed;
|
||||
}
|
||||
|
||||
function sheetBlocked(body) {
|
||||
return SHEET_BLOCKING_CLASSES.some((name) => body.classList.contains(name));
|
||||
}
|
||||
|
||||
function currentSheetOf(body) {
|
||||
const value = body.getAttribute(MOBILE_SHEET_ATTR);
|
||||
return value && Object.hasOwn(MOBILE_SHEET_PANELS, value) ? value : null;
|
||||
}
|
||||
|
||||
function buildChrome(documentRef) {
|
||||
const backdrop = documentRef.createElement('div');
|
||||
backdrop.id = 'mobile-sheet-backdrop';
|
||||
backdrop.hidden = true;
|
||||
|
||||
const tabBar = documentRef.createElement('nav');
|
||||
tabBar.id = 'mobile-tab-bar';
|
||||
tabBar.setAttribute('aria-label', t('Panels'));
|
||||
for (const tab of MOBILE_TABS) {
|
||||
const button = documentRef.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'mobile-tab';
|
||||
button.dataset.mobileSheet = tab.sheet;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
const icon = documentRef.createElement('span');
|
||||
icon.className = 'material-symbols-outlined mobile-tab-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = tab.icon;
|
||||
const label = documentRef.createElement('span');
|
||||
label.className = 'mobile-tab-label';
|
||||
label.textContent = t(tab.label);
|
||||
button.append(icon, label);
|
||||
tabBar.append(button);
|
||||
}
|
||||
documentRef.body.append(backdrop, tabBar);
|
||||
return { backdrop, tabBar };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the phone shell: the media gate, tab bar, backdrop, Escape, focus,
|
||||
* and closing for full-screen modes.
|
||||
* @param {object} input
|
||||
* @param {(panelId: string, collapsed: boolean, options?: object) => void} input.setPanelCollapsed
|
||||
* @param {Document} [input.documentRef]
|
||||
* @param {(query: string) => MediaQueryList} [input.matchMedia]
|
||||
* @param {typeof MutationObserver} [input.MutationObserverImpl]
|
||||
* @param {typeof ResizeObserver} [input.ResizeObserverImpl]
|
||||
* @param {(callback: () => void) => number} [input.requestFrame]
|
||||
* @param {() => void} [input.onSheetsClosed] Runs after the last sheet closes
|
||||
* and its panels are put back (e.g. to refresh the share link).
|
||||
* @returns {{ destroy: () => void, setSheet: (sheet: string|null) => void, getSheet: () => string|null, sharedCollapsed: (panelId: string) => boolean|undefined }}
|
||||
*/
|
||||
export function initMobileShell({
|
||||
setPanelCollapsed,
|
||||
documentRef = globalThis.document,
|
||||
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
||||
MutationObserverImpl = globalThis.MutationObserver,
|
||||
ResizeObserverImpl = globalThis.ResizeObserver,
|
||||
requestFrame = globalThis.requestAnimationFrame?.bind(globalThis),
|
||||
onSheetsClosed = () => {},
|
||||
} = {}) {
|
||||
const body = documentRef.body;
|
||||
const { backdrop, tabBar } = buildChrome(documentRef);
|
||||
const viewport = documentRef.querySelector('meta[name="viewport"]');
|
||||
const viewportContent = viewport?.getAttribute('content') ?? null;
|
||||
const removers = [];
|
||||
const listen = (target, type, handler, options) => {
|
||||
target.addEventListener(type, handler, options);
|
||||
removers.push(() => target.removeEventListener(type, handler, options));
|
||||
};
|
||||
const element = (id) => documentRef.getElementById(id);
|
||||
const rails = ['left-panel-stack', 'right-context-rail']
|
||||
.map(element)
|
||||
.filter(Boolean);
|
||||
const dock = element('command-dock');
|
||||
const shown = (node) =>
|
||||
node && globalThis.getComputedStyle?.(node).display !== 'none';
|
||||
|
||||
/**
|
||||
* Panel states from before the first sheet opened (`before`) and right after
|
||||
* the open sheet was set up (`applied`); null when closed.
|
||||
*/
|
||||
let borrowed = null;
|
||||
let returnFocus = null;
|
||||
const written = new Map();
|
||||
|
||||
const writeVar = (name, value) => {
|
||||
if (written.get(name) === value) return;
|
||||
written.set(name, value);
|
||||
body.style.setProperty(name, value);
|
||||
};
|
||||
|
||||
// The dock (Presets, Search) sits on top of whichever rail is the sheet,
|
||||
// and the required map attribution sits above everything the sheet covers.
|
||||
const measureSheet = () => {
|
||||
if (!body.classList.contains(MOBILE_SHELL_CLASS)) return;
|
||||
const height = Math.max(
|
||||
0,
|
||||
...rails.map((rail) =>
|
||||
shown(rail) ? rail.getBoundingClientRect().height : 0,
|
||||
),
|
||||
);
|
||||
writeVar('--mobile-sheet-height', `${Math.round(height)}px`);
|
||||
const viewportHeight = globalThis.innerHeight || 0;
|
||||
const cover = Math.max(
|
||||
0,
|
||||
...[...rails, currentSheetOf(body) ? dock : null]
|
||||
.filter(shown)
|
||||
.map((node) => viewportHeight - node.getBoundingClientRect().top),
|
||||
);
|
||||
writeVar('--mobile-sheet-cover', `${Math.round(cover)}px`);
|
||||
};
|
||||
|
||||
const resize =
|
||||
typeof ResizeObserverImpl === 'function'
|
||||
? new ResizeObserverImpl(measureSheet)
|
||||
: null;
|
||||
removers.push(() => resize?.disconnect());
|
||||
|
||||
const isCollapsed = (panelId) =>
|
||||
element(panelId)?.classList.contains('collapsed') ?? true;
|
||||
// The user's preference, as share links record it: a collapse that layout
|
||||
// applies only for space (Tactical, Cyber) still counts as expanded.
|
||||
const prefersCollapsed = (panelId) => {
|
||||
const classes = element(panelId)?.classList;
|
||||
if (!classes) return true;
|
||||
return (
|
||||
classes.contains('collapsed') &&
|
||||
!classes.contains('layout-auto-collapsed') &&
|
||||
!classes.contains('cyber-accordion-collapsed')
|
||||
);
|
||||
};
|
||||
|
||||
const panelStates = () =>
|
||||
new Map(
|
||||
BORROWED_PANELS.map((panelId) => [panelId, prefersCollapsed(panelId)]),
|
||||
);
|
||||
|
||||
// Undo only what opening the sheet did (including side effects, such as dock
|
||||
// trays closing each other). A panel the user changed since stays as they
|
||||
// left it, and becomes the state later sheets put back.
|
||||
const restoreBorrowed = () => {
|
||||
if (!borrowed) return;
|
||||
for (const [panelId, before] of borrowed.before) {
|
||||
const now = prefersCollapsed(panelId);
|
||||
if (now !== borrowed.applied.get(panelId))
|
||||
borrowed.before.set(panelId, now);
|
||||
else if (now !== before)
|
||||
setPanelCollapsed(panelId, before, BORROW_OPTIONS);
|
||||
}
|
||||
};
|
||||
|
||||
const present = (sheet) => {
|
||||
if (sheet) body.setAttribute(MOBILE_SHEET_ATTR, sheet);
|
||||
else body.removeAttribute(MOBILE_SHEET_ATTR);
|
||||
backdrop.hidden = !sheet;
|
||||
for (const button of tabBar.querySelectorAll('[data-mobile-sheet]'))
|
||||
button.setAttribute(
|
||||
'aria-expanded',
|
||||
String(button.dataset.mobileSheet === sheet),
|
||||
);
|
||||
measureSheet();
|
||||
};
|
||||
|
||||
/** The first visible control of the sheet's first panel, for focus on open. */
|
||||
const sheetFocusTarget = (sheet) => {
|
||||
if (sheet === 'search') return element('location-search');
|
||||
const panel = element(MOBILE_SHEET_PANELS[sheet][0]);
|
||||
const controls =
|
||||
panel?.querySelectorAll?.(
|
||||
'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
) ?? [];
|
||||
// Sheets hide some panel chrome (tray toggles, pins) that stays in the
|
||||
// DOM, and a panel's own collapse control is a poor first stop.
|
||||
return (
|
||||
[...controls].find(
|
||||
(control) =>
|
||||
control.getClientRects().length > 0 &&
|
||||
!control.matches?.('.panel-collapse-btn'),
|
||||
) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const setSheet = (requested) => {
|
||||
if (!body.classList.contains(MOBILE_SHELL_CLASS)) return;
|
||||
const sheet = sheetBlocked(body) ? null : requested;
|
||||
const previous = currentSheetOf(body);
|
||||
if (previous === sheet) return;
|
||||
if (!previous) {
|
||||
borrowed = { before: panelStates(), applied: panelStates() };
|
||||
returnFocus = documentRef.activeElement ?? null;
|
||||
} else {
|
||||
if (previous === 'search') element('location-search')?.blur?.();
|
||||
restoreBorrowed();
|
||||
}
|
||||
present(sheet);
|
||||
if (!sheet) {
|
||||
borrowed = null;
|
||||
onSheetsClosed();
|
||||
const focus = returnFocus;
|
||||
returnFocus = null;
|
||||
if (focus && focus !== body && body.contains?.(focus)) focus.focus?.();
|
||||
return;
|
||||
}
|
||||
for (const panelId of MOBILE_SHEET_PANELS[sheet])
|
||||
if (isCollapsed(panelId))
|
||||
setPanelCollapsed(panelId, false, BORROW_OPTIONS);
|
||||
borrowed.applied = panelStates();
|
||||
// Rail layout settles on a later frame; what it does is part of opening.
|
||||
const opening = (borrowed.opening ?? 0) + 1;
|
||||
borrowed.opening = opening;
|
||||
const opened = borrowed;
|
||||
requestFrame?.(() => {
|
||||
if (borrowed === opened && opened.opening === opening)
|
||||
opened.applied = panelStates();
|
||||
});
|
||||
measureSheet();
|
||||
sheetFocusTarget(sheet)?.focus?.();
|
||||
};
|
||||
|
||||
const tabFor = (sheet) =>
|
||||
[...tabBar.querySelectorAll('[data-mobile-sheet]')].find(
|
||||
(button) => button.dataset.mobileSheet === sheet,
|
||||
);
|
||||
|
||||
const onMedia = (matches) => {
|
||||
if (matches) {
|
||||
body.classList.add(MOBILE_SHELL_CLASS);
|
||||
// Lets the page draw under the notch and system bars; the shell's CSS
|
||||
// keeps its chrome clear of them.
|
||||
if (viewport && !/viewport-fit\s*=/.test(viewportContent || ''))
|
||||
viewport.setAttribute(
|
||||
'content',
|
||||
`${viewportContent || ''}, viewport-fit=cover`,
|
||||
);
|
||||
for (const node of [...rails, dock].filter(Boolean))
|
||||
resize?.observe(node);
|
||||
measureSheet();
|
||||
} else {
|
||||
// Crossing to desktop closes the sheet, which puts its panels back.
|
||||
setSheet(null);
|
||||
body.classList.remove(MOBILE_SHELL_CLASS);
|
||||
present(null);
|
||||
resize?.disconnect();
|
||||
if (viewport && viewportContent !== null)
|
||||
viewport.setAttribute('content', viewportContent);
|
||||
}
|
||||
tabBar.setAttribute('aria-hidden', String(!matches));
|
||||
};
|
||||
|
||||
const media =
|
||||
typeof matchMedia === 'function'
|
||||
? matchMedia(MOBILE_SHELL_MEDIA_QUERY)
|
||||
: null;
|
||||
onMedia(Boolean(media?.matches));
|
||||
const onChange = (event) => onMedia(event.matches);
|
||||
if (media?.addEventListener) {
|
||||
media.addEventListener('change', onChange);
|
||||
removers.push(() => media.removeEventListener('change', onChange));
|
||||
} else if (media?.addListener) {
|
||||
// Safari before 14 only offers the legacy listener API.
|
||||
media.addListener(onChange);
|
||||
removers.push(() => media.removeListener(onChange));
|
||||
}
|
||||
|
||||
listen(tabBar, 'click', (event) => {
|
||||
const button = event.target?.closest?.('[data-mobile-sheet]');
|
||||
if (!button) return;
|
||||
event.preventDefault();
|
||||
const sheet = nextSheet(currentSheetOf(body), button.dataset.mobileSheet);
|
||||
// Closing from the tab bar returns focus to the tab that was pressed.
|
||||
if (!sheet) returnFocus = button;
|
||||
setSheet(sheet);
|
||||
});
|
||||
listen(backdrop, 'click', () => setSheet(null));
|
||||
// Bubble phase, so fields cancelling an edit, lightboxes, viewers and tools
|
||||
// take Escape first; panels yield it to the sheet (PanelChrome), and an open
|
||||
// dialog keeps its own native close.
|
||||
listen(documentRef, 'keydown', (event) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
||||
const sheet = currentSheetOf(body);
|
||||
if (!sheet || documentRef.querySelector('dialog[open]')) return;
|
||||
event.preventDefault();
|
||||
returnFocus = tabFor(sheet) ?? returnFocus;
|
||||
setSheet(null);
|
||||
});
|
||||
|
||||
if (typeof MutationObserverImpl === 'function') {
|
||||
const observer = new MutationObserverImpl(() => {
|
||||
if (currentSheetOf(body) && sheetBlocked(body)) setSheet(null);
|
||||
});
|
||||
observer.observe(body, { attributes: true, attributeFilter: ['class'] });
|
||||
removers.push(() => observer.disconnect());
|
||||
}
|
||||
|
||||
return {
|
||||
setSheet,
|
||||
getSheet: () => currentSheetOf(body),
|
||||
/**
|
||||
* The collapsed state to share for a panel a sheet borrowed: its state
|
||||
* before the sheets, or the user's own change since. Undefined otherwise.
|
||||
*/
|
||||
sharedCollapsed(panelId) {
|
||||
if (!borrowed?.before.has(panelId)) return undefined;
|
||||
const now = prefersCollapsed(panelId);
|
||||
return now !== borrowed.applied.get(panelId)
|
||||
? now
|
||||
: borrowed.before.get(panelId);
|
||||
},
|
||||
destroy() {
|
||||
setSheet(null);
|
||||
for (const remove of removers.splice(0)) remove();
|
||||
body.classList.remove(MOBILE_SHELL_CLASS);
|
||||
body.removeAttribute(MOBILE_SHEET_ATTR);
|
||||
body.style.removeProperty('--mobile-sheet-height');
|
||||
body.style.removeProperty('--mobile-sheet-cover');
|
||||
if (viewport && viewportContent !== null)
|
||||
viewport.setAttribute('content', viewportContent);
|
||||
backdrop.remove();
|
||||
tabBar.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
MOBILE_SHEET_PANELS,
|
||||
MOBILE_TABS,
|
||||
initMobileShell,
|
||||
nextSheet,
|
||||
sheetForPanelId,
|
||||
} from './mobileShell.js';
|
||||
|
||||
function element(tag, document) {
|
||||
const node = new EventTarget();
|
||||
const attributes = new Map();
|
||||
const classes = new Set();
|
||||
Object.assign(node, {
|
||||
tagName: tag.toUpperCase(),
|
||||
children: [],
|
||||
dataset: {},
|
||||
hidden: false,
|
||||
textContent: '',
|
||||
className: '',
|
||||
parent: null,
|
||||
classList: {
|
||||
add: (...names) => names.forEach((name) => classes.add(name)),
|
||||
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
||||
contains: (name) => classes.has(name),
|
||||
},
|
||||
style: {
|
||||
values: new Map(),
|
||||
setProperty(name, value) {
|
||||
this.values.set(name, value);
|
||||
},
|
||||
removeProperty(name) {
|
||||
this.values.delete(name);
|
||||
},
|
||||
},
|
||||
setAttribute: (key, value) => attributes.set(key, String(value)),
|
||||
getAttribute: (key) => attributes.get(key) ?? null,
|
||||
removeAttribute: (key) => attributes.delete(key),
|
||||
append(...children) {
|
||||
for (const child of children) {
|
||||
child.parent = node;
|
||||
node.children.push(child);
|
||||
if (child.id) document.ids.set(child.id, child);
|
||||
}
|
||||
},
|
||||
remove() {
|
||||
const siblings = node.parent?.children;
|
||||
siblings?.splice(siblings.indexOf(node), 1);
|
||||
document.ids.delete(node.id);
|
||||
},
|
||||
contains(other) {
|
||||
for (let current = other; current; current = current.parent)
|
||||
if (current === node) return true;
|
||||
return false;
|
||||
},
|
||||
closest(selector) {
|
||||
for (let current = node; current; current = current.parent)
|
||||
if (selector === '[data-mobile-sheet]' && current.dataset.mobileSheet)
|
||||
return current;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
const found = [];
|
||||
const matches = (child) =>
|
||||
selector === '[data-mobile-sheet]'
|
||||
? Boolean(child.dataset.mobileSheet)
|
||||
: child.tagName === 'BUTTON';
|
||||
const walk = (parent) => {
|
||||
for (const child of parent.children) {
|
||||
if (matches(child)) found.push(child);
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(node);
|
||||
return found;
|
||||
},
|
||||
getBoundingClientRect: () => ({ top: 0, height: 0 }),
|
||||
getClientRects: () => (node.hidden ? [] : [{}]),
|
||||
matches: (selector) =>
|
||||
selector === '.panel-collapse-btn' &&
|
||||
node.className.split(' ').includes('panel-collapse-btn'),
|
||||
focus() {
|
||||
document.activeElement = node;
|
||||
},
|
||||
blur() {
|
||||
if (document.activeElement === node)
|
||||
document.activeElement = document.body;
|
||||
},
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
const PANEL_IDS = [...new Set(Object.values(MOBILE_SHEET_PANELS).flat())];
|
||||
|
||||
function fixture({ mobile = true, expanded = [] } = {}) {
|
||||
const document = new EventTarget();
|
||||
document.ids = new Map();
|
||||
document.createElement = (tag) => element(tag, document);
|
||||
document.getElementById = (id) => document.ids.get(id) ?? null;
|
||||
const viewport = element('meta', document);
|
||||
viewport.setAttribute('content', 'width=device-width, initial-scale=1.0');
|
||||
document.querySelector = (selector) =>
|
||||
selector === 'meta[name="viewport"]'
|
||||
? viewport
|
||||
: selector === 'dialog[open]' && document.openDialog
|
||||
? {}
|
||||
: null;
|
||||
document.body = element('body', document);
|
||||
document.activeElement = document.body;
|
||||
const search = element('input', document);
|
||||
search.id = 'location-search';
|
||||
document.body.append(search);
|
||||
for (const id of PANEL_IDS) {
|
||||
const panel = element('section', document);
|
||||
panel.id = id;
|
||||
if (!expanded.includes(id)) panel.classList.add('collapsed');
|
||||
// A hidden tray toggle and a collapse control first, like the panels'
|
||||
// own chrome in a sheet.
|
||||
const toggle = element('button', document);
|
||||
toggle.hidden = true;
|
||||
const collapse = element('button', document);
|
||||
collapse.className = 'panel-collapse-btn';
|
||||
panel.append(toggle, collapse, element('button', document));
|
||||
document.body.append(panel);
|
||||
}
|
||||
|
||||
const media = new EventTarget();
|
||||
media.matches = mobile;
|
||||
const calls = [];
|
||||
const frames = [];
|
||||
let closedCount = 0;
|
||||
let observed = null;
|
||||
class FakeMutationObserver {
|
||||
constructor(callback) {
|
||||
observed = callback;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {
|
||||
observed = null;
|
||||
}
|
||||
}
|
||||
const shell = initMobileShell({
|
||||
documentRef: document,
|
||||
matchMedia: () => media,
|
||||
MutationObserverImpl: FakeMutationObserver,
|
||||
ResizeObserverImpl: null,
|
||||
requestFrame: (callback) => frames.push(callback),
|
||||
onSheetsClosed: () => {
|
||||
closedCount += 1;
|
||||
},
|
||||
setPanelCollapsed: (panelId, collapsed, options) => {
|
||||
calls.push({ panelId, collapsed, ...options });
|
||||
const panel = document.getElementById(panelId);
|
||||
if (collapsed) panel.classList.add('collapsed');
|
||||
else panel.classList.remove('collapsed');
|
||||
},
|
||||
});
|
||||
const tab = (sheet) =>
|
||||
document
|
||||
.getElementById('mobile-tab-bar')
|
||||
.children.find((button) => button.dataset.mobileSheet === sheet);
|
||||
const press = (sheet) => {
|
||||
const event = new Event('click', { cancelable: true });
|
||||
Object.defineProperty(event, 'target', { value: tab(sheet) });
|
||||
document.getElementById('mobile-tab-bar').dispatchEvent(event);
|
||||
};
|
||||
const resize = (matches) => {
|
||||
media.matches = matches;
|
||||
const event = new Event('change');
|
||||
event.matches = matches;
|
||||
media.dispatchEvent(event);
|
||||
};
|
||||
const setBodyClass = (name) => {
|
||||
document.body.classList.add(name);
|
||||
observed?.();
|
||||
};
|
||||
const collapsed = (id) =>
|
||||
document.getElementById(id).classList.contains('collapsed');
|
||||
const runFrames = () => frames.splice(0).forEach((callback) => callback());
|
||||
return {
|
||||
document,
|
||||
shell,
|
||||
calls,
|
||||
runFrames,
|
||||
closedCount: () => closedCount,
|
||||
press,
|
||||
tab,
|
||||
resize,
|
||||
viewport,
|
||||
search,
|
||||
setBodyClass,
|
||||
collapsed,
|
||||
};
|
||||
}
|
||||
|
||||
test('every tab names a sheet and every sheet panel maps back to its sheet', () => {
|
||||
assert.deepEqual(
|
||||
MOBILE_TABS.map(({ sheet }) => sheet),
|
||||
Object.keys(MOBILE_SHEET_PANELS),
|
||||
);
|
||||
for (const [sheet, panels] of Object.entries(MOBILE_SHEET_PANELS))
|
||||
for (const panelId of panels) assert.equal(sheetForPanelId(panelId), sheet);
|
||||
assert.equal(sheetForPanelId('unknown-panel'), null);
|
||||
});
|
||||
|
||||
test('pressing a tab opens its sheet, pressing it again closes it', () => {
|
||||
assert.equal(nextSheet(null, 'layers'), 'layers');
|
||||
assert.equal(nextSheet('layers', 'layers'), null);
|
||||
assert.equal(nextSheet('layers', 'more'), 'more');
|
||||
assert.equal(nextSheet('layers', 'nonsense'), null);
|
||||
assert.equal(nextSheet(null, 'constructor'), null);
|
||||
});
|
||||
|
||||
test('one sheet at a time, presented with aria-expanded and a backdrop', () => {
|
||||
const { document, shell, press, tab } = fixture();
|
||||
assert.equal(document.body.classList.contains('mobile-shell'), true);
|
||||
press('layers');
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
assert.equal(document.body.getAttribute('data-mobile-sheet'), 'layers');
|
||||
assert.equal(tab('layers').getAttribute('aria-expanded'), 'true');
|
||||
assert.equal(document.getElementById('mobile-sheet-backdrop').hidden, false);
|
||||
press('more');
|
||||
assert.equal(shell.getSheet(), 'more');
|
||||
assert.equal(tab('layers').getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(tab('more').getAttribute('aria-expanded'), 'true');
|
||||
press('more');
|
||||
assert.equal(shell.getSheet(), null);
|
||||
assert.equal(document.body.getAttribute('data-mobile-sheet'), null);
|
||||
assert.equal(document.getElementById('mobile-sheet-backdrop').hidden, true);
|
||||
});
|
||||
|
||||
test('sheets borrow panels and put them back exactly as they were', () => {
|
||||
// The user left Data Layers and Presets open on desktop.
|
||||
const kept = ['data-panel', 'control-panel'];
|
||||
const { calls, press, collapsed } = fixture({ expanded: kept });
|
||||
press('cameras');
|
||||
assert.equal(collapsed('cctv-panel'), false);
|
||||
assert.equal(collapsed('street-level-panel'), false);
|
||||
press('search');
|
||||
assert.equal(collapsed('cctv-panel'), true, 'Cameras put back on switch');
|
||||
assert.equal(collapsed('location-bar'), false);
|
||||
press('search');
|
||||
for (const id of PANEL_IDS)
|
||||
assert.equal(collapsed(id), !kept.includes(id), `${id} is back as it was`);
|
||||
// Nothing a sheet does is the user's choice, persisted or shared.
|
||||
assert.ok(calls.length > 0);
|
||||
for (const call of calls) {
|
||||
assert.equal(call.explicit, undefined, `${call.panelId} is not explicit`);
|
||||
assert.equal(call.persist, false);
|
||||
assert.equal(call.syncShare, false);
|
||||
}
|
||||
// Already-open panels are not touched.
|
||||
assert.ok(!calls.some(({ panelId }) => kept.includes(panelId)));
|
||||
});
|
||||
|
||||
test('a panel the user changes inside a sheet stays as they left it', () => {
|
||||
const { press, collapsed, document } = fixture();
|
||||
press('cameras');
|
||||
// The user collapses CCTV inside the sheet (their own, persisted choice).
|
||||
document.getElementById('cctv-panel').classList.add('collapsed');
|
||||
press('cameras');
|
||||
assert.equal(collapsed('cctv-panel'), true, 'their collapse is kept');
|
||||
assert.equal(
|
||||
collapsed('street-level-panel'),
|
||||
true,
|
||||
'the borrowed one is put back',
|
||||
);
|
||||
|
||||
// Expanding one in a sheet is kept too, and later sheets respect it.
|
||||
press('layers');
|
||||
document.getElementById('cctv-panel').classList.remove('collapsed');
|
||||
press('more');
|
||||
press('more');
|
||||
assert.equal(collapsed('cctv-panel'), false);
|
||||
});
|
||||
|
||||
test('focus moves into the sheet and returns to the tab on close', () => {
|
||||
const { document, press, search, tab } = fixture();
|
||||
press('search');
|
||||
assert.equal(document.activeElement, search);
|
||||
press('search');
|
||||
assert.equal(document.activeElement, tab('search'));
|
||||
|
||||
press('layers');
|
||||
const dataPanel = document.getElementById('data-panel');
|
||||
// The first visible control that is not the panel's own collapse button.
|
||||
assert.equal(document.activeElement, dataPanel.children[2]);
|
||||
const escape = new Event('keydown', { cancelable: true });
|
||||
escape.key = 'Escape';
|
||||
document.dispatchEvent(escape);
|
||||
assert.equal(escape.defaultPrevented, true);
|
||||
assert.equal(document.activeElement, tab('layers'));
|
||||
});
|
||||
|
||||
test('the share link keeps the state from before the sheets', () => {
|
||||
const { shell, press, document, closedCount } = fixture({
|
||||
expanded: ['street-level-panel'],
|
||||
});
|
||||
assert.equal(shell.sharedCollapsed('data-panel'), undefined);
|
||||
press('layers');
|
||||
// Data Layers is open only because the sheet borrowed it.
|
||||
assert.equal(shell.sharedCollapsed('data-panel'), true);
|
||||
assert.equal(shell.sharedCollapsed('street-level-panel'), false);
|
||||
assert.equal(shell.sharedCollapsed('unrelated-panel'), undefined);
|
||||
// A user change inside the sheet is theirs to share.
|
||||
document.getElementById('cctv-panel').classList.remove('collapsed');
|
||||
assert.equal(shell.sharedCollapsed('cctv-panel'), false);
|
||||
press('more');
|
||||
assert.equal(closedCount(), 0, 'switching sheets is not closing');
|
||||
press('more');
|
||||
assert.equal(closedCount(), 1, 'closing refreshes the share link once');
|
||||
assert.equal(shell.sharedCollapsed('data-panel'), undefined);
|
||||
});
|
||||
|
||||
test('Escape closes the sheet only when nothing else took it', () => {
|
||||
const { document, shell, press } = fixture();
|
||||
const escape = () => {
|
||||
const event = new Event('keydown', { cancelable: true, bubbles: true });
|
||||
event.key = 'Escape';
|
||||
return event;
|
||||
};
|
||||
press('layers');
|
||||
// A field cancelling its edit (or a lightbox closing) handles it first.
|
||||
const handled = escape();
|
||||
handled.preventDefault();
|
||||
document.dispatchEvent(handled);
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
// An open dialog keeps Escape for its native close.
|
||||
document.openDialog = true;
|
||||
document.dispatchEvent(escape());
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
document.openDialog = false;
|
||||
const plain = escape();
|
||||
document.dispatchEvent(plain);
|
||||
assert.equal(shell.getSheet(), null);
|
||||
assert.equal(plain.defaultPrevented, true);
|
||||
});
|
||||
|
||||
test('share state and restores follow the preference, not layout collapse', () => {
|
||||
const { shell, press, collapsed, document } = fixture();
|
||||
// Layout collapsed Street Level only for space; the user left it open.
|
||||
const street = document.getElementById('street-level-panel');
|
||||
street.classList.add('layout-auto-collapsed');
|
||||
press('cameras');
|
||||
assert.equal(shell.sharedCollapsed('street-level-panel'), false);
|
||||
press('cameras');
|
||||
assert.equal(
|
||||
collapsed('street-level-panel'),
|
||||
false,
|
||||
'not turned into a real collapse',
|
||||
);
|
||||
});
|
||||
|
||||
test('layout settling after a sheet opens is part of opening', () => {
|
||||
const { press, runFrames, collapsed, document } = fixture({
|
||||
expanded: ['street-level-panel'],
|
||||
});
|
||||
press('cameras');
|
||||
// A rail layout pass on the next frame collapses Street Level.
|
||||
document.getElementById('street-level-panel').classList.add('collapsed');
|
||||
runFrames();
|
||||
press('cameras');
|
||||
assert.equal(collapsed('street-level-panel'), false, 'put back as it was');
|
||||
});
|
||||
|
||||
test('the backdrop closes the open sheet', () => {
|
||||
const { document, shell, press } = fixture();
|
||||
press('context');
|
||||
document
|
||||
.getElementById('mobile-sheet-backdrop')
|
||||
.dispatchEvent(new Event('click'));
|
||||
assert.equal(shell.getSheet(), null);
|
||||
});
|
||||
|
||||
test('surfaces that own the screen close sheets; scene playback does not', () => {
|
||||
for (const mode of ['cockpit-mode', 'recording-mode', 'ui-clean-view']) {
|
||||
const { shell, press, setBodyClass, collapsed } = fixture();
|
||||
press('layers');
|
||||
setBodyClass(mode);
|
||||
assert.equal(shell.getSheet(), null, `${mode} closes the sheet`);
|
||||
assert.equal(collapsed('data-panel'), true, `${mode} puts panels back`);
|
||||
press('layers');
|
||||
assert.equal(shell.getSheet(), null, `${mode} keeps sheets closed`);
|
||||
}
|
||||
// Scene STOP lives in the Scenes panel of the Layers sheet.
|
||||
const playback = fixture();
|
||||
playback.setBodyClass('scene-playback-mode');
|
||||
playback.press('layers');
|
||||
assert.equal(playback.shell.getSheet(), 'layers');
|
||||
});
|
||||
|
||||
test('desktop widths never see the shell; crossing to desktop restores it', () => {
|
||||
const desktop = fixture({ mobile: false });
|
||||
assert.equal(desktop.document.body.classList.contains('mobile-shell'), false);
|
||||
desktop.press('layers');
|
||||
assert.equal(desktop.shell.getSheet(), null);
|
||||
assert.equal(
|
||||
desktop.viewport.getAttribute('content'),
|
||||
'width=device-width, initial-scale=1.0',
|
||||
);
|
||||
|
||||
const phone = fixture({ expanded: ['street-level-panel'] });
|
||||
assert.match(phone.viewport.getAttribute('content'), /viewport-fit=cover$/);
|
||||
phone.press('cameras');
|
||||
phone.resize(false);
|
||||
assert.equal(phone.shell.getSheet(), null);
|
||||
assert.equal(phone.document.body.classList.contains('mobile-shell'), false);
|
||||
assert.equal(phone.collapsed('cctv-panel'), true);
|
||||
assert.equal(phone.collapsed('street-level-panel'), false);
|
||||
assert.equal(
|
||||
phone.viewport.getAttribute('content'),
|
||||
'width=device-width, initial-scale=1.0',
|
||||
);
|
||||
});
|
||||
|
||||
test('destroy puts panels back and removes the shell', () => {
|
||||
const { document, shell, press, viewport, collapsed } = fixture();
|
||||
press('layers');
|
||||
shell.destroy();
|
||||
assert.equal(collapsed('data-panel'), true);
|
||||
assert.equal(document.getElementById('mobile-tab-bar'), null);
|
||||
assert.equal(document.getElementById('mobile-sheet-backdrop'), null);
|
||||
assert.equal(document.body.classList.contains('mobile-shell'), false);
|
||||
assert.equal(document.body.getAttribute('data-mobile-sheet'), null);
|
||||
assert.equal(document.body.style.values.size, 0);
|
||||
assert.equal(
|
||||
viewport.getAttribute('content'),
|
||||
'width=device-width, initial-scale=1.0',
|
||||
);
|
||||
});
|
||||
|
||||
test('the phone shell keeps the required attribution visible', () => {
|
||||
// Whitespace-normalised, so formatter line wrapping cannot hide a rule.
|
||||
const css = readFileSync(
|
||||
new URL('./styles/mobile-shell.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\( /g, '(')
|
||||
.replace(/ \)/g, ')');
|
||||
/** The declaration block of the rule whose selector list ends with `selector`. */
|
||||
const rule = (selector) => {
|
||||
const end = css.indexOf(`${selector} {`);
|
||||
assert.notEqual(end, -1, `missing rule ending in ${selector}`);
|
||||
const open = css.indexOf('{', end);
|
||||
return css.slice(open, css.indexOf('}', open));
|
||||
};
|
||||
const lift = (block) =>
|
||||
Number(
|
||||
/bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px\)/.exec(block)?.[1],
|
||||
);
|
||||
const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]);
|
||||
const idleCredit = rule(
|
||||
'body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits',
|
||||
);
|
||||
const dock = rule('body.mobile-shell #command-dock');
|
||||
const voice = rule('body.mobile-shell #command-dock #gev-voice-control');
|
||||
const voiceHeightPx = Number(/height: ([\d.]+)rem/.exec(voice)[1]) * 16;
|
||||
// Idle: the credit line clears the voice bar, which clears the tab bar.
|
||||
assert.ok(lift(idleCredit) > lift(dock) + voiceHeightPx);
|
||||
// It must beat the dock's :has() placements, so the offset is !important.
|
||||
assert.match(idleCredit, /\) !important;/);
|
||||
assert.doesNotMatch(
|
||||
idleCredit,
|
||||
/display:\s*none|visibility:\s*hidden|opacity:\s*0/,
|
||||
);
|
||||
// Open sheet: above everything the sheet covers, drawn above the sheets.
|
||||
const sheetCredit = rule(
|
||||
'body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits',
|
||||
);
|
||||
assert.match(
|
||||
sheetCredit,
|
||||
/bottom: calc\(var\(--mobile-sheet-cover, 0px\) \+ 6px\) !important;/,
|
||||
);
|
||||
const openDock = rule('body.mobile-shell[data-mobile-sheet] #command-dock');
|
||||
const railSheet = rule(
|
||||
"body.mobile-shell[data-mobile-sheet='more'] #right-context-rail",
|
||||
);
|
||||
assert.ok(z(sheetCredit) > z(openDock));
|
||||
assert.ok(z(sheetCredit) > z(railSheet));
|
||||
});
|
||||
+17
-3
@@ -46,6 +46,7 @@ export class PanelChrome {
|
||||
readInitialShare,
|
||||
readScrollRestoreOwner,
|
||||
readDisplayScrollTop,
|
||||
readSharedCollapsed = () => undefined,
|
||||
}) {
|
||||
Object.assign(this, elements, operations, {
|
||||
readHud,
|
||||
@@ -54,6 +55,7 @@ export class PanelChrome {
|
||||
readInitialShare,
|
||||
readScrollRestoreOwner,
|
||||
readDisplayScrollTop,
|
||||
readSharedCollapsed,
|
||||
});
|
||||
this._disposed = false;
|
||||
this._lifetime = new UiLifetime();
|
||||
@@ -149,6 +151,9 @@ export class PanelChrome {
|
||||
}
|
||||
|
||||
_collapsePanelOnEscape(event, panelId) {
|
||||
// In a phone sheet, Escape closes the sheet (mobileShell.js), not a panel.
|
||||
if (globalThis.document?.body?.hasAttribute?.('data-mobile-sheet'))
|
||||
return false;
|
||||
return collapsePanelOnEscape(event, {
|
||||
panel: document.getElementById(panelId),
|
||||
onChange: (collapsed, options) =>
|
||||
@@ -250,7 +255,10 @@ export class PanelChrome {
|
||||
disclosure: panel.querySelector(`[data-dock-toggle-target="${panelId}"]`),
|
||||
openDelayMs,
|
||||
closeDelayMs,
|
||||
isActive: () => !this._disposed,
|
||||
// A phone sheet holds its trays open; the tab bar closes them.
|
||||
isActive: () =>
|
||||
!this._disposed &&
|
||||
!globalThis.document?.body?.hasAttribute?.('data-mobile-sheet'),
|
||||
onChange: (collapsed, options) =>
|
||||
this.setPanelCollapsed(panelId, collapsed, options),
|
||||
onEscape: (event) => this._collapsePanelOnEscape(event, panelId),
|
||||
@@ -380,7 +388,11 @@ export class PanelChrome {
|
||||
panelEl.classList.contains('cyber-accordion-collapsed')
|
||||
? false
|
||||
: panelEl.classList.contains('collapsed');
|
||||
const entry = { id: spec.id, collapsed };
|
||||
// A panel the phone shell only borrowed for a sheet shares its own state.
|
||||
const entry = {
|
||||
id: spec.id,
|
||||
collapsed: this.readSharedCollapsed(spec.id) ?? collapsed,
|
||||
};
|
||||
if (spec.pinnable)
|
||||
entry.pinned = panelEl.classList.contains('dock-pinned');
|
||||
specs.push(entry);
|
||||
@@ -439,7 +451,9 @@ export class PanelChrome {
|
||||
!restore &&
|
||||
!nextCollapsed &&
|
||||
document.documentElement?.dataset.uiTheme === 'cyber' &&
|
||||
panelEl.parentElement === this._rightPanelStack
|
||||
panelEl.parentElement === this._rightPanelStack &&
|
||||
// Phone sheets show several rail panels at once.
|
||||
!document.body?.classList?.contains('mobile-shell')
|
||||
) {
|
||||
for (const peer of this._rightPanelStack.children) {
|
||||
if (
|
||||
|
||||
@@ -21,7 +21,7 @@ test('Display uses an inner scroll body only in Cyber', () => {
|
||||
});
|
||||
|
||||
test('Cyber restores one expanded owner, keeps launchers, and restores other theme preferences', () => {
|
||||
for (const mobile of [false, true]) {
|
||||
for (const mobile of [false]) {
|
||||
const f = fixture('right', {
|
||||
mobile,
|
||||
hud: { visible: true, variant: 'cyber' },
|
||||
@@ -50,6 +50,22 @@ test('Cyber restores one expanded owner, keeps launchers, and restores other the
|
||||
}
|
||||
});
|
||||
|
||||
test('Cyber keeps no accordion on phones, where the rail is presented as tab sheets', () => {
|
||||
// A sheet (for example Cameras: CCTV with Street Level) shows several of the
|
||||
// rail's panels at once; the accordion would collapse all but one.
|
||||
const f = fixture('right', {
|
||||
mobile: true,
|
||||
hud: { visible: true, variant: 'cyber' },
|
||||
});
|
||||
f.expand(f.first, 250);
|
||||
f.expand(f.second, 250);
|
||||
f.options.preferredPanelId = f.second.id;
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||||
assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), false);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
});
|
||||
|
||||
test('Cyber restored accordion prefers keyboard focus when no explicit owner exists', () => {
|
||||
const f = fixture('right', { hud: { visible: true, variant: 'cyber' } });
|
||||
f.expand(f.first, 250);
|
||||
|
||||
@@ -91,7 +91,9 @@ export function layoutRightPanelRail({
|
||||
}
|
||||
// Normalize restored state and theme entry without overwriting saved panel
|
||||
// preferences. This marker is distinct from Tactical's space-based collapse.
|
||||
if (hud.variant === 'cyber') {
|
||||
// Phones present the rail as tab sheets that show several panels at once.
|
||||
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
|
||||
if (hud.variant === 'cyber' && !isMobile) {
|
||||
const expanded = panels.filter(
|
||||
(panel) => !panel.classList.contains('collapsed'),
|
||||
);
|
||||
@@ -111,16 +113,19 @@ export function layoutRightPanelRail({
|
||||
onCollapse(panel);
|
||||
}
|
||||
}
|
||||
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
|
||||
const hasExpandedPanel = panels.some(
|
||||
(panel) =>
|
||||
!panel.classList.contains('collapsed') &&
|
||||
(!isMobile || panel.id !== 'pp-toggles'),
|
||||
);
|
||||
const exclusive = shouldHideCollapsedRightPanels({
|
||||
hudVariant: hud.variant,
|
||||
hasExpandedPanel,
|
||||
});
|
||||
// Phones present the rail as tab sheets (mobileShell.js), which choose what
|
||||
// shows; hiding collapsed siblings would empty a sheet and its a11y tree.
|
||||
const exclusive =
|
||||
!isMobile &&
|
||||
shouldHideCollapsedRightPanels({
|
||||
hudVariant: hud.variant,
|
||||
hasExpandedPanel,
|
||||
});
|
||||
stack.classList.toggle('layout-exclusive', exclusive);
|
||||
for (const panel of panels) {
|
||||
if (exclusive && panel.classList.contains('collapsed'))
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
/* ── Phone shell (≤720px, body.mobile-shell; src/ui/mobileShell.js) ─────────
|
||||
* The globe is full screen. A bottom tab bar opens one sheet; the sheet shows
|
||||
* the panels of that tab from the rails or dock, everything else stays hidden.
|
||||
*/
|
||||
:root {
|
||||
--mobile-tab-height: 58px;
|
||||
--mobile-bottom-inset: env(safe-area-inset-bottom, 0px);
|
||||
--mobile-top-inset: env(safe-area-inset-top, 0px);
|
||||
--mobile-tab-space: calc(
|
||||
var(--mobile-tab-height) + var(--mobile-bottom-inset)
|
||||
);
|
||||
--mobile-sheet-max-height: min(62vh, calc(100vh - 168px));
|
||||
--mobile-dock-sheet-max-height: 34vh;
|
||||
--mobile-rail-under-dock-max-height: 40vh;
|
||||
}
|
||||
|
||||
/* Dynamic viewport units keep sheets inside the area a mobile browser's
|
||||
toolbars leave visible. */
|
||||
@supports (height: 1dvh) {
|
||||
:root {
|
||||
--mobile-sheet-max-height: min(62dvh, calc(100dvh - 168px));
|
||||
--mobile-dock-sheet-max-height: 34dvh;
|
||||
--mobile-rail-under-dock-max-height: 40dvh;
|
||||
}
|
||||
}
|
||||
|
||||
#mobile-tab-bar,
|
||||
#mobile-sheet-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell #mobile-tab-bar {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 170;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
height: var(--mobile-tab-space);
|
||||
padding: 0 max(4px, env(safe-area-inset-right, 0px))
|
||||
var(--mobile-bottom-inset) max(4px, env(safe-area-inset-left, 0px));
|
||||
background: rgba(8, 14, 22, 0.94);
|
||||
border-top: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
body.mobile-shell .mobile-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: 6px 0 4px;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: 0;
|
||||
font: 500 10px/1.2 var(--font-sans);
|
||||
letter-spacing: 0.04em;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body.mobile-shell .mobile-tab-icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
body.mobile-shell .mobile-tab[aria-expanded='true'] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
body.mobile-shell .mobile-tab:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) {
|
||||
position: fixed;
|
||||
inset: 0 0 var(--mobile-tab-space);
|
||||
z-index: 155;
|
||||
display: block;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* ── Top bar: logo left, globe actions right ───────────────────────────── */
|
||||
body.mobile-shell #title-bar {
|
||||
top: calc(var(--mobile-top-inset) + 12px);
|
||||
left: 14px;
|
||||
}
|
||||
|
||||
body.mobile-shell #title-bar h1 > :not(.title-logo),
|
||||
body.mobile-shell #title-bar .subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell #top-center-actions {
|
||||
top: calc(var(--mobile-top-inset) + 10px);
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
/* ── HUD: one compact readout under the top bar ────────────────────────── */
|
||||
body.mobile-shell #intel-hud .hud-edge,
|
||||
body.mobile-shell #intel-hud .hud-corner,
|
||||
body.mobile-shell #intel-hud .hud-top-bar,
|
||||
body.mobile-shell #intel-hud .hud-bottom-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell #intel-hud .hud-bottom-left {
|
||||
top: calc(var(--mobile-top-inset) + 62px);
|
||||
bottom: auto;
|
||||
left: 16px;
|
||||
font-size: 9px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ── Panels: hidden unless their tab's sheet is open ───────────────────── */
|
||||
body.mobile-shell #left-panel-stack,
|
||||
body.mobile-shell #right-context-rail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
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 {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
right: 0;
|
||||
bottom: var(--mobile-tab-space);
|
||||
left: 0;
|
||||
z-index: 160;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
max-height: var(--mobile-sheet-max-height);
|
||||
padding: 14px 10px 10px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
pointer-events: auto;
|
||||
background: rgba(8, 14, 22, 0.96);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-bottom: 0;
|
||||
border-radius: 18px 18px 0 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
body.mobile-shell #left-panel-stack > *,
|
||||
body.mobile-shell #right-context-rail > *,
|
||||
/* Rail rules show expanded Weather and Recent Imagery by id; on phones they
|
||||
belong to the Context sheet only. */
|
||||
body.mobile-shell #right-context-rail > #weather-panel,
|
||||
body.mobile-shell #right-context-rail > #recent-imagery-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Rail-scoped ids so a rail's own hiding rules (for example the Tactical HUD
|
||||
hiding collapsed siblings) never take a panel out of an open sheet. */
|
||||
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #data-panel,
|
||||
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #scene-panel,
|
||||
body.mobile-shell[data-mobile-sheet='cameras']
|
||||
#right-context-rail
|
||||
> #cctv-panel,
|
||||
body.mobile-shell[data-mobile-sheet='cameras']
|
||||
#right-context-rail
|
||||
> #street-level-panel,
|
||||
body.mobile-shell[data-mobile-sheet='context']
|
||||
#right-context-rail
|
||||
> #global-context-panel,
|
||||
body.mobile-shell[data-mobile-sheet='context']
|
||||
#right-context-rail
|
||||
> #weather-panel:not([hidden]),
|
||||
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 {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── Dock: voice floats above the tab bar; search and presets are sheets ─ */
|
||||
body.mobile-shell #command-dock {
|
||||
/* The dock's tray rules size voice by this basis; a column makes it a height. */
|
||||
--dock-voice-width: auto;
|
||||
top: auto;
|
||||
bottom: calc(var(--mobile-tab-space) + 10px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
padding: 0 10px;
|
||||
pointer-events: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* The desktop dock centres itself with left: 50% and a translate under
|
||||
:has() selectors that outrank any phone selector; on phones it spans the
|
||||
width, so these few geometry properties must win. */
|
||||
body.mobile-shell #command-dock {
|
||||
right: 0 !important;
|
||||
left: 0 !important;
|
||||
width: auto !important;
|
||||
max-width: none !important;
|
||||
transform: none !important;
|
||||
translate: none !important;
|
||||
}
|
||||
|
||||
body.mobile-shell #command-dock > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
body.mobile-shell #location-bar,
|
||||
body.mobile-shell #control-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet] #command-dock {
|
||||
z-index: 165;
|
||||
bottom: calc(var(--mobile-tab-space) + var(--mobile-sheet-height, 0px) + 8px);
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='search'] #command-dock > #location-bar,
|
||||
body.mobile-shell[data-mobile-sheet='more'] #command-dock > #control-panel {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
display: flex;
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Search and More: the dock becomes the top of the sheet, flush with the rail
|
||||
sheet below it (More) or alone above the tab bar (Search). */
|
||||
body.mobile-shell[data-mobile-sheet='search'] #command-dock,
|
||||
body.mobile-shell[data-mobile-sheet='more'] #command-dock {
|
||||
bottom: calc(var(--mobile-tab-space) + var(--mobile-sheet-height, 0px));
|
||||
align-items: stretch;
|
||||
max-height: var(--mobile-dock-sheet-max-height);
|
||||
padding: 14px 10px 8px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
pointer-events: auto;
|
||||
background: rgba(8, 14, 22, 0.96);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-bottom: 0;
|
||||
border-radius: 18px 18px 0 0;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='more'] #right-context-rail {
|
||||
max-height: var(--mobile-rail-under-dock-max-height);
|
||||
padding-top: 4px;
|
||||
border-top: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Presets and Location show their popover inline instead of as a floating
|
||||
tray; the dock scrolls, so a tray above it would be clipped. */
|
||||
body.mobile-shell[data-mobile-sheet='more']
|
||||
#command-dock
|
||||
#control-panel
|
||||
.panel-inner,
|
||||
body.mobile-shell[data-mobile-sheet='more']
|
||||
#command-dock
|
||||
#control-panel-popover,
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#command-dock
|
||||
#location-bar
|
||||
.location-inner,
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#command-dock
|
||||
#location-bar-popover {
|
||||
position: static;
|
||||
inset: auto;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* The tab names the sheet; the tray toggle and pin have nothing to do here. */
|
||||
body.mobile-shell[data-mobile-sheet='more'] #command-dock #control-panel-toggle,
|
||||
body.mobile-shell[data-mobile-sheet='more']
|
||||
#command-dock
|
||||
#control-panel
|
||||
.dock-pin-btn,
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#command-dock
|
||||
#location-bar-toggle,
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#command-dock
|
||||
#location-bar
|
||||
.panel-collapse-btn,
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#command-dock
|
||||
#location-bar
|
||||
.dock-pin-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='more'] #style-buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Search: the field takes the full width; the place pills scroll beneath it. */
|
||||
body.mobile-shell[data-mobile-sheet='search'] #location-bar .location-city-row {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#location-bar
|
||||
.location-search-wrap {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='search'] #location-search {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='search'] #location-bar .location-pills {
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* With a sheet open, voice steps aside; Search and More own the dock. */
|
||||
body.mobile-shell[data-mobile-sheet] #command-dock > #gev-voice-control {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell #command-dock #gev-voice-control {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
flex: none;
|
||||
align-self: center;
|
||||
width: min(280px, 80vw);
|
||||
height: 5.35rem;
|
||||
margin: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── Credits and corner chips clear the tab bar ────────────────────────── */
|
||||
/* While the tab bar shows, the credit line sits above the voice bar. The
|
||||
dock's placements (including the Minimal HUD's :has() variant) outrank any
|
||||
phone selector, so the offset must win. Clean view keeps its own corner. */
|
||||
body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view)
|
||||
#cesium-credits {
|
||||
right: auto;
|
||||
bottom: calc(var(--mobile-tab-space) + 106px) !important;
|
||||
left: 10px;
|
||||
max-width: calc(100vw - 20px);
|
||||
transform-origin: left bottom;
|
||||
scale: 0.8;
|
||||
}
|
||||
|
||||
/* An open sheet lifts the required attribution above everything it covers. */
|
||||
body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not(
|
||||
.cockpit-mode
|
||||
):not(.ui-clean-view)
|
||||
#cesium-credits {
|
||||
bottom: calc(var(--mobile-sheet-cover, 0px) + 6px) !important;
|
||||
z-index: 166;
|
||||
}
|
||||
|
||||
/* The view switcher clears the tab bar and the voice bar, opposite the credit. */
|
||||
body.mobile-shell:not(.cockpit-mode) #view-switcher {
|
||||
right: 12px;
|
||||
bottom: calc(var(--mobile-tab-space) + 106px);
|
||||
}
|
||||
|
||||
body.mobile-shell #corner-actions {
|
||||
top: calc(var(--mobile-top-inset) + 60px);
|
||||
right: 12px;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
/* Full-screen modes hide the phone chrome like the desktop chrome. Scene
|
||||
playback keeps it: the Scenes panel (Layers tab) holds its STOP control. */
|
||||
body.mobile-shell.ui-clean-view #mobile-tab-bar,
|
||||
body.mobile-shell.recording-mode #mobile-tab-bar,
|
||||
body.mobile-shell.cockpit-mode #mobile-tab-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell.ui-clean-view #command-dock,
|
||||
body.mobile-shell.cockpit-mode #command-dock {
|
||||
bottom: calc(var(--mobile-bottom-inset) + 10px);
|
||||
}
|
||||
Reference in new issue
Block a user