mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 05:59:32 +00:00
Merge 8766875347 into 79477c17a9
This commit is contained in:
commit
6f6532c3f7
14 changed files with 2229 additions and 63 deletions
33
ui/.eslintrc.json
Normal file
33
ui/.eslintrc.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2022": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||
"no-undef": "error",
|
||||
"no-var": "error",
|
||||
"prefer-const": "warn",
|
||||
"eqeqeq": ["error", "always"],
|
||||
"no-eval": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-new-func": "error",
|
||||
"no-script-url": "error",
|
||||
"no-alert": "warn",
|
||||
"no-console": ["warn", { "allow": ["warn", "error", "info"] }],
|
||||
"curly": ["warn", "multi-line"],
|
||||
"no-throw-literal": "error",
|
||||
"prefer-template": "warn",
|
||||
"no-duplicate-imports": "error"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"node_modules/",
|
||||
"mobile/",
|
||||
"vendor/",
|
||||
"*.min.js"
|
||||
]
|
||||
}
|
||||
99
ui/app.js
99
ui/app.js
|
|
@ -10,6 +10,11 @@ import { wsService } from './services/websocket.service.js';
|
|||
import { healthService } from './services/health.service.js';
|
||||
import { sensingService } from './services/sensing.service.js';
|
||||
import { backendDetector } from './utils/backend-detector.js';
|
||||
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() {
|
||||
|
|
@ -30,10 +35,13 @@ class WiFiDensePoseApp {
|
|||
|
||||
// Initialize UI components
|
||||
this.initializeComponents();
|
||||
|
||||
|
||||
// Initialize enhancements
|
||||
this.initializeEnhancements();
|
||||
|
||||
// Set up global event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('WiFi DensePose UI initialized successfully');
|
||||
|
||||
|
|
@ -167,6 +175,42 @@ class WiFiDensePoseApp {
|
|||
}
|
||||
}
|
||||
|
||||
// Initialize enhancement modules (keyboard shortcuts, perf monitor, toast, theme)
|
||||
initializeEnhancements() {
|
||||
// Toast notifications
|
||||
toastManager.init();
|
||||
|
||||
// Theme toggle
|
||||
this.themeToggle = new ThemeToggle();
|
||||
this.themeToggle.init();
|
||||
|
||||
// Performance monitor
|
||||
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
|
||||
handleTabChange(newTab, oldTab) {
|
||||
console.log(`Tab changed from ${oldTab} to ${newTab}`);
|
||||
|
|
@ -272,45 +316,17 @@ class WiFiDensePoseApp {
|
|||
});
|
||||
}
|
||||
|
||||
// Show backend status notification
|
||||
// Show backend status notification (uses enhanced toast system)
|
||||
showBackendStatus(message, type) {
|
||||
// Create status notification if it doesn't exist
|
||||
let statusToast = document.getElementById('backendStatusToast');
|
||||
if (!statusToast) {
|
||||
statusToast = document.createElement('div');
|
||||
statusToast.id = 'backendStatusToast';
|
||||
statusToast.className = 'backend-status-toast';
|
||||
document.body.appendChild(statusToast);
|
||||
}
|
||||
|
||||
statusToast.textContent = message;
|
||||
statusToast.className = `backend-status-toast ${type}`;
|
||||
statusToast.classList.add('show');
|
||||
|
||||
// Auto-hide success messages, keep warnings and errors longer
|
||||
const timeout = type === 'success' ? 3000 : 8000;
|
||||
setTimeout(() => {
|
||||
statusToast.classList.remove('show');
|
||||
}, timeout);
|
||||
const toastType = type === 'success' ? 'success' : 'warning';
|
||||
toastManager[toastType](message, {
|
||||
duration: type === 'success' ? 3000 : 8000
|
||||
});
|
||||
}
|
||||
|
||||
// Show global error message
|
||||
// Show global error message (uses enhanced toast system)
|
||||
showGlobalError(message) {
|
||||
// Create error toast if it doesn't exist
|
||||
let errorToast = document.getElementById('globalErrorToast');
|
||||
if (!errorToast) {
|
||||
errorToast = document.createElement('div');
|
||||
errorToast.id = 'globalErrorToast';
|
||||
errorToast.className = 'error-toast';
|
||||
document.body.appendChild(errorToast);
|
||||
}
|
||||
|
||||
errorToast.textContent = message;
|
||||
errorToast.classList.add('show');
|
||||
|
||||
setTimeout(() => {
|
||||
errorToast.classList.remove('show');
|
||||
}, 5000);
|
||||
toastManager.error(message, { duration: 6000 });
|
||||
}
|
||||
|
||||
// Clean up resources
|
||||
|
|
@ -326,9 +342,16 @@ class WiFiDensePoseApp {
|
|||
|
||||
// Disconnect all WebSocket connections
|
||||
wsService.disconnectAll();
|
||||
|
||||
|
||||
// Stop health monitoring
|
||||
healthService.dispose();
|
||||
|
||||
// Dispose enhancements
|
||||
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();
|
||||
}
|
||||
|
||||
// Public API
|
||||
|
|
|
|||
|
|
@ -19,6 +19,33 @@ export class TabManager {
|
|||
tab.addEventListener('click', () => this.switchTab(tab));
|
||||
});
|
||||
|
||||
// Arrow key navigation within tab bar (WCAG)
|
||||
const nav = this.container.querySelector('.nav-tabs');
|
||||
if (nav) {
|
||||
nav.addEventListener('keydown', (e) => {
|
||||
const buttonTabs = this.tabs.filter(t => t.tagName === 'BUTTON' && !t.disabled);
|
||||
const currentIndex = buttonTabs.indexOf(document.activeElement);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
let nextIndex = -1;
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||
nextIndex = (currentIndex + 1) % buttonTabs.length;
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||
nextIndex = (currentIndex - 1 + buttonTabs.length) % buttonTabs.length;
|
||||
} else if (e.key === 'Home') {
|
||||
nextIndex = 0;
|
||||
} else if (e.key === 'End') {
|
||||
nextIndex = buttonTabs.length - 1;
|
||||
}
|
||||
|
||||
if (nextIndex >= 0) {
|
||||
e.preventDefault();
|
||||
buttonTabs[nextIndex].focus();
|
||||
this.switchTab(buttonTabs[nextIndex]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Activate first tab if none active
|
||||
const activeTab = this.tabs.find(tab => tab.classList.contains('active'));
|
||||
if (activeTab) {
|
||||
|
|
@ -36,14 +63,22 @@ export class TabManager {
|
|||
return;
|
||||
}
|
||||
|
||||
// Update tab states
|
||||
// Update tab states and ARIA attributes
|
||||
this.tabs.forEach(tab => {
|
||||
tab.classList.toggle('active', tab === tabElement);
|
||||
const isActive = tab === tabElement;
|
||||
tab.classList.toggle('active', isActive);
|
||||
if (tab.hasAttribute('aria-selected')) {
|
||||
tab.setAttribute('aria-selected', String(isActive));
|
||||
}
|
||||
});
|
||||
|
||||
// Update content visibility
|
||||
// Update content visibility and ARIA
|
||||
this.tabContents.forEach(content => {
|
||||
content.classList.toggle('active', content.id === tabId);
|
||||
const isActive = content.id === tabId;
|
||||
content.classList.toggle('active', isActive);
|
||||
if (content.hasAttribute('role')) {
|
||||
content.setAttribute('aria-hidden', String(!isActive));
|
||||
}
|
||||
});
|
||||
|
||||
// Update active tab
|
||||
|
|
|
|||
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,38 +3,46 @@
|
|||
<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 -->
|
||||
<a href="#dashboard" class="skip-to-content">Skip to main content</a>
|
||||
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<header class="header" role="banner">
|
||||
<h1>WiFi DensePose</h1>
|
||||
<p class="subtitle">Human Tracking Through Walls Using WiFi Signals</p>
|
||||
<div class="header-info">
|
||||
<span class="api-version"></span>
|
||||
<span class="api-environment"></span>
|
||||
<span class="overall-health"></span>
|
||||
<span class="api-version" aria-label="API version"></span>
|
||||
<span class="api-environment" aria-label="Environment"></span>
|
||||
<span class="overall-health" role="status" aria-live="polite" aria-label="System health"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="nav-tabs">
|
||||
<button class="nav-tab active" data-tab="dashboard">Dashboard</button>
|
||||
<button class="nav-tab" data-tab="hardware">Hardware</button>
|
||||
<button class="nav-tab" data-tab="demo">Live Demo</button>
|
||||
<button class="nav-tab" data-tab="architecture">Architecture</button>
|
||||
<button class="nav-tab" data-tab="performance">Performance</button>
|
||||
<button class="nav-tab" data-tab="applications">Applications</button>
|
||||
<button class="nav-tab" data-tab="sensing">Sensing</button>
|
||||
<button class="nav-tab" data-tab="training">Training</button>
|
||||
<nav class="nav-tabs" role="tablist" aria-label="Main navigation">
|
||||
<button class="nav-tab active" data-tab="dashboard" role="tab" aria-selected="true" aria-controls="dashboard">Dashboard</button>
|
||||
<button class="nav-tab" data-tab="hardware" role="tab" aria-selected="false" aria-controls="hardware">Hardware</button>
|
||||
<button class="nav-tab" data-tab="demo" role="tab" aria-selected="false" aria-controls="demo">Live Demo</button>
|
||||
<button class="nav-tab" data-tab="architecture" role="tab" aria-selected="false" aria-controls="architecture">Architecture</button>
|
||||
<button class="nav-tab" data-tab="performance" role="tab" aria-selected="false" aria-controls="performance">Performance</button>
|
||||
<button class="nav-tab" data-tab="applications" role="tab" aria-selected="false" aria-controls="applications">Applications</button>
|
||||
<button class="nav-tab" data-tab="sensing" role="tab" aria-selected="false" aria-controls="sensing">Sensing</button>
|
||||
<button class="nav-tab" data-tab="training" role="tab" aria-selected="false" aria-controls="training">Training</button>
|
||||
<a href="pose-fusion.html" class="nav-tab" style="text-decoration:none">Pose Fusion</a>
|
||||
<a href="observatory.html" class="nav-tab" style="text-decoration:none">Observatory</a>
|
||||
</nav>
|
||||
|
||||
<!-- Dashboard Tab -->
|
||||
<section id="dashboard" class="tab-content active">
|
||||
<section id="dashboard" class="tab-content active" role="tabpanel" aria-labelledby="dashboard">
|
||||
<div class="hero-section">
|
||||
<h2>Revolutionary WiFi-Based Human Pose Detection</h2>
|
||||
<p class="hero-description">
|
||||
|
|
@ -181,7 +189,7 @@
|
|||
</section>
|
||||
|
||||
<!-- Hardware Tab -->
|
||||
<section id="hardware" class="tab-content">
|
||||
<section id="hardware" class="tab-content" role="tabpanel" aria-labelledby="hardware" aria-hidden="true">
|
||||
<h2>Hardware Configuration</h2>
|
||||
|
||||
<div class="hardware-grid">
|
||||
|
|
@ -259,7 +267,7 @@
|
|||
</section>
|
||||
|
||||
<!-- Demo Tab -->
|
||||
<section id="demo" class="tab-content">
|
||||
<section id="demo" class="tab-content" role="tabpanel" aria-labelledby="demo" aria-hidden="true">
|
||||
<h2>Live Demonstration</h2>
|
||||
|
||||
<div class="demo-controls">
|
||||
|
|
@ -312,7 +320,7 @@
|
|||
</section>
|
||||
|
||||
<!-- Architecture Tab -->
|
||||
<section id="architecture" class="tab-content">
|
||||
<section id="architecture" class="tab-content" role="tabpanel" aria-labelledby="architecture" aria-hidden="true">
|
||||
<h2>System Architecture</h2>
|
||||
|
||||
<div class="architecture-flow">
|
||||
|
|
@ -350,7 +358,7 @@
|
|||
</section>
|
||||
|
||||
<!-- Performance Tab -->
|
||||
<section id="performance" class="tab-content">
|
||||
<section id="performance" class="tab-content" role="tabpanel" aria-labelledby="performance" aria-hidden="true">
|
||||
<h2>Performance Analysis</h2>
|
||||
|
||||
<div class="performance-chart">
|
||||
|
|
@ -422,7 +430,7 @@
|
|||
</section>
|
||||
|
||||
<!-- Applications Tab -->
|
||||
<section id="applications" class="tab-content">
|
||||
<section id="applications" class="tab-content" role="tabpanel" aria-labelledby="applications" aria-hidden="true">
|
||||
<h2>Real-World Applications</h2>
|
||||
|
||||
<div class="applications-grid">
|
||||
|
|
@ -489,10 +497,10 @@
|
|||
</section>
|
||||
|
||||
<!-- Sensing Tab -->
|
||||
<section id="sensing" class="tab-content"></section>
|
||||
<section id="sensing" class="tab-content" role="tabpanel" aria-labelledby="sensing" aria-hidden="true"></section>
|
||||
|
||||
<!-- Training Tab -->
|
||||
<section id="training" class="tab-content">
|
||||
<section id="training" class="tab-content" role="tabpanel" aria-labelledby="training" aria-hidden="true">
|
||||
<div class="tab-header">
|
||||
<h2>Model Training</h2>
|
||||
<p>Record CSI data, train pose estimation models, and manage .rvf files</p>
|
||||
|
|
|
|||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
589
ui/style.css
589
ui/style.css
|
|
@ -2424,3 +2424,592 @@ canvas {
|
|||
.pose-trail-btn:hover {
|
||||
background: rgba(var(--color-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
ENHANCEMENTS: Keyboard Shortcuts, Performance Monitor, Toast, Theme Toggle
|
||||
========================================================================== */
|
||||
|
||||
/* --- Keyboard Shortcuts Overlay --- */
|
||||
|
||||
.shortcuts-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--duration-normal) var(--ease-standard);
|
||||
}
|
||||
|
||||
.shortcuts-overlay.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.shortcuts-panel {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 480px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: shortcuts-enter var(--duration-normal) var(--ease-standard);
|
||||
}
|
||||
|
||||
@keyframes shortcuts-enter {
|
||||
from { transform: scale(0.95) translateY(10px); opacity: 0; }
|
||||
to { transform: scale(1) translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.shortcuts-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-16) var(--space-20);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.shortcuts-header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.shortcuts-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 22px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: var(--space-4);
|
||||
line-height: 1;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--duration-fast);
|
||||
}
|
||||
|
||||
.shortcuts-close:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.shortcuts-close:focus-visible {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.shortcuts-body {
|
||||
padding: var(--space-16) var(--space-20);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.shortcuts-group {
|
||||
margin-bottom: var(--space-16);
|
||||
}
|
||||
|
||||
.shortcuts-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.shortcuts-group h3 {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 var(--space-8);
|
||||
}
|
||||
|
||||
.shortcut-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-12);
|
||||
padding: var(--space-6) 0;
|
||||
}
|
||||
|
||||
.shortcut-row kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 26px;
|
||||
padding: 0 var(--space-8);
|
||||
background: var(--color-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text);
|
||||
box-shadow: 0 1px 0 var(--color-border);
|
||||
}
|
||||
|
||||
.shortcut-row span {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* --- Performance Monitor --- */
|
||||
|
||||
.perf-monitor {
|
||||
position: fixed;
|
||||
bottom: var(--space-16);
|
||||
right: var(--space-16);
|
||||
z-index: 9999;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
box-shadow: var(--shadow-md);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
min-width: 200px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(10px);
|
||||
transition: all var(--duration-normal) var(--ease-standard);
|
||||
}
|
||||
|
||||
.perf-monitor.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.perf-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-6) var(--space-10);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.perf-header span {
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-primary);
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.perf-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 0 var(--space-2);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.perf-close:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.perf-metrics {
|
||||
padding: var(--space-6) var(--space-10);
|
||||
}
|
||||
|
||||
.perf-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.perf-label {
|
||||
color: var(--color-text-secondary);
|
||||
width: 28px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.perf-value {
|
||||
color: var(--color-text);
|
||||
min-width: 52px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.perf-value.perf-warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.perf-value.perf-ok {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.perf-spark {
|
||||
border-radius: 2px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] .perf-spark {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* --- Toast Notifications --- */
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: var(--space-16);
|
||||
right: var(--space-16);
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
max-width: 400px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-10);
|
||||
padding: var(--space-12) var(--space-16);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
box-shadow: var(--shadow-md);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text);
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
transition: all var(--duration-normal) var(--ease-standard);
|
||||
}
|
||||
|
||||
.toast.toast-enter {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.toast.toast-exit {
|
||||
animation: toast-out var(--duration-normal) var(--ease-standard) forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-out {
|
||||
to { opacity: 0; transform: translateX(20px); height: 0; padding: 0; margin: 0; overflow: hidden; }
|
||||
}
|
||||
|
||||
.toast-success { border-left: 3px solid var(--color-success); }
|
||||
.toast-error { border-left: 3px solid var(--color-error); }
|
||||
.toast-warning { border-left: 3px solid var(--color-warning); }
|
||||
.toast-info { border-left: 3px solid var(--color-primary); }
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.toast-success .toast-icon { color: var(--color-success); }
|
||||
.toast-error .toast-icon { color: var(--color-error); }
|
||||
.toast-warning .toast-icon { color: var(--color-warning); }
|
||||
.toast-info .toast-icon { color: var(--color-primary); }
|
||||
|
||||
.toast-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
|
||||
.toast-action {
|
||||
display: inline-block;
|
||||
margin-top: var(--space-4);
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.toast-action:hover {
|
||||
color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.toast-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
padding: 0 var(--space-2);
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toast-dismiss:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.toast-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.toast-progress-bar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transform-origin: left;
|
||||
}
|
||||
|
||||
.toast-progress-animate {
|
||||
animation: toast-progress-shrink linear forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-progress-shrink {
|
||||
from { transform: scaleX(1); }
|
||||
to { transform: scaleX(0); }
|
||||
}
|
||||
|
||||
.toast-success .toast-progress-bar { background: var(--color-success); }
|
||||
.toast-error .toast-progress-bar { background: var(--color-error); }
|
||||
.toast-warning .toast-progress-bar { background: var(--color-warning); }
|
||||
.toast-info .toast-progress-bar { background: var(--color-primary); }
|
||||
|
||||
/* --- Theme Toggle Button --- */
|
||||
|
||||
.theme-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background: var(--color-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: var(--color-secondary-hover);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.theme-toggle:focus-visible {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* --- Focus Visible for Accessibility --- */
|
||||
|
||||
.nav-tab:focus-visible,
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* --- Skip to Content Link --- */
|
||||
|
||||
.skip-to-content {
|
||||
position: absolute;
|
||||
top: -100px;
|
||||
left: var(--space-16);
|
||||
padding: var(--space-8) var(--space-16);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-btn-primary-text);
|
||||
border-radius: var(--radius-base);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-decoration: none;
|
||||
z-index: 10002;
|
||||
transition: top var(--duration-fast);
|
||||
}
|
||||
|
||||
.skip-to-content:focus {
|
||||
top: var(--space-16);
|
||||
}
|
||||
|
||||
/* --- Responsive Toasts --- */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.toast-container {
|
||||
left: var(--space-8);
|
||||
right: var(--space-8);
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.shortcuts-panel {
|
||||
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>
|
||||
168
ui/utils/keyboard-shortcuts.js
Normal file
168
ui/utils/keyboard-shortcuts.js
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
// Keyboard Shortcuts System
|
||||
// Press '?' to show help overlay, number keys to switch tabs, etc.
|
||||
|
||||
export class KeyboardShortcuts {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.shortcuts = new Map();
|
||||
this.helpVisible = false;
|
||||
this.enabled = true;
|
||||
this.overlay = null;
|
||||
this.registerDefaults();
|
||||
}
|
||||
|
||||
registerDefaults() {
|
||||
this.register('?', 'Show keyboard shortcuts', () => this.toggleHelp());
|
||||
this.register('Escape', 'Close overlay / dialog', () => this.closeAll());
|
||||
this.register('1', 'Switch to Dashboard tab', () => this.switchTab('dashboard'));
|
||||
this.register('2', 'Switch to Hardware tab', () => this.switchTab('hardware'));
|
||||
this.register('3', 'Switch to Live Demo tab', () => this.switchTab('demo'));
|
||||
this.register('4', 'Switch to Architecture tab', () => this.switchTab('architecture'));
|
||||
this.register('5', 'Switch to Performance tab', () => this.switchTab('performance'));
|
||||
this.register('6', 'Switch to Applications tab', () => this.switchTab('applications'));
|
||||
this.register('7', 'Switch to Sensing tab', () => this.switchTab('sensing'));
|
||||
this.register('8', 'Switch to Training tab', () => this.switchTab('training'));
|
||||
this.register('p', 'Toggle performance monitor', () => this.togglePerfMonitor());
|
||||
this.register('t', 'Toggle dark/light theme', () => this.toggleTheme());
|
||||
}
|
||||
|
||||
register(key, description, handler) {
|
||||
this.shortcuts.set(key, { description, handler });
|
||||
}
|
||||
|
||||
init() {
|
||||
document.addEventListener('keydown', (e) => this.handleKeydown(e));
|
||||
this.createOverlay();
|
||||
}
|
||||
|
||||
handleKeydown(e) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
// Ignore when typing in inputs
|
||||
const tag = e.target.tagName.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select' || e.target.isContentEditable) {
|
||||
if (e.key === 'Escape') {
|
||||
e.target.blur();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore modified keys (except shift for '?')
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
|
||||
const shortcut = this.shortcuts.get(e.key);
|
||||
if (shortcut) {
|
||||
e.preventDefault();
|
||||
shortcut.handler();
|
||||
}
|
||||
}
|
||||
|
||||
switchTab(tabId) {
|
||||
const tabManager = this.app?.getComponent?.('tabManager');
|
||||
if (tabManager) {
|
||||
tabManager.switchToTab(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
togglePerfMonitor() {
|
||||
const event = new CustomEvent('toggle-perf-monitor');
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
const event = new CustomEvent('toggle-theme');
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
closeAll() {
|
||||
if (this.helpVisible) {
|
||||
this.hideHelp();
|
||||
}
|
||||
}
|
||||
|
||||
createOverlay() {
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.className = 'shortcuts-overlay';
|
||||
this.overlay.setAttribute('role', 'dialog');
|
||||
this.overlay.setAttribute('aria-label', 'Keyboard shortcuts');
|
||||
this.overlay.setAttribute('aria-modal', 'true');
|
||||
this.overlay.innerHTML = this.buildHelpHTML();
|
||||
this.overlay.addEventListener('click', (e) => {
|
||||
if (e.target === this.overlay) this.hideHelp();
|
||||
});
|
||||
document.body.appendChild(this.overlay);
|
||||
}
|
||||
|
||||
buildHelpHTML() {
|
||||
const groups = [
|
||||
{
|
||||
title: 'Navigation',
|
||||
items: Array.from(this.shortcuts.entries())
|
||||
.filter(([key]) => /^[1-8]$/.test(key))
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
items: Array.from(this.shortcuts.entries())
|
||||
.filter(([key]) => /^[a-z]$/.test(key))
|
||||
},
|
||||
{
|
||||
title: 'General',
|
||||
items: Array.from(this.shortcuts.entries())
|
||||
.filter(([key]) => !/^[1-8a-z]$/.test(key))
|
||||
}
|
||||
];
|
||||
|
||||
return `
|
||||
<div class="shortcuts-panel">
|
||||
<div class="shortcuts-header">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<button class="shortcuts-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="shortcuts-body">
|
||||
${groups.map(group => `
|
||||
<div class="shortcuts-group">
|
||||
<h3>${group.title}</h3>
|
||||
${group.items.map(([key, { description }]) => `
|
||||
<div class="shortcut-row">
|
||||
<kbd>${this.formatKey(key)}</kbd>
|
||||
<span>${description}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
formatKey(key) {
|
||||
const map = { Escape: 'Esc', '?': '?' };
|
||||
return map[key] || key.toUpperCase();
|
||||
}
|
||||
|
||||
toggleHelp() {
|
||||
this.helpVisible ? this.hideHelp() : this.showHelp();
|
||||
}
|
||||
|
||||
showHelp() {
|
||||
this.overlay.classList.add('visible');
|
||||
this.helpVisible = true;
|
||||
// Focus close button
|
||||
const closeBtn = this.overlay.querySelector('.shortcuts-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.onclick = () => this.hideHelp();
|
||||
closeBtn.focus();
|
||||
}
|
||||
}
|
||||
|
||||
hideHelp() {
|
||||
this.overlay.classList.remove('visible');
|
||||
this.helpVisible = false;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.overlay?.parentNode) {
|
||||
this.overlay.parentNode.removeChild(this.overlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
216
ui/utils/perf-monitor.js
Normal file
216
ui/utils/perf-monitor.js
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// Performance Monitor Overlay
|
||||
// Shows FPS, memory usage, and network latency in real-time
|
||||
|
||||
export class PerfMonitor {
|
||||
constructor() {
|
||||
this.visible = false;
|
||||
this.panel = null;
|
||||
this.frames = [];
|
||||
this.lastFrameTime = 0;
|
||||
this.rafId = null;
|
||||
this.latencyHistory = [];
|
||||
this.maxHistory = 60;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createPanel();
|
||||
document.addEventListener('toggle-perf-monitor', () => this.toggle());
|
||||
}
|
||||
|
||||
createPanel() {
|
||||
this.panel = document.createElement('div');
|
||||
this.panel.className = 'perf-monitor';
|
||||
this.panel.setAttribute('role', 'status');
|
||||
this.panel.setAttribute('aria-label', 'Performance monitor');
|
||||
this.panel.innerHTML = `
|
||||
<div class="perf-header">
|
||||
<span>PERF</span>
|
||||
<button class="perf-close" aria-label="Close performance monitor">×</button>
|
||||
</div>
|
||||
<div class="perf-metrics">
|
||||
<div class="perf-row">
|
||||
<span class="perf-label">FPS</span>
|
||||
<span class="perf-value" data-metric="fps">--</span>
|
||||
<canvas class="perf-spark" data-spark="fps" width="60" height="20"></canvas>
|
||||
</div>
|
||||
<div class="perf-row">
|
||||
<span class="perf-label">MEM</span>
|
||||
<span class="perf-value" data-metric="memory">--</span>
|
||||
<canvas class="perf-spark" data-spark="memory" width="60" height="20"></canvas>
|
||||
</div>
|
||||
<div class="perf-row">
|
||||
<span class="perf-label">LAT</span>
|
||||
<span class="perf-value" data-metric="latency">--</span>
|
||||
<canvas class="perf-spark" data-spark="latency" width="60" height="20"></canvas>
|
||||
</div>
|
||||
<div class="perf-row">
|
||||
<span class="perf-label">DOM</span>
|
||||
<span class="perf-value" data-metric="dom">--</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.panel.querySelector('.perf-close').addEventListener('click', () => this.hide());
|
||||
|
||||
// Make it draggable
|
||||
this.makeDraggable();
|
||||
|
||||
document.body.appendChild(this.panel);
|
||||
|
||||
this.sparkData = {
|
||||
fps: [],
|
||||
memory: [],
|
||||
latency: []
|
||||
};
|
||||
}
|
||||
|
||||
makeDraggable() {
|
||||
const header = this.panel.querySelector('.perf-header');
|
||||
let dragging = false;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
header.addEventListener('mousedown', (e) => {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
dragging = true;
|
||||
offsetX = e.clientX - this.panel.offsetLeft;
|
||||
offsetY = e.clientY - this.panel.offsetTop;
|
||||
header.style.cursor = 'grabbing';
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!dragging) return;
|
||||
this.panel.style.left = `${e.clientX - offsetX}px`;
|
||||
this.panel.style.top = `${e.clientY - offsetY}px`;
|
||||
this.panel.style.right = 'auto';
|
||||
this.panel.style.bottom = 'auto';
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
dragging = false;
|
||||
header.style.cursor = 'grab';
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.visible ? this.hide() : this.show();
|
||||
}
|
||||
|
||||
show() {
|
||||
this.panel.classList.add('visible');
|
||||
this.visible = true;
|
||||
this.lastFrameTime = performance.now();
|
||||
this.tick();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.panel.classList.remove('visible');
|
||||
this.visible = false;
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (!this.visible) return;
|
||||
|
||||
const now = performance.now();
|
||||
this.frames.push(now);
|
||||
|
||||
// Keep only last second of frames
|
||||
while (this.frames.length > 0 && this.frames[0] < now - 1000) {
|
||||
this.frames.shift();
|
||||
}
|
||||
|
||||
const fps = this.frames.length;
|
||||
this.updateMetric('fps', fps, 'fps');
|
||||
this.pushSpark('fps', fps, 0, 120);
|
||||
|
||||
// Memory (if available)
|
||||
if (performance.memory) {
|
||||
const mb = Math.round(performance.memory.usedJSHeapSize / (1024 * 1024));
|
||||
const total = Math.round(performance.memory.jsHeapSizeLimit / (1024 * 1024));
|
||||
this.updateMetric('memory', `${mb}MB`, mb > total * 0.8 ? 'warning' : 'ok');
|
||||
this.pushSpark('memory', mb, 0, total);
|
||||
} else {
|
||||
this.updateMetric('memory', 'N/A', 'na');
|
||||
}
|
||||
|
||||
// DOM node count
|
||||
const domNodes = document.querySelectorAll('*').length;
|
||||
this.updateMetric('dom', domNodes, domNodes > 3000 ? 'warning' : 'ok');
|
||||
|
||||
// Estimate latency from last navigation or resource timing
|
||||
this.measureLatency();
|
||||
|
||||
this.rafId = requestAnimationFrame(() => this.tick());
|
||||
}
|
||||
|
||||
measureLatency() {
|
||||
const entries = performance.getEntriesByType('resource');
|
||||
if (entries.length > 0) {
|
||||
const last = entries[entries.length - 1];
|
||||
const latency = Math.round(last.responseEnd - last.requestStart);
|
||||
if (latency > 0 && latency < 30000) {
|
||||
this.latencyHistory.push(latency);
|
||||
if (this.latencyHistory.length > this.maxHistory) {
|
||||
this.latencyHistory.shift();
|
||||
}
|
||||
const avg = Math.round(
|
||||
this.latencyHistory.reduce((a, b) => a + b, 0) / this.latencyHistory.length
|
||||
);
|
||||
this.updateMetric('latency', `${avg}ms`, avg > 500 ? 'warning' : 'ok');
|
||||
this.pushSpark('latency', avg, 0, 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateMetric(metric, value, status) {
|
||||
const el = this.panel.querySelector(`[data-metric="${metric}"]`);
|
||||
if (!el) return;
|
||||
el.textContent = value;
|
||||
el.className = `perf-value perf-${status}`;
|
||||
}
|
||||
|
||||
pushSpark(name, value, min, max) {
|
||||
const data = this.sparkData[name];
|
||||
if (!data) return;
|
||||
data.push(value);
|
||||
if (data.length > 60) data.shift();
|
||||
this.drawSpark(name, data, min, max);
|
||||
}
|
||||
|
||||
drawSpark(name, data, min, max) {
|
||||
const canvas = this.panel.querySelector(`[data-spark="${name}"]`);
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (data.length < 2) return;
|
||||
|
||||
const range = max - min || 1;
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = 'rgba(50, 184, 198, 0.8)';
|
||||
ctx.lineWidth = 1.5;
|
||||
|
||||
data.forEach((val, i) => {
|
||||
const x = (i / (data.length - 1)) * w;
|
||||
const y = h - ((val - min) / range) * h;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.hide();
|
||||
if (this.panel?.parentNode) {
|
||||
this.panel.parentNode.removeChild(this.panel);
|
||||
}
|
||||
}
|
||||
}
|
||||
86
ui/utils/theme-toggle.js
Normal file
86
ui/utils/theme-toggle.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// Theme Toggle - Manual dark/light mode switch with persistence
|
||||
|
||||
export class ThemeToggle {
|
||||
constructor() {
|
||||
this.button = null;
|
||||
this.currentTheme = this.getSavedTheme() || this.getSystemTheme();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createButton();
|
||||
this.applyTheme(this.currentTheme);
|
||||
document.addEventListener('toggle-theme', () => this.toggle());
|
||||
|
||||
// Listen for system theme changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!this.getSavedTheme()) {
|
||||
this.applyTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createButton() {
|
||||
this.button = document.createElement('button');
|
||||
this.button.className = 'theme-toggle';
|
||||
this.button.setAttribute('aria-label', 'Toggle dark/light theme');
|
||||
this.button.setAttribute('title', 'Toggle theme (T)');
|
||||
this.updateIcon();
|
||||
this.button.addEventListener('click', () => this.toggle());
|
||||
|
||||
// Insert into header
|
||||
const headerInfo = document.querySelector('.header-info');
|
||||
if (headerInfo) {
|
||||
headerInfo.prepend(this.button);
|
||||
} else {
|
||||
const header = document.querySelector('.header');
|
||||
if (header) header.appendChild(this.button);
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.currentTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
|
||||
this.applyTheme(this.currentTheme);
|
||||
this.saveTheme(this.currentTheme);
|
||||
}
|
||||
|
||||
applyTheme(theme) {
|
||||
this.currentTheme = theme;
|
||||
document.documentElement.setAttribute('data-color-scheme', theme);
|
||||
this.updateIcon();
|
||||
}
|
||||
|
||||
updateIcon() {
|
||||
if (!this.button) return;
|
||||
const isDark = this.currentTheme === 'dark';
|
||||
this.button.innerHTML = isDark
|
||||
? '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>'
|
||||
: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
|
||||
this.button.setAttribute('aria-label', isDark ? 'Switch to light theme' : 'Switch to dark theme');
|
||||
}
|
||||
|
||||
getSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
getSavedTheme() {
|
||||
try {
|
||||
return localStorage.getItem('ruview-theme');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
saveTheme(theme) {
|
||||
try {
|
||||
localStorage.setItem('ruview-theme', theme);
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.button?.parentNode) {
|
||||
this.button.parentNode.removeChild(this.button);
|
||||
}
|
||||
}
|
||||
}
|
||||
150
ui/utils/toast.js
Normal file
150
ui/utils/toast.js
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// Enhanced Toast Notification System
|
||||
// Supports multiple types: success, error, warning, info
|
||||
// Stacking, auto-dismiss, manual close, progress bar
|
||||
|
||||
export class ToastManager {
|
||||
constructor() {
|
||||
this.container = null;
|
||||
this.toasts = [];
|
||||
this.idCounter = 0;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'toast-container';
|
||||
this.container.setAttribute('role', 'region');
|
||||
this.container.setAttribute('aria-label', 'Notifications');
|
||||
this.container.setAttribute('aria-live', 'polite');
|
||||
document.body.appendChild(this.container);
|
||||
}
|
||||
|
||||
show(message, options = {}) {
|
||||
const {
|
||||
type = 'info',
|
||||
duration = 5000,
|
||||
closable = true,
|
||||
icon = null,
|
||||
action = null
|
||||
} = options;
|
||||
|
||||
const id = ++this.idCounter;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.dataset.toastId = id;
|
||||
|
||||
const iconMap = {
|
||||
success: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M13.5 4.5L6 12L2.5 8.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
||||
error: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
|
||||
warning: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 5v4M8 11h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M7.13 2.22L1.09 12.5a1 1 0 00.87 1.5h12.08a1 1 0 00.87-1.5L8.87 2.22a1 1 0 00-1.74 0z" stroke="currentColor" stroke-width="1.5"/></svg>',
|
||||
info: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.5"/><path d="M8 7v4M8 5h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>'
|
||||
};
|
||||
|
||||
const displayIcon = icon || iconMap[type] || iconMap.info;
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="toast-icon">${displayIcon}</div>
|
||||
<div class="toast-content">
|
||||
<span class="toast-message">${this.escapeHtml(message)}</span>
|
||||
${action ? `<button class="toast-action">${this.escapeHtml(action.label)}</button>` : ''}
|
||||
</div>
|
||||
${closable ? '<button class="toast-dismiss" aria-label="Dismiss">×</button>' : ''}
|
||||
${duration > 0 ? '<div class="toast-progress"><div class="toast-progress-bar"></div></div>' : ''}
|
||||
`;
|
||||
|
||||
// Bind events
|
||||
if (closable) {
|
||||
toast.querySelector('.toast-dismiss').addEventListener('click', () => this.dismiss(id));
|
||||
}
|
||||
if (action?.onClick) {
|
||||
toast.querySelector('.toast-action')?.addEventListener('click', () => {
|
||||
action.onClick();
|
||||
this.dismiss(id);
|
||||
});
|
||||
}
|
||||
|
||||
this.container.appendChild(toast);
|
||||
|
||||
// Trigger enter animation
|
||||
requestAnimationFrame(() => toast.classList.add('toast-enter'));
|
||||
|
||||
// Auto-dismiss
|
||||
let timeoutId = null;
|
||||
if (duration > 0) {
|
||||
const progressBar = toast.querySelector('.toast-progress-bar');
|
||||
if (progressBar) {
|
||||
progressBar.style.animationDuration = `${duration}ms`;
|
||||
progressBar.classList.add('toast-progress-animate');
|
||||
}
|
||||
timeoutId = setTimeout(() => this.dismiss(id), duration);
|
||||
}
|
||||
|
||||
// Pause on hover
|
||||
toast.addEventListener('mouseenter', () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
const bar = toast.querySelector('.toast-progress-bar');
|
||||
if (bar) bar.style.animationPlayState = 'paused';
|
||||
}
|
||||
});
|
||||
toast.addEventListener('mouseleave', () => {
|
||||
if (duration > 0) {
|
||||
const bar = toast.querySelector('.toast-progress-bar');
|
||||
if (bar) bar.style.animationPlayState = 'running';
|
||||
timeoutId = setTimeout(() => this.dismiss(id), duration / 2);
|
||||
}
|
||||
});
|
||||
|
||||
this.toasts.push({ id, toast, timeoutId });
|
||||
return id;
|
||||
}
|
||||
|
||||
dismiss(id) {
|
||||
const index = this.toasts.findIndex(t => t.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
const { toast, timeoutId } = this.toasts[index];
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
toast.classList.add('toast-exit');
|
||||
toast.addEventListener('animationend', () => {
|
||||
toast.remove();
|
||||
}, { once: true });
|
||||
|
||||
this.toasts.splice(index, 1);
|
||||
}
|
||||
|
||||
success(message, options = {}) {
|
||||
return this.show(message, { ...options, type: 'success' });
|
||||
}
|
||||
|
||||
error(message, options = {}) {
|
||||
return this.show(message, { ...options, type: 'error', duration: options.duration || 8000 });
|
||||
}
|
||||
|
||||
warning(message, options = {}) {
|
||||
return this.show(message, { ...options, type: 'warning', duration: options.duration || 6000 });
|
||||
}
|
||||
|
||||
info(message, options = {}) {
|
||||
return this.show(message, { ...options, type: 'info' });
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.toasts.forEach(({ timeoutId }) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
});
|
||||
this.toasts = [];
|
||||
if (this.container?.parentNode) {
|
||||
this.container.parentNode.removeChild(this.container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const toastManager = new ToastManager();
|
||||
Loading…
Add table
Add a link
Reference in a new issue