diff --git a/index.html b/index.html index 8a01415b6..a9874d550 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - +
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f2efea33c..43b765214 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs index 894d8f7ed..3032a25fe 100644 --- a/src/creditAttribution.test.mjs +++ b/src/creditAttribution.test.mjs @@ -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 }); } } diff --git a/src/exclusiveSurfaces.js b/src/exclusiveSurfaces.js new file mode 100644 index 000000000..af941d69f --- /dev/null +++ b/src/exclusiveSurfaces.js @@ -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', +]); diff --git a/src/firstRunExperience.js b/src/firstRunExperience.js index 59ddf3e6a..408152295 100644 --- a/src/firstRunExperience.js +++ b/src/firstRunExperience.js @@ -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? diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index e4a6d2483..40d42befc 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -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', () => { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3c5602ae7..fd55b2e25 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7a790e04e..442312de2 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3010,5 +3010,9 @@ "hemisphere|E": "В", "hemisphere|W": "З", "OK": "НОРМА", - "{category} ({count})": "{category} ({count})" + "{category} ({count})": "{category} ({count})", + "Layers": "Слои", + "Search": "Поиск", + "More": "Ещё", + "Panels": "Панели" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 46ccf96a7..fa08cca1f 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -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": "面板" } diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 82413f43e..de37ab036 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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(); diff --git a/src/ui/mobileShell.js b/src/ui/mobileShell.js new file mode 100644 index 000000000..a2b8638b2 --- /dev/null +++ b/src/ui/mobileShell.js @@ -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(); + }, + }; +} diff --git a/src/ui/mobileShell.test.mjs b/src/ui/mobileShell.test.mjs new file mode 100644 index 000000000..751333c9a --- /dev/null +++ b/src/ui/mobileShell.test.mjs @@ -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)); +}); diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index e0bd37355..d920e8545 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -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 ( diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 9be9ffbaf..270ba61f5 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -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); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 836fd75d4..5d3e5e40c 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -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')) diff --git a/src/ui/styles/mobile-shell.css b/src/ui/styles/mobile-shell.css new file mode 100644 index 000000000..4cf32513a --- /dev/null +++ b/src/ui/styles/mobile-shell.css @@ -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); +} diff --git a/style.css b/style.css index 0117150ce..1b55c459f 100644 --- a/style.css +++ b/style.css @@ -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';