diff --git a/ui/app.js b/ui/app.js index e7fb0924..66569b01 100644 --- a/ui/app.js +++ b/ui/app.js @@ -14,6 +14,7 @@ import { KeyboardShortcuts } from './utils/keyboard-shortcuts.js'; import { PerfMonitor } from './utils/perf-monitor.js'; import { toastManager } from './utils/toast.js'; import { ThemeToggle } from './utils/theme-toggle.js'; +import { MobileNav } from './utils/mobile-nav.js'; class WiFiDensePoseApp { constructor() { @@ -187,9 +188,27 @@ class WiFiDensePoseApp { this.perfMonitor = new PerfMonitor(); this.perfMonitor.init(); + // Mobile navigation (hamburger menu for small screens) + this.mobileNav = new MobileNav(); + this.mobileNav.init(); + // Keyboard shortcuts (pass app reference for tab switching) this.keyboardShortcuts = new KeyboardShortcuts(this); this.keyboardShortcuts.init(); + + // Register PWA service worker + this.registerServiceWorker(); + } + + // Register service worker for offline capability + registerServiceWorker() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('./sw.js').then(reg => { + console.info('Service worker registered:', reg.scope); + }).catch(err => { + console.warn('Service worker registration failed:', err); + }); + } } // Handle tab changes @@ -331,6 +350,7 @@ class WiFiDensePoseApp { if (this.keyboardShortcuts) this.keyboardShortcuts.dispose(); if (this.perfMonitor) this.perfMonitor.dispose(); if (this.themeToggle) this.themeToggle.dispose(); + if (this.mobileNav) this.mobileNav.dispose(); toastManager.dispose(); } diff --git a/ui/icons/generate.html b/ui/icons/generate.html new file mode 100644 index 00000000..161ad7c6 --- /dev/null +++ b/ui/icons/generate.html @@ -0,0 +1,66 @@ + + +RuView Icon Generator + +

Open this file in a browser and right-click to save the canvas images as icon-192.png and icon-512.png

+ + + + + diff --git a/ui/index.html b/ui/index.html index 2d7f31e3..2708c8e1 100644 --- a/ui/index.html +++ b/ui/index.html @@ -3,8 +3,13 @@ + + + + WiFi DensePose: Human Tracking Through Walls + diff --git a/ui/manifest.json b/ui/manifest.json new file mode 100644 index 00000000..06f25ebb --- /dev/null +++ b/ui/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "RuView - WiFi DensePose", + "short_name": "RuView", + "description": "WiFi-based human pose estimation, vital sign detection, and presence sensing through walls", + "start_url": "/", + "display": "standalone", + "background_color": "#1f2121", + "theme_color": "#21808d", + "orientation": "any", + "categories": ["utilities", "medical"], + "icons": [ + { + "src": "icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/ui/style.css b/ui/style.css index d117883b..c7b8a433 100644 --- a/ui/style.css +++ b/ui/style.css @@ -2858,3 +2858,158 @@ a:focus-visible, width: 95vw; } } + +/* ========================================================================== + MOBILE NAVIGATION - Hamburger menu for small screens + ========================================================================== */ + +/* Hamburger button - hidden on desktop */ +.mobile-hamburger { + display: none; + position: absolute; + top: 50%; + right: var(--space-16); + transform: translateY(-50%); + width: 40px; + height: 40px; + padding: var(--space-8); + background: var(--color-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-base); + cursor: pointer; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + z-index: 100; + transition: all var(--duration-fast); +} + +.mobile-hamburger:hover { + background: var(--color-secondary-hover); +} + +.mobile-hamburger:focus-visible { + outline: var(--focus-outline); + outline-offset: 2px; +} + +.hamburger-line { + display: block; + width: 18px; + height: 2px; + background: var(--color-text); + border-radius: 1px; + transition: all var(--duration-normal) var(--ease-standard); +} + +.mobile-hamburger.open .hamburger-line:nth-child(1) { + transform: rotate(45deg) translate(5px, 5px); +} + +.mobile-hamburger.open .hamburger-line:nth-child(2) { + opacity: 0; +} + +.mobile-hamburger.open .hamburger-line:nth-child(3) { + transform: rotate(-45deg) translate(5px, -5px); +} + +/* Backdrop */ +.mobile-nav-backdrop { + position: fixed; + inset: 0; + z-index: 9990; + background: rgba(0, 0, 0, 0.5); + opacity: 0; + pointer-events: none; + transition: opacity var(--duration-normal); +} + +.mobile-nav-backdrop.open { + opacity: 1; + pointer-events: auto; +} + +/* Drawer */ +.mobile-nav-drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 9991; + width: 280px; + max-width: 80vw; + background: var(--color-surface); + border-left: 1px solid var(--color-border); + box-shadow: -8px 0 24px rgba(0, 0, 0, 0.12); + transform: translateX(100%); + transition: transform var(--duration-normal) var(--ease-standard); + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.mobile-nav-drawer.open { + transform: translateX(0); +} + +.mobile-nav-list { + padding: var(--space-16) 0; + flex: 1; +} + +.mobile-nav-item { + display: block; + width: 100%; + padding: var(--space-12) var(--space-24); + background: none; + border: none; + text-align: left; + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + cursor: pointer; + transition: all var(--duration-fast); + text-decoration: none; + border-left: 3px solid transparent; +} + +.mobile-nav-item:hover { + background: var(--color-secondary); + color: var(--color-text); +} + +.mobile-nav-item:focus-visible { + outline: var(--focus-outline); + outline-offset: -2px; +} + +.mobile-nav-item.active { + color: var(--color-primary); + border-left-color: var(--color-primary); + background: rgba(var(--color-success-rgb), 0.05); + font-weight: var(--font-weight-semibold); +} + +.mobile-nav-hint { + padding: var(--space-16) var(--space-24); + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + border-top: 1px solid var(--color-border); +} + +/* On mobile: show hamburger, hide desktop tabs */ +@media (max-width: 768px) { + .mobile-hamburger { + display: flex; + } + + .mobile-nav-active .nav-tabs { + display: none; + } + + .header { + padding-right: 60px; + } +} diff --git a/ui/sw.js b/ui/sw.js new file mode 100644 index 00000000..eb84e2b4 --- /dev/null +++ b/ui/sw.js @@ -0,0 +1,124 @@ +// RuView Service Worker - Offline caching for the dashboard shell +// Strategy: Network-first for API calls, Cache-first for static assets + +const CACHE_NAME = 'ruview-v1'; +const SHELL_ASSETS = [ + '/', + '/index.html', + '/style.css', + '/app.js', + '/config/api.config.js', + '/components/TabManager.js', + '/components/DashboardTab.js', + '/components/HardwareTab.js', + '/components/LiveDemoTab.js', + '/components/SensingTab.js', + '/components/PoseDetectionCanvas.js', + '/services/api.service.js', + '/services/websocket.service.js', + '/services/health.service.js', + '/services/sensing.service.js', + '/services/pose.service.js', + '/services/stream.service.js', + '/utils/backend-detector.js', + '/utils/keyboard-shortcuts.js', + '/utils/perf-monitor.js', + '/utils/toast.js', + '/utils/theme-toggle.js', + '/utils/command-palette.js', + '/utils/activity-log.js', + '/utils/data-export.js', + '/utils/fullscreen.js', + '/utils/connection-status.js', + '/utils/mobile-nav.js' +]; + +// Install - cache shell assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(SHELL_ASSETS).catch((err) => { + // Don't fail install if some assets are missing (dev mode) + console.warn('[SW] Some assets failed to cache:', err); + }); + }) + ); + self.skipWaiting(); +}); + +// Activate - clean old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => { + return Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)) + ); + }) + ); + self.clients.claim(); +}); + +// Fetch - network-first for API, cache-first for static +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests + if (request.method !== 'GET') return; + + // Skip WebSocket upgrade requests + if (request.headers.get('Upgrade') === 'websocket') return; + + // Skip cross-origin requests + if (url.origin !== self.location.origin) return; + + // API calls: network-first with cache fallback + if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/health/')) { + event.respondWith(networkFirst(request)); + return; + } + + // Static assets: cache-first with network fallback + event.respondWith(cacheFirst(request)); +}); + +async function cacheFirst(request) { + const cached = await caches.match(request); + if (cached) return cached; + + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, response.clone()); + } + return response; + } catch { + // Return offline fallback for HTML navigation + if (request.headers.get('Accept')?.includes('text/html')) { + const fallback = await caches.match('/index.html'); + if (fallback) return fallback; + } + return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); + } +} + +async function networkFirst(request) { + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, response.clone()); + } + return response; + } catch { + const cached = await caches.match(request); + if (cached) return cached; + return new Response(JSON.stringify({ error: 'offline' }), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }); + } +} diff --git a/ui/tests/unit-tests.html b/ui/tests/unit-tests.html new file mode 100644 index 00000000..4c4e03ca --- /dev/null +++ b/ui/tests/unit-tests.html @@ -0,0 +1,472 @@ + + + + + + RuView UI - Unit Tests + + + +

RuView UI - Unit Tests

+

Tests for UI components and utility modules

+
+
+ + + + diff --git a/ui/utils/mobile-nav.js b/ui/utils/mobile-nav.js new file mode 100644 index 00000000..afc9f3fa --- /dev/null +++ b/ui/utils/mobile-nav.js @@ -0,0 +1,171 @@ +// Mobile Navigation - Hamburger menu for small screens +// Replaces wrapped tab bar with a slide-out drawer on mobile + +export class MobileNav { + constructor() { + this.drawer = null; + this.backdrop = null; + this.hamburger = null; + this.isOpen = false; + this.mql = window.matchMedia('(max-width: 768px)'); + } + + init() { + this.createHamburger(); + this.createDrawer(); + this.bindEvents(); + this.onMediaChange(this.mql); + } + + createHamburger() { + this.hamburger = document.createElement('button'); + this.hamburger.className = 'mobile-hamburger'; + this.hamburger.setAttribute('aria-label', 'Open navigation menu'); + this.hamburger.setAttribute('aria-expanded', 'false'); + this.hamburger.innerHTML = ` + + + + `; + this.hamburger.addEventListener('click', () => this.toggle()); + + const header = document.querySelector('.header'); + if (header) { + header.style.position = 'relative'; + header.appendChild(this.hamburger); + } + } + + createDrawer() { + // Backdrop + this.backdrop = document.createElement('div'); + this.backdrop.className = 'mobile-nav-backdrop'; + this.backdrop.addEventListener('click', () => this.close()); + document.body.appendChild(this.backdrop); + + // Drawer + this.drawer = document.createElement('nav'); + this.drawer.className = 'mobile-nav-drawer'; + this.drawer.setAttribute('role', 'navigation'); + this.drawer.setAttribute('aria-label', 'Mobile navigation'); + + // Clone tabs into drawer + const tabs = document.querySelectorAll('.nav-tabs .nav-tab'); + const list = document.createElement('div'); + list.className = 'mobile-nav-list'; + + tabs.forEach(tab => { + const item = document.createElement(tab.tagName === 'A' ? 'a' : 'button'); + item.className = 'mobile-nav-item'; + item.textContent = tab.textContent.trim(); + + if (tab.tagName === 'A') { + item.href = tab.href; + } else { + const tabId = tab.getAttribute('data-tab'); + item.dataset.tab = tabId; + if (tab.classList.contains('active')) { + item.classList.add('active'); + } + item.addEventListener('click', () => { + // Activate tab via the original tab manager + tab.click(); + this.close(); + // Update active states in drawer + list.querySelectorAll('.mobile-nav-item').forEach(i => i.classList.remove('active')); + item.classList.add('active'); + }); + } + + list.appendChild(item); + }); + + this.drawer.appendChild(list); + + // Keyboard hint at bottom + const hint = document.createElement('div'); + hint.className = 'mobile-nav-hint'; + hint.textContent = 'Tip: Press Ctrl+K for command palette'; + this.drawer.appendChild(hint); + + document.body.appendChild(this.drawer); + + // Sync active tab when tabs change externally + const observer = new MutationObserver(() => { + const activeTab = document.querySelector('.nav-tabs .nav-tab.active'); + if (activeTab) { + const activeId = activeTab.getAttribute('data-tab'); + list.querySelectorAll('.mobile-nav-item').forEach(item => { + item.classList.toggle('active', item.dataset.tab === activeId); + }); + } + }); + + const navTabs = document.querySelector('.nav-tabs'); + if (navTabs) { + observer.observe(navTabs, { attributes: true, subtree: true, attributeFilter: ['class'] }); + } + } + + bindEvents() { + // Listen for media query changes + this.mql.addEventListener('change', (e) => this.onMediaChange(e)); + + // Close on escape + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.isOpen) this.close(); + }); + + // Swipe to close + let touchStartX = 0; + this.drawer.addEventListener('touchstart', (e) => { + touchStartX = e.touches[0].clientX; + }, { passive: true }); + this.drawer.addEventListener('touchend', (e) => { + const deltaX = e.changedTouches[0].clientX - touchStartX; + if (deltaX < -50) this.close(); // Swipe left to close + }, { passive: true }); + } + + onMediaChange(mql) { + const isMobile = mql.matches !== undefined ? mql.matches : mql; + document.body.classList.toggle('mobile-nav-active', isMobile); + + if (!isMobile && this.isOpen) { + this.close(); + } + } + + toggle() { + this.isOpen ? this.close() : this.open(); + } + + open() { + this.isOpen = true; + this.drawer.classList.add('open'); + this.backdrop.classList.add('open'); + this.hamburger.classList.add('open'); + this.hamburger.setAttribute('aria-expanded', 'true'); + document.body.style.overflow = 'hidden'; + + // Focus first item + const first = this.drawer.querySelector('.mobile-nav-item'); + if (first) first.focus(); + } + + close() { + this.isOpen = false; + this.drawer.classList.remove('open'); + this.backdrop.classList.remove('open'); + this.hamburger.classList.remove('open'); + this.hamburger.setAttribute('aria-expanded', 'false'); + document.body.style.overflow = ''; + } + + dispose() { + this.close(); + this.hamburger?.remove(); + this.drawer?.remove(); + this.backdrop?.remove(); + } +}