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();
+ }
+}