mirror of
https://github.com/smittix/intercept.git
synced 2026-04-23 22:30:00 -07:00
chore: exclude docs/superpowers/ from git tracking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -67,4 +67,5 @@ data/subghz/captures/
|
||||
|
||||
# Local utility scripts
|
||||
reset-sdr.*
|
||||
.superpowers/
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,866 +0,0 @@
|
||||
# Bluetooth UI Polish Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Apply the WiFi scanner's visual polish to the Bluetooth scanner: WiFi-style 2-line device rows, CSS animated radar sweep with trailing arc, and an enhanced device list header with scan indicator and sort controls.
|
||||
|
||||
**Architecture:** Pure frontend — HTML structure in `templates/index.html`, styles in `static/css/index.css`, JS logic in `static/js/modes/bluetooth.js`, and the shared radar component `static/js/components/proximity-radar.js`. Each task is independently committable and leaves the UI functional.
|
||||
|
||||
**Tech Stack:** Vanilla JS (ES6 IIFE module pattern), CSS animations, inline SVG, Flask/Jinja2 templates.
|
||||
|
||||
---
|
||||
|
||||
## Spec & reference
|
||||
|
||||
- **Spec:** `docs/superpowers/specs/2026-03-27-bluetooth-ui-polish-design.md`
|
||||
- **Start the app for manual verification:**
|
||||
```bash
|
||||
sudo -E venv/bin/python intercept.py
|
||||
# Open http://localhost:5050/?mode=bluetooth
|
||||
```
|
||||
|
||||
## File map
|
||||
|
||||
| File | What changes |
|
||||
|---|---|
|
||||
| `static/js/components/proximity-radar.js` | `createSVG()` — add clip path + trailing arc group + CSS class; remove `animateSweep()` and its call; update `setPaused()` |
|
||||
| `static/css/index.css` | Add `.bt-radar-sweep` + `@keyframes bt-radar-rotate` (~line 4410); add `.bt-scan-indicator`, `.bt-scan-dot` (~line 4836); add `.bt-controls-row`, `.bt-sort-group`, `.bt-filter-group`, `.bt-sort-btn` (~line 4944); replace `.bt-device-row` and its children with 2-line structure (~line 5130) |
|
||||
| `templates/index.html` | Add `#btScanIndicator` to header (~line 1189); insert `.bt-controls-row` between signal strip and search (~line 1228); remove old `.bt-device-filters` div (lines 1231–1237) |
|
||||
| `static/js/modes/bluetooth.js` | Add `sortBy` state; add `initSortControls()`; add `renderAllDevices()`; update `initDeviceFilters()` to use new `#btFilterGroup`; update `setScanning()` to drive `#btScanIndicator`; remove `locateBtn` branch from `initListInteractions()`; rewrite `createSimpleDeviceCard()` |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Proximity Radar — CSS animation + trailing glow arc
|
||||
|
||||
**Files:**
|
||||
- Modify: `static/js/components/proximity-radar.js` (lines 58–165)
|
||||
- Modify: `static/css/index.css` (~line 4410, after `.bt-radar-panel #btProximityRadar` block)
|
||||
|
||||
### Context
|
||||
|
||||
`createSVG()` currently renders a `<line class="radar-sweep">` and then calls `animateSweep()` which runs a `requestAnimationFrame` loop that mutates the line's `x2`/`y2` attributes each frame. We replace this with:
|
||||
- A `<g class="bt-radar-sweep">` containing two trailing arc `<path>` elements and the sweep `<line>`, all clipped to the radar circle
|
||||
- A CSS `@keyframes` rotation on `.bt-radar-sweep` (same approach as the WiFi radar's `.wifi-radar-sweep`)
|
||||
- `animateSweep()` deleted entirely
|
||||
- `setPaused()` updated to toggle `animationPlayState` instead of the `isPaused` flag check in `rotate()`
|
||||
|
||||
**Geometry** (`CONFIG.size = 280`, so `center = 140`, `outerRadius = center − CONFIG.padding = 120`):
|
||||
- Sweep line: `x1=140 y1=140 x2=140 y2=20` (pointing up from centre)
|
||||
- Clip circle: `cx=140 cy=140 r=120`
|
||||
- 90° trailing arc (light): `M140,140 L140,20 A120,120 0 0,1 260,140 Z`
|
||||
- 60° trailing arc (denser): `M140,140 L140,20 A120,120 0 0,1 244,200 Z`
|
||||
_(these match the proportional geometry used by the WiFi radar at its scale)_
|
||||
|
||||
- [ ] **Step 1: Replace `createSVG()` sweep section**
|
||||
|
||||
In `proximity-radar.js`, find and replace the lines that render the sweep and call `animateSweep` (the last 20 lines of `createSVG()`, roughly lines 97–134):
|
||||
|
||||
Replace:
|
||||
```js
|
||||
<!-- Sweep line (animated) -->
|
||||
<line class="radar-sweep" x1="${center}" y1="${center}"
|
||||
x2="${center}" y2="${CONFIG.padding}"
|
||||
stroke="rgba(0, 212, 255, 0.5)" stroke-width="1" />
|
||||
```
|
||||
With (inside the template literal):
|
||||
```js
|
||||
<!-- Clip path to keep arc inside circle -->
|
||||
<clipPath id="radarClip"><circle cx="${center}" cy="${center}" r="${center - CONFIG.padding}"/></clipPath>
|
||||
|
||||
<!-- CSS-animated sweep group: trailing arcs + sweep line -->
|
||||
<g class="bt-radar-sweep" clip-path="url(#radarClip)">
|
||||
<path d="M${center},${center} L${center},${CONFIG.padding} A${center - CONFIG.padding},${center - CONFIG.padding} 0 0,1 ${center + (center - CONFIG.padding)},${center} Z"
|
||||
fill="#00b4d8" opacity="0.035"/>
|
||||
<path d="M${center},${center} L${center},${CONFIG.padding} A${center - CONFIG.padding},${center - CONFIG.padding} 0 0,1 ${Math.round(center + (center - CONFIG.padding) * Math.sin(Math.PI / 3))},${Math.round(center + (center - CONFIG.padding) * (1 - Math.cos(Math.PI / 3)))} Z"
|
||||
fill="#00b4d8" opacity="0.07"/>
|
||||
<line x1="${center}" y1="${center}" x2="${center}" y2="${CONFIG.padding}"
|
||||
stroke="#00b4d8" stroke-width="1.5" opacity="0.75"/>
|
||||
</g>
|
||||
```
|
||||
|
||||
Also add `<clipPath id="radarClip">` to the `<defs>` block (before the closing `</defs>`):
|
||||
```js
|
||||
<clipPath id="radarClip">
|
||||
<circle cx="${center}" cy="${center}" r="${center - CONFIG.padding}"/>
|
||||
</clipPath>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove `animateSweep()` call and function**
|
||||
|
||||
At the end of `createSVG()` (line ~133), remove:
|
||||
```js
|
||||
// Add sweep animation
|
||||
animateSweep();
|
||||
```
|
||||
|
||||
Delete the entire `animateSweep()` function (lines 139–165):
|
||||
```js
|
||||
/**
|
||||
* Animate the radar sweep line
|
||||
*/
|
||||
function animateSweep() {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `setPaused()` to use CSS animationPlayState**
|
||||
|
||||
Replace the current `setPaused()` (line 494):
|
||||
```js
|
||||
function setPaused(paused) {
|
||||
isPaused = paused;
|
||||
}
|
||||
```
|
||||
With:
|
||||
```js
|
||||
function setPaused(paused) {
|
||||
isPaused = paused;
|
||||
const sweep = svg?.querySelector('.bt-radar-sweep');
|
||||
if (sweep) sweep.style.animationPlayState = paused ? 'paused' : 'running';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add CSS animation to `index.css`**
|
||||
|
||||
In `index.css`, find the line `.bt-radar-panel #btProximityRadar {` block (line ~4402). Add the following immediately after its closing `}` (after line ~4409):
|
||||
|
||||
```css
|
||||
/* Bluetooth radar — CSS sweep animation (replaces rAF loop in proximity-radar.js) */
|
||||
.bt-radar-sweep {
|
||||
transform-origin: 140px 140px;
|
||||
animation: bt-radar-rotate 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bt-radar-rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify manually**
|
||||
|
||||
Start the app (`sudo -E venv/bin/python intercept.py`), navigate to `/?mode=bluetooth`, and confirm:
|
||||
- Radar sweep rotates continuously with a trailing blue glow arc
|
||||
- Clicking "Pause" stops the rotation (the sweep group freezes)
|
||||
- Clicking the filter buttons (New Only / Strongest / Unapproved) still works
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add static/js/components/proximity-radar.js static/css/index.css
|
||||
git commit -m "feat(bluetooth): CSS animated radar sweep with trailing glow arc"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Device list header — scan indicator + controls row
|
||||
|
||||
**Files:**
|
||||
- Modify: `templates/index.html` (lines 1186–1237)
|
||||
- Modify: `static/css/index.css` (~lines 4836 and 4944)
|
||||
|
||||
### Context
|
||||
|
||||
The header row (`wifi-device-list-header`) currently has title + count. We add a pulsing scan indicator (IDLE/SCANNING) right-aligned in that row.
|
||||
|
||||
Between the signal distribution strip (`.bt-list-signal-strip`, ends ~line 1227) and the search toolbar (`.bt-device-toolbar`, line 1228) we insert a new `.bt-controls-row` with two halves:
|
||||
- Left: sort buttons (Signal / Name / Seen / Dist), contained in `#btSortGroup`
|
||||
- Right: filter buttons (All / New / Named / Strong / Trackers), contained in `#btFilterGroup`
|
||||
|
||||
The old `.bt-device-filters` div (lines 1231–1237) is deleted entirely — filters move into the controls row.
|
||||
|
||||
- [ ] **Step 1: Add scan indicator to the header**
|
||||
|
||||
In `index.html`, find the `.wifi-device-list-header` block for BT (~line 1186):
|
||||
```html
|
||||
<div class="wifi-device-list-header">
|
||||
<h5>Bluetooth Devices</h5>
|
||||
<span class="device-count">(<span id="btDeviceListCount">0</span>)</span>
|
||||
</div>
|
||||
```
|
||||
Replace with:
|
||||
```html
|
||||
<div class="wifi-device-list-header">
|
||||
<h5>Bluetooth Devices</h5>
|
||||
<span class="device-count">(<span id="btDeviceListCount">0</span>)</span>
|
||||
<div class="bt-scan-indicator" id="btScanIndicator">
|
||||
<span class="bt-scan-dot" style="display:none;"></span>
|
||||
<span class="bt-scan-text">IDLE</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Insert controls row, remove old filter div**
|
||||
|
||||
In `index.html`, find the `.bt-device-toolbar` and `.bt-device-filters` block (lines 1228–1237):
|
||||
```html
|
||||
<div class="bt-device-toolbar">
|
||||
<input type="search" id="btDeviceSearch" class="bt-device-search" placeholder="Filter by name, MAC, manufacturer...">
|
||||
</div>
|
||||
<div class="bt-device-filters" id="btDeviceFilters">
|
||||
<button class="bt-filter-btn active" data-filter="all">All</button>
|
||||
<button class="bt-filter-btn" data-filter="new">New</button>
|
||||
<button class="bt-filter-btn" data-filter="named">Named</button>
|
||||
<button class="bt-filter-btn" data-filter="strong">Strong</button>
|
||||
<button class="bt-filter-btn" data-filter="trackers">Trackers</button>
|
||||
</div>
|
||||
```
|
||||
Replace with:
|
||||
```html
|
||||
<div class="bt-controls-row">
|
||||
<div class="bt-sort-group" id="btSortGroup">
|
||||
<span class="bt-sort-label">Sort</span>
|
||||
<button class="bt-sort-btn active" data-sort="rssi">Signal</button>
|
||||
<button class="bt-sort-btn" data-sort="name">Name</button>
|
||||
<button class="bt-sort-btn" data-sort="seen">Seen</button>
|
||||
<button class="bt-sort-btn" data-sort="distance">Dist</button>
|
||||
</div>
|
||||
<div class="bt-filter-group" id="btFilterGroup">
|
||||
<button class="bt-filter-btn active" data-filter="all">All</button>
|
||||
<button class="bt-filter-btn" data-filter="new">New</button>
|
||||
<button class="bt-filter-btn" data-filter="named">Named</button>
|
||||
<button class="bt-filter-btn" data-filter="strong">Strong</button>
|
||||
<button class="bt-filter-btn" data-filter="trackers">Trackers</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bt-device-toolbar">
|
||||
<input type="search" id="btDeviceSearch" class="bt-device-search" placeholder="Filter by name, MAC, manufacturer...">
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add scan indicator CSS**
|
||||
|
||||
In `index.css`, find `.bt-list-summary {` (~line 4837). Add the following immediately before it:
|
||||
|
||||
```css
|
||||
/* Bluetooth scan indicator (header) */
|
||||
.bt-scan-indicator {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.bt-scan-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-cyan);
|
||||
animation: bt-scan-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes bt-scan-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.7); }
|
||||
}
|
||||
|
||||
.bt-scan-text {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.bt-scan-text.active {
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 600;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add controls row CSS**
|
||||
|
||||
In `index.css`, find `.bt-device-filters {` (~line 4933). Replace the entire `.bt-device-filters` block (lines 4933–4944) and the `.bt-filter-btn` blocks (lines 4946–4969) with:
|
||||
|
||||
```css
|
||||
/* Bluetooth controls row: sort + filter combined */
|
||||
.bt-controls-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 44px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.bt-sort-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 5px 10px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 5px 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bt-sort-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.bt-sort-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.bt-sort-btn:hover { color: var(--text-primary); }
|
||||
.bt-sort-btn.active { color: var(--accent-cyan); background: rgba(74,163,255,0.08); }
|
||||
|
||||
.bt-filter-btn {
|
||||
padding: 3px 8px;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.bt-filter-btn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.bt-filter-btn.active {
|
||||
color: var(--accent-cyan);
|
||||
border-color: rgba(74,163,255,0.4);
|
||||
background: rgba(74,163,255,0.08);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify manually**
|
||||
|
||||
Reload the app, navigate to `/?mode=bluetooth`. Confirm:
|
||||
- Header shows "IDLE" text right-aligned (no pulsing dot yet — JS wiring is Task 3)
|
||||
- Controls row appears between signal strip and search: "Sort Signal Name Seen Dist | All New Named Strong Trackers"
|
||||
- Filter and sort buttons are styled and visually clickable (they don't work yet — JS is Task 3)
|
||||
- Old `.bt-device-filters` div is gone
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add templates/index.html static/css/index.css
|
||||
git commit -m "feat(bluetooth): scan indicator and sort+filter controls row in device list header"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: JS wiring — scan indicator, sort, filter handler, locate branch cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `static/js/modes/bluetooth.js`
|
||||
- `setScanning()` (~line 984)
|
||||
- `initDeviceFilters()` (~line 130)
|
||||
- `init()` (~line 91)
|
||||
- `initListInteractions()` (~line 161)
|
||||
- Module-level state (~line 38)
|
||||
|
||||
### Context
|
||||
|
||||
Four independent changes to `bluetooth.js`:
|
||||
1. `setScanning()` drives `#btScanIndicator` (dot visible + "SCANNING" text when scanning)
|
||||
2. `initDeviceFilters()` targets the new `#btFilterGroup` instead of `#btDeviceFilters`
|
||||
3. New `initSortControls()` + `renderAllDevices()` functions, called from `init()`
|
||||
4. The `locateBtn` branch in `initListInteractions()` is removed (no locate buttons in rows)
|
||||
|
||||
- [ ] **Step 1: Add `sortBy` state variable**
|
||||
|
||||
In `bluetooth.js`, find the module-level state block (~line 38, near `let currentDeviceFilter = 'all'`). Add:
|
||||
```js
|
||||
let sortBy = 'rssi';
|
||||
```
|
||||
Place it directly after `let currentDeviceFilter = 'all';`.
|
||||
|
||||
- [ ] **Step 2: Update `setScanning()` to drive the scan indicator**
|
||||
|
||||
In `bluetooth.js`, find `function setScanning(scanning)` (~line 984). At the end of the function body (after the `statusDot`/`statusText` block, around line 1010), add:
|
||||
```js
|
||||
// Drive the per-panel scan indicator
|
||||
const scanDot = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-dot');
|
||||
const scanText = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-text');
|
||||
if (scanDot) scanDot.style.display = scanning ? 'inline-block' : 'none';
|
||||
if (scanText) {
|
||||
scanText.textContent = scanning ? 'SCANNING' : 'IDLE';
|
||||
scanText.classList.toggle('active', scanning);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `initDeviceFilters()` to use new container ID**
|
||||
|
||||
In `bluetooth.js`, find `function initDeviceFilters()` (~line 130). Change:
|
||||
```js
|
||||
const filterContainer = document.getElementById('btDeviceFilters');
|
||||
```
|
||||
To:
|
||||
```js
|
||||
const filterContainer = document.getElementById('btFilterGroup');
|
||||
```
|
||||
(Everything else in the function — the click handler, search input listener — stays identical.)
|
||||
|
||||
- [ ] **Step 4: Add `renderAllDevices()` function**
|
||||
|
||||
In `bluetooth.js`, add the following new function after `renderDevice()` (~after line 1367):
|
||||
```js
|
||||
/**
|
||||
* Re-render all devices in the current sort order, then re-apply the active filter.
|
||||
*/
|
||||
function renderAllDevices() {
|
||||
if (!deviceContainer) return;
|
||||
deviceContainer.innerHTML = '';
|
||||
|
||||
const sorted = [...devices.values()].sort((a, b) => {
|
||||
if (sortBy === 'rssi') return (b.rssi_current ?? -100) - (a.rssi_current ?? -100);
|
||||
if (sortBy === 'name') return (a.name || '\uFFFF').localeCompare(b.name || '\uFFFF');
|
||||
if (sortBy === 'seen') return (b.seen_count || 0) - (a.seen_count || 0);
|
||||
if (sortBy === 'distance') return (a.estimated_distance_m ?? 9999) - (b.estimated_distance_m ?? 9999);
|
||||
return 0;
|
||||
});
|
||||
|
||||
sorted.forEach(device => renderDevice(device, false));
|
||||
applyDeviceFilter();
|
||||
if (selectedDeviceId) highlightSelectedDevice(selectedDeviceId);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add `initSortControls()` function**
|
||||
|
||||
In `bluetooth.js`, add the following new function after `initDeviceFilters()` (~after line 159):
|
||||
```js
|
||||
function initSortControls() {
|
||||
const sortGroup = document.getElementById('btSortGroup');
|
||||
if (!sortGroup) return;
|
||||
sortGroup.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.bt-sort-btn');
|
||||
if (!btn) return;
|
||||
const sort = btn.dataset.sort;
|
||||
if (!sort) return;
|
||||
sortBy = sort;
|
||||
sortGroup.querySelectorAll('.bt-sort-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
renderAllDevices();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Call `initSortControls()` from `init()`**
|
||||
|
||||
In `bluetooth.js`, find `function init()` (~line 91). After the line `initDeviceFilters();` (~line 120), add:
|
||||
```js
|
||||
initSortControls();
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Remove `locateBtn` branch from `initListInteractions()`**
|
||||
|
||||
In `bluetooth.js`, find `function initListInteractions()` (~line 161). Remove these lines from the click handler:
|
||||
```js
|
||||
const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]');
|
||||
if (locateBtn) {
|
||||
event.preventDefault();
|
||||
locateById(locateBtn.dataset.locateId);
|
||||
return;
|
||||
}
|
||||
```
|
||||
The click handler body should now go directly to:
|
||||
```js
|
||||
const row = event.target.closest('.bt-device-row[data-bt-device-id]');
|
||||
if (!row) return;
|
||||
selectDevice(row.dataset.btDeviceId);
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Verify manually**
|
||||
|
||||
Reload. Navigate to `/?mode=bluetooth`. Start a scan.
|
||||
- Header shows pulsing dot + "SCANNING" text; stops when scan ends → "IDLE"
|
||||
- Sort buttons work: clicking "Name" re-orders the device list alphabetically; "Signal" puts strongest first
|
||||
- Filter buttons work: "New" shows only new devices; "Trackers" shows only trackers
|
||||
- Clicking a device row still opens the detail panel
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add static/js/modes/bluetooth.js
|
||||
git commit -m "feat(bluetooth): scan indicator, sort controls, updated filter handler"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Device row rewrite — WiFi-style 2-line layout
|
||||
|
||||
**Files:**
|
||||
- Modify: `static/js/modes/bluetooth.js` — `createSimpleDeviceCard()` (~line 1369)
|
||||
- Modify: `static/css/index.css` — `.bt-device-row` block (~lines 4790, 5130–5333)
|
||||
|
||||
### Context
|
||||
|
||||
`createSimpleDeviceCard()` currently produces a 3-part layout (`.bt-row-main` / `.bt-row-secondary` / `.bt-row-actions`). We replace it with a 2-line WiFi-style layout:
|
||||
|
||||
**Top line (`.bt-row-top`):** protocol badge + device name + tracker/IRK/risk/cluster badges (left); flag badges + status dot (right)
|
||||
|
||||
**Bottom line (`.bt-row-bottom`):** full-width signal bar + flex meta row (manufacturer or address · distance · RSSI value)
|
||||
|
||||
The locate button moves out of the row entirely (it exists in the detail panel, which is unchanged).
|
||||
|
||||
The `.bt-status-dot.known` colour changes from green to grey (matching WiFi's "safe" colour logic — green was misleading for "known" devices).
|
||||
|
||||
**CSS classes removed** (no longer emitted by JS, safe to delete):
|
||||
`.bt-row-main`, `.bt-row-left`, `.bt-row-right`, `.bt-rssi-container`, `.bt-rssi-bar-bg`, `.bt-rssi-bar`, `.bt-rssi-value`, `.bt-row-secondary`, `.bt-row-actions`, `.bt-row-actions .bt-locate-btn` (and its `:hover`, `:active`, `svg` variants)
|
||||
|
||||
**CSS classes added:**
|
||||
`.bt-row-top`, `.bt-row-top-left`, `.bt-row-top-right`, `.bt-row-name`, `.bt-unnamed`, `.bt-signal-bar-wrap`, `.bt-signal-track`, `.bt-signal-fill` (+ `.strong`, `.medium`, `.weak`), `.bt-row-bottom`, `.bt-row-meta`, `.bt-row-rssi` (+ `.strong`, `.medium`, `.weak`)
|
||||
|
||||
- [ ] **Step 1: Update `.bt-device-row` base CSS**
|
||||
|
||||
In `index.css`, find `.bt-device-row {` (~line 5130). Replace the entire block:
|
||||
```css
|
||||
.bt-device-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: 4px solid #666;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
```
|
||||
With:
|
||||
```css
|
||||
.bt-device-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 3px solid transparent;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `.bt-device-row` interactive states**
|
||||
|
||||
Find `.bt-device-row:last-child`, `.bt-device-row:hover`, `.bt-device-row:focus-visible` (~lines 5143–5155). Replace all three:
|
||||
```css
|
||||
.bt-device-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.bt-device-row:hover { background: var(--bg-tertiary); }
|
||||
|
||||
.bt-device-row:focus-visible {
|
||||
outline: 1px solid var(--accent-cyan);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
```
|
||||
|
||||
Also find `.bt-device-row.selected` (~line 4790). Replace:
|
||||
```css
|
||||
.bt-device-row.selected {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
```
|
||||
With:
|
||||
```css
|
||||
.bt-device-row.selected {
|
||||
background: rgba(74, 163, 255, 0.07);
|
||||
border-left-color: var(--accent-cyan) !important;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove old row-structure CSS, add new 2-line CSS**
|
||||
|
||||
In `index.css`, find and delete the following blocks (lines ~5157–5333):
|
||||
- `.bt-row-main { … }`
|
||||
- `.bt-row-left { … }`
|
||||
- `.bt-row-right { … }`
|
||||
- `.bt-rssi-container { … }`
|
||||
- `.bt-rssi-bar-bg { … }`
|
||||
- `.bt-rssi-bar { … }`
|
||||
- `.bt-rssi-value { … }`
|
||||
- `.bt-row-secondary { … }`
|
||||
- `.bt-row-actions { … }`
|
||||
- `.bt-row-actions .bt-locate-btn { … }` (and the `:hover`, `:active`, `svg` variants)
|
||||
|
||||
In their place, add:
|
||||
```css
|
||||
/* Bluetooth device row — 2-line WiFi-style layout */
|
||||
.bt-row-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.bt-row-top-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bt-row-top-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-row-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bt-row-name.bt-unnamed {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.bt-row-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bt-signal-bar-wrap { flex: 1; }
|
||||
|
||||
.bt-signal-track {
|
||||
height: 4px;
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bt-signal-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.bt-signal-fill.strong { background: linear-gradient(90deg, var(--accent-green), #88d49b); }
|
||||
.bt-signal-fill.medium { background: linear-gradient(90deg, var(--accent-green), var(--accent-orange)); }
|
||||
.bt-signal-fill.weak { background: linear-gradient(90deg, var(--accent-orange), var(--accent-red)); }
|
||||
|
||||
.bt-row-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bt-row-rssi { font-family: var(--font-mono); font-size: 10px; }
|
||||
.bt-row-rssi.strong { color: var(--accent-green); }
|
||||
.bt-row-rssi.medium { color: var(--accent-amber, #eab308); }
|
||||
.bt-row-rssi.weak { color: var(--accent-red); }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `.bt-status-dot.known` colour**
|
||||
|
||||
Find `.bt-status-dot.known` (~line 5274). The current value is `background: #22c55e`. Change to:
|
||||
```css
|
||||
.bt-status-dot.known {
|
||||
background: #484f58;
|
||||
}
|
||||
```
|
||||
(Green was misleading — "known" is neutral, not safe.)
|
||||
|
||||
- [ ] **Step 5: Rewrite `createSimpleDeviceCard()`**
|
||||
|
||||
In `bluetooth.js`, replace the entire body of `createSimpleDeviceCard(device)` (~lines 1369–1511) with:
|
||||
|
||||
```js
|
||||
function createSimpleDeviceCard(device) {
|
||||
const protocol = device.protocol || 'ble';
|
||||
const rssi = device.rssi_current;
|
||||
const inBaseline = device.in_baseline || false;
|
||||
const isNew = !inBaseline;
|
||||
const hasName = !!device.name;
|
||||
const isTracker = device.is_tracker === true;
|
||||
const trackerType = device.tracker_type;
|
||||
const trackerConfidence = device.tracker_confidence;
|
||||
const riskScore = device.risk_score || 0;
|
||||
const agentName = device._agent || 'Local';
|
||||
const seenBefore = device.seen_before === true;
|
||||
|
||||
// Signal bar
|
||||
const rssiPercent = rssi != null ? Math.max(0, Math.min(100, ((rssi + 100) / 70) * 100)) : 0;
|
||||
const fillClass = rssi == null ? 'weak'
|
||||
: rssi >= -60 ? 'strong'
|
||||
: rssi >= -75 ? 'medium' : 'weak';
|
||||
|
||||
const displayName = device.name || formatDeviceId(device.address);
|
||||
const name = escapeHtml(displayName);
|
||||
const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown'));
|
||||
const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : '';
|
||||
const seenCount = device.seen_count || 0;
|
||||
const searchIndex = [
|
||||
displayName, device.address, device.manufacturer_name,
|
||||
device.tracker_name, device.tracker_type, agentName
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
|
||||
// Protocol badge
|
||||
const protoBadge = protocol === 'ble'
|
||||
? '<span class="bt-proto-badge ble">BLE</span>'
|
||||
: '<span class="bt-proto-badge classic">CLASSIC</span>';
|
||||
|
||||
// Tracker badge
|
||||
let trackerBadge = '';
|
||||
if (isTracker) {
|
||||
const confColor = trackerConfidence === 'high' ? '#ef4444'
|
||||
: trackerConfidence === 'medium' ? '#f97316' : '#eab308';
|
||||
const confBg = trackerConfidence === 'high' ? 'rgba(239,68,68,0.15)'
|
||||
: trackerConfidence === 'medium' ? 'rgba(249,115,22,0.15)' : 'rgba(234,179,8,0.15)';
|
||||
const typeLabel = trackerType === 'airtag' ? 'AirTag'
|
||||
: trackerType === 'tile' ? 'Tile'
|
||||
: trackerType === 'samsung_smarttag' ? 'SmartTag'
|
||||
: trackerType === 'findmy_accessory' ? 'FindMy'
|
||||
: trackerType === 'chipolo' ? 'Chipolo' : 'TRACKER';
|
||||
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor
|
||||
+ ';font-size:9px;padding:1px 5px;border-radius:3px;font-weight:600;">' + typeLabel + '</span>';
|
||||
}
|
||||
|
||||
// IRK badge
|
||||
const irkBadge = device.has_irk ? '<span class="bt-irk-badge">IRK</span>' : '';
|
||||
|
||||
// Risk badge
|
||||
let riskBadge = '';
|
||||
if (riskScore >= 0.3) {
|
||||
const riskColor = riskScore >= 0.5 ? '#ef4444' : '#f97316';
|
||||
riskBadge = '<span class="bt-risk-badge" style="color:' + riskColor
|
||||
+ ';font-size:8px;font-weight:600;">' + Math.round(riskScore * 100) + '% RISK</span>';
|
||||
}
|
||||
|
||||
// MAC cluster badge
|
||||
const clusterBadge = device.mac_cluster_count > 1
|
||||
? '<span class="bt-mac-cluster-badge">' + device.mac_cluster_count + ' MACs</span>'
|
||||
: '';
|
||||
|
||||
// Flag badges (go to top-right, before status dot)
|
||||
const hFlags = device.heuristic_flags || [];
|
||||
let flagBadges = '';
|
||||
if (device.is_persistent || hFlags.includes('persistent'))
|
||||
flagBadges += '<span class="bt-flag-badge persistent">PERSIST</span>';
|
||||
if (device.is_beacon_like || hFlags.includes('beacon_like'))
|
||||
flagBadges += '<span class="bt-flag-badge beacon-like">BEACON</span>';
|
||||
if (device.is_strong_stable || hFlags.includes('strong_stable'))
|
||||
flagBadges += '<span class="bt-flag-badge strong-stable">STABLE</span>';
|
||||
|
||||
// Status dot
|
||||
let statusDot;
|
||||
if (isTracker && trackerConfidence === 'high') {
|
||||
statusDot = '<span class="bt-status-dot tracker" style="background:#ef4444;"></span>';
|
||||
} else if (isNew) {
|
||||
statusDot = '<span class="bt-status-dot new"></span>';
|
||||
} else {
|
||||
statusDot = '<span class="bt-status-dot known"></span>';
|
||||
}
|
||||
|
||||
// Bottom meta items
|
||||
const metaLabel = mfr || addr; // already HTML-escaped above
|
||||
const distM = device.estimated_distance_m;
|
||||
const distStr = distM != null ? '~' + distM.toFixed(1) + 'm' : '';
|
||||
let metaHtml = '<span>' + metaLabel + '</span>';
|
||||
if (distStr) metaHtml += '<span>' + distStr + '</span>';
|
||||
metaHtml += '<span class="bt-row-rssi ' + fillClass + '">' + (rssi != null ? rssi : '—') + '</span>';
|
||||
if (seenBefore) metaHtml += '<span class="bt-history-badge">SEEN</span>';
|
||||
if (agentName !== 'Local')
|
||||
metaHtml += '<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">'
|
||||
+ escapeHtml(agentName) + '</span>';
|
||||
|
||||
// Left border colour
|
||||
const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444'
|
||||
: isTracker ? '#f97316'
|
||||
: rssi != null && rssi >= -60 ? 'var(--accent-green)'
|
||||
: rssi != null && rssi >= -75 ? 'var(--accent-amber, #eab308)'
|
||||
: 'var(--accent-red)';
|
||||
|
||||
return '<div class="bt-device-row' + (isTracker ? ' is-tracker' : '') + '"'
|
||||
+ ' data-bt-device-id="' + escapeAttr(device.device_id) + '"'
|
||||
+ ' data-is-new="' + isNew + '"'
|
||||
+ ' data-has-name="' + hasName + '"'
|
||||
+ ' data-rssi="' + (rssi ?? -100) + '"'
|
||||
+ ' data-is-tracker="' + isTracker + '"'
|
||||
+ ' data-search="' + escapeAttr(searchIndex) + '"'
|
||||
+ ' role="button" tabindex="0" data-keyboard-activate="true"'
|
||||
+ ' style="border-left-color:' + borderColor + ';">'
|
||||
// Top line
|
||||
+ '<div class="bt-row-top">'
|
||||
+ '<div class="bt-row-top-left">'
|
||||
+ protoBadge
|
||||
+ '<span class="bt-row-name' + (hasName ? '' : ' bt-unnamed') + '">' + name + '</span>'
|
||||
+ trackerBadge + irkBadge + riskBadge + clusterBadge
|
||||
+ '</div>'
|
||||
+ '<div class="bt-row-top-right">'
|
||||
+ flagBadges + statusDot
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
// Bottom line
|
||||
+ '<div class="bt-row-bottom">'
|
||||
+ '<div class="bt-signal-bar-wrap">'
|
||||
+ '<div class="bt-signal-track">'
|
||||
+ '<div class="bt-signal-fill ' + fillClass + '" style="width:' + rssiPercent.toFixed(1) + '%"></div>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="bt-row-meta">' + metaHtml + '</div>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify manually**
|
||||
|
||||
Reload and start a scan. Confirm:
|
||||
- Each device row has two lines: name + badges on top, signal bar + meta on bottom
|
||||
- Locate button is gone from rows; still present in the detail panel (right-click a device, check the detail panel at left)
|
||||
- Strong signal rows have green bar + green RSSI; medium amber; weak red
|
||||
- Tracker rows have red left border; AirTag/Tile labels show
|
||||
- Unnamed devices show address in italic grey
|
||||
- Selecting a row highlights it in cyan; detail panel populates
|
||||
- `PERSIST`, `BEACON`, `STABLE` flag badges appear top-right when set
|
||||
|
||||
- [ ] **Step 7: Run backend tests to confirm no regressions**
|
||||
|
||||
```bash
|
||||
pytest tests/test_bluetooth.py tests/test_bluetooth_api.py -v
|
||||
```
|
||||
Expected: all pass (frontend-only change, backend untouched).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add static/js/modes/bluetooth.js static/css/index.css
|
||||
git commit -m "feat(bluetooth): WiFi-style 2-line device rows"
|
||||
```
|
||||
@@ -1,517 +0,0 @@
|
||||
# Design System Uplift + Sidebar Polish — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Establish the "Mission Control" visual language as the new baseline — deeper blacks, cyan-glow active states, panel polish, scanline texture, and nav group state persistence — so every subsequent sub-project inherits a consistent, polished foundation.
|
||||
|
||||
**Architecture:** Four targeted CSS file edits (variables, layout, components, index) plus one JS enhancement for localStorage-backed nav state. No structural changes; all changes are additive or small replacements within existing rules.
|
||||
|
||||
**Tech Stack:** CSS custom properties, Jinja2 templates, vanilla JS, localStorage API
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `static/css/core/variables.css` | Modify | Deepen bg tokens; add `--accent-cyan-glow` and `--scanline` |
|
||||
| `static/css/core/layout.css` | Modify | Nav active state → left-border glow; hover → cyan-glow bg; group header accent line |
|
||||
| `static/css/core/components.css` | Modify | Panel indicator pulse animation; card vignette; scanline texture on visuals containers |
|
||||
| `static/css/index.css` | Modify | Scanline texture on existing `.visuals-container` if not covered by components.css |
|
||||
| `templates/partials/nav.html` | Modify | Add `data-group` attributes to dropdown containers (already present); verify `toggleNavDropdown` call exists |
|
||||
| `templates/index.html` | Modify | Add `initNavGroupState()` call in page init; add the JS function |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Deepen Background Tokens
|
||||
|
||||
**Files:**
|
||||
- Modify: `static/css/core/variables.css`
|
||||
|
||||
Note on current values (from file, not the UI guide which is out of date):
|
||||
- `--bg-primary` is currently `#0b1118`
|
||||
- `--bg-secondary` is currently `#101823`
|
||||
- `--bg-tertiary` is currently `#151f2b`
|
||||
- `--bg-card` is currently `#121a25`
|
||||
|
||||
- [ ] **Step 1: Update background tokens in dark theme**
|
||||
|
||||
In `static/css/core/variables.css`, replace the four background values in the `:root` block:
|
||||
|
||||
```css
|
||||
/* Backgrounds - layered depth system */
|
||||
--bg-primary: #07090e;
|
||||
--bg-secondary: #0b1018;
|
||||
--bg-tertiary: #101520;
|
||||
--bg-card: #0d1219;
|
||||
--bg-elevated: #161d28;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `--accent-cyan-glow` and `--scanline` tokens**
|
||||
|
||||
In `static/css/core/variables.css`, after the `--accent-amber-dim` line, add:
|
||||
|
||||
```css
|
||||
--accent-cyan-glow: rgba(74, 163, 255, 0.12);
|
||||
```
|
||||
|
||||
After the `--noise-image` line, add a new comment block and token:
|
||||
|
||||
```css
|
||||
/* Scanline overlay texture */
|
||||
--scanline: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.04) 2px,
|
||||
rgba(0, 0, 0, 0.04) 4px
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update light theme overrides to match**
|
||||
|
||||
In `static/css/core/variables.css`, in the `[data-theme="light"]` block, the bg values don't need changing (they're already light colours). But add the missing `--accent-cyan-glow` override so it doesn't bleed through in light mode:
|
||||
|
||||
```css
|
||||
--accent-cyan-glow: rgba(31, 95, 168, 0.08);
|
||||
--scanline: none;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify no visual regressions**
|
||||
|
||||
Start the dev server: `sudo -E venv/bin/python intercept.py`
|
||||
|
||||
Open `http://localhost:5000` and check:
|
||||
- Background is noticeably deeper/richer without being pure black
|
||||
- Text remains readable
|
||||
- No elements that used bg colours are now invisible (white text on near-white, etc.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add static/css/core/variables.css
|
||||
git commit -m "style: deepen background tokens and add scanline/glow variables"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Nav Active State → Left-Border Glow
|
||||
|
||||
**Files:**
|
||||
- Modify: `static/css/core/layout.css`
|
||||
|
||||
The current active state uses `box-shadow: inset 0 -2px 0 var(--accent-cyan)` (a bottom underline). We're replacing this with a left-border glow — more ops-center, less browser-tab.
|
||||
|
||||
- [ ] **Step 1: Replace `.mode-nav-btn.active` style**
|
||||
|
||||
In `static/css/core/layout.css`, find and replace the `.mode-nav-btn.active` block (currently at line ~749):
|
||||
|
||||
```css
|
||||
.mode-nav-btn.active {
|
||||
background: var(--accent-cyan-glow);
|
||||
color: var(--text-primary);
|
||||
border-color: transparent;
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
box-shadow: -2px 0 8px rgba(74, 163, 255, 0.2);
|
||||
padding-left: 12px; /* compensate for 2px border */
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace `.mode-nav-dropdown.has-active .mode-nav-dropdown-btn` style**
|
||||
|
||||
Find and replace the `.mode-nav-dropdown.has-active .mode-nav-dropdown-btn` block (currently at line ~856):
|
||||
|
||||
```css
|
||||
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||
background: var(--accent-cyan-glow);
|
||||
color: var(--text-primary);
|
||||
border-color: transparent;
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
box-shadow: -2px 0 8px rgba(74, 163, 255, 0.2);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace `.mode-nav-dropdown-menu .mode-nav-btn.active` style**
|
||||
|
||||
Find and replace the block at line ~903:
|
||||
|
||||
```css
|
||||
.mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||
background: var(--accent-cyan-glow);
|
||||
color: var(--text-primary);
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
box-shadow: -2px 0 6px rgba(74, 163, 255, 0.15);
|
||||
padding-left: 10px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Enhance hover state with cyan-glow background**
|
||||
|
||||
Find `.mode-nav-btn:hover` (line ~743) and replace:
|
||||
|
||||
```css
|
||||
.mode-nav-btn:hover {
|
||||
background: var(--accent-cyan-glow);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
```
|
||||
|
||||
Find `.mode-nav-dropdown-btn:hover` (line ~840) and replace:
|
||||
|
||||
```css
|
||||
.mode-nav-dropdown-btn:hover {
|
||||
background: var(--accent-cyan-glow);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update light theme active state overrides**
|
||||
|
||||
Find the `[data-theme="light"] .mode-nav-btn.active` block (line ~1105) and replace:
|
||||
|
||||
```css
|
||||
[data-theme="light"] .mode-nav-btn.active {
|
||||
background: rgba(31, 95, 168, 0.08);
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
box-shadow: -2px 0 6px rgba(31, 95, 168, 0.15);
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-btn:hover,
|
||||
[data-theme="light"] .mode-nav-dropdown.open .mode-nav-dropdown-btn,
|
||||
[data-theme="light"] .mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||
background: rgba(31, 95, 168, 0.06);
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
box-shadow: -2px 0 6px rgba(31, 95, 168, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||
background: rgba(31, 95, 168, 0.08);
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
padding-left: 10px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify in browser**
|
||||
|
||||
Open `http://localhost:5000`, switch between a few modes. Verify:
|
||||
- Active mode button has visible left-border cyan glow
|
||||
- Hover on inactive buttons shows subtle cyan-glow background
|
||||
- Light theme still works (toggle via moon/sun icon)
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add static/css/core/layout.css
|
||||
git commit -m "style: nav active state → left-border cyan glow, hover → glow bg"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Panel Indicator Pulse Animation
|
||||
|
||||
**Files:**
|
||||
- Modify: `static/css/core/components.css`
|
||||
|
||||
The `.panel-indicator.active` currently has a static green dot with glow. We're adding a CSS pulse animation so active panels visually breathe.
|
||||
|
||||
- [ ] **Step 1: Write a test for the panel indicator class**
|
||||
|
||||
```bash
|
||||
# Check that panel-indicator.active elements exist in the rendered HTML
|
||||
# (manual spot-check — open any mode and inspect the DOM)
|
||||
# Confirm .panel-indicator.active is present on the panel header dot
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add pulse keyframes and apply to active indicator**
|
||||
|
||||
In `static/css/core/components.css`, find `.panel-indicator.active` (line ~236) and replace the block:
|
||||
|
||||
```css
|
||||
@keyframes panel-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 4px var(--status-online), 0 0 8px rgba(56, 193, 128, 0.4);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 8px var(--status-online), 0 0 16px rgba(56, 193, 128, 0.6);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-indicator.active {
|
||||
background: var(--status-online);
|
||||
box-shadow: 0 0 8px var(--status-online);
|
||||
animation: panel-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Respect reduced-motion preference**
|
||||
|
||||
Add below the above block:
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.panel-indicator.active {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify in browser**
|
||||
|
||||
Open `http://localhost:5000`, start any mode (e.g. Pager). Verify the panel indicator dot pulses gently when active, is static when inactive.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add static/css/core/components.css
|
||||
git commit -m "style: add pulse animation to active panel indicators"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Card Vignette + Scanline Texture
|
||||
|
||||
**Files:**
|
||||
- Modify: `static/css/core/components.css`
|
||||
- Modify: `static/css/index.css` (for `.visuals-container` if not in components.css)
|
||||
|
||||
- [ ] **Step 1: Add inner vignette to `.panel` cards**
|
||||
|
||||
In `static/css/core/components.css`, find the `.panel` rule. After the existing properties, add `box-shadow`:
|
||||
|
||||
```css
|
||||
.panel {
|
||||
/* ... existing properties ... */
|
||||
box-shadow: var(--shadow-sm), inset 0 0 40px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
```
|
||||
|
||||
If a `.panel` rule doesn't exist at the top level in components.css, search for it:
|
||||
```bash
|
||||
grep -n "^\.panel {" static/css/core/components.css static/css/index.css
|
||||
```
|
||||
Add the `box-shadow` to whichever file defines it.
|
||||
|
||||
- [ ] **Step 2: Add scanline texture to visuals containers**
|
||||
|
||||
Visuals containers are mode-specific and likely defined in `static/css/index.css`. Search for the class:
|
||||
|
||||
```bash
|
||||
grep -n "visuals-container" static/css/index.css static/css/core/components.css
|
||||
```
|
||||
|
||||
In whichever file defines `.visuals-container`, add an `::after` pseudo-element:
|
||||
|
||||
```css
|
||||
.visuals-container {
|
||||
position: relative; /* ensure this is set */
|
||||
}
|
||||
|
||||
.visuals-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--scanline);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify scanline doesn't block interactions**
|
||||
|
||||
Open a mode with a visuals container (e.g. Bluetooth radar, TSCM). Verify:
|
||||
- Subtle horizontal scanline texture is visible
|
||||
- Clicking/interacting with the visual still works (pointer-events: none is set)
|
||||
- Light theme: scanline is `none` (set in Task 1 Step 3)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add static/css/core/components.css static/css/index.css
|
||||
git commit -m "style: add card vignette and scanline texture to visuals containers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Nav Group State Persistence
|
||||
|
||||
**Files:**
|
||||
- Modify: `templates/index.html`
|
||||
|
||||
The nav HTML already has `data-group` attributes on `.mode-nav-dropdown` containers and calls `toggleNavDropdown()` on button clicks. We need to persist which groups are open/closed so the state survives page reloads.
|
||||
|
||||
- [ ] **Step 1: Find where `toggleNavDropdown` is defined**
|
||||
|
||||
```bash
|
||||
grep -n "toggleNavDropdown" templates/index.html
|
||||
```
|
||||
|
||||
Note the line number where the function is defined (it will be inside a `<script>` block).
|
||||
|
||||
- [ ] **Step 2: Write a unit test for state persistence logic**
|
||||
|
||||
Create `tests/test_nav_state.py`:
|
||||
|
||||
```python
|
||||
"""Tests for nav group localStorage persistence (JS logic verified via structure check)."""
|
||||
import pytest
|
||||
from app import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app = create_app({'TESTING': True})
|
||||
with app.test_client() as client:
|
||||
yield client
|
||||
|
||||
|
||||
def test_index_page_includes_nav_state_init(client):
|
||||
"""nav group init function must be present in the index page."""
|
||||
resp = client.get('/')
|
||||
assert resp.status_code == 200
|
||||
html = resp.data.decode()
|
||||
assert 'initNavGroupState' in html
|
||||
assert 'localStorage' in html
|
||||
|
||||
|
||||
def test_nav_groups_have_data_group_attributes(client):
|
||||
"""Each nav group must have a data-group attribute for state keying."""
|
||||
resp = client.get('/')
|
||||
html = resp.data.decode()
|
||||
for group in ['signals', 'tracking', 'space', 'wireless', 'intel']:
|
||||
assert f'data-group="{group}"' in html, f"Missing data-group={group}"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the test to confirm it fails (function not yet added)**
|
||||
|
||||
```bash
|
||||
pytest tests/test_nav_state.py -v
|
||||
```
|
||||
|
||||
Expected: FAIL — `initNavGroupState` not in HTML yet.
|
||||
|
||||
- [ ] **Step 4: Add `initNavGroupState` function to index.html**
|
||||
|
||||
Locate the `toggleNavDropdown` function in `templates/index.html`. Immediately after it, add:
|
||||
|
||||
```javascript
|
||||
function initNavGroupState() {
|
||||
const NAV_STATE_KEY = 'intercept_nav_groups';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(localStorage.getItem(NAV_STATE_KEY) || '{}');
|
||||
} catch (e) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
document.querySelectorAll('.mode-nav-dropdown[data-group]').forEach(dropdown => {
|
||||
const group = dropdown.dataset.group;
|
||||
// If saved state says closed AND this group has no active item, close it
|
||||
if (savedState[group] === false) {
|
||||
const hasActive = dropdown.classList.contains('has-active');
|
||||
if (!hasActive) {
|
||||
dropdown.classList.remove('open');
|
||||
const btn = dropdown.querySelector('.mode-nav-dropdown-btn');
|
||||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
} else if (savedState[group] === true) {
|
||||
dropdown.classList.add('open');
|
||||
const btn = dropdown.querySelector('.mode-nav-dropdown-btn');
|
||||
if (btn) btn.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function saveNavGroupState() {
|
||||
const NAV_STATE_KEY = 'intercept_nav_groups';
|
||||
const state = {};
|
||||
document.querySelectorAll('.mode-nav-dropdown[data-group]').forEach(dropdown => {
|
||||
state[dropdown.dataset.group] = dropdown.classList.contains('open');
|
||||
});
|
||||
try {
|
||||
localStorage.setItem(NAV_STATE_KEY, JSON.stringify(state));
|
||||
} catch (e) { /* storage full or unavailable */ }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `toggleNavDropdown` to call `saveNavGroupState`**
|
||||
|
||||
Find the existing `toggleNavDropdown` function. At the end of its body (before the closing `}`), add:
|
||||
|
||||
```javascript
|
||||
saveNavGroupState();
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Call `initNavGroupState` on page load**
|
||||
|
||||
Find where other init functions are called on page load (look for `DOMContentLoaded` or a function like `initApp()`). Add:
|
||||
|
||||
```javascript
|
||||
initNavGroupState();
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run tests to confirm they pass**
|
||||
|
||||
```bash
|
||||
pytest tests/test_nav_state.py -v
|
||||
```
|
||||
|
||||
Expected: PASS — both tests green.
|
||||
|
||||
- [ ] **Step 8: Verify in browser**
|
||||
|
||||
Open `http://localhost:5000`. Open the Signals group, close the Tracking group. Reload the page. Verify state is restored. Verify that a group containing the active mode never closes even if saved as closed.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add templates/index.html tests/test_nav_state.py
|
||||
git commit -m "feat: persist nav group open/closed state to localStorage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Final Visual Review
|
||||
|
||||
- [ ] **Step 1: Run the full test suite**
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
Expected: all tests pass (0 failures).
|
||||
|
||||
- [ ] **Step 2: Full visual walkthrough**
|
||||
|
||||
Open `http://localhost:5000` and check each of the following:
|
||||
|
||||
1. **Backgrounds** — page feels deeper/richer. Cards have subtle inner vignette.
|
||||
2. **Active nav item** — left-border cyan glow. Looks crisp, not garish.
|
||||
3. **Hover states** — subtle cyan glow background on hover.
|
||||
4. **Active panels** — indicator dot pulses gently.
|
||||
5. **Visuals containers** — faint scanline texture visible on radar, waterfall, etc.
|
||||
6. **Nav group persistence** — collapse groups, reload, state is preserved.
|
||||
7. **Light theme** — toggle via header icon. No scanlines, no dark backgrounds bleeding through.
|
||||
8. **Reduced motion** — in DevTools > Rendering, enable "Emulate CSS prefers-reduced-motion: reduce". Pulse animation stops.
|
||||
|
||||
- [ ] **Step 3: Final commit if any touch-up needed**
|
||||
|
||||
```bash
|
||||
git add -p # stage only intentional changes
|
||||
git commit -m "style: design system uplift visual touch-ups"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
- **Spec coverage:** Token deepening ✓, `--accent-cyan-glow` ✓, `--scanline` ✓, active left-border glow ✓, hover glow bg ✓, panel indicator pulse ✓, card vignette ✓, scanline on visuals ✓, nav group persistence ✓, localStorage state ✓
|
||||
- **No placeholders:** All steps have exact code or exact commands
|
||||
- **Type consistency:** `initNavGroupState` / `saveNavGroupState` / `toggleNavDropdown` names consistent throughout
|
||||
- **Light theme:** Every dark-theme change has a corresponding light-theme override or is guarded
|
||||
- **Reduced motion:** Pulse animation explicitly disabled under `prefers-reduced-motion`
|
||||
@@ -1,360 +0,0 @@
|
||||
# WiFi Scanner UI Redesign
|
||||
|
||||
**Date:** 2026-03-26
|
||||
**Status:** Approved
|
||||
**Scope:** Frontend only — CSS, JS, HTML. No backend changes.
|
||||
|
||||
## Overview
|
||||
|
||||
Redesign the WiFi scanner's main content area for better space utilisation, visual clarity, and polish. The three-panel layout (networks table / proximity radar / analysis) is kept but each panel is significantly enhanced.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Table row density | Slightly taller (2-line rows) | Adds visual richness without losing too many rows |
|
||||
| Radar style | Rotating sweep with trailing glow arc | Most recognisable "radar" metaphor, eye-catching |
|
||||
| Channel analysis | Heatmap (channels × time) | Shows congestion history, more useful than a snapshot bar chart |
|
||||
| Row click behaviour | Right panel takeover (detail replaces heatmap) | Keeps spatial layout stable; no scroll disruption |
|
||||
|
||||
## CSS Tokens
|
||||
|
||||
Tokens confirmed in `index.css :root`: `--bg-primary`, `--bg-secondary`, `--bg-tertiary`, `--bg-card`, `--border-color`, `--border-light`, `--text-primary`, `--text-secondary`, `--text-dim`, `--accent-cyan`, `--accent-cyan-dim`, `--accent-green`, `--accent-green-dim`, `--accent-red`, `--accent-red-dim`, `--accent-orange`, `--accent-amber`, `--accent-amber-dim`.
|
||||
|
||||
**Not defined in `index.css :root`** — do not use:
|
||||
- `--accent-yellow` (used in some existing WiFi rules but undefined — use `--accent-orange` instead)
|
||||
- `--accent-cyan-rgb` (undefined — use the literal `rgba(74, 163, 255, 0.07)` for the selected row tint)
|
||||
|
||||
## Component Designs
|
||||
|
||||
### 1. Status Bar
|
||||
|
||||
Enhanced version of the existing `wifi-status-bar`:
|
||||
|
||||
- Existing fields preserved: Networks · Clients · Hidden (IDs: `wifiNetworkCount`, `wifiClientCount`, `wifiHiddenCount`)
|
||||
- **New — Open count:** Add `<div class="wifi-status-item"><span class="wifi-status-label">Open</span><span class="wifi-status-value" id="wifiOpenCount" style="color:var(--accent-red);">0</span></div>` after the Hidden item. Populated by `renderNetworks()` counting networks where `security === 'Open'`.
|
||||
- **Scan indicator — HTML:** Replace the existing `<div class="wifi-status-item" id="wifiScanStatus">` with:
|
||||
```html
|
||||
<div class="wifi-scan-indicator" id="wifiScanIndicator">
|
||||
<span class="wifi-scan-dot"></span>
|
||||
<span class="wifi-scan-text">IDLE</span>
|
||||
</div>
|
||||
```
|
||||
CSS: `.wifi-scan-dot` is a 7×7px circle with `animation: wifi-pulse 1.2s ease-in-out infinite` (opacity 1→0.4→1, scale 1→0.7→1). The dot is hidden (`display:none`) when idle; shown when scanning. `.wifi-scan-indicator` is floated/pushed to `margin-left: auto`.
|
||||
- `updateScanningState(scanning, scanMode)` — revised body:
|
||||
```js
|
||||
const dot = elements.scanIndicator?.querySelector('.wifi-scan-dot');
|
||||
const text = elements.scanIndicator?.querySelector('.wifi-scan-text');
|
||||
if (dot) dot.style.display = scanning ? 'inline-block' : 'none';
|
||||
if (text) text.textContent = scanning
|
||||
? `SCANNING (${scanMode === 'quick' ? 'Quick' : 'Deep'})`
|
||||
: 'IDLE';
|
||||
```
|
||||
`elements.scanIndicator` replaces `elements.scanStatus` in the `elements` map, pointing to `#wifiScanIndicator`.
|
||||
|
||||
### 2. Networks Table
|
||||
|
||||
**Structural change:** Remove `<table id="wifiNetworkTable">` (including `<thead>` and `<tbody id="wifiNetworkTableBody">`). Replace with `<div id="wifiNetworkList" class="wifi-network-list">`. Update `elements.networkTable → elements.networkList` and `elements.networkTableBody → elements.networkList` in the `elements` map in `wifi.js`.
|
||||
|
||||
**Sort controls:** The `th[data-sort]` click listener on the old table is removed. Add three small text-style sort buttons to the `.wifi-networks-header`:
|
||||
```html
|
||||
<div class="wifi-sort-controls">
|
||||
<span class="wifi-sort-label">Sort:</span>
|
||||
<button class="wifi-sort-btn active" data-sort="signal">Signal</button>
|
||||
<button class="wifi-sort-btn" data-sort="ssid">SSID</button>
|
||||
<button class="wifi-sort-btn" data-sort="channel">Ch</button>
|
||||
</div>
|
||||
```
|
||||
Clicking a button sets the existing `sortBy` state variable and calls `renderNetworks()`. Active button gets `.active` class (cyan text).
|
||||
|
||||
**Filter buttons:** `#wifiNetworkFilters` / `.wifi-filter-btn` bar (All / 2.4G / 5G / Open / Hidden) is kept as-is in HTML. The JS `applyFilters()` function is adapted to operate on `<div class="network-row">` elements using their `data-band` and `data-security` attributes (same logic, different element type).
|
||||
|
||||
**Row HTML structure:**
|
||||
```html
|
||||
<div class="network-row threat-{open|safe|hidden}"
|
||||
data-bssid="AA:BB:CC:DD:EE:FF"
|
||||
data-band="2.4"
|
||||
data-security="Open"
|
||||
onclick="WiFiMode.selectNetwork('AA:BB:CC:DD:EE:FF')">
|
||||
<div class="row-top">
|
||||
<span class="row-ssid [hidden-net]">SSID or [Hidden] BSSID</span>
|
||||
<div class="row-badges">
|
||||
<span class="badge open|wpa2|wpa3|wep">LABEL</span>
|
||||
<!-- if hidden: --><span class="badge hidden-tag">HIDDEN</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-bottom">
|
||||
<div class="signal-bar-wrap">
|
||||
<div class="signal-track">
|
||||
<div class="signal-fill strong|medium|weak" style="width: N%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-meta">
|
||||
<span>ch N</span>
|
||||
<span>N ↔</span>
|
||||
<span class="row-rssi">−N</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Left border colour (CSS class on `.network-row`):**
|
||||
- `.threat-open` → `border-left: 3px solid var(--accent-red)`
|
||||
- `.threat-safe` → `border-left: 3px solid var(--accent-green)` (WPA2, WPA3)
|
||||
- `.threat-hidden` → `border-left: 3px solid var(--border-color)`
|
||||
|
||||
**Signal bar fill width:** `pct = Math.max(0, Math.min(100, (rssi + 100) / 80 * 100))` where −100 dBm → 0%, −20 dBm → 100%.
|
||||
|
||||
**Signal fill classes:**
|
||||
- `.strong` (rssi > −55): `background: linear-gradient(90deg, var(--accent-green), #88d49b)`
|
||||
- `.medium` (−55 ≥ rssi > −70): `background: linear-gradient(90deg, var(--accent-green), var(--accent-orange))`
|
||||
- `.weak` (rssi ≤ −70): `background: linear-gradient(90deg, var(--accent-orange), var(--accent-red))`
|
||||
|
||||
**Security string → badge class mapping** (mirrors existing `securityClass` logic in `wifi.js`):
|
||||
```js
|
||||
function securityBadgeClass(security) {
|
||||
const s = (security || '').toLowerCase();
|
||||
if (s === 'open' || s === '') return 'open';
|
||||
if (s.includes('wpa3')) return 'wpa3';
|
||||
if (s.includes('wpa')) return 'wpa2'; // covers WPA2, WPA/WPA2, WPA PSK, etc.
|
||||
if (s.includes('wep')) return 'wep';
|
||||
return 'wpa2'; // fallback for unknown encrypted
|
||||
}
|
||||
```
|
||||
|
||||
**Security badge colours:**
|
||||
- `.badge.open` — `var(--accent-red)` text + border, `var(--accent-red-dim)` background
|
||||
- `.badge.wpa2` — `var(--accent-green)` text + border, `var(--accent-green-dim)` background
|
||||
- `.badge.wpa3` — `var(--accent-cyan)` text + border, `var(--accent-cyan-dim)` background
|
||||
- `.badge.wep` — `var(--accent-orange)` text + border, `var(--accent-amber-dim)` background
|
||||
- `.badge.hidden-tag` — `var(--text-dim)` text, `var(--border-color)` border, transparent background
|
||||
|
||||
**Radar dot colours (same semantic mapping):**
|
||||
- Open: `var(--accent-red)` / `#e25d5d`
|
||||
- WPA2 / WPA3: `var(--accent-green)` / `#38c180`
|
||||
- WEP: `var(--accent-orange)` / `#d6a85e`
|
||||
- Unknown / Hidden: `#484f58`
|
||||
|
||||
**Selected state persistence across re-renders:** `WiFiMode` stores the selected BSSID in module-level `let selectedBssid = null`. After `renderNetworks()` rebuilds the list, if `selectedBssid` is set, find the matching row by `data-bssid` and add `.selected` to it; also refresh `#wifiDetailView` with updated data for that network.
|
||||
|
||||
**Empty state:** When network list is empty, insert `<div class="wifi-network-placeholder"><p>No networks detected.<br>Start a scan to begin.</p></div>`.
|
||||
|
||||
**Row states:**
|
||||
- Hover: `background: var(--bg-tertiary)`
|
||||
- Selected: `background: rgba(74, 163, 255, 0.07)` + `border-left-color: var(--accent-cyan)` (via `.selected` class, overrides threat colour)
|
||||
|
||||
### 3. Proximity Radar
|
||||
|
||||
**Existing `#wifiProximityRadar` div** contents replaced with an inline SVG. `wifi.js` adds `renderRadar(networks)`.
|
||||
|
||||
**SVG:** `width="100%" viewBox="0 0 210 210"`, centre `(105, 105)`. A `<clipPath id="wifi-radar-clip"><circle cx="105" cy="105" r="100"/></clipPath>` is applied to the rotating group to prevent arc overflow.
|
||||
|
||||
**Rings (static, outside rotating group):**
|
||||
```html
|
||||
<circle cx="105" cy="105" r="100" fill="none" stroke="#00b4d8" stroke-width="0.5" opacity="0.12"/>
|
||||
<circle cx="105" cy="105" r="70" fill="none" stroke="#00b4d8" stroke-width="0.5" opacity="0.18"/>
|
||||
<circle cx="105" cy="105" r="40" fill="none" stroke="#00b4d8" stroke-width="0.5" opacity="0.25"/>
|
||||
<circle cx="105" cy="105" r="15" fill="none" stroke="#00b4d8" stroke-width="0.5" opacity="0.35"/>
|
||||
```
|
||||
|
||||
**Sweep animation — CSS:**
|
||||
```css
|
||||
.wifi-radar-sweep {
|
||||
transform-origin: 105px 105px;
|
||||
animation: wifi-radar-rotate 3s linear infinite;
|
||||
}
|
||||
@keyframes wifi-radar-rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
```
|
||||
|
||||
**Sweep group** `<g class="wifi-radar-sweep" clip-path="url(#wifi-radar-clip)">`:
|
||||
- Trailing arc 60°: `<path d="M105,105 L105,5 A100,100 0 0,1 191.6,155 Z" fill="#00b4d8" opacity="0.08"/>`
|
||||
_(endpoint derived: x = 105 + 100·sin(60°) ≈ 191.6, y = 105 − 100·cos(60°) + 100 = 155)_
|
||||
- Trailing arc 90°: `<path d="M105,105 L105,5 A100,100 0 0,1 205,105 Z" fill="#00b4d8" opacity="0.04"/>`
|
||||
_(endpoint: x=205, y=105 — 90° clockwise from top)_
|
||||
- Sweep line: `<line x1="105" y1="105" x2="105" y2="5" stroke="#00b4d8" stroke-width="1.5" opacity="0.7"/>`
|
||||
|
||||
**Network dots** (rendered as SVG `<circle>` elements outside the rotating group, replaced by `renderRadar(networks)`):
|
||||
- Angle per dot: determined by `bssidToAngle(bssid)` — a simple hash of the BSSID string modulo 2π. This produces stable positions across re-renders for the same network.
|
||||
- Radius from centre: `dotR = 5 + (1 - Math.max(0, Math.min(1, (rssi + 100) / 80))) * 90` — stronger signal → smaller radius
|
||||
- Zone thresholds: dotR < 35 = Near, 35–70 = Mid, >70 = Far
|
||||
- Visual radius: Near → 6, Mid → 4.5, Far → 3 (px)
|
||||
- Each dot: a main `<circle>` + a glow halo `<circle>` at 1.5× visual radius, same fill colour, 12% opacity
|
||||
- Colour: see Section 2 "Radar dot colours"
|
||||
|
||||
**Zone counts:** `renderRadar()` updates `#wifiZoneImmediate`, `#wifiZoneNear`, `#wifiZoneFar` (IDs unchanged).
|
||||
|
||||
### 4. Right Panel — Channel Heatmap + Security Ring
|
||||
|
||||
**Removed elements:** The existing `.wifi-channel-section` (containing `.wifi-channel-tabs` and `#wifiChannelChart`), `.wifi-security-section`, and the IDs `openCount`, `wpa2Count`, `wpa3Count`, `wepCount` are all removed from the HTML. Any JS references to these IDs are also removed.
|
||||
|
||||
**New `.wifi-analysis-panel` inner HTML:**
|
||||
```html
|
||||
<div class="wifi-analysis-panel-header">
|
||||
<span class="panel-title" id="wifiRightPanelTitle">Channel Heatmap</span>
|
||||
<button class="wifi-detail-back-btn" id="wifiDetailBackBtn" style="display:none"
|
||||
onclick="WiFiMode.closeDetail()">← Back</button>
|
||||
</div>
|
||||
|
||||
<div id="wifiHeatmapView">
|
||||
<div class="wifi-heatmap-wrap">
|
||||
<div class="wifi-heatmap-label">2.4 GHz · Last <span id="wifiHeatmapCount">0</span> scans</div>
|
||||
<div class="wifi-heatmap-ch-labels">
|
||||
<!-- 11 divs, text content 1–11 -->
|
||||
</div>
|
||||
<div class="wifi-heatmap-grid" id="wifiHeatmapGrid"></div>
|
||||
<div class="wifi-heatmap-legend">
|
||||
<span>Low</span>
|
||||
<div class="wifi-heatmap-legend-grad"></div>
|
||||
<span>High</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wifi-security-ring-wrap">
|
||||
<svg id="wifiSecurityRingSvg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<!-- arcs injected by renderSecurityRing() -->
|
||||
<circle cx="24" cy="24" r="9" fill="var(--bg-primary)"/>
|
||||
</svg>
|
||||
<div class="wifi-security-ring-legend" id="wifiSecurityRingLegend">
|
||||
<!-- legend rows injected by renderSecurityRing() -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wifiDetailView" style="display:none">
|
||||
<!-- detail content — see Section 5 -->
|
||||
</div>
|
||||
```
|
||||
|
||||
**5 GHz heatmap:** Tab toggle removed. Heatmap always shows 2.4 GHz. **5 GHz networks are excluded from `channelHistory` snapshots** — only networks with `band === '2.4'` are counted when building each snapshot.
|
||||
|
||||
**Heatmap data source:** Module-level `let channelHistory = []` (max 10 entries). Each `renderNetworks()` prepends `{ timestamp: Date.now(), channels: { 1:N, …, 11:N } }` built from 2.4 GHz networks only. Entries beyond 10 are dropped. `wifiHeatmapCount` span is updated with `channelHistory.length`.
|
||||
|
||||
**Heatmap grid DOM structure:** `#wifiHeatmapGrid` is a CSS grid container (`display: grid; grid-template-columns: 26px repeat(11, 1fr); gap: 2px`). `renderHeatmap()` clears and rebuilds it on every call. For each of the up to 10 history entries (newest first), it creates one row of 12 elements: a time-label `<div class="wifi-heatmap-time-label">` (text "now" for index 0, empty for others) followed by 11 cell `<div class="wifi-heatmap-cell">` elements whose `background` is set by `congestionColor()`. Total DOM nodes: up to 10 × 12 = 120 divs, fully rebuilt on each render call.
|
||||
|
||||
**Cell colour — `congestionColor(value, maxValue)`:**
|
||||
```js
|
||||
function congestionColor(value, maxValue) {
|
||||
if (value === 0 || maxValue === 0) return '#0d1117';
|
||||
const ratio = value / maxValue;
|
||||
if (ratio < 0.05) return '#0d1117';
|
||||
if (ratio < 0.25) return `rgba(13,74,110,${(ratio * 4).toFixed(2)})`;
|
||||
if (ratio < 0.5) return `rgba(14,165,233,${ratio.toFixed(2)})`;
|
||||
if (ratio < 0.75) return `rgba(249,115,22,${ratio.toFixed(2)})`;
|
||||
return `rgba(239,68,68,${ratio.toFixed(2)})`;
|
||||
}
|
||||
```
|
||||
`maxValue` = the maximum cell value across the entire `channelHistory` array (for consistent colour scale).
|
||||
|
||||
**Empty/loading state:** When `channelHistory.length === 0`, `#wifiHeatmapGrid` shows a single placeholder div: `<div class="wifi-heatmap-empty">Scan to populate channel history</div>`.
|
||||
|
||||
**Security ring — `renderSecurityRing(networks)`:**
|
||||
|
||||
Circumference `C = 2 * Math.PI * 15 ≈ 94.25`. Each segment is a `<circle>` element with `stroke-dasharray="${arcLen} ${C - arcLen}"` and a `stroke-dashoffset` that positions it after the previous segments. The ring starts at the top by applying `transform="rotate(-90 24 24)"` to each arc.
|
||||
|
||||
Standard SVG donut-segment technique — `dashoffset` for segment N = `-(sum of all preceding arcLengths)`:
|
||||
|
||||
```js
|
||||
const C = 2 * Math.PI * 15; // ≈ 94.25
|
||||
const segments = [
|
||||
{ label: 'WPA2', color: 'var(--accent-green)', count: wpa2 },
|
||||
{ label: 'Open', color: 'var(--accent-red)', count: open },
|
||||
{ label: 'WPA3', color: 'var(--accent-cyan)', count: wpa3 },
|
||||
{ label: 'WEP', color: 'var(--accent-orange)',count: wep },
|
||||
];
|
||||
const total = segments.reduce((s, seg) => s + seg.count, 0) || 1;
|
||||
let offset = 0;
|
||||
segments.forEach(seg => {
|
||||
const arcLen = (seg.count / total) * C;
|
||||
// <circle cx="24" cy="24" r="15" fill="none"
|
||||
// stroke="seg.color" stroke-width="7"
|
||||
// stroke-dasharray="${arcLen} ${C - arcLen}"
|
||||
// stroke-dashoffset="${-offset}"
|
||||
// transform="rotate(-90 24 24)"/>
|
||||
offset += arcLen;
|
||||
});
|
||||
// Worked example: total=10, WPA2=7, Open=3:
|
||||
// WPA2: arcLen=66.0, dasharray="66 28.2", dashoffset="0"
|
||||
// Open: arcLen=28.2, dasharray="28.2 66", dashoffset="-66"
|
||||
```
|
||||
|
||||
- Centre hole: `<circle cx="24" cy="24" r="9" fill="var(--bg-primary)"/>` injected last (on top)
|
||||
- Legend rows injected into `#wifiSecurityRingLegend`: coloured square + name + count
|
||||
|
||||
### 5. Right Panel — Network Detail
|
||||
|
||||
**`#wifiDetailDrawer` deletion:** Delete the entire `<div class="wifi-detail-drawer" id="wifiDetailDrawer">` block (~lines 940–1000 of `index.html`). Also remove all associated CSS from `index.css`: `.wifi-detail-drawer`, `.wifi-detail-drawer.open`, `.wifi-detail-header`, `.wifi-detail-title`, `.wifi-detail-essid`, `.wifi-detail-bssid`, `.wifi-detail-close`, `.wifi-detail-content`, `.wifi-detail-grid`, `.wifi-detail-stat`. In `wifi.js`, remove `detailDrawer` from the `elements` map and remove its usage in the existing `closeDetail()` (`detailDrawer.classList.remove('open')`) since `closeDetail()` is being replaced.
|
||||
|
||||
**`#wifiDetailView` inner HTML:**
|
||||
```html
|
||||
<div class="wifi-detail-inner">
|
||||
<div class="wifi-detail-head">
|
||||
<div class="wifi-detail-essid" id="wifiDetailEssid">—</div>
|
||||
<div class="wifi-detail-bssid" id="wifiDetailBssid">—</div>
|
||||
</div>
|
||||
|
||||
<div class="wifi-detail-signal-bar">
|
||||
<div class="wifi-detail-signal-labels">
|
||||
<span>Signal</span>
|
||||
<span id="wifiDetailRssi">—</span>
|
||||
</div>
|
||||
<div class="wifi-detail-signal-track">
|
||||
<div class="wifi-detail-signal-fill" id="wifiDetailSignalFill" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wifi-detail-grid">
|
||||
<div class="wifi-detail-stat"><span class="label">Channel</span><span class="value" id="wifiDetailChannel">—</span></div>
|
||||
<div class="wifi-detail-stat"><span class="label">Band</span><span class="value" id="wifiDetailBand">—</span></div>
|
||||
<div class="wifi-detail-stat"><span class="label">Security</span><span class="value" id="wifiDetailSecurity">—</span></div>
|
||||
<div class="wifi-detail-stat"><span class="label">Cipher</span><span class="value" id="wifiDetailCipher">—</span></div>
|
||||
<div class="wifi-detail-stat"><span class="label">Clients</span><span class="value" id="wifiDetailClients">—</span></div>
|
||||
<div class="wifi-detail-stat"><span class="label">First Seen</span><span class="value" id="wifiDetailFirstSeen">—</span></div>
|
||||
<div class="wifi-detail-stat"><span class="label">Vendor</span><span class="value" id="wifiDetailVendor" style="font-size:11px;">—</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Existing client list sub-panel — copy verbatim from current #wifiDetailDrawer -->
|
||||
<div class="wifi-detail-clients" id="wifiDetailClientList" style="display: none;">
|
||||
<h6>Connected Clients <span class="wifi-client-count-badge" id="wifiClientCountBadge"></span></h6>
|
||||
<div class="wifi-client-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="wifi-detail-actions">
|
||||
<!-- Copy this onclick verbatim from the existing locate button in #wifiDetailDrawer: -->
|
||||
<button class="wfl-locate-btn" title="Locate this AP"
|
||||
onclick="(function(){ var p={bssid: document.getElementById('wifiDetailBssid')?.textContent, ssid: document.getElementById('wifiDetailEssid')?.textContent}; if(typeof WiFiLocate!=='undefined'){WiFiLocate.handoff(p);return;} if(typeof switchMode==='function'){switchMode('wifi_locate').then(function(){if(typeof WiFiLocate!=='undefined')WiFiLocate.handoff(p);});} })()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>
|
||||
Locate
|
||||
</button>
|
||||
<button class="wifi-detail-close-btn" onclick="WiFiMode.closeDetail()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Signal bar fill width:** Same formula as table rows — `pct = Math.max(0, Math.min(100, (rssi + 100) / 80 * 100))`.
|
||||
|
||||
**Show/hide:**
|
||||
- `WiFiMode.selectNetwork(bssid)`: hides `#wifiHeatmapView`, shows `#wifiDetailView`, sets `#wifiRightPanelTitle` text to "Network Detail", shows `#wifiDetailBackBtn`, stores `selectedBssid = bssid`
|
||||
- `WiFiMode.closeDetail()`: reverses — shows heatmap, hides detail, restores title to "Channel Heatmap", hides back button, sets `selectedBssid = null`
|
||||
|
||||
**"← Back" vs "Close":** Both call `WiFiMode.closeDetail()`. "← Back" is in the panel header (always reachable at top); "Close" is at the bottom of the detail body for users who scrolled down.
|
||||
|
||||
**`wifiClientCountBadge`** is preserved inside `#wifiDetailClientList` — no changes to the client list sub-panel.
|
||||
|
||||
## File Changes
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `static/css/index.css` | Update WiFi section CSS (~line 3515): add `.wifi-scan-indicator`, `.wifi-scan-dot` + keyframes; replace table CSS with `.wifi-network-list`, `.network-row`, `.row-top`, `.row-bottom`, `.signal-bar-*`, `.badge.*`, `.wifi-sort-*`; add `.wifi-radar-sweep` + `@keyframes wifi-radar-rotate`; replace channel/security section CSS with `.wifi-heatmap-*`, `.wifi-security-ring-*`, `.wifi-analysis-panel-header`, `.wifi-detail-back-btn`; add `.wifi-detail-inner`, `.wifi-detail-*` |
|
||||
| `templates/index.html` | WiFi section (~line 820): replace `<table>` with `<div id="wifiNetworkList">`, add sort controls to header, add `#wifiOpenCount` to status bar, replace `#wifiScanStatus` with `#wifiScanIndicator`, replace right panel contents with `#wifiHeatmapView` / `#wifiDetailView`, add `#wifiRightPanelTitle` + `#wifiDetailBackBtn` to panel header, inline radar SVG shell (static rings + empty dot group), remove `#wifiDetailDrawer` |
|
||||
| `static/js/modes/wifi.js` | Update `elements` map (remove `networkTable`, `networkTableBody`, `detailDrawer`, `wpa3Count`, `wpa2Count`, `wepCount`, `openCount`; add `networkList`, `openCount` → `wifiOpenCount`, `scanIndicator`, `heatmapGrid`, `heatmapCount`, `securityRingSvg`, `securityRingLegend`, `heatmapView`, `detailView`, `rightPanelTitle`, `detailBackBtn`, `detailSignalFill`); update `renderNetworks()` (div rows, filter + sort, persistence of `selectedBssid`); update `updateScanningState()`; add `renderRadar(networks)`; add `renderHeatmap()`; add `renderSecurityRing(networks)`; add `selectNetwork(bssid)` / `closeDetail()`; remove `th[data-sort]` listener |
|
||||
| `templates/partials/modes/wifi.html` | No changes — sidebar out of scope |
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- WiFi locate mode (separate)
|
||||
- Sidebar panel (signal source, scan settings, attack options, handshake capture)
|
||||
- Mobile/responsive layout changes
|
||||
- 5 GHz channel heatmap data population
|
||||
- Any backend / route changes
|
||||
@@ -1,225 +0,0 @@
|
||||
# Bluetooth UI Polish — Apply WiFi Treatment
|
||||
|
||||
**Date:** 2026-03-27
|
||||
**Status:** Approved
|
||||
**Scope:** Frontend only — CSS, JS, HTML. No backend changes.
|
||||
|
||||
## Overview
|
||||
|
||||
Apply the same visual polish introduced in the WiFi scanner redesign to the Bluetooth scanner. Three areas are updated: device rows, proximity radar, and the device list header. The signal distribution strip is retained.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Row structure | Match WiFi 2-line layout closely | Visual consistency across modes |
|
||||
| Locate button placement | Remove from rows, keep in detail panel only | Rows are already information-dense; locate is a detail-panel action |
|
||||
| Radar sweep | CSS animation + trailing glow arc | Matches WiFi, replaces rAF loop in ProximityRadar |
|
||||
| Header additions | Scan indicator + sort controls | Matches WiFi; sort by Signal / Name / Seen / Distance |
|
||||
| Signal distribution strip | Keep | Provides a useful at-a-glance signal breakdown not present in WiFi |
|
||||
| Sort/filter layout | Sort group + filter group on one combined controls row | Saves vertical space vs separate rows |
|
||||
|
||||
## CSS Tokens
|
||||
|
||||
Reuse tokens already defined in `index.css :root` — no new tokens needed:
|
||||
`--bg-primary`, `--bg-secondary`, `--bg-tertiary`, `--bg-card`, `--border-color`, `--border-light`, `--text-primary`, `--text-secondary`, `--text-dim`, `--accent-cyan`, `--accent-cyan-dim`, `--accent-green`, `--accent-green-dim`, `--accent-red`, `--accent-red-dim`, `--accent-orange`, `--accent-amber-dim`.
|
||||
|
||||
Hardcoded literals used in WiFi are reused here for consistency:
|
||||
- Selected row tint: `rgba(74, 163, 255, 0.07)`
|
||||
- Radar arc fill: `rgba(0, 180, 216, N)` (same as WiFi's `#00b4d8`)
|
||||
|
||||
## Component Designs
|
||||
|
||||
### 1. Device List Header
|
||||
|
||||
**Scan indicator** — new `<div class="bt-scan-indicator" id="btScanIndicator">` added to `.wifi-device-list-header` (right-aligned via `margin-left: auto`):
|
||||
```html
|
||||
<div class="bt-scan-indicator" id="btScanIndicator">
|
||||
<span class="bt-scan-dot"></span>
|
||||
<span class="bt-scan-text">IDLE</span>
|
||||
</div>
|
||||
```
|
||||
`.bt-scan-dot` is 7×7px circle, `animation: bt-scan-pulse 1.2s ease-in-out infinite` (opacity/scale identical to WiFi). Dot hidden when idle (same as WiFi pattern).
|
||||
|
||||
`setScanning(scanning)` updated — add after the existing start/stop btn toggle:
|
||||
```js
|
||||
const dot = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-dot');
|
||||
const text = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-text');
|
||||
if (dot) dot.style.display = scanning ? 'inline-block' : 'none';
|
||||
if (text) text.textContent = scanning ? 'SCANNING' : 'IDLE';
|
||||
```
|
||||
|
||||
### 2. Sort + Filter Controls Row
|
||||
|
||||
**Replaces** the separate `.bt-device-toolbar` (search stays above) and `.bt-device-filters` divs. A new combined controls row sits between the signal strip and the search input:
|
||||
|
||||
```html
|
||||
<div class="bt-controls-row">
|
||||
<div class="bt-sort-group">
|
||||
<span class="bt-sort-label">Sort</span>
|
||||
<button class="bt-sort-btn active" data-sort="rssi">Signal</button>
|
||||
<button class="bt-sort-btn" data-sort="name">Name</button>
|
||||
<button class="bt-sort-btn" data-sort="seen">Seen</button>
|
||||
<button class="bt-sort-btn" data-sort="distance">Dist</button>
|
||||
</div>
|
||||
<div class="bt-filter-group">
|
||||
<button class="bt-filter-btn active" data-filter="all">All</button>
|
||||
<button class="bt-filter-btn" data-filter="new">New</button>
|
||||
<button class="bt-filter-btn" data-filter="named">Named</button>
|
||||
<button class="bt-filter-btn" data-filter="strong">Strong</button>
|
||||
<button class="bt-filter-btn" data-filter="trackers">Trackers</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Sort state:** add `let sortBy = 'rssi'` to module-level state. Sort button click handler (bound in `init()`):
|
||||
```js
|
||||
document.querySelectorAll('.bt-sort-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
sortBy = btn.dataset.sort;
|
||||
document.querySelectorAll('.bt-sort-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
renderAllDevices();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**`renderAllDevices()`** — new function that re-renders all devices in sorted order:
|
||||
```js
|
||||
function renderAllDevices() {
|
||||
if (!deviceContainer) return;
|
||||
deviceContainer.innerHTML = '';
|
||||
const sorted = [...devices.values()].sort((a, b) => {
|
||||
if (sortBy === 'rssi') return (b.rssi_current ?? -100) - (a.rssi_current ?? -100);
|
||||
if (sortBy === 'name') return (a.name || 'zzz').localeCompare(b.name || 'zzz');
|
||||
if (sortBy === 'seen') return (b.seen_count || 0) - (a.seen_count || 0);
|
||||
if (sortBy === 'distance') return (a.estimated_distance_m ?? 999) - (b.estimated_distance_m ?? 999);
|
||||
return 0;
|
||||
});
|
||||
sorted.forEach(device => renderDevice(device, false));
|
||||
applyDeviceFilter();
|
||||
highlightSelectedDevice(selectedDeviceId);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Device Rows
|
||||
|
||||
**`createSimpleDeviceCard(device)`** is rewritten to produce WiFi-style 2-line rows. All existing data attributes and filter attributes are preserved.
|
||||
|
||||
**Row HTML structure:**
|
||||
```html
|
||||
<div class="bt-device-row [is-tracker]"
|
||||
data-bt-device-id="…"
|
||||
data-is-new="…"
|
||||
data-has-name="…"
|
||||
data-rssi="…"
|
||||
data-is-tracker="…"
|
||||
data-search="…"
|
||||
role="button" tabindex="0" data-keyboard-activate="true"
|
||||
style="border-left-color: COLOR;">
|
||||
<div class="bt-row-top">
|
||||
<div class="bt-row-top-left">
|
||||
<span class="bt-proto-badge [ble|classic]">BLE|CLASSIC</span>
|
||||
<span class="bt-row-name [bt-unnamed]">NAME or ADDRESS</span>
|
||||
<!-- conditional badges: tracker, IRK, risk, flags, cluster -->
|
||||
</div>
|
||||
<div class="bt-row-top-right">
|
||||
<!-- conditional: flag badges -->
|
||||
<span class="bt-status-dot [new|known|tracker]"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bt-row-bottom">
|
||||
<div class="bt-signal-bar-wrap">
|
||||
<div class="bt-signal-track">
|
||||
<div class="bt-signal-fill [strong|medium|weak]" style="width: N%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bt-row-meta">
|
||||
<span>MANUFACTURER or —</span>
|
||||
<span>~Xm</span> <!-- omitted if no distance -->
|
||||
<span class="bt-row-rssi [strong|medium|weak]">−N</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Signal fill width:** `pct = rssi != null ? Math.max(0, Math.min(100, (rssi + 100) / 70 * 100)) : 0` (preserves existing BT formula — range is −100 to −30, not −100 to −20 as in WiFi).
|
||||
|
||||
**Signal fill class thresholds** (match existing `getRssiColor` breakpoints):
|
||||
- `.strong` (rssi ≥ −60): `background: linear-gradient(90deg, var(--accent-green), #88d49b)`
|
||||
- `.medium` (−60 > rssi ≥ −75): `background: linear-gradient(90deg, var(--accent-green), var(--accent-orange))`
|
||||
- `.weak` (rssi < −75): `background: linear-gradient(90deg, var(--accent-orange), var(--accent-red))`
|
||||
|
||||
**Left border colour:**
|
||||
- High-confidence tracker: `#ef4444`
|
||||
- Any tracker (lower confidence): `#f97316`
|
||||
- Non-tracker: result of `getRssiColor(rssi)` (unchanged)
|
||||
|
||||
**Unnamed device:** Address shown in `.bt-row-name.bt-unnamed` with `color: var(--text-dim); font-style: italic`.
|
||||
|
||||
**Badges moved to top-right (not top-left):** `PERSIST`, `BEACON`, `STABLE` flag badges move to `.bt-row-top-right` (before status dot), keeping top-left clean. Tracker, IRK, risk, and cluster badges remain in `.bt-row-top-left` after the name.
|
||||
|
||||
**Locate button:** Removed from the row entirely. It exists only in `#btDetailContent` (already present, no change needed).
|
||||
|
||||
**Selected row state:**
|
||||
```css
|
||||
.bt-device-row.selected {
|
||||
background: rgba(74, 163, 255, 0.07);
|
||||
border-left-color: var(--accent-cyan) !important;
|
||||
}
|
||||
```
|
||||
`highlightSelectedDevice()` is unchanged — it adds/removes `.selected` by `data-bt-device-id`.
|
||||
|
||||
### 4. Proximity Radar
|
||||
|
||||
**`proximity-radar.js` changes:**
|
||||
|
||||
`createSVG()` — add trailing arc group and CSS sweep class:
|
||||
- Replace `<line class="radar-sweep" …>` with a `<g class="bt-radar-sweep" clip-path="url(#radarClip)">` containing:
|
||||
- Two trailing arc `<path>` elements (90° and 60°) with low opacity fills
|
||||
- The sweep `<line>` element
|
||||
- Add `<clipPath id="radarClip"><circle …/></clipPath>` to `<defs>` to prevent arc overflow
|
||||
|
||||
`animateSweep()` — remove entirely. The CSS animation replaces it.
|
||||
|
||||
**CSS in `index.css` (BT section):**
|
||||
```css
|
||||
.bt-radar-sweep {
|
||||
transform-origin: CENTER_X px CENTER_Y px; /* 140px 140px — half of CONFIG.size */
|
||||
animation: bt-radar-rotate 3s linear infinite;
|
||||
}
|
||||
@keyframes bt-radar-rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
```
|
||||
|
||||
`isPaused` handling: when paused, add `animation-play-state: paused` to `.bt-radar-sweep` via a class toggle instead of the rAF check. `setPaused(paused)` updated:
|
||||
```js
|
||||
const sweep = svg?.querySelector('.bt-radar-sweep');
|
||||
if (sweep) sweep.style.animationPlayState = paused ? 'paused' : 'running';
|
||||
```
|
||||
|
||||
**Arc path geometry** (center = 140, outerRadius = center − padding = 120):
|
||||
- 90° arc endpoint: `(140 + 120, 140)` → `(260, 140)`
|
||||
- 60° arc endpoint: `x = 140 + 120·sin(60°) ≈ 244`, `y = 140 − 120·cos(60°) + 120 = 200` → `(244, 200)`
|
||||
- Sweep line: `x1=140 y1=140 x2=140 y2=20` (straight up)
|
||||
|
||||
## File Changes
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `templates/index.html` | BT device list header: add `#btScanIndicator`; insert new `.bt-controls-row` (sort group + filter group) between `.bt-list-signal-strip` and `.bt-device-toolbar`; remove old standalone `.bt-device-filters` div; `.bt-device-toolbar` (search input) is kept unchanged |
|
||||
| `static/js/modes/bluetooth.js` | Add `sortBy` state; add `renderAllDevices()`; rewrite `createSimpleDeviceCard()` for 2-line rows (no locate button); update `setScanning()` to drive `#btScanIndicator`; bind sort button listener in `init()`; remove the `locateBtn` branch from the delegated click handler in `bindListListeners()` (no `.bt-locate-btn[data-locate-id]` elements will exist in rows) |
|
||||
| `static/js/components/proximity-radar.js` | `createSVG()`: add clip path + trailing arc group + CSS class on sweep; remove `animateSweep()` function and its call; update `setPaused()` to use `animationPlayState` |
|
||||
| `static/css/index.css` | BT section: add `.bt-scan-indicator`, `.bt-scan-dot` + `@keyframes bt-scan-pulse`; replace `.bt-row-main`, `.bt-row-left/right`, `.bt-rssi-*`, `.bt-row-actions` with `.bt-row-top/bottom`, `.bt-signal-*`, `.bt-row-meta`, `.bt-row-name`; add `.bt-sort-btn`, `.bt-controls-row`, `.bt-sort-group`, `.bt-filter-group`; add `.bt-radar-sweep` + `@keyframes bt-radar-rotate` |
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Sidebar panel (scanner config, export, baseline)
|
||||
- Tracker detection panel (left column)
|
||||
- Zone summary cards under radar
|
||||
- Radar filter buttons (New Only / Strongest / Unapproved) and Pause button
|
||||
- Device detail panel (`#btDetailContent`) — already well-structured
|
||||
- Bluetooth locate mode
|
||||
- Any backend / route changes
|
||||
@@ -1,203 +0,0 @@
|
||||
# INTERCEPT UI/UX Improvements — Design Spec
|
||||
|
||||
**Date:** 2026-04-13
|
||||
**Status:** Approved
|
||||
**Scope:** Frontend (CSS, JS, HTML) + backend stubs for new features
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
INTERCEPT has grown to 20+ modes faster than its design system has kept pace. The goal of this initiative is to establish a "Mission Control" visual identity — tactical, ops-center, every pixel earning its place — and apply it consistently across the entire application. Work is decomposed into four sequential sub-projects, each building on the last.
|
||||
|
||||
## Aesthetic Direction
|
||||
|
||||
**Mission Control.** Dense, tactical, ops-center feel. Radar aesthetics, glowing blips, HUD overlays, tight data-rich layouts. Think NORAD or air traffic control.
|
||||
|
||||
**Maps:** Premium dark map base (rich land/water separation, minimal labels) with tactical chrome on top — HUD corner panels, range rings, lat/lon grid, custom markers.
|
||||
|
||||
**Navigation:** Keep existing sidebar structure, polish execution — density, group headers, active states, collapsible groups.
|
||||
|
||||
---
|
||||
|
||||
## Sub-Project 1: Design System Uplift + Sidebar Polish
|
||||
|
||||
**Goal:** Establish the Mission Control visual language as the new baseline. Every subsequent sub-project inherits from this.
|
||||
|
||||
### CSS Token Changes (`static/css/core/variables.css`)
|
||||
|
||||
| Token | Current | New | Reason |
|
||||
|---|---|---|---|
|
||||
| `--bg-primary` | `#0a0c10` | `#06080d` | Deeper black for richer depth |
|
||||
| `--bg-secondary` | `#0f1218` | `#090c12` | Consistent depth step |
|
||||
| New: `--accent-cyan-glow` | — | `rgba(74,158,255,0.15)` | Panel borders and active states |
|
||||
| `--text-xs` | `10px` | `9px` | Tighter data density |
|
||||
| `--text-sm` | `12px` | `11px` | Tighter data density |
|
||||
| New: `--scanline` | — | `repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.03) 2px, rgba(0,0,0,0.03) 4px)` | Subtle scanline texture for visual containers |
|
||||
|
||||
### Sidebar (`templates/partials/nav.html`, `static/css/core/layout.css`)
|
||||
|
||||
- **Group headers:** Uppercase letter-spacing treatment with a fine left-border accent line.
|
||||
- **Active mode button:** Replace background fill with a 3px left-border glow (`--accent-cyan` with blur) + subtle background tint.
|
||||
- **Icon sizing:** Standardise all nav button icons to a consistent size (currently varies between modes).
|
||||
- **Hover state:** Replace flat highlight with `--accent-cyan-glow` background.
|
||||
- **Group collapse/expand:** Each nav group gets a toggle arrow. State persisted in `localStorage`. Allows users to hide groups they don't use.
|
||||
|
||||
### Global Panel Treatment (`static/css/core/components.css`)
|
||||
|
||||
- Panel headers: thin gradient top-border (`--accent-cyan` → transparent) for visual hierarchy.
|
||||
- Panel indicator dots: pulse animation when active (extend existing `.panel-indicator.active` rule).
|
||||
- Card backgrounds: subtle inner vignette via `box-shadow: inset 0 0 40px rgba(0,0,0,0.3)`.
|
||||
- Scanline texture applied to all `.visuals-container` elements via `::after` pseudo-element.
|
||||
|
||||
---
|
||||
|
||||
## Sub-Project 2: Maps Overhaul
|
||||
|
||||
**Goal:** Premium map base + tactical chrome. Shared utility applied to all map-using modes.
|
||||
|
||||
**Affected pages:** ADS-B dashboard, AIS dashboard, Satellite dashboard, APRS, GPS, Radiosonde map.
|
||||
|
||||
### Tile Upgrade
|
||||
|
||||
- New default tile provider: **Stadia Maps "Alidade Smooth Dark"** — cleaner land/water separation, minimal label noise, free tier available (requires a free API key from stadiamaps.com; key stored in settings/env var `INTERCEPT_STADIA_KEY`).
|
||||
- Add **"Tactical"** option in settings tile picker: near-black base, minimal labels, overlays do the visual work.
|
||||
- Settings modal tile picker updated to include previews of each style.
|
||||
|
||||
### Custom Markers
|
||||
|
||||
Replace default Leaflet `divIcon` circles with SVG symbols:
|
||||
|
||||
- **Aircraft:** Rotated arrow/chevron by heading. Coloured by category: civil (`--accent-cyan`), military (`--accent-orange`), emergency (`--accent-red`). Sized by altitude band (high/mid/low).
|
||||
- **Vessels:** Ship-silhouette SVG rotated by COG. Coloured by vessel type.
|
||||
- **Satellites:** Simple diamond marker with pass arc overlay.
|
||||
- **Selection popup:** Dark glass panel (backdrop-filter blur, `--bg-elevated` background, `--border-color` border), monospace data rows, no default Leaflet chrome.
|
||||
|
||||
### Tactical Overlays (shared map utility `static/js/map-utils.js`)
|
||||
|
||||
- **Range rings:** Dashed SVG circles at configurable NM/km intervals, label at ring edge.
|
||||
- **Lat/lon grid:** Subtle graticule overlay, toggleable, 5° or 10° spacing.
|
||||
- **Observer reticle:** Crosshair SVG replacing the current circle marker.
|
||||
- **Flight/vessel trails:** Gradient fade — full opacity at current position, transparent at tail. Trail length configurable.
|
||||
- **HUD corner panels:**
|
||||
- Top-left: contact count + mode name
|
||||
- Top-right: UTC clock + SDR status dot
|
||||
- Bottom-right: scale bar
|
||||
- Panels use `backdrop-filter: blur(8px)`, dark glass style.
|
||||
|
||||
### Implementation Note
|
||||
|
||||
Extract shared map initialisation into `static/js/map-utils.js`. Each dashboard calls `MapUtils.init(container, options)` and `MapUtils.addTacticalOverlays(map, options)`. Reduces duplication across the 6 map-using pages.
|
||||
|
||||
---
|
||||
|
||||
## Sub-Project 3: Mode Polish Sprint
|
||||
|
||||
**Goal:** Apply the new design system to the 12 flagged modes. Consistent structure, no mode feeling like a rough prototype.
|
||||
|
||||
**Flagged modes:** Pager, 433MHz Sensors, Sub-GHz, Morse/OOK, GPS, APRS, Radiosonde, WeFax, System Health, Meshtastic, VDL2, ACARS.
|
||||
|
||||
### Shared Treatment (all 12 modes)
|
||||
|
||||
- **Scan indicator:** Animated dot + status text in list header. Pattern from WiFi/Bluetooth applied universally.
|
||||
- **Sort + filter controls row:** Where a list exists, add sort controls (signal/name/time) in the list header.
|
||||
- **Empty states:** All modes use `components/empty_state.html` consistently with mode-specific copy.
|
||||
- **Control panels:** Unified layout — label left, input right, consistent `--space-3` gaps.
|
||||
- **Data rows:** 2-line row treatment — primary info on line 1, secondary metadata on line 2 (signal level, timestamp, etc.).
|
||||
|
||||
### Mode-Specific Highlights
|
||||
|
||||
**Pager, ACARS, VDL2:**
|
||||
- Message rows: signal strength bar on the left edge, collapsible raw data section, relative timestamp with absolute on hover.
|
||||
- Message type badge (coloured by category/priority).
|
||||
|
||||
**433MHz Sensors:**
|
||||
- Sensor cards grouped by device type with a category header.
|
||||
- Last-seen freshness indicator (green → amber → red based on age).
|
||||
- Per-sensor RSSI sparkline (reuse Chart.js pattern from WiFi signal history).
|
||||
|
||||
**Sub-GHz, Morse/OOK, WeFax:**
|
||||
- Signal visualisation panels get scanline texture + dark glass aesthetic.
|
||||
- WeFax image gallery upgraded: lightbox overlay, larger thumbnails, metadata strip below each image.
|
||||
|
||||
**Meshtastic:**
|
||||
- Chat-style message layout (sent right, received left, timestamp inline).
|
||||
- Node list with signal quality bars (RSSI + SNR visualised as bar segments).
|
||||
|
||||
**System Health:**
|
||||
- Metric cards in Grafana style: large value, small label, sparkline, status colour border.
|
||||
- Process list: coloured status pill (running/stopped/error), uptime, PID.
|
||||
|
||||
**GPS, APRS, Radiosonde:**
|
||||
- Map-centric layout using the Sub-Project 2 shared map utility (requires Sub-Project 2 complete first).
|
||||
- Sub-Project 2 handles the map surface itself (tiles, markers, overlays). Sub-Project 3 handles the surrounding UI: control panel layout, telemetry sidebar panel, empty states, and scan indicator.
|
||||
- These two modes should be done last in the Sub-Project 3 sprint, after Sub-Project 2 is merged.
|
||||
|
||||
---
|
||||
|
||||
## Sub-Project 4: New Features
|
||||
|
||||
### 4a — Spectrum Overview Dashboard (`/spectrum`)
|
||||
|
||||
A new full-screen dashboard showing a wideband frequency sweep (rtl_power) as a waterfall/bar chart. Colour-coded markers overlay the sweep at frequencies used by active modes. Clicking a marker navigates to that mode.
|
||||
|
||||
- Backend: new `/spectrum` route + SSE stream from rtl_power.
|
||||
- Frontend: Chart.js frequency bar chart, mode markers as overlaid labels.
|
||||
- Entry point: new nav item in Signals group, plus a link from the System Health mode.
|
||||
|
||||
### 4b — Alerts & Trigger Engine
|
||||
|
||||
Rule-based alert system evaluated server-side against SSE event streams.
|
||||
|
||||
- **Rule schema:** `{ event_type, condition_field, condition_op, condition_value, action_type, action_payload }`
|
||||
- **Storage:** New `alerts` SQLite table in `instance/`.
|
||||
- **UI:** Rule builder modal accessible from a new "Alerts" nav item. Rule list with enable/disable toggle, test button.
|
||||
- **Initial event types:**
|
||||
- Aircraft: callsign match, squawk code, altitude threshold, military flag
|
||||
- Bluetooth: new tracker detected, specific MAC seen
|
||||
- Pager: message keyword match
|
||||
- AIS: vessel type, MMSI match
|
||||
- **Actions:** Desktop notification, audio alert (reuse existing alert sounds), log to file.
|
||||
|
||||
### 4c — Signal Recording & Playback
|
||||
|
||||
Per-mode session recording of decoded output (not I/Q — storage/complexity deferred).
|
||||
|
||||
- **Record:** "Record" toggle button in each mode's control panel. Saves decoded events as timestamped JSON to `data/recordings/<mode>/<timestamp>.json`.
|
||||
- **Playback:** Recording browser in a modal. Select a recording, choose playback speed (1×, 2×, 5×). Playback re-streams events through the existing SSE infrastructure via a `/playback/stream` endpoint.
|
||||
- **I/Q recording:** Out of scope for this phase.
|
||||
|
||||
### 4d — Signal Identification
|
||||
|
||||
"What is this?" capability in the Listening Post (waterfall) mode.
|
||||
|
||||
- User selects a frequency range on the waterfall.
|
||||
- Backend calls `inspectrum` (if installed) or a lightweight Python classifier against the captured I/Q segment.
|
||||
- Returns: estimated modulation type, bandwidth, symbol rate, and a match against a curated known-signals database (JSON file).
|
||||
- UI: result panel slides up from the bottom of the waterfall with signal details and suggested decoder.
|
||||
- Graceful degradation: button is hidden if `inspectrum` is not installed.
|
||||
|
||||
### 4e — Mobile PWA
|
||||
|
||||
Installable, responsive INTERCEPT for field use.
|
||||
|
||||
- `static/manifest.json`: name, icons, display standalone, theme colour.
|
||||
- Service worker (`static/sw.js`): cache shell assets for offline load. SSE streams remain live-only.
|
||||
- **Responsive breakpoints** (`static/css/responsive.css`): sidebar collapses to bottom tab bar on `max-width: 768px`. Priority mobile views: BT Locate, WiFi Locate, GPS (field-use modes). Map-heavy modes scale gracefully. Control panels stack vertically on narrow screens.
|
||||
- Home screen icons: generate from existing `favicon.svg` at 192×192 and 512×512.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Sub-Project 1 — Design System + Sidebar (foundation, ~1 week)
|
||||
2. Sub-Project 2 — Maps Overhaul (high impact, ~1 week)
|
||||
3. Sub-Project 3 — Mode Polish Sprint (breadth, ~2 weeks)
|
||||
4. Sub-Project 4 — New Features (sequentially: Spectrum → Alerts → Recording → Signal ID → PWA)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- I/Q signal recording (deferred to a later phase)
|
||||
- Backend infrastructure changes beyond what new features require
|
||||
- New SDR hardware support
|
||||
- Existing WiFi and Bluetooth modes (recently redesigned, not flagged)
|
||||
Reference in New Issue
Block a user