From 373ee81641e8a7414595e3f8871e11c3b61b132e Mon Sep 17 00:00:00 2001 From: Eldoon Nemar Date: Thu, 4 Jun 2026 22:46:11 -0400 Subject: [PATCH] fix(UI): Additional fixes for issue #1532 (#1580) - Eliminated extra space to the right of the map filters. - Made the map filters and mesh live a single line with a divider - Resized the input and dropdowns in the map filters so they meet WCAG 2.5.5 by being at least 44px high, but appearing 30px high - Turned the filters cog and the fullscreen button into native leaflet icons that are large enough to meet WCAV 2.5.5 compliance - Increased the size of the zoom buttons to meet WCAG 2.5.5 compliance on both the live and map pages - If the top nav bar is pinned, it won't disappear during fullscreen but if it isn't pinned, it will disappear with everything else. - The cog and full screen button change color to show they're active Final Outcome in 4k image Final Outcome in 1080p image --- public/live.css | 120 ++++++++++++++++++-------- public/live.js | 132 ++++++++++++++++++++--------- public/style.css | 4 + test-e2e-playwright.js | 91 ++++++++++++++++++++ test-issue-1532-live-fullscreen.js | 33 ++------ test-live.js | 9 ++ 6 files changed, 288 insertions(+), 101 deletions(-) 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 @@
+
+
+ MESH LIVE +
+
0 pkts
@@ -1070,11 +1075,7 @@ -
-
- MESH LIVE -
-
+ @@ -1099,12 +1100,16 @@ Show only favorited and claimed nodes
-
- - - +
+
+ + + +
+
-
- -
@@ -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;