mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 05:59:32 +00:00
feat(ui): add mobile hamburger nav, PWA support, and 40 unit tests
- Mobile hamburger navigation: slide-out drawer replacing tab bar on <768px, swipe-to-close, animated hamburger icon, auto-sync with tab manager - PWA manifest + service worker: installable dashboard, offline shell caching (cache-first for static, network-first for API), auto-cleanup of old caches - 40 unit tests for ToastManager, ThemeToggle, KeyboardShortcuts, PerfMonitor, TabManager - browser-based test runner at ui/tests/unit-tests.html - PWA meta tags: theme-color, apple-mobile-web-app-capable, manifest link - Icon generator page for creating PWA icons (ui/icons/generate.html)
This commit is contained in:
parent
f50a705dfc
commit
8766875347
8 changed files with 1038 additions and 0 deletions
20
ui/app.js
20
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
66
ui/icons/generate.html
Normal file
66
ui/icons/generate.html
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>RuView Icon Generator</title></head>
|
||||
<body>
|
||||
<p>Open this file in a browser and right-click to save the canvas images as icon-192.png and icon-512.png</p>
|
||||
<canvas id="c192" width="192" height="192"></canvas>
|
||||
<canvas id="c512" width="512" height="512"></canvas>
|
||||
<script>
|
||||
function drawIcon(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const s = canvas.width;
|
||||
// Background
|
||||
ctx.fillStyle = '#1f2121';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, 0, s, s, s * 0.15);
|
||||
ctx.fill();
|
||||
// WiFi arcs
|
||||
ctx.strokeStyle = '#32b8c6';
|
||||
ctx.lineWidth = s * 0.035;
|
||||
ctx.lineCap = 'round';
|
||||
const cx = s * 0.5, cy = s * 0.55;
|
||||
[0.35, 0.25, 0.15].forEach(r => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, s * r, -Math.PI * 0.75, -Math.PI * 0.25);
|
||||
ctx.stroke();
|
||||
});
|
||||
// Center dot
|
||||
ctx.fillStyle = '#32b8c6';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, s * 0.03, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Person silhouette
|
||||
ctx.strokeStyle = '#21808d';
|
||||
ctx.lineWidth = s * 0.025;
|
||||
// Head
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy - s * 0.15, s * 0.045, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
// Body
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy - s * 0.1);
|
||||
ctx.lineTo(cx, cy + s * 0.05);
|
||||
ctx.stroke();
|
||||
// Arms
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - s * 0.08, cy - s * 0.04);
|
||||
ctx.lineTo(cx + s * 0.08, cy - s * 0.04);
|
||||
ctx.stroke();
|
||||
// Legs
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy + s * 0.05);
|
||||
ctx.lineTo(cx - s * 0.06, cy + s * 0.15);
|
||||
ctx.moveTo(cx, cy + s * 0.05);
|
||||
ctx.lineTo(cx + s * 0.06, cy + s * 0.15);
|
||||
ctx.stroke();
|
||||
// Text
|
||||
ctx.fillStyle = '#f5f5f5';
|
||||
ctx.font = `bold ${s * 0.08}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('RuView', cx, s * 0.88);
|
||||
}
|
||||
drawIcon(document.getElementById('c192'));
|
||||
drawIcon(document.getElementById('c512'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -3,8 +3,13 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#21808d">
|
||||
<meta name="description" content="WiFi-based human pose estimation, vital sign detection, and presence sensing through walls">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>WiFi DensePose: Human Tracking Through Walls</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Skip to main content link for keyboard/screen reader users -->
|
||||
|
|
|
|||
25
ui/manifest.json
Normal file
25
ui/manifest.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
155
ui/style.css
155
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
124
ui/sw.js
Normal file
124
ui/sw.js
Normal file
|
|
@ -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' }
|
||||
});
|
||||
}
|
||||
}
|
||||
472
ui/tests/unit-tests.html
Normal file
472
ui/tests/unit-tests.html
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RuView UI - Unit Tests</title>
|
||||
<style>
|
||||
* { margin: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 24px; }
|
||||
h1 { font-size: 20px; margin-bottom: 4px; color: #32b8c6; }
|
||||
.subtitle { font-size: 13px; color: #a7a9a9; margin-bottom: 20px; }
|
||||
.suite { margin-bottom: 16px; }
|
||||
.suite-name { font-size: 14px; font-weight: 600; margin-bottom: 6px; color: #a7a9a9; }
|
||||
.test { padding: 4px 0 4px 16px; font-size: 13px; font-family: monospace; }
|
||||
.pass { color: #32b8c6; }
|
||||
.fail { color: #ff5459; }
|
||||
.pass::before { content: "PASS "; font-weight: bold; }
|
||||
.fail::before { content: "FAIL "; font-weight: bold; }
|
||||
.summary { margin-top: 24px; padding: 12px; border-top: 1px solid #333; font-size: 14px; font-weight: 600; }
|
||||
.error-detail { color: #ff8a8a; font-size: 12px; padding-left: 32px; white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>RuView UI - Unit Tests</h1>
|
||||
<p class="subtitle">Tests for UI components and utility modules</p>
|
||||
<div id="output"></div>
|
||||
<div id="summary" class="summary"></div>
|
||||
|
||||
<script type="module">
|
||||
// ---- Minimal test framework (zero deps) ----
|
||||
const results = [];
|
||||
let currentSuite = '';
|
||||
|
||||
function describe(name, fn) { currentSuite = name; fn(); }
|
||||
|
||||
function it(name, fn) {
|
||||
try { fn(); results.push({ suite: currentSuite, name, passed: true }); }
|
||||
catch (e) { results.push({ suite: currentSuite, name, passed: false, error: e.message }); }
|
||||
}
|
||||
|
||||
function expect(actual) {
|
||||
return {
|
||||
toBe(exp) { if (actual !== exp) throw new Error(`Expected ${JSON.stringify(exp)}, got ${JSON.stringify(actual)}`); },
|
||||
toEqual(exp) { if (JSON.stringify(actual) !== JSON.stringify(exp)) throw new Error(`Expected ${JSON.stringify(exp)}, got ${JSON.stringify(actual)}`); },
|
||||
toBeTruthy() { if (!actual) throw new Error(`Expected truthy, got ${JSON.stringify(actual)}`); },
|
||||
toBeFalsy() { if (actual) throw new Error(`Expected falsy, got ${JSON.stringify(actual)}`); },
|
||||
toBeGreaterThan(n) { if (!(actual > n)) throw new Error(`Expected ${actual} > ${n}`); },
|
||||
toContain(str) { if (typeof actual === 'string' ? !actual.includes(str) : !actual.includes(str)) throw new Error(`Expected to contain "${str}"`); },
|
||||
not: {
|
||||
toBe(exp) { if (actual === exp) throw new Error(`Expected not ${JSON.stringify(exp)}`); },
|
||||
toContain(str) { if (typeof actual === 'string' && actual.includes(str)) throw new Error(`Expected not to contain "${str}"`); }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function mockDOM() {
|
||||
const c = document.createElement('div');
|
||||
c.className = 'container';
|
||||
c.innerHTML = `
|
||||
<header class="header"><div class="header-info"></div></header>
|
||||
<nav class="nav-tabs">
|
||||
<button class="nav-tab active" data-tab="dashboard" role="tab" aria-selected="true">Dashboard</button>
|
||||
<button class="nav-tab" data-tab="hardware" role="tab" aria-selected="false">Hardware</button>
|
||||
<button class="nav-tab" data-tab="demo" role="tab" aria-selected="false">Live Demo</button>
|
||||
</nav>
|
||||
<section id="dashboard" class="tab-content active" role="tabpanel"></section>
|
||||
<section id="hardware" class="tab-content" role="tabpanel"></section>
|
||||
<section id="demo" class="tab-content" role="tabpanel"></section>
|
||||
`;
|
||||
document.body.appendChild(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
// ===== ToastManager =====
|
||||
const { ToastManager } = await import('../utils/toast.js');
|
||||
|
||||
describe('ToastManager', () => {
|
||||
it('creates container with role=region on init', () => {
|
||||
const tm = new ToastManager();
|
||||
tm.init();
|
||||
expect(tm.container.getAttribute('role')).toBe('region');
|
||||
expect(tm.container.getAttribute('aria-live')).toBe('polite');
|
||||
tm.dispose();
|
||||
});
|
||||
|
||||
it('show() returns unique incremental ids', () => {
|
||||
const tm = new ToastManager();
|
||||
tm.init();
|
||||
const a = tm.show('A'); const b = tm.show('B');
|
||||
expect(b).toBeGreaterThan(a);
|
||||
tm.dispose();
|
||||
});
|
||||
|
||||
it('dismiss() removes toast from list', () => {
|
||||
const tm = new ToastManager();
|
||||
tm.init();
|
||||
const id = tm.show('X', { duration: 0 });
|
||||
expect(tm.toasts.length).toBe(1);
|
||||
tm.dismiss(id);
|
||||
expect(tm.toasts.length).toBe(0);
|
||||
tm.dispose();
|
||||
});
|
||||
|
||||
it('dismiss() is safe to call with unknown id', () => {
|
||||
const tm = new ToastManager();
|
||||
tm.init();
|
||||
tm.dismiss(99999); // should not throw
|
||||
expect(tm.toasts.length).toBe(0);
|
||||
tm.dispose();
|
||||
});
|
||||
|
||||
it('success/error/warning/info create correct types', () => {
|
||||
const tm = new ToastManager();
|
||||
tm.init();
|
||||
tm.success('a'); tm.error('b'); tm.warning('c'); tm.info('d');
|
||||
expect(tm.toasts.length).toBe(4);
|
||||
tm.dispose();
|
||||
});
|
||||
|
||||
it('escapes HTML entities to prevent XSS', () => {
|
||||
const tm = new ToastManager();
|
||||
const safe = tm.escapeHtml('<img src=x onerror=alert(1)>');
|
||||
expect(safe).not.toContain('<img');
|
||||
expect(safe).toContain('<img');
|
||||
});
|
||||
|
||||
it('stacks multiple toasts in container', () => {
|
||||
const tm = new ToastManager();
|
||||
tm.init();
|
||||
tm.show('1', { duration: 0 });
|
||||
tm.show('2', { duration: 0 });
|
||||
tm.show('3', { duration: 0 });
|
||||
expect(tm.container.children.length).toBe(3);
|
||||
tm.dispose();
|
||||
});
|
||||
|
||||
it('dispose() removes container from DOM', () => {
|
||||
const tm = new ToastManager();
|
||||
tm.init();
|
||||
tm.show('Z', { duration: 0 });
|
||||
const c = tm.container;
|
||||
tm.dispose();
|
||||
expect(c.parentNode).toBeFalsy();
|
||||
expect(tm.toasts.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== ThemeToggle =====
|
||||
const { ThemeToggle } = await import('../utils/theme-toggle.js');
|
||||
|
||||
describe('ThemeToggle', () => {
|
||||
const dom = mockDOM();
|
||||
|
||||
it('detects system theme as dark or light', () => {
|
||||
const tt = new ThemeToggle();
|
||||
const t = tt.getSystemTheme();
|
||||
expect(t === 'dark' || t === 'light').toBeTruthy();
|
||||
});
|
||||
|
||||
it('creates button with aria-label in header', () => {
|
||||
const tt = new ThemeToggle();
|
||||
tt.init();
|
||||
expect(tt.button).toBeTruthy();
|
||||
expect(tt.button.getAttribute('aria-label')).toBeTruthy();
|
||||
tt.dispose();
|
||||
});
|
||||
|
||||
it('toggle() alternates between dark and light', () => {
|
||||
const tt = new ThemeToggle();
|
||||
tt.init();
|
||||
const initial = tt.currentTheme;
|
||||
tt.toggle();
|
||||
expect(tt.currentTheme).not.toBe(initial);
|
||||
tt.toggle();
|
||||
expect(tt.currentTheme).toBe(initial);
|
||||
tt.dispose();
|
||||
});
|
||||
|
||||
it('applyTheme() sets data-color-scheme on <html>', () => {
|
||||
const tt = new ThemeToggle();
|
||||
tt.applyTheme('dark');
|
||||
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('dark');
|
||||
tt.applyTheme('light');
|
||||
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('light');
|
||||
});
|
||||
|
||||
it('persists and retrieves theme from localStorage', () => {
|
||||
const tt = new ThemeToggle();
|
||||
tt.saveTheme('dark');
|
||||
expect(tt.getSavedTheme()).toBe('dark');
|
||||
tt.saveTheme('light');
|
||||
expect(tt.getSavedTheme()).toBe('light');
|
||||
localStorage.removeItem('ruview-theme');
|
||||
});
|
||||
|
||||
dom.remove();
|
||||
});
|
||||
|
||||
// ===== KeyboardShortcuts =====
|
||||
const { KeyboardShortcuts } = await import('../utils/keyboard-shortcuts.js');
|
||||
|
||||
describe('KeyboardShortcuts', () => {
|
||||
it('has default shortcuts for ?, Escape, and number keys', () => {
|
||||
const ks = new KeyboardShortcuts(null);
|
||||
expect(ks.shortcuts.has('?')).toBeTruthy();
|
||||
expect(ks.shortcuts.has('Escape')).toBeTruthy();
|
||||
expect(ks.shortcuts.has('1')).toBeTruthy();
|
||||
expect(ks.shortcuts.has('8')).toBeTruthy();
|
||||
ks.dispose();
|
||||
});
|
||||
|
||||
it('register() adds custom handler', () => {
|
||||
const ks = new KeyboardShortcuts(null);
|
||||
let ran = false;
|
||||
ks.register('z', 'Test', () => { ran = true; });
|
||||
expect(ks.shortcuts.has('z')).toBeTruthy();
|
||||
ks.shortcuts.get('z').handler();
|
||||
expect(ran).toBeTruthy();
|
||||
ks.dispose();
|
||||
});
|
||||
|
||||
it('formatKey() maps Escape to Esc', () => {
|
||||
const ks = new KeyboardShortcuts(null);
|
||||
expect(ks.formatKey('Escape')).toBe('Esc');
|
||||
expect(ks.formatKey('a')).toBe('A');
|
||||
ks.dispose();
|
||||
});
|
||||
|
||||
it('init() creates dialog overlay', () => {
|
||||
const ks = new KeyboardShortcuts(null);
|
||||
ks.init();
|
||||
expect(ks.overlay).toBeTruthy();
|
||||
expect(ks.overlay.getAttribute('role')).toBe('dialog');
|
||||
expect(ks.overlay.getAttribute('aria-modal')).toBe('true');
|
||||
ks.dispose();
|
||||
});
|
||||
|
||||
it('showHelp/hideHelp toggles overlay visibility', () => {
|
||||
const ks = new KeyboardShortcuts(null);
|
||||
ks.init();
|
||||
ks.showHelp();
|
||||
expect(ks.helpVisible).toBeTruthy();
|
||||
expect(ks.overlay.classList.contains('visible')).toBeTruthy();
|
||||
ks.hideHelp();
|
||||
expect(ks.helpVisible).toBeFalsy();
|
||||
ks.dispose();
|
||||
});
|
||||
|
||||
it('buildHelpHTML() includes Navigation/Actions/General groups', () => {
|
||||
const ks = new KeyboardShortcuts(null);
|
||||
const html = ks.buildHelpHTML();
|
||||
expect(html).toContain('Navigation');
|
||||
expect(html).toContain('Actions');
|
||||
expect(html).toContain('General');
|
||||
ks.dispose();
|
||||
});
|
||||
|
||||
it('dispose() removes overlay from DOM', () => {
|
||||
const ks = new KeyboardShortcuts(null);
|
||||
ks.init();
|
||||
const o = ks.overlay;
|
||||
ks.dispose();
|
||||
expect(o.parentNode).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ===== PerfMonitor =====
|
||||
const { PerfMonitor } = await import('../utils/perf-monitor.js');
|
||||
|
||||
describe('PerfMonitor', () => {
|
||||
it('creates panel with role=status and aria-label', () => {
|
||||
const pm = new PerfMonitor();
|
||||
pm.init();
|
||||
expect(pm.panel.getAttribute('role')).toBe('status');
|
||||
expect(pm.panel.getAttribute('aria-label')).toBe('Performance monitor');
|
||||
pm.dispose();
|
||||
});
|
||||
|
||||
it('show/hide updates visible state', () => {
|
||||
const pm = new PerfMonitor();
|
||||
pm.init();
|
||||
pm.show();
|
||||
expect(pm.visible).toBeTruthy();
|
||||
expect(pm.panel.classList.contains('visible')).toBeTruthy();
|
||||
pm.hide();
|
||||
expect(pm.visible).toBeFalsy();
|
||||
pm.dispose();
|
||||
});
|
||||
|
||||
it('toggle() flips visibility', () => {
|
||||
const pm = new PerfMonitor();
|
||||
pm.init();
|
||||
pm.toggle();
|
||||
expect(pm.visible).toBeTruthy();
|
||||
pm.toggle();
|
||||
expect(pm.visible).toBeFalsy();
|
||||
pm.dispose();
|
||||
});
|
||||
|
||||
it('updateMetric() sets text and CSS class', () => {
|
||||
const pm = new PerfMonitor();
|
||||
pm.init();
|
||||
pm.updateMetric('fps', 60, 'ok');
|
||||
const el = pm.panel.querySelector('[data-metric="fps"]');
|
||||
expect(el.textContent).toBe('60');
|
||||
expect(el.className).toContain('perf-ok');
|
||||
pm.updateMetric('fps', 15, 'warning');
|
||||
expect(el.className).toContain('perf-warning');
|
||||
pm.dispose();
|
||||
});
|
||||
|
||||
it('pushSpark() appends data and caps at 60', () => {
|
||||
const pm = new PerfMonitor();
|
||||
pm.init();
|
||||
for (let i = 0; i < 70; i++) pm.pushSpark('fps', i, 0, 120);
|
||||
expect(pm.sparkData.fps.length).toBe(60);
|
||||
pm.dispose();
|
||||
});
|
||||
|
||||
it('dispose() cleans up panel', () => {
|
||||
const pm = new PerfMonitor();
|
||||
pm.init();
|
||||
pm.show();
|
||||
const p = pm.panel;
|
||||
pm.dispose();
|
||||
expect(p.parentNode).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ===== TabManager =====
|
||||
const { TabManager } = await import('../components/TabManager.js');
|
||||
|
||||
describe('TabManager', () => {
|
||||
it('initializes and finds all tabs', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
expect(tm.tabs.length).toBe(3);
|
||||
expect(tm.activeTab).toBe('dashboard');
|
||||
d.remove();
|
||||
});
|
||||
|
||||
it('switchToTab() changes active tab', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
tm.switchToTab('hardware');
|
||||
expect(tm.activeTab).toBe('hardware');
|
||||
expect(d.querySelector('[data-tab="hardware"]').classList.contains('active')).toBeTruthy();
|
||||
expect(d.querySelector('[data-tab="dashboard"]').classList.contains('active')).toBeFalsy();
|
||||
d.remove();
|
||||
});
|
||||
|
||||
it('updates aria-selected on tab switch', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
tm.switchToTab('demo');
|
||||
expect(d.querySelector('[data-tab="dashboard"]').getAttribute('aria-selected')).toBe('false');
|
||||
expect(d.querySelector('[data-tab="demo"]').getAttribute('aria-selected')).toBe('true');
|
||||
d.remove();
|
||||
});
|
||||
|
||||
it('fires onTabChange callbacks with correct args', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
let newId = '', oldId = '';
|
||||
tm.onTabChange((n, o) => { newId = n; oldId = o; });
|
||||
tm.switchToTab('hardware');
|
||||
expect(newId).toBe('hardware');
|
||||
expect(oldId).toBe('dashboard');
|
||||
d.remove();
|
||||
});
|
||||
|
||||
it('does not fire callback when switching to already active tab', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
let count = 0;
|
||||
tm.onTabChange(() => { count++; });
|
||||
tm.switchToTab('dashboard');
|
||||
expect(count).toBe(0);
|
||||
d.remove();
|
||||
});
|
||||
|
||||
it('onTabChange() returns unsubscribe function', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
let count = 0;
|
||||
const unsub = tm.onTabChange(() => { count++; });
|
||||
tm.switchToTab('hardware');
|
||||
expect(count).toBe(1);
|
||||
unsub();
|
||||
tm.switchToTab('demo');
|
||||
expect(count).toBe(1); // not incremented
|
||||
d.remove();
|
||||
});
|
||||
|
||||
it('setTabEnabled(false) disables tab button', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
tm.setTabEnabled('hardware', false);
|
||||
const btn = d.querySelector('[data-tab="hardware"]');
|
||||
expect(btn.disabled).toBeTruthy();
|
||||
expect(btn.classList.contains('disabled')).toBeTruthy();
|
||||
tm.setTabEnabled('hardware', true);
|
||||
expect(btn.disabled).toBeFalsy();
|
||||
d.remove();
|
||||
});
|
||||
|
||||
it('setTabVisible(false) hides tab', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
tm.setTabVisible('demo', false);
|
||||
expect(d.querySelector('[data-tab="demo"]').style.display).toBe('none');
|
||||
tm.setTabVisible('demo', true);
|
||||
expect(d.querySelector('[data-tab="demo"]').style.display).toBe('');
|
||||
d.remove();
|
||||
});
|
||||
|
||||
it('setTabBadge() adds/removes badge', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
tm.setTabBadge('hardware', '3');
|
||||
const badge = d.querySelector('[data-tab="hardware"] .tab-badge');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.textContent).toBe('3');
|
||||
tm.setTabBadge('hardware', null);
|
||||
expect(d.querySelector('[data-tab="hardware"] .tab-badge')).toBeFalsy();
|
||||
d.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// ===== RENDER RESULTS =====
|
||||
const output = document.getElementById('output');
|
||||
let lastSuite = '', passed = 0, failed = 0;
|
||||
|
||||
results.forEach(r => {
|
||||
if (r.suite !== lastSuite) {
|
||||
lastSuite = r.suite;
|
||||
const s = document.createElement('div');
|
||||
s.className = 'suite';
|
||||
s.innerHTML = `<div class="suite-name">${r.suite}</div>`;
|
||||
output.appendChild(s);
|
||||
}
|
||||
const t = document.createElement('div');
|
||||
t.className = `test ${r.passed ? 'pass' : 'fail'}`;
|
||||
t.textContent = r.name;
|
||||
output.lastChild.appendChild(t);
|
||||
if (!r.passed) {
|
||||
const e = document.createElement('div');
|
||||
e.className = 'error-detail';
|
||||
e.textContent = r.error;
|
||||
output.lastChild.appendChild(e);
|
||||
}
|
||||
r.passed ? passed++ : failed++;
|
||||
});
|
||||
|
||||
const summary = document.getElementById('summary');
|
||||
summary.textContent = `${passed + failed} tests: ${passed} passed, ${failed} failed`;
|
||||
summary.style.color = failed === 0 ? '#32b8c6' : '#ff5459';
|
||||
|
||||
console.info(`[UNIT-TESTS] ${passed + failed} tests: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) results.filter(r => !r.passed).forEach(r => console.error(`[FAIL] ${r.suite} > ${r.name}: ${r.error}`));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
171
ui/utils/mobile-nav.js
Normal file
171
ui/utils/mobile-nav.js
Normal file
|
|
@ -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 = `
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
`;
|
||||
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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue