From e964eaf14f22554ddf70e98e31466c228cc32727 Mon Sep 17 00:00:00 2001 From: Winter Lau <45165192+winter-lau@users.noreply.github.com> Date: Tue, 19 May 2026 22:01:52 +0800 Subject: [PATCH 01/26] =?UTF-8?q?fix(deps):=20bump=20ndarray=200.15?= =?UTF-8?q?=E2=86=920.17=20and=20ndarray-npy=200.8=E2=86=920.10=20(closes?= =?UTF-8?q?=20#626)=20(#627)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- v2/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/v2/Cargo.toml b/v2/Cargo.toml index b5174269..c7ebf97b 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -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) From f54f0285bdf4c409af291188685d711d47bcfd10 Mon Sep 17 00:00:00 2001 From: Blossom Date: Tue, 19 May 2026 22:02:00 +0800 Subject: [PATCH 02/26] =?UTF-8?q?fix(ci):=20build=20multi-arch=20wifi-dens?= =?UTF-8?q?epose=20image=20=E2=80=94=20linux/arm64=20was=20missing=20(clos?= =?UTF-8?q?es=20#625)=20(#631)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/sensing-server-docker.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sensing-server-docker.yml b/.github/workflows/sensing-server-docker.yml index 1766d24c..6c74a09d 100644 --- a/.github/workflows/sensing-server-docker.yml +++ b/.github/workflows/sensing-server-docker.yml @@ -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: From c00f45e29650f518c4f8f42be8b0fd9e8fb63130 Mon Sep 17 00:00:00 2001 From: Rahul <137375203+therahul-yo@users.noreply.github.com> Date: Tue, 19 May 2026 19:32:08 +0530 Subject: [PATCH 03/26] =?UTF-8?q?fix(sensing):=20finish=20#611=20NaN-panic?= =?UTF-8?q?=20audit=20=E2=80=94=207=20more=20sites=20missed=20by=20#613=20?= =?UTF-8?q?(#624)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- CHANGELOG.md | 18 ++++++++++++++++++ .../src/adaptive_classifier.rs | 10 ++++++---- .../wifi-densepose-sensing-server/src/csi.rs | 6 ++++-- .../wifi-densepose-sensing-server/src/main.rs | 9 ++++++--- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44174dd3..20d3a897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs b/v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs index cc652f43..0c6f804b 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs @@ -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 Result>) -> } } + // 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; } diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 5c763445..b68ee4b9 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -2559,12 +2559,15 @@ fn estimate_persons_from_correlation(frame_history: &VecDeque>) -> 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 { From 3439fb14022d7d44c3379d316d321cda2609359a Mon Sep 17 00:00:00 2001 From: NgoQuocViet2001 <123613986+NgoQuocViet2001@users.noreply.github.com> Date: Tue, 19 May 2026 21:03:58 +0700 Subject: [PATCH 04/26] fix(provision): recognize swarm/hopping flags as config values (#617) --- firmware/esp32-csi-node/provision.py | 45 +++++++++---- .../esp32-csi-node/tests/test_provision.py | 63 +++++++++++++++++++ 2 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 firmware/esp32-csi-node/tests/test_provision.py diff --git a/firmware/esp32-csi-node/provision.py b/firmware/esp32-csi-node/provision.py index 7b5575dd..d450de99 100644 --- a/firmware/esp32-csi-node/provision.py +++ b/firmware/esp32-csi-node/provision.py @@ -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. diff --git a/firmware/esp32-csi-node/tests/test_provision.py b/firmware/esp32-csi-node/tests/test_provision.py new file mode 100644 index 00000000..9ea9d0f6 --- /dev/null +++ b/firmware/esp32-csi-node/tests/test_provision.py @@ -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() From 49fb2ca9f4c7aac48502db0cf952de6e703c2de2 Mon Sep 17 00:00:00 2001 From: nai <81188562+natiixnt@users.noreply.github.com> Date: Tue, 19 May 2026 16:04:59 +0200 Subject: [PATCH 05/26] =?UTF-8?q?feat(ui):=20UI=20overhaul=20=E2=80=94=20c?= =?UTF-8?q?onsolidates=20#305-#309=20(keyboard=20shortcuts,=20perf=20monit?= =?UTF-8?q?or,=20toasts,=20theme,=20command=20palette,=20activity=20log,?= =?UTF-8?q?=20data=20export,=20mobile=20PWA,=20accessibility,=20i18n)=20(#?= =?UTF-8?q?620)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- ui/.eslintrc.json | 33 + ui/app.js | 201 +++- ui/components/TabManager.js | 43 +- ui/icons/generate.html | 66 ++ ui/index.html | 68 +- ui/manifest.json | 25 + ui/style.css | 1741 +++++++++++++++++++++++++++++++ ui/sw.js | 124 +++ ui/tests/unit-tests.html | 472 +++++++++ ui/utils/activity-log.js | 181 ++++ ui/utils/command-palette.js | 311 ++++++ ui/utils/connection-status.js | 84 ++ ui/utils/data-export.js | 148 +++ ui/utils/fullscreen.js | 79 ++ ui/utils/i18n.js | 264 +++++ ui/utils/idle-manager.js | 83 ++ ui/utils/keyboard-shortcuts.js | 168 +++ ui/utils/mobile-nav.js | 171 +++ ui/utils/notification-center.js | 233 +++++ ui/utils/onboarding.js | 192 ++++ ui/utils/perf-monitor.js | 216 ++++ ui/utils/quick-settings.js | 191 ++++ ui/utils/router.js | 47 + ui/utils/screenshot.js | 160 +++ ui/utils/theme-toggle.js | 86 ++ ui/utils/toast.js | 150 +++ ui/utils/uptime-clock.js | 61 ++ 27 files changed, 5526 insertions(+), 72 deletions(-) create mode 100644 ui/.eslintrc.json create mode 100644 ui/icons/generate.html create mode 100644 ui/manifest.json create mode 100644 ui/sw.js create mode 100644 ui/tests/unit-tests.html create mode 100644 ui/utils/activity-log.js create mode 100644 ui/utils/command-palette.js create mode 100644 ui/utils/connection-status.js create mode 100644 ui/utils/data-export.js create mode 100644 ui/utils/fullscreen.js create mode 100644 ui/utils/i18n.js create mode 100644 ui/utils/idle-manager.js create mode 100644 ui/utils/keyboard-shortcuts.js create mode 100644 ui/utils/mobile-nav.js create mode 100644 ui/utils/notification-center.js create mode 100644 ui/utils/onboarding.js create mode 100644 ui/utils/perf-monitor.js create mode 100644 ui/utils/quick-settings.js create mode 100644 ui/utils/router.js create mode 100644 ui/utils/screenshot.js create mode 100644 ui/utils/theme-toggle.js create mode 100644 ui/utils/toast.js create mode 100644 ui/utils/uptime-clock.js diff --git a/ui/.eslintrc.json b/ui/.eslintrc.json new file mode 100644 index 00000000..8e5a89e7 --- /dev/null +++ b/ui/.eslintrc.json @@ -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" + ] +} diff --git a/ui/app.js b/ui/app.js index a1c94ded..5c5bada6 100644 --- a/ui/app.js +++ b/ui/app.js @@ -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 diff --git a/ui/components/TabManager.js b/ui/components/TabManager.js index d559c2ea..c2d35297 100644 --- a/ui/components/TabManager.js +++ b/ui/components/TabManager.js @@ -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 diff --git a/ui/icons/generate.html b/ui/icons/generate.html new file mode 100644 index 00000000..161ad7c6 --- /dev/null +++ b/ui/icons/generate.html @@ -0,0 +1,66 @@ + + +RuView Icon Generator + +

Open this file in a browser and right-click to save the canvas images as icon-192.png and icon-512.png

+ + + + + diff --git a/ui/index.html b/ui/index.html index a68dc799..857ebf2f 100644 --- a/ui/index.html +++ b/ui/index.html @@ -3,40 +3,48 @@ + + + + WiFi DensePose: Human Tracking Through Walls + + + Skip to main content +
-
+ -