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

51 KiB
Raw Blame History

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:
    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:

<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:

.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:

// Status bar
scanStatus: document.getElementById('wifiScanStatus'),

With:

// 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:

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:

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

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:

<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:

/* 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:

networkTable: document.getElementById('wifiNetworkTable'),
networkTableBody: document.getElementById('wifiNetworkTableBody'),

With:

networkList: document.getElementById('wifiNetworkList'),
  • Step 4: Rename selectedNetworkselectedBssid 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:

// old:
if (!elements.networkTableBody) return;
// new:
if (!elements.networkList) return;

Replace the empty-state block (the if (filtered.length === 0) section) with:

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:

// 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:

// 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):

// old: if (pendingRender.table) updateNetworkTable();
if (pendingRender.table) renderNetworks();
  • Step 6: Rewrite createNetworkRow() to produce div rows

Replace the entire createNetworkRow(network) function body:

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:

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:

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:

// 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:

elements.networkList?.querySelectorAll('.network-row').forEach(row => {
    row.classList.remove('selected');
});
  • Step 10: Verify
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

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:

<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:

.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:

// 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:

if (pendingRender.radar) updateProximityRadar();

With:

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

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:

<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:

/* 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:

channelChart: document.getElementById('wifiChannelChart'),
channelBandTabs: document.getElementById('wifiChannelBandTabs'),
wpa3Count: document.getElementById('wpa3Count'),
wpa2Count: document.getElementById('wpa2Count'),
wepCount: document.getElementById('wepCount'),

Add:

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:

let channelHistory = []; // max 10 entries, each { timestamp, channels: {1:N,...,11:N} }
  • Step 5: Add heatmap initialisation (channel labels)

Replace initChannelChart() with:

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():

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:

// 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:

renderHeatmap();
renderSecurityRing(Array.from(networks.values()));
  • Step 8: Remove updateChannelChart() call from scheduleRender()

In scheduleRender()'s animation frame, replace:

if (pendingRender.chart) updateChannelChart();

With nothing (delete this line). The heatmap is now updated from within renderNetworks().

  • Step 9: Verify
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

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:

<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:

/* 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:

detailDrawer: document.getElementById('wifiDetailDrawer'),

Add:

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):

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):

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:

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):

// old: updateDetailPanel(selectedNetwork, ...)
// new:
if (pendingRender.detail && selectedBssid) {
    updateDetailPanel(selectedBssid, { refreshClients: false });
}
  • Step 8: Verify
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

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
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.