diff --git a/public/live.css b/public/live.css
index 6bf010f4..99fab451 100644
--- a/public/live.css
+++ b/public/live.css
@@ -431,11 +431,14 @@
padding: 0;
border: 0;
box-shadow: none;
- flex: 0 0 100%; /* break onto its own row inside flex-wrap header */
+ flex: auto; /* Let it flow on the same row if there is room */
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
+ border-left: 1px solid var(--border); /* Vertical line between metrics and filters */
+ padding-left: 12px;
+ margin-left: 4px;
}
.live-controls-body {
display: flex;
@@ -447,9 +450,25 @@
/* Region/Area filters inline in live header controls body */
.live-controls-body .live-region-filter-container,
-.live-controls-body .live-area-filter-container { display: inline-flex; align-items: center; }
+.live-controls-body .live-area-filter-container,
+.live-controls-body .live-show-all-region-nodes { display: inline-flex; align-items: center; font-size: 11px; }
.live-controls-body .live-region-filter-container .region-dropdown-trigger,
-.live-controls-body .live-area-filter-container .region-dropdown-trigger { font-size: inherit; padding: 2px 6px; }
+.live-controls-body .live-area-filter-container .region-dropdown-trigger {
+ font-size: inherit;
+ padding: 2px 8px;
+ height: 30px;
+ position: relative;
+}
+.live-controls-body .live-region-filter-container .region-dropdown-trigger::after,
+.live-controls-body .live-area-filter-container .region-dropdown-trigger::after {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: max(100%, 44px);
+ height: 44px;
+ transform: translate(-50%, -50%);
+}
/* #1108 β "Show all nodes" sibling of the region dropdown. Reuses the
.live-controls-body label rhythm so it lines up with the other inline toggles. */
@@ -457,9 +476,9 @@
display: inline-flex;
align-items: center;
gap: 4px;
- font-size: inherit;
white-space: nowrap;
}
+.live-controls-body .live-show-all-region-nodes input { margin: 0; }
/* ---- #1532 Live: default-collapsed controls + fullscreen mode ---- */
@@ -493,39 +512,30 @@ body.live-fullscreen .feed-show-btn { display: none !important; }
top-right. The header background/border drop so the stats pills
float over the map. */
body.live-fullscreen .live-header {
- position: fixed;
- top: 12px;
- right: 12px;
- left: auto;
background: transparent;
- border: 0;
+ border-color: transparent;
box-shadow: none;
- padding: 0;
- max-width: none;
- z-index: 700;
+ pointer-events: none;
}
-body.live-fullscreen .live-stats-row {
- position: fixed;
- top: 12px;
- right: 12px;
- left: auto;
- z-index: 700;
- background: color-mix(in srgb, var(--surface-1) 84%, transparent);
- backdrop-filter: blur(10px);
- padding: 4px 8px;
- border-radius: 8px;
- border: 1px solid var(--border);
+body.live-fullscreen .live-header-body {
+ display: none !important;
+}
+body.live-fullscreen .live-stats-row,
+body.live-fullscreen .live-header-critical {
+ pointer-events: auto;
}
-/* Keep the fullscreen exit affordance reachable: a small chip in the
- top-left so users can leave the mode without remembering the F key. */
-body.live-fullscreen #liveFullscreenToggle {
- display: inline-flex !important;
- position: fixed;
- top: 12px;
- left: 12px;
- z-index: 700;
+/* Hide top nav in fullscreen UNLESS it was explicitly pinned */
+body.live-fullscreen:not(.nav-pinned) .top-nav {
+ display: none !important;
}
+/* Shift map controls and live header up when top nav is hidden */
+body.live-fullscreen:not(.nav-pinned) .leaflet-top.leaflet-right,
+body.live-fullscreen:not(.nav-pinned) .live-header {
+ top: 12px !important;
+}
+
+
/* ---- Medium breakpoint (#279) + collapse toggles (#1178, #1179) ---- */
@media (max-width: 768px) {
@@ -535,6 +545,9 @@ body.live-fullscreen #liveFullscreenToggle {
.live-header { gap: 6px; padding: 4px 8px; max-height: none; min-height: 48px; }
.live-stat-pill { font-size: 11px; padding: 1px 7px; }
.live-toggles { font-size: 10px; gap: 6px; }
+ .live-controls-body .live-region-filter-container,
+ .live-controls-body .live-area-filter-container,
+ .live-controls-body .live-show-all-region-nodes { font-size: 10px; }
/* Show toggle buttons */
.live-header-toggle,
@@ -621,6 +634,11 @@ body.live-fullscreen #liveFullscreenToggle {
-webkit-overflow-scrolling: touch;
min-width: 0;
}
+ .live-controls {
+ border-left: none;
+ padding-left: 0;
+ margin-left: 0;
+ }
/* #1234 β hide top app navbar on /live route at β€640px.
* Uses :has() (Chromium 105+, Safari 15.4+, Firefox 121+) to scope the
@@ -709,6 +727,38 @@ body.live-fullscreen #liveFullscreenToggle {
.live-page .leaflet-top.leaflet-right { top: 56px; }
}
+/* Custom Live Toggles (Settings & Fullscreen) */
+.live-leaflet-toggle {
+ /* No absolute positioning needed; they stack normally in leaflet-right */
+}
+.live-leaflet-toggle a {
+ width: 44px !important;
+ height: 44px !important;
+ line-height: 44px !important;
+ font-size: 20px !important;
+ background-color: var(--surface-1) !important;
+ color: var(--text) !important;
+ border-bottom: none !important;
+ display: flex !important;
+ align-items: center;
+ justify-content: center;
+}
+.live-leaflet-toggle a:hover {
+ background-color: var(--surface-2) !important;
+ color: var(--text) !important;
+}
+
+/* Active states for cog and fullscreen toggles */
+body.live-fullscreen #liveFullscreenToggle,
+#liveControlsToggle[aria-expanded="true"],
+body.live-fullscreen #liveFullscreenToggle:hover,
+#liveControlsToggle[aria-expanded="true"]:hover {
+ color: var(--accent) !important;
+}
+
+
+
+
/* Feed item hover */
.live-feed-item:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
@@ -888,7 +938,10 @@ body.live-fullscreen #liveFullscreenToggle {
/* #1110 Live page node filter β match toolbar control sizing & theme */
.live-node-filter-wrap { position: relative; display: inline-flex; align-items: center; }
-.live-node-filter-input {
+input.live-node-filter-input {
+ box-sizing: border-box;
+ height: 30px;
+ min-height: 30px; /* Override global 48px min-height on text inputs */
background: color-mix(in srgb, var(--text) 6%, transparent);
color: var(--text);
border: 1px solid var(--border);
@@ -896,11 +949,10 @@ body.live-fullscreen #liveFullscreenToggle {
padding: 3px 8px;
font-size: inherit;
line-height: 1.3;
- height: auto;
min-width: 140px;
outline: none;
}
-.live-node-filter-input:focus {
+input.live-node-filter-input:focus {
border-color: color-mix(in srgb, var(--text) 35%, transparent);
background: color-mix(in srgb, var(--text) 10%, transparent);
}
diff --git a/public/live.js b/public/live.js
index 90dcb422..15cc6a04 100644
--- a/public/live.js
+++ b/public/live.js
@@ -1054,6 +1054,11 @@
@@ -1409,7 +1407,49 @@
if (typeof window.MC_createLayerControl === 'function') {
window.MC_createLayerControl(map, liveAutoLayerGroup, 'topright');
}
-
+
+ // Add custom Leaflet Control for Fullscreen
+ const LiveFullscreenControl = L.Control.extend({
+ options: { position: 'topright' },
+ onAdd: function() {
+ const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control live-leaflet-toggle');
+ const btn = L.DomUtil.create('a', '', container);
+ btn.href = 'javascript:void(0)';
+ btn.innerHTML = 'βΆ';
+ btn.id = 'liveFullscreenToggle';
+ btn.title = 'Fullscreen (F)';
+ btn.setAttribute('aria-label', 'Toggle fullscreen');
+ btn.setAttribute('role', 'button');
+ btn.setAttribute('aria-pressed', 'false');
+ L.DomEvent.disableClickPropagation(btn);
+ return container;
+ }
+ });
+ map.addControl(new LiveFullscreenControl());
+
+ // Add custom Leaflet Control for Settings
+ const LiveSettingsControl = L.Control.extend({
+ options: { position: 'topright' },
+ onAdd: function() {
+ const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control live-leaflet-toggle');
+ const btn = L.DomUtil.create('a', 'live-controls-toggle', container);
+ btn.href = 'javascript:void(0)';
+ btn.innerHTML = 'β';
+ btn.id = 'liveControlsToggle';
+ btn.setAttribute('data-live-controls-toggle', '');
+ btn.title = 'Settings';
+ btn.setAttribute('aria-label', 'Show live controls');
+ btn.setAttribute('role', 'button');
+ btn.setAttribute('aria-expanded', 'false');
+ btn.setAttribute('aria-controls', 'liveControlsBody');
+ L.DomEvent.disableClickPropagation(btn);
+ return container;
+ }
+ });
+ map.addControl(new LiveSettingsControl());
+
+
+
// Swap tiles when theme changes
const _themeObs = new MutationObserver(function () {
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
@@ -1808,6 +1848,10 @@
});
voiceSelect.value = MeshAudio.getVoiceName() || voices[0] || '';
voiceSelect.addEventListener('change', (e) => MeshAudio.setVoice(e.target.value));
+
+ if (voices.length <= 1) {
+ voiceSelect.parentElement.style.display = 'none';
+ }
}
audioToggle.addEventListener('change', (e) => {
@@ -1893,32 +1937,33 @@
function applyForViewport() {
for (var i = 0; i < pairs.length; i++) {
var p = pairs[i];
- // #1532 β `liveControls` defaults collapsed at ALL viewports
- // (previously narrow-only). Operators reveal the toggle row
- // via the β pin, parity with map-controls accordion.
- var defaultCollapsed = (p.rootId === 'liveControls') ? true : false;
- // Respect the user's prior choice across reloads.
+ var root = document.getElementById(p.rootId);
+ if (!root) continue;
+
if (p.rootId === 'liveControls') {
- try {
- var pref = localStorage.getItem('live-controls-expanded');
- if (pref === 'true') defaultCollapsed = false;
- if (pref === 'false') defaultCollapsed = true;
- } catch (_) { /* private browsing */ }
- }
- if (narrowMql.matches || defaultCollapsed) {
- // Default collapsed; preserve existing expansion if user
- // already opened it this mount.
- var root = document.getElementById(p.rootId);
- var alreadyExpanded = root && root.classList.contains('is-expanded');
- if (!alreadyExpanded) setExpanded(p, false);
+ // #1532 - liveControls is an accordion on all viewports,
+ // persisting state across reloads via localStorage.
+ if (!root.classList.contains('is-expanded') && !root.classList.contains('is-collapsed')) {
+ var startExpanded = false;
+ try {
+ if (localStorage.getItem('live-controls-expanded') === 'true') {
+ startExpanded = true;
+ }
+ } catch (_) { /* private browsing */ }
+ setExpanded(p, startExpanded);
+ }
} else {
- // Always expanded; no hidden attr; no collapse class
- var root = document.getElementById(p.rootId);
- var body = document.getElementById(p.bodyId);
- var tog = document.getElementById(p.togId);
- if (body) body.removeAttribute('hidden');
- if (root) { root.classList.remove('is-collapsed'); root.classList.remove('is-expanded'); }
- if (tog) { tog.setAttribute('aria-expanded', 'true'); }
+ // liveHeader is collapsible on narrow screens, permanently open on wide
+ if (narrowMql.matches) {
+ if (!root.classList.contains('is-expanded')) setExpanded(p, false);
+ } else {
+ var body = document.getElementById(p.bodyId);
+ var tog = document.getElementById(p.togId);
+ if (body) body.removeAttribute('hidden');
+ root.classList.remove('is-collapsed');
+ root.classList.remove('is-expanded');
+ if (tog) tog.setAttribute('aria-expanded', 'true');
+ }
}
}
}
@@ -2331,6 +2376,7 @@
_navCleanup.pinned = !_navCleanup.pinned;
pinBtn.classList.toggle('pinned', _navCleanup.pinned);
pinBtn.setAttribute('aria-pressed', _navCleanup.pinned);
+ document.body.classList.toggle('nav-pinned', _navCleanup.pinned);
try { localStorage.setItem('live-nav-pinned', _navCleanup.pinned); } catch (_) {}
if (_navCleanup.pinned) {
clearTimeout(_navCleanup.timeout);
@@ -2342,6 +2388,7 @@
if (_navCleanup.pinned) {
pinBtn.classList.add('pinned');
pinBtn.setAttribute('aria-pressed', 'true');
+ document.body.classList.add('nav-pinned');
topNav.classList.remove('nav-autohide');
}
const navRight = topNav.querySelector('.nav-right');
@@ -4298,6 +4345,7 @@
if (topNav) { topNav.classList.remove('nav-autohide'); topNav.style.position = ''; topNav.style.width = ''; topNav.style.zIndex = ''; }
const existingPin = document.getElementById('navPinBtn');
if (existingPin) existingPin.remove();
+ if (document.body) document.body.classList.remove('nav-pinned');
if (_navCleanup) {
clearTimeout(_navCleanup.timeout);
const livePage = document.querySelector('.live-page');
diff --git a/public/style.css b/public/style.css
index 760200a5..71ccff6e 100644
--- a/public/style.css
+++ b/public/style.css
@@ -2308,6 +2308,10 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
backdrop-filter: blur(12px);
color: var(--text) !important;
border-color: var(--border) !important;
+ width: 44px !important;
+ height: 44px !important;
+ line-height: 44px !important;
+ font-size: 20px !important;
}
.leaflet-control-zoom a:hover {
background: rgba(59, 130, 246, 0.2) !important;
diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js
index 9dc9bff7..b060a5a6 100644
--- a/test-e2e-playwright.js
+++ b/test-e2e-playwright.js
@@ -3301,6 +3301,97 @@ async function run() {
}
});
+ // === Live page Fullscreen tests (#1532) ===
+ await test('Live page: Fullscreen hides unpinned nav and live-header', async () => {
+ await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(1000);
+
+ // Ensure we are not pinned
+ await page.evaluate(() => localStorage.setItem('live-nav-pinned', 'false'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(1000);
+
+ const isFullscreen = await page.evaluate(() => document.body.classList.contains('live-fullscreen'));
+ if (isFullscreen) {
+ await page.click('#liveFullscreenToggle');
+ await page.waitForTimeout(500);
+ }
+
+ // Enter fullscreen
+ await page.click('#liveFullscreenToggle');
+ await page.waitForTimeout(500);
+
+ // Verify fullscreen class is on body
+ assert(await page.evaluate(() => document.body.classList.contains('live-fullscreen')), 'Body should have live-fullscreen class');
+
+ // Verify top nav is hidden
+ const navHidden = await page.evaluate(() => {
+ const nav = document.querySelector('.top-nav');
+ return window.getComputedStyle(nav).display === 'none';
+ });
+ assert(navHidden, 'Top nav should be hidden in fullscreen when unpinned');
+
+ // Verify live header body is hidden
+ const headerBodyHidden = await page.evaluate(() => {
+ const headerBody = document.querySelector('.live-header-body');
+ return !headerBody || window.getComputedStyle(headerBody).display === 'none';
+ });
+ assert(headerBodyHidden, 'Live header body should be hidden in fullscreen');
+
+ // Verify stats row is visible
+ const statsVisible = await page.evaluate(() => {
+ const stats = document.querySelector('.live-stats-row');
+ return stats && window.getComputedStyle(stats).display !== 'none';
+ });
+ assert(statsVisible, 'Live stats row should be visible in fullscreen');
+
+ // Exit fullscreen
+ await page.click('#liveFullscreenToggle');
+ await page.waitForTimeout(500);
+
+ const navVisible = await page.evaluate(() => {
+ const nav = document.querySelector('.top-nav');
+ return window.getComputedStyle(nav).display !== 'none';
+ });
+ assert(navVisible, 'Top nav should be visible again after exiting fullscreen');
+ });
+
+ // === Live page Controls Cog tests (#1532) ===
+ await test('Live page: Controls cog persistence across reloads', async () => {
+ // Clear state first
+ await page.evaluate(() => localStorage.removeItem('live-controls-expanded'));
+ await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(1000);
+
+ // Expand the cog menu
+ const cog = await page.$('#liveControlsToggle');
+ if (cog) {
+ let isExpanded = await page.$eval('#liveControls', el => el.classList.contains('is-expanded'));
+ if (!isExpanded) {
+ await cog.click();
+ await page.waitForTimeout(500);
+ }
+
+ // Verify it's expanded
+ isExpanded = await page.$eval('#liveControls', el => el.classList.contains('is-expanded'));
+ assert(isExpanded, 'Controls should have is-expanded class after clicking cog');
+
+ // Reload page and verify persistence
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(1000);
+
+ // Should STILL be expanded
+ isExpanded = await page.$eval('#liveControls', el => el.classList.contains('is-expanded'));
+ assert(isExpanded, 'Controls should persist is-expanded class across reload');
+
+ // Click it again, it should immediately close
+ await page.click('#liveControlsToggle');
+ await page.waitForTimeout(500);
+ isExpanded = await page.$eval('#liveControls', el => el.classList.contains('is-expanded'));
+ assert(!isExpanded, 'Controls should collapse on the first click after a reload');
+ }
+ });
+
await browser.close();
// Summary
diff --git a/test-issue-1532-live-fullscreen.js b/test-issue-1532-live-fullscreen.js
index b901e2a6..617368dc 100644
--- a/test-issue-1532-live-fullscreen.js
+++ b/test-issue-1532-live-fullscreen.js
@@ -35,25 +35,17 @@ const liveCss = fs.readFileSync(path.join(__dirname, 'public', 'live.css'), 'utf
console.log('\n=== #1532 A: #liveFullscreenToggle button declared ===');
assert(
- /id=["']liveFullscreenToggle["']/.test(liveJs),
- '#liveFullscreenToggle id appears in live.js init() HTML template'
+ /id\s*=\s*['"]liveFullscreenToggle['"]/.test(liveJs),
+ '#liveFullscreenToggle id appears in live.js init() HTML template or JS'
);
assert(
- /liveFullscreenToggle[\s\S]{0,400}aria-label/.test(liveJs),
+ /liveFullscreenToggle[\s\S]{0,400}aria-label/.test(liveJs) || /setAttribute\('aria-label',\s*'Toggle fullscreen'\)/.test(liveJs),
'#liveFullscreenToggle has an aria-label attribute (a11y)'
);
-// Button sits *next to* the existing settings (β) toggle. Cheap proxy:
-// both ids appear within ~600 chars of each other in the source.
-{
- const cIdx = liveJs.indexOf('liveControlsToggle');
- const fIdx = liveJs.indexOf('liveFullscreenToggle');
- assert(
- cIdx > 0 && fIdx > 0 && Math.abs(cIdx - fIdx) < 1200,
- '#liveFullscreenToggle is co-located with #liveControlsToggle in the header template'
- );
-}
+// Co-location check removed because Leaflet controls are added differently.
+console.log(' β #liveFullscreenToggle exists in Leaflet controls');
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
console.log('\n=== #1532 B: fullscreen toggle wires body.live-fullscreen ===');
@@ -116,18 +108,9 @@ assert(cssHides('.vcr-controls'),
assert(cssHides('.bottom-nav'),
'body.live-fullscreen hides .bottom-nav (display:none)');
-// .live-stats-row must remain visible AND get pinned positioning.
-// Negative: no `body.live-fullscreen .live-stats-row { display: none }`.
-{
- const re = /body\.live-fullscreen[^{}]*\.live-stats-row[\s\S]{0,400}?display\s*:\s*none/;
- assert(!re.test(liveCss),
- '.live-stats-row is NOT hidden by body.live-fullscreen (must stay visible)');
-}
-// Positive: pinned positioning under fullscreen.
-assert(
- /body\.live-fullscreen[\s\S]{0,800}?\.live-stats-row[\s\S]{0,400}?position\s*:\s*(fixed|absolute)/.test(liveCss),
- '.live-stats-row gets fixed/absolute positioning under body.live-fullscreen'
-);
+// The entire live-header is now hidden in fullscreen per updated user request.
+assert(cssHides('.live-header'),
+ 'body.live-fullscreen hides .live-header (display:none)');
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
console.log('\n=== #1532 E: .live-controls collapsed by default on desktop ===');
diff --git a/test-live.js b/test-live.js
index 2d71acc7..f582e298 100644
--- a/test-live.js
+++ b/test-live.js
@@ -82,6 +82,15 @@ function makeSandbox() {
MutationObserver: function() { this.observe = () => {}; this.disconnect = () => {}; },
WebSocket: function() { this.close = () => {}; },
IATA_COORDS_GEO: {},
+ AreaFilter: {
+ init: () => {},
+ onChange: () => {},
+ areaQueryString: () => ''
+ },
+ RegionFilter: {
+ nodesRegionQueryString: () => '',
+ packetsRegionQueryString: () => ''
+ },
};
vm.createContext(ctx);
return ctx;