mirror of
https://github.com/ruvnet/RuView
synced 2026-07-17 16:33:18 +00:00
49fb2ca9f4
* feat(ui): add keyboard shortcuts, perf monitor, toast system, theme toggle, and WCAG accessibility - Keyboard shortcuts overlay (press ? for help, 1-8 for tabs, T for theme, P for perf) - Real-time performance monitor with FPS, memory, latency sparklines (draggable) - Enhanced toast notification system with stacking, auto-dismiss, progress bars - Dark/light theme toggle with localStorage persistence and system preference detection - WCAG accessibility: skip-to-content link, ARIA roles/attributes on tabs and panels, arrow key navigation in tab bar, focus-visible outlines - ESLint config for UI directory with security and quality rules * feat(ui): add command palette, activity log, data export, fullscreen mode, connection status - Command palette (Ctrl+K / Cmd+K) with fuzzy search across tabs and actions - Activity log panel (L key) with real-time console interception, filters, resizable - Data export utility (E key) for sensor data as JSON/CSV with dialog - Fullscreen mode (F key / F11) for visualization tabs with exit button - Connection status widget in header showing WebSocket state and reconnect * 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) * feat(ui): add URL routing, onboarding tour, idle detection, notification center - Hash router: tabs are bookmarkable/shareable via URL (#demo, #sensing, etc.), syncs with TabManager, supports browser back/forward navigation - Onboarding tour: interactive 6-step first-run walkthrough with spotlight highlighting, step indicators, skip/back/next controls, localStorage persistence - Idle detection: pauses health polling and reduces CSS animations after 3 min of inactivity, resumes on user interaction, integrates with Page Visibility API - Notification center: bell icon in header with unread badge, event history panel with mark-read/clear, persists across page views via sessionStorage * feat(ui): add i18n (EN/PL), screenshot tool, settings panel, reduced motion, uptime clock - i18n: English/Polish translations with auto-detection, language selector in header, data-i18n attributes on dashboard elements, localStorage persistence - Screenshot tool (S key): captures active tab to clipboard or downloads PNG, flash effect, canvas rendering with watermark, fallback for tainted canvases - Quick settings panel (gear icon): reduced motion toggle, high contrast mode, compact layout mode, health polling toggle, clear data, reset onboarding - Uptime clock: current time + session duration in header - prefers-reduced-motion: system-level and manual toggle, disables all animations and transitions for vestibular accessibility - High contrast mode: WCAG AAA compliant colors for both light and dark themes - Compact mode: condensed layout for dense information display
217 lines
6.1 KiB
JavaScript
217 lines
6.1 KiB
JavaScript
// 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);
|
|
}
|
|
}
|
|
}
|