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:
Sameh Khamis authored and GitHub committed 2026-10-22 22:26:49 -07:00
commit d48b9c4df9
22 files changed
+1525 -23

No files matched your search

+4
View File
@@ -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).
+26
View File
@@ -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
View File
@@ -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 -->
+5
View File
@@ -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",
+3
View File
@@ -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',
+84 -3
View File
@@ -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`);
+3
View File
@@ -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',
+3
View File
@@ -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 });
}
}
+11
View File
@@ -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',
]);
+2 -6
View File
@@ -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?
+6
View File
@@ -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', () => {
+5 -1
View File
@@ -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"
}
+5 -1
View File
@@ -3010,5 +3010,9 @@
"hemisphere|E": "В",
"hemisphere|W": "З",
"OK": "НОРМА",
"{category} ({count})": "{category} ({count})"
"{category} ({count})": "{category} ({count})",
"Layers": "Слои",
"Search": "Поиск",
"More": "Ещё",
"Panels": "Панели"
}
+5 -1
View File
@@ -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": "面板"
}
+18
View File
@@ -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();
+400
View File
@@ -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();
},
};
}
+483
View File
@@ -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
View File
@@ -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 (
+17 -1
View File
@@ -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);
+11 -6
View File
@@ -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'))
+415
View File
@@ -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);
}
+1
View File
@@ -33,4 +33,5 @@
@import './src/ui/styles/panel-surfaces.css';
@import './src/ui/styles/segmented-control.css';
@import './src/ui/styles/cjk.css';
@import './src/ui/styles/mobile-shell.css';
@import './src/ui/styles/cyber.css';