feat: Priority+ nav pattern for tablet viewports (768-1023px) (#345)

## Priority+ Navigation Pattern for Tablet Viewports

Phase 2 of responsive nav improvements for #322.

### What this does

On **tablet viewports (768-1023px)**, implements the [Priority+
navigation
pattern](https://css-tricks.com/the-priority-plus-navigation-pattern/):

- **5 high-priority tabs** shown inline: Home, Nodes, Packets, Map, Live
- **6 low-priority tabs** collapse into a "More ▾" dropdown: Channels,
Traces, Observers, Analytics, Perf, Lab
- The "More" button highlights when a low-priority page is active

**Desktop (>=1024px)** and **mobile (<768px)** behavior is unchanged.

### Changes

| File | Change |
|------|--------|
| `public/index.html` | Added `data-priority="high"` to 5 primary nav
links; added More button + dropdown menu |
| `public/style.css` | Split ≤1023px hamburger query into tablet
Priority+ (768-1023px) and mobile hamburger (<768px); added More
dropdown styles |
| `public/app.js` | Added `closeMoreMenu()`, More button toggle,
outside-click/Escape close, active state on More button |
| Cache busters | Bumped in same commit |

### Accessibility

- `aria-haspopup="true"` and `aria-expanded` on More button
- `role="menu"` / `role="menuitem"` on dropdown
- Focus moves to first item on open
- Escape key closes dropdown

### Testing

- All 308 existing tests pass (217 frontend-helpers + 62 packet-filter +
29 aging)
- No new dependencies added
- No build step changes

### Breakpoint summary

| Viewport | Behavior |
|----------|----------|
| >= 1024px | Full horizontal nav (unchanged) |
| 768-1023px | Priority+ pattern: 5 tabs + More dropdown **← NEW** |
| < 768px | Hamburger drawer with all items (unchanged) |

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kpa-clawbot
2026-03-31 23:38:54 -07:00
committed by GitHub
co-authored by Kpa-clawbot Copilot
parent 711889c823
commit d1cb84b596
3 changed files with 120 additions and 38 deletions
+49 -1
View File
@@ -412,6 +412,14 @@ function closeNav() {
document.body.classList.remove('nav-open');
var btn = document.getElementById('hamburger');
if (btn) btn.setAttribute('aria-expanded', 'false');
closeMoreMenu();
}
function closeMoreMenu() {
var menu = document.getElementById('navMoreMenu');
var btn = document.getElementById('navMoreBtn');
if (menu) menu.classList.remove('open');
if (btn) btn.setAttribute('aria-expanded', 'false');
}
function navigate() {
@@ -448,6 +456,13 @@ function navigate() {
document.querySelectorAll('.nav-link[data-route]').forEach(el => {
el.classList.toggle('active', el.dataset.route === basePage);
});
// Update "More" button to show active state if a low-priority page is selected
var moreBtn = document.getElementById('navMoreBtn');
if (moreBtn) {
var moreMenu = document.getElementById('navMoreMenu');
var hasActiveMore = moreMenu && moreMenu.querySelector('.nav-link.active');
moreBtn.classList.toggle('active', !!hasActiveMore);
}
if (currentPage && pages[currentPage]?.destroy) {
pages[currentPage].destroy();
@@ -551,8 +566,36 @@ window.addEventListener('DOMContentLoaded', () => {
navLinks.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', closeNav);
});
// --- "More" dropdown (tablet Priority+ nav) ---
const navMoreBtn = document.getElementById('navMoreBtn');
const navMoreMenu = document.getElementById('navMoreMenu');
if (navMoreBtn && navMoreMenu) {
// Build More menu dynamically from non-priority nav links (DRY)
navMoreMenu.innerHTML = '';
document.querySelectorAll('.nav-links a:not([data-priority="high"])').forEach(function(link) {
var clone = link.cloneNode(true);
clone.setAttribute('role', 'menuitem');
clone.addEventListener('click', closeMoreMenu);
navMoreMenu.appendChild(clone);
});
navMoreBtn.addEventListener('click', (e) => {
e.stopPropagation();
const opening = !navMoreMenu.classList.contains('open');
navMoreMenu.classList.toggle('open');
navMoreBtn.setAttribute('aria-expanded', String(opening));
if (opening) {
var firstLink = navMoreMenu.querySelector('.nav-link');
if (firstLink) firstLink.focus();
}
});
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && navLinks.classList.contains('open')) closeNav();
if (e.key === 'Escape') {
if (navMoreMenu && navMoreMenu.classList.contains('open')) closeMoreMenu();
if (navLinks.classList.contains('open')) closeNav();
}
});
document.addEventListener('click', (e) => {
if (navLinks.classList.contains('open') &&
@@ -560,6 +603,11 @@ window.addEventListener('DOMContentLoaded', () => {
!hamburger.contains(e.target)) {
closeNav();
}
if (navMoreMenu && navMoreMenu.classList.contains('open') &&
!navMoreMenu.contains(e.target) &&
!navMoreBtn.contains(e.target)) {
closeMoreMenu();
}
});
// --- Favorites dropdown ---
+37 -33
View File
@@ -22,9 +22,9 @@
<meta name="twitter:title" content="CoreScope">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1775023782">
<link rel="stylesheet" href="home.css?v=1775023782">
<link rel="stylesheet" href="live.css?v=1775023782">
<link rel="stylesheet" href="style.css?v=1775022775">
<link rel="stylesheet" href="home.css?v=1775022775">
<link rel="stylesheet" href="live.css?v=1775022775">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -44,18 +44,22 @@
<span class="live-dot" id="liveDot" title="WebSocket connected" aria-label="WebSocket connected"></span>
</a>
<div class="nav-links">
<a href="#/home" class="nav-link" data-route="home">Home</a>
<a href="#/packets" class="nav-link" data-route="packets">Packets</a>
<a href="#/map" class="nav-link" data-route="map">Map</a>
<a href="#/live" class="nav-link" data-route="live">🔴 Live</a>
<a href="#/home" class="nav-link" data-route="home" data-priority="high">Home</a>
<a href="#/packets" class="nav-link" data-route="packets" data-priority="high">Packets</a>
<a href="#/map" class="nav-link" data-route="map" data-priority="high">Map</a>
<a href="#/live" class="nav-link" data-route="live" data-priority="high">🔴 Live</a>
<a href="#/channels" class="nav-link" data-route="channels">Channels</a>
<a href="#/nodes" class="nav-link" data-route="nodes">Nodes</a>
<a href="#/nodes" class="nav-link" data-route="nodes" data-priority="high">Nodes</a>
<a href="#/traces" class="nav-link" data-route="traces">Traces</a>
<a href="#/observers" class="nav-link" data-route="observers">Observers</a>
<a href="#/analytics" class="nav-link" data-route="analytics">Analytics</a>
<a href="#/perf" class="nav-link" data-route="perf">⚡ Perf</a>
<a href="#/audio-lab" class="nav-link" data-route="audio-lab">🎵 Lab</a>
</div>
<div class="nav-more-wrap">
<button class="nav-btn nav-more-btn" id="navMoreBtn" aria-haspopup="true" aria-expanded="false" aria-controls="navMoreMenu" title="More pages">More ▾</button>
<div class="nav-more-menu" id="navMoreMenu" role="menu"></div>
</div>
</div>
<div class="nav-right">
<div class="nav-stats" id="navStats" title="Live stats"></div>
@@ -81,30 +85,30 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1775023782"></script>
<script src="customize.js?v=1775023782" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1775023782"></script>
<script src="hop-resolver.js?v=1775023782"></script>
<script src="hop-display.js?v=1775023782"></script>
<script src="app.js?v=1775023782"></script>
<script src="home.js?v=1775023782"></script>
<script src="packet-filter.js?v=1775023782"></script>
<script src="packets.js?v=1775023782"></script>
<script src="geo-filter-overlay.js?v=1775023782"></script>
<script src="map.js?v=1775023782" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1775023782" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1775023782" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1775023782" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1775023782" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1775023782" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1775023782" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1775023782" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1775023782" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1775023782" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1775023782" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1775023782" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1775023782" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1775023782" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1775023782" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1775022775"></script>
<script src="customize.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1775022775"></script>
<script src="hop-resolver.js?v=1775022775"></script>
<script src="hop-display.js?v=1775022775"></script>
<script src="app.js?v=1775022775"></script>
<script src="home.js?v=1775022775"></script>
<script src="packet-filter.js?v=1775022775"></script>
<script src="packets.js?v=1775022775"></script>
<script src="geo-filter-overlay.js?v=1775022775"></script>
<script src="map.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+34 -4
View File
@@ -5,6 +5,7 @@
--nav-bg2: #1a1a2e;
--nav-text: #ffffff;
--nav-text-muted: #cbd5e1;
--nav-active-bg: rgba(74, 158, 255, 0.15);
--accent: #4a9eff;
--geo-filter-color: #3b82f6;
--status-green: #22c55e;
@@ -128,7 +129,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
.nav-link.active {
color: var(--nav-text);
border-bottom-color: transparent;
background: rgba(74, 158, 255, 0.15);
background: var(--nav-active-bg);
border-radius: 6px;
margin: 4px 0;
padding: 10px 12px;
@@ -837,6 +838,22 @@ button.ch-item.selected { background: var(--selected-bg); }
/* === Hamburger (hidden on desktop) === */
.hamburger { display: none; }
/* "More" button (hidden on desktop) */
.nav-more-wrap { display: none; position: relative; }
.nav-more-btn { display: inline-flex; }
.nav-more-menu {
display: none; position: absolute; top: calc(var(--top-nav-h, 52px) - 4px); right: 0;
background: var(--nav-bg); border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); flex-direction: column;
min-width: 160px; padding: 4px 0; z-index: 1200;
}
.nav-more-menu.open { display: flex; }
.nav-more-menu .nav-link {
padding: 10px 16px; border-bottom: none; border-radius: 0; margin: 0;
white-space: nowrap;
}
.nav-more-menu .nav-link:hover { background: var(--nav-bg2); color: var(--nav-text); }
.nav-more-menu .nav-link.active { background: var(--nav-active-bg); }
/* Ensure nav stays above Leaflet map */
.nav-links.open { z-index: 1100; }
#map-wrap .leaflet-container { z-index: 1; }
@@ -851,18 +868,31 @@ button.ch-item.selected { background: var(--selected-bg); }
.map-controls { width: 180px; font-size: 12px; }
}
/* === Responsive — Hamburger nav (1023px) === */
@media (max-width: 1023px) {
/* === Responsive — Tablet Priority+ nav (7681023px) === */
@media (min-width: 768px) and (max-width: 1023px) {
.nav-links { display: flex !important; flex-direction: row; gap: 2px; }
.nav-links a:not([data-priority="high"]) { display: none; }
.nav-more-wrap { display: flex; align-items: center; }
.hamburger { display: none; }
.nav-link { padding: 14px 8px; font-size: 13px; }
.nav-links a[data-priority="high"] { order: -1; }
.nav-link.active { background: var(--nav-active-bg); border-radius: 6px; margin: 4px 0; padding: 10px 8px; }
}
/* === Responsive — Hamburger nav (<768px) === */
@media (max-width: 767px) {
.hamburger { display: inline-flex; }
.nav-more-wrap { display: none !important; }
.nav-links {
display: none; position: absolute; top: 52px; left: 0; right: 0;
background: var(--nav-bg); flex-direction: column; padding: 8px 0;
box-shadow: 0 8px 24px rgba(0,0,0,.4); z-index: 1100;
max-height: calc(100dvh - 52px); overflow-y: auto;
}
.nav-links a:not([data-priority="high"]) { display: flex; }
.nav-links.open { display: flex; }
.nav-link { padding: 12px 20px; border-bottom: none; }
.nav-link.active { background: rgba(74,158,255,0.15); border-radius: 0; margin: 0; padding: 12px 20px; }
.nav-link.active { background: var(--nav-active-bg); border-radius: 0; margin: 0; padding: 12px 20px; }
.nav-left { gap: 12px; }
body.nav-open { overflow: hidden; }
}