mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-23 06:12:27 +00:00
feat: mobile map de-cramp — collapsible Layers menu, feed chip, coverage details expander
- MobileControls: stacked top panel replaced with a floating bar (Layers menu toggle + Live feed chip) and a collapsible menu sheet: layers grid, view modes, LOS tools, node search, watchlist. Closes on Esc/outside tap. - App: mobileFeedOpen state drives data-feed-open so the packet feed stays hidden on mobile until the Live chip is pressed (desktop unchanged). - RfCoverageStatus: details dl + note wrap behind a Show/Hide toggle on mobile only; gradient + tier buttons stay visible. - map-app.css: new ≤640px block (floating bar, menu sheet, feed gating, coverage details); removed superseded .mobile-controls/__primary rules.
This commit is contained in:
1 parent
9da23d0200
commit
efa746d7bf
4 files changed
+423
-148
No files matched your search
@@ -115,6 +115,7 @@ export const App: React.FC = () => {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(() => new URLSearchParams(window.location.search).get('node'));
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState<boolean>(() => !!new URLSearchParams(window.location.search).get('node'));
|
||||
const [fullScreenMap, setFullScreenMap] = useState(false);
|
||||
const [mobileFeedOpen, setMobileFeedOpen] = useState(false);
|
||||
const [highContrast, setHighContrast] = useState(() => localStorage.getItem('meshcore-contrast') === 'high');
|
||||
// MapLibre map instance — used by MobileControls/NodeSearch for flyTo
|
||||
const [mlMap, setMlMap] = useState<maplibregl.Map | null>(null);
|
||||
@@ -524,7 +525,7 @@ export const App: React.FC = () => {
|
||||
}, [handleShare]);
|
||||
|
||||
return (
|
||||
<div className="app-shell" data-node-open={selectedNodeId ? 'true' : undefined} data-live-feed={filters.livePackets ? 'true' : undefined} data-mobile-fullscreen={fullScreenMap ? 'true' : undefined}>
|
||||
<div className="app-shell" data-node-open={selectedNodeId ? 'true' : undefined} data-live-feed={filters.livePackets ? 'true' : undefined} data-mobile-fullscreen={fullScreenMap ? 'true' : undefined} data-feed-open={mobileFeedOpen ? 'true' : undefined}>
|
||||
<AppTopBar
|
||||
homeUrl={site.appHomeUrl}
|
||||
wsState={wsState}
|
||||
@@ -553,6 +554,8 @@ export const App: React.FC = () => {
|
||||
onNodeSelect={setSelectedNodeId}
|
||||
fullScreenMap={fullScreenMap}
|
||||
onToggleFullScreenMap={() => setFullScreenMap((value) => !value)}
|
||||
feedOpen={mobileFeedOpen}
|
||||
onToggleFeed={() => setMobileFeedOpen((value) => !value)}
|
||||
/>
|
||||
|
||||
<div className="map-layer">
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
rfNodeCoverageState,
|
||||
type RfCoverageMeta,
|
||||
@@ -38,6 +40,7 @@ export function RfCoverageStatus({
|
||||
onClearNode?: () => void;
|
||||
visible: boolean;
|
||||
}) {
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
if (!visible) return null;
|
||||
const nodeEntry = nodePublicKey ? meta?.node_coverage?.[nodePublicKey.toLowerCase()] : undefined;
|
||||
const product = nodePublicKey ? nodeEntry?.standard : meta?.coverage?.[tier];
|
||||
@@ -94,6 +97,7 @@ export function RfCoverageStatus({
|
||||
<div className="rf-coverage-gradient" aria-hidden="true" />
|
||||
<div className="rf-coverage-gradient-labels"><span>Below threshold</span><span>Strong margin</span></div>
|
||||
{product ? (
|
||||
<div className={`rf-coverage-details-wrap${detailsOpen ? ' rf-coverage-details-wrap--open' : ''}`}>
|
||||
<dl className="rf-coverage-details">
|
||||
<div><dt>Frequency</dt><dd>{product.frequency_mhz.toFixed(3)} MHz</dd></div>
|
||||
<div><dt>Generated</dt><dd>{new Date(product.generated_at ?? meta?.generated_at ?? '').toLocaleString()}</dd></div>
|
||||
@@ -101,6 +105,16 @@ export function RfCoverageStatus({
|
||||
<div><dt>Source</dt><dd>{meta?.run?.source_version ?? meta?.version ?? 'unknown'}</dd></div>
|
||||
{nodePublicKey && <div><dt>Status</dt><dd>{nodeState === 'pending' ? 'Pending' : nodeState === 'stale' ? 'Stale' : 'Available'}</dd></div>}
|
||||
</dl>
|
||||
{product?.assumptions?.note && <p className="rf-coverage-note">{product.assumptions.note}</p>}
|
||||
<button
|
||||
type="button"
|
||||
className="rf-coverage-details-toggle"
|
||||
onClick={() => setDetailsOpen((value) => !value)}
|
||||
aria-expanded={detailsOpen}
|
||||
>
|
||||
{detailsOpen ? 'Hide details ▴' : 'Show details ▾'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="rf-coverage-waiting">
|
||||
{nodePublicKey
|
||||
@@ -110,7 +124,6 @@ export function RfCoverageStatus({
|
||||
: 'Standard coverage is being prepared.'}
|
||||
</p>
|
||||
)}
|
||||
{product?.assumptions?.note && <p className="rf-coverage-note">{product.assumptions.note}</p>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import type * as maplibregl from 'maplibre-gl';
|
||||
import { NodeSearch } from '../Map/NodeSearch.js';
|
||||
import { visibleFilterRows, type Filters } from '../FilterPanel/FilterPanel.js';
|
||||
@@ -21,6 +21,8 @@ type MobileControlsProps = {
|
||||
onNodeSelect: (nodeId: string) => void;
|
||||
fullScreenMap: boolean;
|
||||
onToggleFullScreenMap: () => void;
|
||||
feedOpen: boolean;
|
||||
onToggleFeed: () => void;
|
||||
};
|
||||
|
||||
export const MobileControls: React.FC<MobileControlsProps> = ({
|
||||
@@ -37,123 +39,203 @@ export const MobileControls: React.FC<MobileControlsProps> = ({
|
||||
onNodeSelect,
|
||||
fullScreenMap,
|
||||
onToggleFullScreenMap,
|
||||
feedOpen,
|
||||
onToggleFeed,
|
||||
}) => {
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const customLosMode = useOverlayStore((state) => state.customLosMode);
|
||||
const planRepeaterMode = useOverlayStore((state) => state.planRepeaterMode);
|
||||
const setCustomLosMode = useOverlayStore((state) => state.setCustomLosMode);
|
||||
const clearCustomLos = useOverlayStore((state) => state.clearCustomLos);
|
||||
const setPlanRepeaterMode = useOverlayStore((state) => state.setPlanRepeaterMode);
|
||||
|
||||
return (
|
||||
<div className="mobile-controls">
|
||||
<div className={`mobile-controls__primary${fullScreenMap ? ' mobile-controls__primary--fullscreen' : ''}`}>
|
||||
{!fullScreenMap && <MapModeSelector
|
||||
activeMode={activeMode}
|
||||
viewshedEnabled={viewshedEnabled}
|
||||
onChange={onModeChange}
|
||||
onShare={onShare}
|
||||
shareLabel={shareLabel}
|
||||
/>}
|
||||
// Close the menu on Escape or on any tap outside the control cluster.
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (rootRef.current && target && !rootRef.current.contains(target)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setMenuOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
const filterRows = visibleFilterRows(viewshedEnabled, heatmapEnabled, rfCoverageEnabled);
|
||||
const activeFilterCount = filterRows.filter(({ key }) => filters[key]).length;
|
||||
|
||||
if (fullScreenMap) {
|
||||
return (
|
||||
<div className="mobile-controls mobile-controls--fullscreen" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-fullscreen-toggle"
|
||||
onClick={onToggleFullScreenMap}
|
||||
aria-pressed={fullScreenMap}
|
||||
>
|
||||
{fullScreenMap ? 'Restore panels' : 'Full-screen map'}
|
||||
Restore panels
|
||||
</button>
|
||||
</div>
|
||||
{!fullScreenMap && (
|
||||
<div className="mobile-map-tools" aria-label="Map planning tools">
|
||||
<button
|
||||
type="button"
|
||||
className={customLosMode ? 'mobile-map-tools__button mobile-map-tools__button--active' : 'mobile-map-tools__button'}
|
||||
aria-pressed={customLosMode}
|
||||
onClick={() => {
|
||||
if (customLosMode) clearCustomLos();
|
||||
else {
|
||||
setPlanRepeaterMode(false);
|
||||
setCustomLosMode(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Line of sight
|
||||
</button>
|
||||
{viewshedEnabled && (
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mobile-controls" ref={rootRef}>
|
||||
<div className="mobile-controls__bar">
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-menu-toggle"
|
||||
onClick={() => setMenuOpen((value) => !value)}
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-controls="mobile-map-menu"
|
||||
>
|
||||
<span className="mobile-menu-toggle__icon" aria-hidden="true">☰</span>
|
||||
<span className="mobile-menu-toggle__label">Layers</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="mobile-menu-toggle__badge">{activeFilterCount}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-feed-toggle"
|
||||
onClick={onToggleFeed}
|
||||
aria-pressed={feedOpen && filters.livePackets}
|
||||
>
|
||||
<span aria-hidden="true">▤</span>
|
||||
Live
|
||||
</button>
|
||||
</div>
|
||||
{menuOpen && (
|
||||
<div className="mobile-menu" id="mobile-map-menu" role="dialog" aria-label="Map settings">
|
||||
<div className="mobile-menu__head">
|
||||
<span className="mobile-menu__title">Map settings</span>
|
||||
<button
|
||||
type="button"
|
||||
className={planRepeaterMode ? 'mobile-map-tools__button mobile-map-tools__button--active' : 'mobile-map-tools__button'}
|
||||
aria-pressed={planRepeaterMode}
|
||||
onClick={() => {
|
||||
if (planRepeaterMode) setPlanRepeaterMode(false);
|
||||
else {
|
||||
clearCustomLos();
|
||||
setPlanRepeaterMode(true);
|
||||
}
|
||||
}}
|
||||
className="mobile-menu__close"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
aria-label="Close menu"
|
||||
>
|
||||
Plan repeater
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mobile-menu__body">
|
||||
<section className="mobile-menu__section" aria-label="Layers">
|
||||
<h3 className="mobile-menu__heading">Layers</h3>
|
||||
<div className="mobile-filter-grid">
|
||||
{filterRows.map(({ key, label, color, hollow }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={key}
|
||||
className={`filter-row${filters[key] ? ' filter-row--on' : ''}`}
|
||||
onClick={() => onFiltersChange({ ...filters, [key]: !filters[key] })}
|
||||
aria-pressed={!!filters[key]}
|
||||
>
|
||||
<span className="filter-row__label">
|
||||
{hollow ? (
|
||||
<span className="filter-dot filter-dot--hollow" style={{ borderColor: color, opacity: filters[key] ? 1 : 0.4 }} />
|
||||
) : (
|
||||
<span className="filter-dot" style={{ background: color, opacity: filters[key] ? 1 : 0.3 }} />
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={`filter-toggle${filters[key] ? ' filter-toggle--on' : ''}`}
|
||||
style={filters[key] ? { background: `${color}22`, borderColor: color } : {}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filters.hexClashes && (
|
||||
<div className="filter-slider" style={{ margin: '10px 0 0' }}>
|
||||
<label className="filter-slider__label" htmlFor="mobile-hex-clash-hops">
|
||||
Hex clash hops: {Math.round(filters.hexClashMaxHops)}
|
||||
</label>
|
||||
<input
|
||||
id="mobile-hex-clash-hops"
|
||||
className="filter-slider__input"
|
||||
type="range"
|
||||
min={0}
|
||||
max={3}
|
||||
step={1}
|
||||
value={Math.round(filters.hexClashMaxHops)}
|
||||
onChange={(e) => onFiltersChange({ ...filters, hexClashMaxHops: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="mobile-menu__section" aria-label="View">
|
||||
<h3 className="mobile-menu__heading">View</h3>
|
||||
<MapModeSelector
|
||||
activeMode={activeMode}
|
||||
viewshedEnabled={viewshedEnabled}
|
||||
onChange={onModeChange}
|
||||
onShare={onShare}
|
||||
shareLabel={shareLabel}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-fullscreen-toggle"
|
||||
onClick={onToggleFullScreenMap}
|
||||
>
|
||||
Full-screen map
|
||||
</button>
|
||||
</section>
|
||||
<section className="mobile-menu__section" aria-label="Map planning tools">
|
||||
<h3 className="mobile-menu__heading">Tools</h3>
|
||||
<div className="mobile-map-tools">
|
||||
<button
|
||||
type="button"
|
||||
className={customLosMode ? 'mobile-map-tools__button mobile-map-tools__button--active' : 'mobile-map-tools__button'}
|
||||
aria-pressed={customLosMode}
|
||||
onClick={() => {
|
||||
if (customLosMode) clearCustomLos();
|
||||
else {
|
||||
setPlanRepeaterMode(false);
|
||||
setCustomLosMode(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Line of sight
|
||||
</button>
|
||||
{viewshedEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
className={planRepeaterMode ? 'mobile-map-tools__button mobile-map-tools__button--active' : 'mobile-map-tools__button'}
|
||||
aria-pressed={planRepeaterMode}
|
||||
onClick={() => {
|
||||
if (planRepeaterMode) setPlanRepeaterMode(false);
|
||||
else {
|
||||
clearCustomLos();
|
||||
setPlanRepeaterMode(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Plan repeater
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="mobile-menu__section" aria-label="Find a node">
|
||||
<h3 className="mobile-menu__heading">Find</h3>
|
||||
<div className="mobile-search">
|
||||
<NodeSearch map={map} onNodeSelect={onNodeSelect} />
|
||||
</div>
|
||||
</section>
|
||||
<section className="mobile-menu__section" aria-label="Watchlist">
|
||||
<WatchlistPanel />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!fullScreenMap && <button
|
||||
type="button"
|
||||
className="mobile-legend-toggle"
|
||||
onClick={() => setShowFilters((v) => !v)}
|
||||
aria-expanded={showFilters}
|
||||
>
|
||||
<span>Layers</span>
|
||||
<span>{showFilters ? 'Hide' : 'Show'}</span>
|
||||
</button>}
|
||||
{!fullScreenMap && <div className={`mobile-filter-wrap${showFilters ? '' : ' mobile-filter-wrap--hidden'}`}>
|
||||
<div className="mobile-filter-grid">
|
||||
{visibleFilterRows(viewshedEnabled, heatmapEnabled, rfCoverageEnabled).map(({ key, label, color, hollow }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={key}
|
||||
className={`filter-row${filters[key] ? ' filter-row--on' : ''}`}
|
||||
onClick={() => onFiltersChange({ ...filters, [key]: !filters[key] })}
|
||||
aria-pressed={!!filters[key]}
|
||||
>
|
||||
<span className="filter-row__label">
|
||||
{hollow ? (
|
||||
<span className="filter-dot filter-dot--hollow" style={{ borderColor: color, opacity: filters[key] ? 1 : 0.4 }} />
|
||||
) : (
|
||||
<span className="filter-dot" style={{ background: color, opacity: filters[key] ? 1 : 0.3 }} />
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={`filter-toggle${filters[key] ? ' filter-toggle--on' : ''}`}
|
||||
style={filters[key] ? { background: `${color}22`, borderColor: color } : {}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filters.hexClashes && (
|
||||
<div className="filter-slider" style={{ margin: '0 8px 8px' }}>
|
||||
<label className="filter-slider__label" htmlFor="mobile-hex-clash-hops">
|
||||
Hex clash hops: {Math.round(filters.hexClashMaxHops)}
|
||||
</label>
|
||||
<input
|
||||
id="mobile-hex-clash-hops"
|
||||
className="filter-slider__input"
|
||||
type="range"
|
||||
min={0}
|
||||
max={3}
|
||||
step={1}
|
||||
value={Math.round(filters.hexClashMaxHops)}
|
||||
onChange={(e) => onFiltersChange({ ...filters, hexClashMaxHops: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
{!fullScreenMap && <div className="mobile-search">
|
||||
<NodeSearch map={map} onNodeSelect={onNodeSelect} />
|
||||
</div>}
|
||||
{!fullScreenMap && <WatchlistPanel />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+225
-48
@@ -1736,60 +1736,12 @@
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.filter-panel { display: none; }
|
||||
.mobile-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
max-height: min(34dvh, 240px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.rf-coverage-status {
|
||||
left: 8px;
|
||||
bottom: 8px;
|
||||
width: min(330px, calc(100% - 16px));
|
||||
}
|
||||
.rf-coverage-details { grid-template-columns: 1fr 1fr; }
|
||||
.mobile-controls__primary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mobile-controls__primary .map-modes {
|
||||
display: contents;
|
||||
}
|
||||
.mobile-controls__primary .map-modes__buttons {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
}
|
||||
.mobile-controls__primary .map-modes__button {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.mobile-controls__primary .map-modes__share {
|
||||
grid-column: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.mobile-controls__primary .mobile-fullscreen-toggle {
|
||||
grid-column: 2;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 7px 8px;
|
||||
}
|
||||
.mobile-controls__primary--fullscreen {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.mobile-controls__primary--fullscreen .mobile-fullscreen-toggle {
|
||||
grid-column: 1;
|
||||
}
|
||||
.mobile-map-tools {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -2028,3 +1980,228 @@
|
||||
bottom: calc(8px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
/* ═══ Mobile map menu — de-cramp pass (2026-08-12) ═════════════════════════
|
||||
Appended after the earlier responsive blocks so these rules win the
|
||||
cascade. Only active ≤640px. Desktop layout untouched. */
|
||||
|
||||
.rf-coverage-details-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.mobile-controls {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 8px;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
max-width: calc(100vw - 16px);
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
overscroll-behavior: contain;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.mobile-controls--fullscreen {
|
||||
top: 56px;
|
||||
right: 8px;
|
||||
left: auto;
|
||||
}
|
||||
.mobile-controls__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.mobile-menu-toggle,
|
||||
.mobile-feed-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 44px;
|
||||
padding: 8px 13px;
|
||||
border: 1px solid var(--border-bright);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(8, 13, 20, 0.92);
|
||||
color: var(--text-primary);
|
||||
font: 700 11px var(--font-mono);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mobile-menu-toggle[aria-expanded='true'] {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.mobile-feed-toggle[aria-pressed='true'] {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(0, 196, 255, 0.14);
|
||||
}
|
||||
.mobile-menu-toggle__icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.mobile-menu-toggle__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-base);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.mobile-menu {
|
||||
position: fixed;
|
||||
top: 108px;
|
||||
left: 8px;
|
||||
z-index: 950;
|
||||
width: min(350px, calc(100vw - 16px));
|
||||
max-height: calc(100dvh - 124px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid var(--border-bright);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-panel);
|
||||
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.mobile-menu__head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.mobile-menu__title {
|
||||
color: var(--text-primary);
|
||||
font: 700 11px var(--font-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.mobile-menu__close {
|
||||
padding: 4px 8px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mobile-menu__close:hover,
|
||||
.mobile-menu__close:focus-visible {
|
||||
color: var(--accent);
|
||||
}
|
||||
.mobile-menu__section {
|
||||
padding: 10px 14px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.mobile-menu__section:first-of-type {
|
||||
border-top: 0;
|
||||
}
|
||||
.mobile-menu__heading {
|
||||
margin: 0 0 9px;
|
||||
color: var(--text-secondary);
|
||||
font: 700 10px var(--font-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.mobile-menu .mobile-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.mobile-menu .mobile-filter-grid .filter-row {
|
||||
min-height: 44px;
|
||||
padding: 9px 8px;
|
||||
}
|
||||
.mobile-menu .map-modes {
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
.mobile-menu .map-modes__buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.mobile-menu .map-modes__button {
|
||||
min-height: 40px;
|
||||
margin: 0;
|
||||
}
|
||||
.mobile-menu .map-modes__share {
|
||||
width: 100%;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.mobile-menu .mobile-fullscreen-toggle {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(8, 13, 20, 0.92);
|
||||
color: var(--text-primary);
|
||||
font: 11px var(--font-mono);
|
||||
}
|
||||
.mobile-menu .mobile-map-tools {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.mobile-menu .mobile-map-tools__button {
|
||||
min-height: 44px;
|
||||
padding: 9px 8px;
|
||||
border: 1px solid var(--border-bright);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-panel-alt);
|
||||
color: var(--text-primary);
|
||||
font: 11px var(--font-mono);
|
||||
}
|
||||
.mobile-menu .mobile-search {
|
||||
display: block;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
/* Packet feed stays hidden on mobile until the Live chip is pressed. */
|
||||
.app-shell:not([data-feed-open='true']) .packet-feed {
|
||||
display: none;
|
||||
}
|
||||
/* Coverage details collapse behind the toggle on mobile. */
|
||||
.rf-coverage-details-wrap:not(.rf-coverage-details-wrap--open)
|
||||
:is(.rf-coverage-details, .rf-coverage-note) {
|
||||
display: none;
|
||||
}
|
||||
.rf-coverage-details-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-bright);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-panel-alt);
|
||||
color: var(--text-secondary);
|
||||
font: 10px var(--font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rf-coverage-details-toggle:hover,
|
||||
.rf-coverage-details-toggle:focus-visible {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user