Files
intercept/docs/superpowers/plans/2026-03-26-wifi-scanner-redesign.md
James Smith efb7d0ed20 chore: add AGENTS.md and superpowers plan docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:59:51 +01:00

1481 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# WiFi Scanner Redesign 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:** Redesign the WiFi scanner's main content area with richer network rows, an animated proximity radar sweep, a channel utilisation heatmap, a security ring chart, and a right-panel network detail view replacing the slide-up drawer.
**Architecture:** All changes are pure frontend — HTML structure in `templates/index.html`, styles in `static/css/index.css`, and JS logic in `static/js/modes/wifi.js`. No backend routes are touched. The five tasks are independent enough to be committed separately and the UI remains functional after each one.
**Tech Stack:** Vanilla JS (ES6 IIFE module pattern), CSS animations, inline SVG, Flask/Jinja2 templates.
---
## Spec & reference
- **Spec:** `docs/superpowers/specs/2026-03-26-wifi-scanner-redesign-design.md`
- **Start the app for manual verification:**
```bash
sudo -E venv/bin/python intercept.py
# Open http://localhost:5050/?mode=wifi
```
## File map
| File | What changes |
|---|---|
| `templates/index.html` | All structural HTML changes (lines ~8221005 for WiFi section) |
| `static/css/index.css` | WiFi section CSS (lines ~35153970+) |
| `static/js/modes/wifi.js` | `cacheDOM()`, `scheduleRender()`, `updateNetworkTable()` → `renderNetworks()`, `updateStats()`, `initProximityRadar()` → `renderRadar()`, `initChannelChart()` → `renderHeatmap()` + `renderSecurityRing()`, `selectNetwork()`, `closeDetail()`, `updateDetailPanel()` |
---
## Task 1: Status bar — Open count + scan indicator
**Files:**
- Modify: `templates/index.html` (lines ~824841)
- Modify: `static/css/index.css` (lines ~35313570)
- Modify: `static/js/modes/wifi.js` (`cacheDOM()` ~line 183, `updateScanningState()` ~line 670, `updateStats()` ~line 1475)
### Context
The status bar currently has: Networks · Clients · Hidden · [scan status text]. We're adding an Open count (red) and replacing the text status with a pulsing dot indicator.
The existing `#wifiScanStatus` element (line 837 of `index.html`) is replaced by `#wifiScanIndicator`. The existing `updateScanningState()` function (line ~670 of `wifi.js`) currently sets `.textContent` and `.className` on `elements.scanStatus` — it needs to toggle the dot's `display` instead.
- [ ] **Step 1: Update status bar HTML**
In `templates/index.html`, find the `wifi-status-bar` div (~line 824) and replace its contents:
```html
<div class="wifi-status-bar">
<div class="wifi-status-item">
<span class="wifi-status-label">Networks:</span>
<span class="wifi-status-value" id="wifiNetworkCount">0</span>
</div>
<div class="wifi-status-item">
<span class="wifi-status-label">Clients:</span>
<span class="wifi-status-value" id="wifiClientCount">0</span>
</div>
<div class="wifi-status-item">
<span class="wifi-status-label">Hidden:</span>
<span class="wifi-status-value" id="wifiHiddenCount">0</span>
</div>
<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>
<div class="wifi-scan-indicator" id="wifiScanIndicator">
<span class="wifi-scan-dot"></span>
<span class="wifi-scan-text">IDLE</span>
</div>
</div>
```
- [ ] **Step 2: Add scan indicator CSS**
In `static/css/index.css`, find the `.wifi-status-bar` block (~line 3531) and add after it:
```css
.wifi-scan-indicator {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: var(--accent-cyan);
letter-spacing: 0.5px;
}
.wifi-scan-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent-cyan);
display: none;
animation: wifi-scan-pulse 1.2s ease-in-out infinite;
}
@keyframes wifi-scan-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
```
- [ ] **Step 3: Update JS — `cacheDOM()`**
In `wifi.js`, find `cacheDOM()` (~line 183). Replace:
```js
// Status bar
scanStatus: document.getElementById('wifiScanStatus'),
```
With:
```js
// Status bar
scanIndicator: document.getElementById('wifiScanIndicator'),
openCount: document.getElementById('wifiOpenCount'),
```
(Keep `networkCount`, `clientCount`, `hiddenCount` unchanged. Remove the old `openCount: document.getElementById('openCount')` line in the security counts section.)
- [ ] **Step 4: Update JS — `updateScanningState()`**
Find `updateScanningState()` (~line 670). Replace the body that references `elements.scanStatus` with:
```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';
```
- [ ] **Step 5: Update JS — `updateStats()` — add Open count, remove old security IDs**
In `updateStats()` (~line 1475), find the block that updates `elements.wpa3Count`, `elements.wpa2Count`, `elements.wepCount`, `elements.openCount`. Replace the four element update lines with:
```js
if (elements.openCount) elements.openCount.textContent = securityCounts.open;
```
(Remove the `wpa3Count`, `wpa2Count`, `wepCount` lines — those elements no longer exist. Keep the `securityCounts` calculation above unchanged, it's still needed by Task 4.)
Also remove `wpa3Count`, `wpa2Count`, `wepCount` from `cacheDOM()` entirely.
- [ ] **Step 6: Verify**
```bash
sudo -E venv/bin/python intercept.py
```
Open `http://localhost:5050/?mode=wifi`. Check:
- Status bar shows Networks / Clients / Hidden / Open (red)
- Clicking Quick Scan shows pulsing cyan dot + "SCANNING (Quick)"
- Stopping shows "IDLE" with no dot
- Open count increments as open networks are discovered
- [ ] **Step 7: Commit**
```bash
git add templates/index.html static/css/index.css static/js/modes/wifi.js
git commit -m "feat(wifi): enhanced status bar with open count and scan indicator"
```
---
## Task 2: Networks table → styled div list
**Files:**
- Modify: `templates/index.html` (~lines 846881)
- Modify: `static/css/index.css` (~lines 35823765)
- Modify: `static/js/modes/wifi.js` (`cacheDOM()`, `updateNetworkTable()`, `createNetworkRow()`, `initNetworkFilters()`, `initSortControls()`, `selectNetwork()`, `closeDetail()`)
### Context
The existing `<table id="wifiNetworkTable">` with 7 columns is replaced by `<div id="wifiNetworkList">`. The `updateNetworkTable()` / `createNetworkRow()` functions are rewritten to generate `<div class="network-row">` elements with two visual lines (SSID + badges on top, signal bar + meta on bottom).
The existing `selectedNetwork` variable is renamed to `selectedBssid` throughout `wifi.js`.
- [ ] **Step 1: Replace table HTML in `index.html`**
Find `.wifi-networks-panel` (~line 846). Replace the `<div class="wifi-networks-header">` and everything inside `.wifi-networks-panel` with:
```html
<div class="wifi-networks-panel">
<div class="wifi-networks-header">
<h5>Discovered Networks</h5>
<div class="wifi-network-filters" id="wifiNetworkFilters">
<button class="wifi-filter-btn active" data-filter="all">All</button>
<button class="wifi-filter-btn" data-filter="2.4">2.4G</button>
<button class="wifi-filter-btn" data-filter="5">5G</button>
<button class="wifi-filter-btn" data-filter="open">Open</button>
<button class="wifi-filter-btn" data-filter="hidden">Hidden</button>
</div>
<div class="wifi-sort-controls">
<span class="wifi-sort-label">Sort:</span>
<button class="wifi-sort-btn active" data-sort="rssi">Signal</button>
<button class="wifi-sort-btn" data-sort="essid">SSID</button>
<button class="wifi-sort-btn" data-sort="channel">Ch</button>
</div>
</div>
<div class="wifi-networks-table-wrapper">
<div id="wifiNetworkList" class="wifi-network-list">
<div class="wifi-network-placeholder">
<p>No networks detected.<br>Start a scan to begin.</p>
</div>
</div>
</div>
</div>
```
- [ ] **Step 2: Replace table CSS with row CSS**
In `static/css/index.css`, find the section starting with `/* WiFi Networks Panel (LEFT) */` (~line 3582). Remove all CSS rules for:
- `.wifi-networks-table`, `.wifi-networks-table thead`, `.wifi-networks-table th`, `.wifi-networks-table td`, `.wifi-networks-table th.sortable`, `.wifi-networks-table th:hover`
- `.col-essid`, `.col-bssid`, `.col-channel`, `.col-rssi`, `.col-security`, `.col-clients`, `.col-agent`
- `.wifi-network-row` (old table row)
- `.security-badge`, `.security-open`, `.security-wpa`, `.security-wpa3`, `.security-wep`
- `.signal-strong`, `.signal-medium`, `.signal-weak`, `.signal-very-weak` (old signal classes)
- `.agent-badge`, `.agent-local`, `.agent-remote`
Add in their place:
```css
/* WiFi Network List */
.wifi-network-list {
display: flex;
flex-direction: column;
}
.wifi-network-placeholder {
padding: 32px 16px;
text-align: center;
color: var(--text-dim);
font-size: 11px;
line-height: 1.6;
}
/* Network rows */
.network-row {
padding: 9px 14px;
border-bottom: 1px solid var(--bg-secondary);
border-left: 3px solid transparent;
cursor: pointer;
transition: background 0.15s;
}
.network-row:hover { background: var(--bg-tertiary); }
.network-row.selected {
background: rgba(74, 163, 255, 0.07);
border-left-color: var(--accent-cyan) !important;
}
.network-row.threat-open { border-left-color: var(--accent-red); }
.network-row.threat-safe { border-left-color: var(--accent-green); }
.network-row.threat-hidden { border-left-color: var(--border-color); }
.row-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 5px;
}
.row-ssid {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 55%;
}
.row-ssid.hidden-net {
color: var(--text-dim);
font-style: italic;
}
.row-badges { display: flex; gap: 4px; align-items: center; flex-shrink: 0; }
.badge {
font-size: 9px;
padding: 2px 5px;
border-radius: 3px;
font-weight: 600;
letter-spacing: 0.5px;
border: 1px solid transparent;
}
.badge.open { color: var(--accent-red); background: var(--accent-red-dim); border-color: var(--accent-red); }
.badge.wpa2 { color: var(--accent-green); background: var(--accent-green-dim); border-color: var(--accent-green); }
.badge.wpa3 { color: var(--accent-cyan); background: var(--accent-cyan-dim); border-color: var(--accent-cyan); }
.badge.wep { color: var(--accent-orange); background: var(--accent-amber-dim); border-color: var(--accent-orange); }
.badge.hidden-tag { color: var(--text-dim); background: transparent; border-color: var(--border-color); font-size: 8px; }
.row-bottom {
display: flex;
align-items: center;
gap: 8px;
}
.signal-bar-wrap { flex: 1; max-width: 130px; }
.signal-track {
height: 4px;
background: var(--bg-elevated);
border-radius: 2px;
overflow: hidden;
}
.signal-fill { height: 100%; border-radius: 2px; transition: width 0.3s; }
.signal-fill.strong { background: linear-gradient(90deg, var(--accent-green), #88d49b); }
.signal-fill.medium { background: linear-gradient(90deg, var(--accent-green), var(--accent-orange)); }
.signal-fill.weak { background: linear-gradient(90deg, var(--accent-orange), var(--accent-red)); }
.row-meta {
display: flex;
gap: 10px;
margin-left: auto;
color: var(--text-dim);
font-size: 10px;
}
.row-rssi { color: var(--text-secondary); }
/* Sort controls */
.wifi-sort-controls {
display: flex;
align-items: center;
gap: 4px;
}
.wifi-sort-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wifi-sort-btn {
padding: 2px 6px;
font-size: 9px;
font-family: inherit;
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
transition: color 0.15s;
}
.wifi-sort-btn:hover { color: var(--text-primary); }
.wifi-sort-btn.active { color: var(--accent-cyan); }
```
- [ ] **Step 3: Update `cacheDOM()` — swap table refs**
In `wifi.js`'s `cacheDOM()` (~line 183), replace:
```js
networkTable: document.getElementById('wifiNetworkTable'),
networkTableBody: document.getElementById('wifiNetworkTableBody'),
```
With:
```js
networkList: document.getElementById('wifiNetworkList'),
```
- [ ] **Step 4: Rename `selectedNetwork` → `selectedBssid` throughout `wifi.js`**
Find all occurrences of `selectedNetwork` in `wifi.js` and rename to `selectedBssid`. There are ~6 occurrences (declaration, `selectNetwork()`, `closeDetail()`, `scheduleRender` block, `updateNetworkRow()`). Use a search-and-replace.
- [ ] **Step 5: Rewrite `updateNetworkTable()` → `renderNetworks()`**
Rename `updateNetworkTable()` to `renderNetworks()`. Replace the guard at the top:
```js
// old:
if (!elements.networkTableBody) return;
// new:
if (!elements.networkList) return;
```
Replace the empty-state block (the `if (filtered.length === 0)` section) with:
```js
if (filtered.length === 0) {
let message = networks.size > 0
? 'No networks match current filters'
: (isScanning ? 'Scanning for networks...' : 'Start scanning to discover networks');
elements.networkList.innerHTML = `<div class="wifi-network-placeholder"><p>${escapeHtml(message)}</p></div>`;
return;
}
```
Replace the render line:
```js
// old:
elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join('');
// new:
elements.networkList.innerHTML = filtered.map(n => createNetworkRow(n)).join('');
```
Add selected-state re-application after the render line:
```js
// Re-apply selected state after re-render
if (selectedBssid) {
const sel = elements.networkList.querySelector(`[data-bssid="${CSS.escape(selectedBssid)}"]`);
if (sel) sel.classList.add('selected');
}
```
Update the `scheduleRender` call in the `requestAnimationFrame` block (line ~1091):
```js
// old: if (pendingRender.table) updateNetworkTable();
if (pendingRender.table) renderNetworks();
```
- [ ] **Step 6: Rewrite `createNetworkRow()` to produce div rows**
Replace the entire `createNetworkRow(network)` function body:
```js
function createNetworkRow(network) {
const rssi = network.rssi_current;
const security = network.security || 'Unknown';
// Badge class
const sec = security.toLowerCase();
const badgeClass = sec === 'open' || sec === '' ? 'open'
: sec.includes('wpa3') ? 'wpa3'
: sec.includes('wpa') ? 'wpa2'
: sec.includes('wep') ? 'wep'
: 'wpa2';
// Threat class (left border)
const threatClass = badgeClass === 'open' ? 'threat-open'
: badgeClass === 'wpa2' || badgeClass === 'wpa3' ? 'threat-safe'
: 'threat-hidden';
// Signal bar width + class
const pct = rssi != null ? Math.max(0, Math.min(100, (rssi + 100) / 80 * 100)) : 0;
const fillClass = rssi > -55 ? 'strong' : rssi > -70 ? 'medium' : 'weak';
const displayName = escapeHtml(network.display_name || network.essid || '[Hidden]');
const isHidden = network.is_hidden;
const hiddenTag = isHidden ? '<span class="badge hidden-tag">HIDDEN</span>' : '';
return `
<div class="network-row ${threatClass}"
data-bssid="${escapeHtml(network.bssid)}"
data-band="${escapeHtml(network.band || '')}"
data-security="${escapeHtml(security)}"
onclick="WiFiMode.selectNetwork('${escapeHtml(network.bssid)}')">
<div class="row-top">
<span class="row-ssid${isHidden ? ' hidden-net' : ''}">${displayName}</span>
<div class="row-badges">
<span class="badge ${badgeClass}">${escapeHtml(security)}</span>
${hiddenTag}
</div>
</div>
<div class="row-bottom">
<div class="signal-bar-wrap">
<div class="signal-track">
<div class="signal-fill ${fillClass}" style="width:${pct.toFixed(1)}%"></div>
</div>
</div>
<div class="row-meta">
<span>ch ${network.channel || '?'}</span>
<span>${network.client_count || 0} ↔</span>
<span class="row-rssi">${rssi != null ? rssi : '?'}</span>
</div>
</div>
</div>
`;
}
```
- [ ] **Step 7: Update `initNetworkFilters()` to filter div rows**
In `initNetworkFilters()` (~line 1024), find where filter buttons update the display. The existing logic toggles row visibility. Update it to operate on `.network-row` elements and their `data-band` / `data-security` attributes:
```js
function applyFilter(filter) {
currentFilter = filter;
renderNetworks(); // simplest approach: just re-render with new filter
}
```
(The existing filter logic is already applied inside `updateNetworkTable()` / `renderNetworks()` via `currentFilter` — no DOM-level show/hide needed. If the existing `initNetworkFilters()` does DOM-level hiding, simplify it to just call `renderNetworks()` when `currentFilter` changes.)
- [ ] **Step 8: Update `initSortControls()` to use `.wifi-sort-btn`**
In `initSortControls()` (~line 1050), replace the existing `th[data-sort]` listener with:
```js
function initSortControls() {
document.querySelectorAll('.wifi-sort-btn').forEach(btn => {
btn.addEventListener('click', () => {
const field = btn.dataset.sort;
if (currentSort.field === field) {
currentSort.order = currentSort.order === 'desc' ? 'asc' : 'desc';
} else {
currentSort.field = field;
currentSort.order = 'desc';
}
document.querySelectorAll('.wifi-sort-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
scheduleRender({ table: true });
});
});
}
```
- [ ] **Step 9: Update `selectNetwork()` and `closeDetail()` to use div rows**
In `selectNetwork()` (~line 1241), replace the row-selection query:
```js
// old:
elements.networkTableBody?.querySelectorAll('.wifi-network-row').forEach(...)
// new:
elements.networkList?.querySelectorAll('.network-row').forEach(row => {
row.classList.toggle('selected', row.dataset.bssid === bssid);
});
```
In `closeDetail()` (~line 1315), replace the row-deselection query similarly:
```js
elements.networkList?.querySelectorAll('.network-row').forEach(row => {
row.classList.remove('selected');
});
```
- [ ] **Step 10: Verify**
```bash
sudo -E venv/bin/python intercept.py
```
Open `http://localhost:5050/?mode=wifi`. Check:
- Network list shows styled div rows (two lines each, signal bars, coloured left borders)
- Filter buttons (All / 2.4G / 5G / Open / Hidden) still work
- Sort buttons (Signal / SSID / Ch) work
- Clicking a row highlights it (cyan left border + tinted background)
- Clicking a different row deselects the previous one
- [ ] **Step 11: Commit**
```bash
git add templates/index.html static/css/index.css static/js/modes/wifi.js
git commit -m "feat(wifi): replace table with styled div network rows"
```
---
## Task 3: Proximity radar — animated sweep
**Files:**
- Modify: `templates/index.html` (~lines 882900)
- Modify: `static/css/index.css` (~line 3787)
- Modify: `static/js/modes/wifi.js` (`initProximityRadar()`, `updateProximityRadar()`, `scheduleRender` block)
### Context
The existing radar uses the external `ProximityRadar` component (`static/js/components/proximity-radar.js`). We're replacing this with a hand-rolled inline SVG. The static SVG rings and the rotating sweep `<g>` are placed directly in the template; JS only manages the network dot positions.
- [ ] **Step 1: Replace radar HTML**
In `index.html`, find `<div id="wifiProximityRadar" class="wifi-radar-container"></div>` (~line 884). Replace the entire `<div class="wifi-radar-panel">` contents with:
```html
<div class="wifi-radar-panel">
<h5>Proximity Radar</h5>
<div id="wifiProximityRadar" class="wifi-radar-container">
<svg width="100%" viewBox="0 0 210 210" id="wifiRadarSvg">
<defs>
<clipPath id="wifi-radar-clip">
<circle cx="105" cy="105" r="100"/>
</clipPath>
<filter id="wifi-glow-sm">
<feGaussianBlur stdDeviation="2.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="wifi-glow-md">
<feGaussianBlur stdDeviation="4" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<!-- Background rings (static) -->
<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"/>
<!-- Crosshairs -->
<line x1="5" y1="105" x2="205" y2="105" stroke="#00b4d8" stroke-width="0.3" opacity="0.1"/>
<line x1="105" y1="5" x2="105" y2="205" stroke="#00b4d8" stroke-width="0.3" opacity="0.1"/>
<!-- Rotating sweep group -->
<g class="wifi-radar-sweep" clip-path="url(#wifi-radar-clip)">
<!-- Primary trailing arc: 60° -->
<path d="M105,105 L105,5 A100,100 0 0,1 191.6,155 Z" fill="#00b4d8" opacity="0.08"/>
<!-- Secondary trailing arc: 90° -->
<path d="M105,105 L105,5 A100,100 0 0,1 205,105 Z" fill="#00b4d8" opacity="0.04"/>
<!-- Sweep line -->
<line x1="105" y1="105" x2="105" y2="5" stroke="#00b4d8" stroke-width="1.5" opacity="0.7"
filter="url(#wifi-glow-sm)"/>
</g>
<!-- Centre dot -->
<circle cx="105" cy="105" r="3" fill="#00b4d8" opacity="0.8"/>
<!-- Network dots (managed by renderRadar()) -->
<g id="wifiRadarDots"></g>
</svg>
</div>
<div class="wifi-zone-summary">
<div class="wifi-zone near">
<span class="wifi-zone-count" id="wifiZoneImmediate">0</span>
<span class="wifi-zone-label">Near</span>
</div>
<div class="wifi-zone mid">
<span class="wifi-zone-count" id="wifiZoneNear">0</span>
<span class="wifi-zone-label">Mid</span>
</div>
<div class="wifi-zone far">
<span class="wifi-zone-count" id="wifiZoneFar">0</span>
<span class="wifi-zone-label">Far</span>
</div>
</div>
</div>
```
- [ ] **Step 2: Add sweep animation CSS**
In `static/css/index.css`, find `.wifi-radar-panel` (~line 3768). Add after its closing brace:
```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); }
}
```
- [ ] **Step 3: Add `bssidToAngle()` helper and `renderRadar()` in `wifi.js`**
In `wifi.js`, find the `// Proximity Radar` section (~line 1519). Replace `initProximityRadar()` and `updateProximityRadar()` entirely with:
```js
// Simple hash of BSSID string → stable angle in radians
function bssidToAngle(bssid) {
let hash = 0;
for (let i = 0; i < bssid.length; i++) {
hash = (hash * 31 + bssid.charCodeAt(i)) & 0xffffffff;
}
return (hash >>> 0) / 0xffffffff * 2 * Math.PI;
}
function renderRadar(networksList) {
const dotsGroup = document.getElementById('wifiRadarDots');
if (!dotsGroup) return;
const dots = [];
const zoneCounts = { immediate: 0, near: 0, far: 0 };
networksList.forEach(network => {
const rssi = network.rssi_current ?? -100;
const strength = Math.max(0, Math.min(1, (rssi + 100) / 80));
const dotR = 5 + (1 - strength) * 90; // stronger = closer to centre
const angle = bssidToAngle(network.bssid);
const cx = 105 + dotR * Math.cos(angle);
const cy = 105 + dotR * Math.sin(angle);
// Zone counts
if (dotR < 35) zoneCounts.immediate++;
else if (dotR < 70) zoneCounts.near++;
else zoneCounts.far++;
// Visual radius by zone
const vr = dotR < 35 ? 6 : dotR < 70 ? 4.5 : 3;
// Colour by security
const sec = (network.security || '').toLowerCase();
const colour = sec === 'open' || sec === '' ? '#e25d5d'
: sec.includes('wpa') ? '#38c180'
: sec.includes('wep') ? '#d6a85e'
: '#484f58';
dots.push(`
<circle cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" r="${vr * 1.5}"
fill="${colour}" opacity="0.12"/>
<circle cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" r="${vr}"
fill="${colour}" opacity="0.9" filter="url(#wifi-glow-sm)"/>
`);
});
dotsGroup.innerHTML = dots.join('');
if (elements.zoneImmediate) elements.zoneImmediate.textContent = zoneCounts.immediate;
if (elements.zoneNear) elements.zoneNear.textContent = zoneCounts.near;
if (elements.zoneFar) elements.zoneFar.textContent = zoneCounts.far;
}
```
- [ ] **Step 4: Wire `renderRadar()` into `scheduleRender()`**
In `scheduleRender()`'s `requestAnimationFrame` callback (~line 1088), replace:
```js
if (pendingRender.radar) updateProximityRadar();
```
With:
```js
if (pendingRender.radar) renderRadar(Array.from(networks.values()));
```
Also update `init()` to remove the `initProximityRadar()` call (it's now a no-op since the SVG is static in the template).
- [ ] **Step 5: Update `cacheDOM()` — remove old radar ref**
Remove `channelBandTabs: document.getElementById('wifiChannelBandTabs')` (will be removed in Task 4 anyway). Remove `channelChart: document.getElementById('wifiChannelChart')`. Keep `proximityRadar` if referenced elsewhere, otherwise remove.
- [ ] **Step 6: Verify**
```bash
sudo -E venv/bin/python intercept.py
```
Open `http://localhost:5050/?mode=wifi`. Check:
- Radar panel shows a slowly rotating sweep line with a trailing cyan arc
- Starting a scan populates coloured dots at stable positions (same BSSID always at same angle)
- Zone counts (Near / Mid / Far) update
- Open network dots are red; WPA2 dots are green
- [ ] **Step 7: Commit**
```bash
git add templates/index.html static/css/index.css static/js/modes/wifi.js
git commit -m "feat(wifi): animated SVG proximity radar with sweep rotation"
```
---
## Task 4: Channel heatmap + security ring
**Files:**
- Modify: `templates/index.html` (~lines 902938, the `.wifi-analysis-panel`)
- Modify: `static/css/index.css` (~lines 38243916, WiFi analysis panel CSS)
- Modify: `static/js/modes/wifi.js` (`cacheDOM()`, `initChannelChart()`, `updateChannelChart()`, `scheduleRender`)
### Context
The existing right panel has two sub-sections (`.wifi-channel-section` with a bar chart, `.wifi-security-section` with coloured dots). Both are replaced. The new panel has a shared header (`#wifiRightPanelTitle` + `#wifiDetailBackBtn`) used in Task 5, a `#wifiHeatmapView` with the heatmap grid and security ring, and a hidden `#wifiDetailView` placeholder (wired up in Task 5).
- [ ] **Step 1: Replace right panel HTML**
In `index.html`, find `<div class="wifi-analysis-panel">` (~line 902). Replace the entire block (up to the closing `</div>` of `.wifi-analysis-panel`) with:
```html
<div class="wifi-analysis-panel">
<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>
<!-- Default: heatmap + security ring -->
<div id="wifiHeatmapView" style="display:flex; flex-direction:column; flex:1; overflow:hidden;">
<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" id="wifiHeatmapChLabels">
<!-- 11 channel labels (111), generated once by JS -->
</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">
<circle cx="24" cy="24" r="9" fill="var(--bg-primary)"/>
</svg>
<div class="wifi-security-ring-legend" id="wifiSecurityRingLegend"></div>
</div>
</div>
<!-- On network click: detail panel (wired in Task 5) -->
<div id="wifiDetailView" style="display:none; flex:1; overflow-y:auto;">
<!-- populated in Task 5 -->
</div>
</div>
```
- [ ] **Step 2: Replace analysis panel CSS**
In `static/css/index.css`, find `/* WiFi Analysis Panel (RIGHT) */` (~line 3824). Remove all existing rules for `.wifi-analysis-panel`, `.wifi-channel-section`, `.wifi-security-section`, `.wifi-channel-tabs`, `.channel-band-tab`, `.wifi-channel-chart`, `.wifi-security-stats`, `.wifi-security-item`, `.wifi-security-dot`, `.wifi-security-count`.
Add in their place:
```css
/* WiFi Analysis Panel */
.wifi-analysis-panel {
display: flex;
flex-direction: column;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.wifi-analysis-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.wifi-analysis-panel-header .panel-title {
color: var(--accent-cyan);
font-size: 10px;
letter-spacing: 1.5px;
text-transform: uppercase;
}
.wifi-detail-back-btn {
font-family: inherit;
font-size: 9px;
color: var(--text-dim);
background: none;
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 2px 8px;
cursor: pointer;
transition: color 0.15s;
}
.wifi-detail-back-btn:hover { color: var(--text-primary); }
/* Heatmap */
.wifi-heatmap-wrap {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
overflow: hidden;
}
.wifi-heatmap-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 2px;
}
.wifi-heatmap-ch-labels {
display: grid;
grid-template-columns: 26px repeat(11, 1fr);
gap: 2px;
}
.wifi-heatmap-ch-label {
text-align: center;
font-size: 8px;
color: var(--text-dim);
}
.wifi-heatmap-grid {
display: grid;
grid-template-columns: 26px repeat(11, 1fr);
gap: 2px;
flex: 1;
min-height: 0;
}
.wifi-heatmap-time-label {
font-size: 8px;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 4px;
}
.wifi-heatmap-cell {
border-radius: 2px;
min-height: 10px;
}
.wifi-heatmap-empty {
grid-column: 1 / -1;
padding: 16px;
text-align: center;
color: var(--text-dim);
font-size: 10px;
}
.wifi-heatmap-legend {
display: flex;
align-items: center;
gap: 6px;
font-size: 9px;
color: var(--text-dim);
margin-top: 2px;
}
.wifi-heatmap-legend-grad {
flex: 1;
height: 6px;
border-radius: 3px;
background: linear-gradient(90deg, #0d1117 0%, #0d4a6e 30%, #0ea5e9 60%, #f97316 80%, #ef4444 100%);
}
/* Security ring */
.wifi-security-ring-wrap {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
flex-shrink: 0;
}
.wifi-security-ring-legend {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.wifi-security-ring-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
}
.wifi-security-ring-dot {
width: 7px;
height: 7px;
border-radius: 1px;
flex-shrink: 0;
}
.wifi-security-ring-name { color: var(--text-dim); flex: 1; }
.wifi-security-ring-count { color: var(--text-primary); font-weight: 600; }
```
- [ ] **Step 3: Update `cacheDOM()` — add heatmap elements, remove old chart/security refs**
In `cacheDOM()`, remove:
```js
channelChart: document.getElementById('wifiChannelChart'),
channelBandTabs: document.getElementById('wifiChannelBandTabs'),
wpa3Count: document.getElementById('wpa3Count'),
wpa2Count: document.getElementById('wpa2Count'),
wepCount: document.getElementById('wepCount'),
```
Add:
```js
heatmapGrid: document.getElementById('wifiHeatmapGrid'),
heatmapChLabels: document.getElementById('wifiHeatmapChLabels'),
heatmapCount: document.getElementById('wifiHeatmapCount'),
securityRingSvg: document.getElementById('wifiSecurityRingSvg'),
securityRingLegend: document.getElementById('wifiSecurityRingLegend'),
heatmapView: document.getElementById('wifiHeatmapView'),
detailView: document.getElementById('wifiDetailView'),
rightPanelTitle: document.getElementById('wifiRightPanelTitle'),
detailBackBtn: document.getElementById('wifiDetailBackBtn'),
```
- [ ] **Step 4: Add `channelHistory` state variable**
Near the top of the module (where `networks`, `clients` etc. are declared), add:
```js
let channelHistory = []; // max 10 entries, each { timestamp, channels: {1:N,...,11:N} }
```
- [ ] **Step 5: Add heatmap initialisation (channel labels)**
Replace `initChannelChart()` with:
```js
function initHeatmap() {
if (!elements.heatmapChLabels) return;
// Time-label placeholder + 11 channel labels
elements.heatmapChLabels.innerHTML =
'<div class="wifi-heatmap-ch-label"></div>' +
[1,2,3,4,5,6,7,8,9,10,11].map(ch =>
`<div class="wifi-heatmap-ch-label">${ch}</div>`
).join('');
}
```
Call `initHeatmap()` from `init()` instead of `initChannelChart()`.
- [ ] **Step 6: Add `renderHeatmap()` and `renderSecurityRing()` functions**
Add after `initHeatmap()`:
```js
function renderHeatmap() {
if (!elements.heatmapGrid) return;
if (channelHistory.length === 0) {
elements.heatmapGrid.innerHTML =
'<div class="wifi-heatmap-empty">Scan to populate channel history</div>';
if (elements.heatmapCount) elements.heatmapCount.textContent = '0';
return;
}
if (elements.heatmapCount) elements.heatmapCount.textContent = channelHistory.length;
// Find max value for colour scale
let maxVal = 1;
channelHistory.forEach(snap => {
Object.values(snap.channels).forEach(v => { if (v > maxVal) maxVal = v; });
});
const rows = channelHistory.map((snap, i) => {
const timeLabel = i === 0 ? 'now' : '';
const cells = [1,2,3,4,5,6,7,8,9,10,11].map(ch => {
const v = snap.channels[ch] || 0;
return `<div class="wifi-heatmap-cell" style="background:${congestionColor(v, maxVal)}"></div>`;
});
return `<div class="wifi-heatmap-time-label">${timeLabel}</div>${cells.join('')}`;
});
elements.heatmapGrid.innerHTML = rows.join('');
}
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)})`;
}
function renderSecurityRing(networksList) {
const svg = elements.securityRingSvg;
const legend = elements.securityRingLegend;
if (!svg || !legend) return;
const C = 2 * Math.PI * 15; // circumference ≈ 94.25
const sec = networksList.reduce((acc, n) => {
const s = (n.security || '').toLowerCase();
if (s.includes('wpa3')) acc.wpa3++;
else if (s.includes('wpa')) acc.wpa2++;
else if (s.includes('wep')) acc.wep++;
else acc.open++;
return acc;
}, { wpa2: 0, open: 0, wpa3: 0, wep: 0 });
const total = networksList.length || 1;
const segments = [
{ label: 'WPA2', color: '#38c180', count: sec.wpa2 },
{ label: 'Open', color: '#e25d5d', count: sec.open },
{ label: 'WPA3', color: '#4aa3ff', count: sec.wpa3 },
{ label: 'WEP', color: '#d6a85e', count: sec.wep },
];
let offset = 0;
const arcs = segments.map(seg => {
const arcLen = (seg.count / total) * C;
const arc = `<circle cx="24" cy="24" r="15" fill="none"
stroke="${seg.color}" stroke-width="7"
stroke-dasharray="${arcLen.toFixed(2)} ${(C - arcLen).toFixed(2)}"
stroke-dashoffset="${(-offset).toFixed(2)}"
transform="rotate(-90 24 24)"/>`;
offset += arcLen;
return arc;
});
svg.innerHTML = arcs.join('') +
'<circle cx="24" cy="24" r="9" fill="var(--bg-primary)"/>';
legend.innerHTML = segments.map(seg => `
<div class="wifi-security-ring-item">
<div class="wifi-security-ring-dot" style="background:${seg.color}"></div>
<span class="wifi-security-ring-name">${seg.label}</span>
<span class="wifi-security-ring-count">${seg.count}</span>
</div>
`).join('');
}
```
- [ ] **Step 7: Snapshot channel history in `renderNetworks()` and call render functions**
At the top of `renderNetworks()` (just after the filter/sort), add the history snapshot:
```js
// Snapshot 2.4 GHz channel utilisation
const snapshot = { timestamp: Date.now(), channels: {} };
for (let ch = 1; ch <= 11; ch++) snapshot.channels[ch] = 0;
Array.from(networks.values())
.filter(n => n.band && n.band.startsWith('2.4'))
.forEach(n => {
const ch = parseInt(n.channel);
if (ch >= 1 && ch <= 11) snapshot.channels[ch]++;
});
channelHistory.unshift(snapshot);
if (channelHistory.length > 10) channelHistory.pop();
```
Then after `elements.networkList.innerHTML = ...`, add:
```js
renderHeatmap();
renderSecurityRing(Array.from(networks.values()));
```
- [ ] **Step 8: Remove `updateChannelChart()` call from `scheduleRender()`**
In `scheduleRender()`'s animation frame, replace:
```js
if (pendingRender.chart) updateChannelChart();
```
With nothing (delete this line). The heatmap is now updated from within `renderNetworks()`.
- [ ] **Step 9: Verify**
```bash
sudo -E venv/bin/python intercept.py
```
Open `http://localhost:5050/?mode=wifi`. Check:
- Right panel shows "Channel Heatmap" header
- After scanning, heatmap grid populates with coloured cells (channels 6 and 11 should be hottest if neighbours visible)
- "Last N scans" count increments with each render
- Security ring shows proportional arcs for WPA2/Open/WPA3/WEP with counts
- [ ] **Step 10: Commit**
```bash
git add templates/index.html static/css/index.css static/js/modes/wifi.js
git commit -m "feat(wifi): channel heatmap and security ring chart"
```
---
## Task 5: Network detail panel (right panel takeover)
**Files:**
- Modify: `templates/index.html` (remove `#wifiDetailDrawer`, populate `#wifiDetailView`)
- Modify: `static/css/index.css` (remove drawer CSS, add detail panel CSS)
- Modify: `static/js/modes/wifi.js` (`cacheDOM()`, `selectNetwork()`, `closeDetail()`, `updateDetailPanel()`)
### Context
The existing `#wifiDetailDrawer` slides up from the bottom. It is deleted. The new `#wifiDetailView` div (already added to the HTML in Task 4) is populated here. Clicking a network row hides `#wifiHeatmapView` and shows `#wifiDetailView` in the right panel.
- [ ] **Step 1: Remove `#wifiDetailDrawer` from `index.html`**
Find `<div class="wifi-detail-drawer" id="wifiDetailDrawer">` (~line 940) and delete the entire block (from the opening div to its matching closing `</div>`, approximately 60 lines).
- [ ] **Step 2: Populate `#wifiDetailView` in `index.html`**
Find `<div id="wifiDetailView" style="display:none; flex:1; overflow-y:auto;">` (added in Task 4). Replace the `<!-- populated in Task 5 -->` placeholder with:
```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" style="grid-column: 1 / -1;">
<span class="label">Vendor</span>
<span class="value" id="wifiDetailVendor" style="font-size:11px;">—</span>
</div>
</div>
<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">
<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>
```
- [ ] **Step 3: Remove old drawer CSS, add detail panel CSS**
In `static/css/index.css`, find and remove the CSS rules for:
`.wifi-detail-drawer`, `.wifi-detail-drawer.open`, `.wifi-detail-header`, `.wifi-detail-title`, `.wifi-detail-essid` (old), `.wifi-detail-bssid` (old), `.wifi-detail-close`, `.wifi-detail-content`, `.wifi-detail-grid` (old), `.wifi-detail-stat` (old).
Add the new detail panel styles:
```css
/* WiFi Detail Panel */
.wifi-detail-inner {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
height: 100%;
}
.wifi-detail-head { display: flex; flex-direction: column; gap: 3px; }
.wifi-detail-essid {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
word-break: break-word;
}
.wifi-detail-bssid {
font-size: 10px;
font-family: monospace;
color: var(--text-dim);
}
.wifi-detail-signal-bar {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 8px 10px;
}
.wifi-detail-signal-labels {
display: flex;
justify-content: space-between;
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.wifi-detail-signal-track {
height: 6px;
background: var(--bg-elevated);
border-radius: 3px;
overflow: hidden;
}
.wifi-detail-signal-fill {
height: 100%;
border-radius: 3px;
background: linear-gradient(90deg, var(--accent-green), var(--accent-orange));
transition: width 0.3s;
}
.wifi-detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.wifi-detail-stat {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 6px 8px;
}
.wifi-detail-stat .label {
display: block;
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 2px;
}
.wifi-detail-stat .value {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.wifi-detail-actions {
display: flex;
gap: 6px;
margin-top: auto;
padding-top: 4px;
}
.wifi-detail-close-btn {
padding: 7px 12px;
font-family: inherit;
font-size: 10px;
background: var(--bg-secondary);
color: var(--text-dim);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
transition: color 0.15s;
}
.wifi-detail-close-btn:hover { color: var(--text-primary); }
```
- [ ] **Step 4: Update `cacheDOM()` — remove `detailDrawer`, add new detail elements**
Remove:
```js
detailDrawer: document.getElementById('wifiDetailDrawer'),
```
Add:
```js
detailSignalFill: document.getElementById('wifiDetailSignalFill'),
```
(All other detail element IDs are unchanged and already in `cacheDOM()`.)
- [ ] **Step 5: Rewrite `selectNetwork()` to show right panel detail view**
Replace `selectNetwork(bssid)` (~line 1241):
```js
function selectNetwork(bssid) {
selectedBssid = bssid;
// Highlight selected row
elements.networkList?.querySelectorAll('.network-row').forEach(row => {
row.classList.toggle('selected', row.dataset.bssid === bssid);
});
// Show detail in right panel
if (elements.heatmapView) elements.heatmapView.style.display = 'none';
if (elements.detailView) elements.detailView.style.display = 'flex';
if (elements.rightPanelTitle) elements.rightPanelTitle.textContent = 'Network Detail';
if (elements.detailBackBtn) elements.detailBackBtn.style.display = 'inline-block';
updateDetailPanel(bssid);
}
```
- [ ] **Step 6: Rewrite `closeDetail()` to restore heatmap**
Replace `closeDetail()` (~line 1315):
```js
function closeDetail() {
selectedBssid = null;
// Deselect all rows
elements.networkList?.querySelectorAll('.network-row').forEach(row => {
row.classList.remove('selected');
});
// Restore heatmap in right panel
if (elements.detailView) elements.detailView.style.display = 'none';
if (elements.heatmapView) elements.heatmapView.style.display = 'flex';
if (elements.rightPanelTitle) elements.rightPanelTitle.textContent = 'Channel Heatmap';
if (elements.detailBackBtn) elements.detailBackBtn.style.display = 'none';
}
```
- [ ] **Step 7: Update `updateDetailPanel()` — remove drawer reference, add signal bar**
In `updateDetailPanel()` (~line 1262), remove the drawer guard and the `elements.detailDrawer.classList.add('open')` call:
```js
function updateDetailPanel(bssid, options = {}) {
const { refreshClients = true } = options;
// (remove the 'if (!elements.detailDrawer) return;' guard)
const network = networks.get(bssid);
if (!network) {
closeDetail();
return;
}
// ... existing field updates (detailEssid, detailBssid, detailRssi, etc.) ...
// Add signal bar width update:
if (elements.detailSignalFill) {
const rssi = network.rssi_current;
const pct = rssi != null ? Math.max(0, Math.min(100, (rssi + 100) / 80 * 100)) : 0;
elements.detailSignalFill.style.width = pct.toFixed(1) + '%';
}
// Remove: elements.detailDrawer.classList.add('open');
if (refreshClients) {
fetchClientsForNetwork(network.bssid);
}
}
```
Also update the `scheduleRender` block (line ~1095):
```js
// old: updateDetailPanel(selectedNetwork, ...)
// new:
if (pendingRender.detail && selectedBssid) {
updateDetailPanel(selectedBssid, { refreshClients: false });
}
```
- [ ] **Step 8: Verify**
```bash
sudo -E venv/bin/python intercept.py
```
Open `http://localhost:5050/?mode=wifi`. Check:
- Clicking a network row: right panel transitions to "Network Detail" with ← Back button
- SSID, BSSID, signal bar, channel, band, security, cipher, clients, first seen, vendor all populate
- Signal bar width reflects RSSI (48 dBm ≈ 65% width)
- "← Back" button and "Close" button both return to heatmap view
- If the network updates while detail is open, the detail refreshes (via `scheduleRender({ detail: true })`)
- "Locate AP" button switches to wifi_locate mode (requires wifi_locate to be loaded)
- [ ] **Step 9: Run the full test suite to check for regressions**
```bash
cd /Users/jsmith/Documents/Dev/intercept
pytest tests/ -x -q 2>&1 | tail -20
```
Expected: all existing Python tests pass (none of them test frontend HTML/JS directly).
- [ ] **Step 10: Commit**
```bash
git add templates/index.html static/css/index.css static/js/modes/wifi.js
git commit -m "feat(wifi): network detail panel replaces slide-up drawer"
```
---
## Done
All five tasks complete. The WiFi scanner now has:
- Animated sweep radar
- Richer network rows with signal bars and colour-coded threat borders
- Channel heatmap with congestion history
- Security ring chart
- Right-panel network detail view
Final smoke check: open `http://localhost:5050/?mode=wifi`, run a Quick Scan, click a network, verify all panels update correctly, click ← Back, verify heatmap returns.