Files
intercept/docs/superpowers/plans/2026-03-27-bluetooth-ui-polish.md
2026-03-27 16:48:08 +00:00

33 KiB
Raw Blame History

Bluetooth UI Polish Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Apply the WiFi scanner's visual polish to the Bluetooth scanner: WiFi-style 2-line device rows, CSS animated radar sweep with trailing arc, and an enhanced device list header with scan indicator and sort controls.

Architecture: Pure frontend — HTML structure in templates/index.html, styles in static/css/index.css, JS logic in static/js/modes/bluetooth.js, and the shared radar component static/js/components/proximity-radar.js. Each task is independently committable and leaves the UI functional.

Tech Stack: Vanilla JS (ES6 IIFE module pattern), CSS animations, inline SVG, Flask/Jinja2 templates.


Spec & reference

  • Spec: docs/superpowers/specs/2026-03-27-bluetooth-ui-polish-design.md
  • Start the app for manual verification:
    sudo -E venv/bin/python intercept.py
    # Open http://localhost:5050/?mode=bluetooth
    

File map

File What changes
static/js/components/proximity-radar.js createSVG() — add clip path + trailing arc group + CSS class; remove animateSweep() and its call; update setPaused()
static/css/index.css Add .bt-radar-sweep + @keyframes bt-radar-rotate (~line 4410); add .bt-scan-indicator, .bt-scan-dot (~line 4836); add .bt-controls-row, .bt-sort-group, .bt-filter-group, .bt-sort-btn (~line 4944); replace .bt-device-row and its children with 2-line structure (~line 5130)
templates/index.html Add #btScanIndicator to header (~line 1189); insert .bt-controls-row between signal strip and search (~line 1228); remove old .bt-device-filters div (lines 12311237)
static/js/modes/bluetooth.js Add sortBy state; add initSortControls(); add renderAllDevices(); update initDeviceFilters() to use new #btFilterGroup; update setScanning() to drive #btScanIndicator; remove locateBtn branch from initListInteractions(); rewrite createSimpleDeviceCard()

Task 1: Proximity Radar — CSS animation + trailing glow arc

Files:

  • Modify: static/js/components/proximity-radar.js (lines 58165)
  • Modify: static/css/index.css (~line 4410, after .bt-radar-panel #btProximityRadar block)

Context

createSVG() currently renders a <line class="radar-sweep"> and then calls animateSweep() which runs a requestAnimationFrame loop that mutates the line's x2/y2 attributes each frame. We replace this with:

  • A <g class="bt-radar-sweep"> containing two trailing arc <path> elements and the sweep <line>, all clipped to the radar circle
  • A CSS @keyframes rotation on .bt-radar-sweep (same approach as the WiFi radar's .wifi-radar-sweep)
  • animateSweep() deleted entirely
  • setPaused() updated to toggle animationPlayState instead of the isPaused flag check in rotate()

Geometry (CONFIG.size = 280, so center = 140, outerRadius = center CONFIG.padding = 120):

  • Sweep line: x1=140 y1=140 x2=140 y2=20 (pointing up from centre)

  • Clip circle: cx=140 cy=140 r=120

  • 90° trailing arc (light): M140,140 L140,20 A120,120 0 0,1 260,140 Z

  • 60° trailing arc (denser): M140,140 L140,20 A120,120 0 0,1 244,200 Z (these match the proportional geometry used by the WiFi radar at its scale)

  • Step 1: Replace createSVG() sweep section

In proximity-radar.js, find and replace the lines that render the sweep and call animateSweep (the last 20 lines of createSVG(), roughly lines 97134):

Replace:

                <!-- Sweep line (animated) -->
                <line class="radar-sweep" x1="${center}" y1="${center}"
                      x2="${center}" y2="${CONFIG.padding}"
                      stroke="rgba(0, 212, 255, 0.5)" stroke-width="1" />

With (inside the template literal):

                <!-- Clip path to keep arc inside circle -->
                <clipPath id="radarClip"><circle cx="${center}" cy="${center}" r="${center - CONFIG.padding}"/></clipPath>

                <!-- CSS-animated sweep group: trailing arcs + sweep line -->
                <g class="bt-radar-sweep" clip-path="url(#radarClip)">
                    <path d="M${center},${center} L${center},${CONFIG.padding} A${center - CONFIG.padding},${center - CONFIG.padding} 0 0,1 ${center + (center - CONFIG.padding)},${center} Z"
                          fill="#00b4d8" opacity="0.035"/>
                    <path d="M${center},${center} L${center},${CONFIG.padding} A${center - CONFIG.padding},${center - CONFIG.padding} 0 0,1 ${Math.round(center + (center - CONFIG.padding) * Math.sin(Math.PI / 3))},${Math.round(center + (center - CONFIG.padding) * (1 - Math.cos(Math.PI / 3)))} Z"
                          fill="#00b4d8" opacity="0.07"/>
                    <line x1="${center}" y1="${center}" x2="${center}" y2="${CONFIG.padding}"
                          stroke="#00b4d8" stroke-width="1.5" opacity="0.75"/>
                </g>

Also add <clipPath id="radarClip"> to the <defs> block (before the closing </defs>):

                    <clipPath id="radarClip">
                        <circle cx="${center}" cy="${center}" r="${center - CONFIG.padding}"/>
                    </clipPath>
  • Step 2: Remove animateSweep() call and function

At the end of createSVG() (line ~133), remove:

        // Add sweep animation
        animateSweep();

Delete the entire animateSweep() function (lines 139165):

    /**
     * Animate the radar sweep line
     */
    function animateSweep() {
        ...
    }
  • Step 3: Update setPaused() to use CSS animationPlayState

Replace the current setPaused() (line 494):

    function setPaused(paused) {
        isPaused = paused;
    }

With:

    function setPaused(paused) {
        isPaused = paused;
        const sweep = svg?.querySelector('.bt-radar-sweep');
        if (sweep) sweep.style.animationPlayState = paused ? 'paused' : 'running';
    }
  • Step 4: Add CSS animation to index.css

In index.css, find the line .bt-radar-panel #btProximityRadar { block (line ~4402). Add the following immediately after its closing } (after line ~4409):

/* Bluetooth radar — CSS sweep animation (replaces rAF loop in proximity-radar.js) */
.bt-radar-sweep {
    transform-origin: 140px 140px;
    animation: bt-radar-rotate 3s linear infinite;
}

@keyframes bt-radar-rotate {
    from { transform: rotate(0deg); }
    to   { transform: rotate(360deg); }
}
  • Step 5: Verify manually

Start the app (sudo -E venv/bin/python intercept.py), navigate to /?mode=bluetooth, and confirm:

  • Radar sweep rotates continuously with a trailing blue glow arc

  • Clicking "Pause" stops the rotation (the sweep group freezes)

  • Clicking the filter buttons (New Only / Strongest / Unapproved) still works

  • Step 6: Commit

git add static/js/components/proximity-radar.js static/css/index.css
git commit -m "feat(bluetooth): CSS animated radar sweep with trailing glow arc"

Task 2: Device list header — scan indicator + controls row

Files:

  • Modify: templates/index.html (lines 11861237)
  • Modify: static/css/index.css (~lines 4836 and 4944)

Context

The header row (wifi-device-list-header) currently has title + count. We add a pulsing scan indicator (IDLE/SCANNING) right-aligned in that row.

Between the signal distribution strip (.bt-list-signal-strip, ends ~line 1227) and the search toolbar (.bt-device-toolbar, line 1228) we insert a new .bt-controls-row with two halves:

  • Left: sort buttons (Signal / Name / Seen / Dist), contained in #btSortGroup
  • Right: filter buttons (All / New / Named / Strong / Trackers), contained in #btFilterGroup

The old .bt-device-filters div (lines 12311237) is deleted entirely — filters move into the controls row.

  • Step 1: Add scan indicator to the header

In index.html, find the .wifi-device-list-header block for BT (~line 1186):

                        <div class="wifi-device-list-header">
                            <h5>Bluetooth Devices</h5>
                            <span class="device-count">(<span id="btDeviceListCount">0</span>)</span>
                        </div>

Replace with:

                        <div class="wifi-device-list-header">
                            <h5>Bluetooth Devices</h5>
                            <span class="device-count">(<span id="btDeviceListCount">0</span>)</span>
                            <div class="bt-scan-indicator" id="btScanIndicator">
                                <span class="bt-scan-dot" style="display:none;"></span>
                                <span class="bt-scan-text">IDLE</span>
                            </div>
                        </div>
  • Step 2: Insert controls row, remove old filter div

In index.html, find the .bt-device-toolbar and .bt-device-filters block (lines 12281237):

                        <div class="bt-device-toolbar">
                            <input type="search" id="btDeviceSearch" class="bt-device-search" placeholder="Filter by name, MAC, manufacturer...">
                        </div>
                        <div class="bt-device-filters" id="btDeviceFilters">
                            <button class="bt-filter-btn active" data-filter="all">All</button>
                            <button class="bt-filter-btn" data-filter="new">New</button>
                            <button class="bt-filter-btn" data-filter="named">Named</button>
                            <button class="bt-filter-btn" data-filter="strong">Strong</button>
                            <button class="bt-filter-btn" data-filter="trackers">Trackers</button>
                        </div>

Replace with:

                        <div class="bt-controls-row">
                            <div class="bt-sort-group" id="btSortGroup">
                                <span class="bt-sort-label">Sort</span>
                                <button class="bt-sort-btn active" data-sort="rssi">Signal</button>
                                <button class="bt-sort-btn" data-sort="name">Name</button>
                                <button class="bt-sort-btn" data-sort="seen">Seen</button>
                                <button class="bt-sort-btn" data-sort="distance">Dist</button>
                            </div>
                            <div class="bt-filter-group" id="btFilterGroup">
                                <button class="bt-filter-btn active" data-filter="all">All</button>
                                <button class="bt-filter-btn" data-filter="new">New</button>
                                <button class="bt-filter-btn" data-filter="named">Named</button>
                                <button class="bt-filter-btn" data-filter="strong">Strong</button>
                                <button class="bt-filter-btn" data-filter="trackers">Trackers</button>
                            </div>
                        </div>
                        <div class="bt-device-toolbar">
                            <input type="search" id="btDeviceSearch" class="bt-device-search" placeholder="Filter by name, MAC, manufacturer...">
                        </div>
  • Step 3: Add scan indicator CSS

In index.css, find .bt-list-summary { (~line 4837). Add the following immediately before it:

/* Bluetooth scan indicator (header) */
.bt-scan-indicator {
    margin-left: auto;
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 10px;
    color: var(--text-dim);
    letter-spacing: 0.5px;
}

.bt-scan-dot {
    width: 7px;
    height: 7px;
    border-radius: 50%;
    background: var(--accent-cyan);
    animation: bt-scan-pulse 1.2s ease-in-out infinite;
}

@keyframes bt-scan-pulse {
    0%, 100% { opacity: 1; transform: scale(1); }
    50%       { opacity: 0.4; transform: scale(0.7); }
}

.bt-scan-text {
    font-size: 10px;
    color: var(--text-dim);
    letter-spacing: 0.05em;
}

.bt-scan-text.active {
    color: var(--accent-cyan);
    font-weight: 600;
}
  • Step 4: Add controls row CSS

In index.css, find .bt-device-filters { (~line 4933). Replace the entire .bt-device-filters block (lines 49334944) and the .bt-filter-btn blocks (lines 49464969) with:

/* Bluetooth controls row: sort + filter combined */
.bt-controls-row {
    display: flex;
    align-items: stretch;
    border-bottom: 1px solid var(--border-color);
    background: var(--bg-primary);
    flex-shrink: 0;
    position: sticky;
    top: 44px;
    z-index: 3;
}

.bt-sort-group {
    display: flex;
    align-items: center;
    gap: 2px;
    padding: 5px 10px;
    border-right: 1px solid var(--border-color);
    flex-shrink: 0;
}

.bt-filter-group {
    display: flex;
    align-items: center;
    gap: 3px;
    padding: 5px 8px;
    flex-wrap: wrap;
}

.bt-sort-label {
    font-size: 9px;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: 0.05em;
    margin-right: 4px;
}

.bt-sort-btn {
    background: none;
    border: none;
    color: var(--text-dim);
    font-size: 10px;
    font-family: var(--font-mono);
    cursor: pointer;
    padding: 2px 6px;
    border-radius: 3px;
    transition: color 0.15s;
}

.bt-sort-btn:hover { color: var(--text-primary); }
.bt-sort-btn.active { color: var(--accent-cyan); background: rgba(74,163,255,0.08); }

.bt-filter-btn {
    padding: 3px 8px;
    font-size: 10px;
    font-family: var(--font-mono);
    background: none;
    border: 1px solid var(--border-color);
    border-radius: 3px;
    color: var(--text-dim);
    cursor: pointer;
    transition: all 0.15s;
}

.bt-filter-btn:hover {
    color: var(--text-primary);
    border-color: var(--border-light);
}

.bt-filter-btn.active {
    color: var(--accent-cyan);
    border-color: rgba(74,163,255,0.4);
    background: rgba(74,163,255,0.08);
}
  • Step 5: Verify manually

Reload the app, navigate to /?mode=bluetooth. Confirm:

  • Header shows "IDLE" text right-aligned (no pulsing dot yet — JS wiring is Task 3)

  • Controls row appears between signal strip and search: "Sort Signal Name Seen Dist | All New Named Strong Trackers"

  • Filter and sort buttons are styled and visually clickable (they don't work yet — JS is Task 3)

  • Old .bt-device-filters div is gone

  • Step 6: Commit

git add templates/index.html static/css/index.css
git commit -m "feat(bluetooth): scan indicator and sort+filter controls row in device list header"

Task 3: JS wiring — scan indicator, sort, filter handler, locate branch cleanup

Files:

  • Modify: static/js/modes/bluetooth.js
    • setScanning() (~line 984)
    • initDeviceFilters() (~line 130)
    • init() (~line 91)
    • initListInteractions() (~line 161)
    • Module-level state (~line 38)

Context

Four independent changes to bluetooth.js:

  1. setScanning() drives #btScanIndicator (dot visible + "SCANNING" text when scanning)
  2. initDeviceFilters() targets the new #btFilterGroup instead of #btDeviceFilters
  3. New initSortControls() + renderAllDevices() functions, called from init()
  4. The locateBtn branch in initListInteractions() is removed (no locate buttons in rows)
  • Step 1: Add sortBy state variable

In bluetooth.js, find the module-level state block (~line 38, near let currentDeviceFilter = 'all'). Add:

    let sortBy = 'rssi';

Place it directly after let currentDeviceFilter = 'all';.

  • Step 2: Update setScanning() to drive the scan indicator

In bluetooth.js, find function setScanning(scanning) (~line 984). At the end of the function body (after the statusDot/statusText block, around line 1010), add:

        // Drive the per-panel scan indicator
        const scanDot  = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-dot');
        const scanText = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-text');
        if (scanDot)  scanDot.style.display  = scanning ? 'inline-block' : 'none';
        if (scanText) {
            scanText.textContent = scanning ? 'SCANNING' : 'IDLE';
            scanText.classList.toggle('active', scanning);
        }
  • Step 3: Update initDeviceFilters() to use new container ID

In bluetooth.js, find function initDeviceFilters() (~line 130). Change:

        const filterContainer = document.getElementById('btDeviceFilters');

To:

        const filterContainer = document.getElementById('btFilterGroup');

(Everything else in the function — the click handler, search input listener — stays identical.)

  • Step 4: Add renderAllDevices() function

In bluetooth.js, add the following new function after renderDevice() (~after line 1367):

    /**
     * Re-render all devices in the current sort order, then re-apply the active filter.
     */
    function renderAllDevices() {
        if (!deviceContainer) return;
        deviceContainer.innerHTML = '';

        const sorted = [...devices.values()].sort((a, b) => {
            if (sortBy === 'rssi')     return (b.rssi_current ?? -100) - (a.rssi_current ?? -100);
            if (sortBy === 'name')     return (a.name || '\uFFFF').localeCompare(b.name || '\uFFFF');
            if (sortBy === 'seen')     return (b.seen_count || 0) - (a.seen_count || 0);
            if (sortBy === 'distance') return (a.estimated_distance_m ?? 9999) - (b.estimated_distance_m ?? 9999);
            return 0;
        });

        sorted.forEach(device => renderDevice(device, false));
        applyDeviceFilter();
        if (selectedDeviceId) highlightSelectedDevice(selectedDeviceId);
    }
  • Step 5: Add initSortControls() function

In bluetooth.js, add the following new function after initDeviceFilters() (~after line 159):

    function initSortControls() {
        const sortGroup = document.getElementById('btSortGroup');
        if (!sortGroup) return;
        sortGroup.addEventListener('click', (e) => {
            const btn = e.target.closest('.bt-sort-btn');
            if (!btn) return;
            const sort = btn.dataset.sort;
            if (!sort) return;
            sortBy = sort;
            sortGroup.querySelectorAll('.bt-sort-btn').forEach(b => b.classList.remove('active'));
            btn.classList.add('active');
            renderAllDevices();
        });
    }
  • Step 6: Call initSortControls() from init()

In bluetooth.js, find function init() (~line 91). After the line initDeviceFilters(); (~line 120), add:

        initSortControls();
  • Step 7: Remove locateBtn branch from initListInteractions()

In bluetooth.js, find function initListInteractions() (~line 161). Remove these lines from the click handler:

                const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]');
                if (locateBtn) {
                    event.preventDefault();
                    locateById(locateBtn.dataset.locateId);
                    return;
                }

The click handler body should now go directly to:

                const row = event.target.closest('.bt-device-row[data-bt-device-id]');
                if (!row) return;
                selectDevice(row.dataset.btDeviceId);
  • Step 8: Verify manually

Reload. Navigate to /?mode=bluetooth. Start a scan.

  • Header shows pulsing dot + "SCANNING" text; stops when scan ends → "IDLE"

  • Sort buttons work: clicking "Name" re-orders the device list alphabetically; "Signal" puts strongest first

  • Filter buttons work: "New" shows only new devices; "Trackers" shows only trackers

  • Clicking a device row still opens the detail panel

  • Step 9: Commit

git add static/js/modes/bluetooth.js
git commit -m "feat(bluetooth): scan indicator, sort controls, updated filter handler"

Task 4: Device row rewrite — WiFi-style 2-line layout

Files:

  • Modify: static/js/modes/bluetooth.jscreateSimpleDeviceCard() (~line 1369)
  • Modify: static/css/index.css.bt-device-row block (~lines 4790, 51305333)

Context

createSimpleDeviceCard() currently produces a 3-part layout (.bt-row-main / .bt-row-secondary / .bt-row-actions). We replace it with a 2-line WiFi-style layout:

Top line (.bt-row-top): protocol badge + device name + tracker/IRK/risk/cluster badges (left); flag badges + status dot (right)

Bottom line (.bt-row-bottom): full-width signal bar + flex meta row (manufacturer or address · distance · RSSI value)

The locate button moves out of the row entirely (it exists in the detail panel, which is unchanged).

The .bt-status-dot.known colour changes from green to grey (matching WiFi's "safe" colour logic — green was misleading for "known" devices).

CSS classes removed (no longer emitted by JS, safe to delete): .bt-row-main, .bt-row-left, .bt-row-right, .bt-rssi-container, .bt-rssi-bar-bg, .bt-rssi-bar, .bt-rssi-value, .bt-row-secondary, .bt-row-actions, .bt-row-actions .bt-locate-btn (and its :hover, :active, svg variants)

CSS classes added: .bt-row-top, .bt-row-top-left, .bt-row-top-right, .bt-row-name, .bt-unnamed, .bt-signal-bar-wrap, .bt-signal-track, .bt-signal-fill (+ .strong, .medium, .weak), .bt-row-bottom, .bt-row-meta, .bt-row-rssi (+ .strong, .medium, .weak)

  • Step 1: Update .bt-device-row base CSS

In index.css, find .bt-device-row { (~line 5130). Replace the entire block:

.bt-device-row {
    display: flex;
    flex-direction: column;
    background: var(--bg-tertiary);
    border: 1px solid var(--border-color);
    border-left: 4px solid #666;
    border-radius: 6px;
    padding: 10px 12px;
    margin-bottom: 6px;
    cursor: pointer;
    transition: all 0.15s ease;
}

With:

.bt-device-row {
    display: flex;
    flex-direction: column;
    border-left: 3px solid transparent;
    padding: 9px 12px;
    cursor: pointer;
    border-bottom: 1px solid rgba(255, 255, 255, 0.03);
    transition: background 0.12s;
}
  • Step 2: Update .bt-device-row interactive states

Find .bt-device-row:last-child, .bt-device-row:hover, .bt-device-row:focus-visible (~lines 51435155). Replace all three:

.bt-device-row:last-child {
    border-bottom: none;
}

.bt-device-row:hover { background: var(--bg-tertiary); }

.bt-device-row:focus-visible {
    outline: 1px solid var(--accent-cyan);
    outline-offset: -1px;
}

Also find .bt-device-row.selected (~line 4790). Replace:

.bt-device-row.selected {
    background: rgba(0, 212, 255, 0.1);
    border-color: var(--accent-cyan);
}

With:

.bt-device-row.selected {
    background: rgba(74, 163, 255, 0.07);
    border-left-color: var(--accent-cyan) !important;
}
  • Step 3: Remove old row-structure CSS, add new 2-line CSS

In index.css, find and delete the following blocks (lines ~51575333):

  • .bt-row-main { … }
  • .bt-row-left { … }
  • .bt-row-right { … }
  • .bt-rssi-container { … }
  • .bt-rssi-bar-bg { … }
  • .bt-rssi-bar { … }
  • .bt-rssi-value { … }
  • .bt-row-secondary { … }
  • .bt-row-actions { … }
  • .bt-row-actions .bt-locate-btn { … } (and the :hover, :active, svg variants)

In their place, add:

/* Bluetooth device row — 2-line WiFi-style layout */
.bt-row-top {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 6px;
    margin-bottom: 7px;
}

.bt-row-top-left {
    display: flex;
    align-items: center;
    gap: 5px;
    min-width: 0;
    flex: 1;
    overflow: hidden;
}

.bt-row-top-right {
    display: flex;
    align-items: center;
    gap: 4px;
    flex-shrink: 0;
}

.bt-row-name {
    font-size: 12px;
    font-weight: 600;
    color: var(--text-primary);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.bt-row-name.bt-unnamed {
    color: var(--text-dim);
    font-style: italic;
}

.bt-row-bottom {
    display: flex;
    align-items: center;
    gap: 8px;
}

.bt-signal-bar-wrap { flex: 1; }

.bt-signal-track {
    height: 4px;
    background: var(--border-color);
    border-radius: 2px;
    overflow: hidden;
}

.bt-signal-fill {
    height: 100%;
    border-radius: 2px;
    transition: width 0.4s ease;
}

.bt-signal-fill.strong { background: linear-gradient(90deg, var(--accent-green), #88d49b); }
.bt-signal-fill.medium { background: linear-gradient(90deg, var(--accent-green), var(--accent-orange)); }
.bt-signal-fill.weak   { background: linear-gradient(90deg, var(--accent-orange), var(--accent-red)); }

.bt-row-meta {
    display: flex;
    align-items: center;
    gap: 6px;
    flex-shrink: 0;
    font-size: 10px;
    color: var(--text-dim);
    white-space: nowrap;
}

.bt-row-rssi { font-family: var(--font-mono); font-size: 10px; }
.bt-row-rssi.strong { color: var(--accent-green); }
.bt-row-rssi.medium { color: var(--accent-amber, #eab308); }
.bt-row-rssi.weak   { color: var(--accent-red); }
  • Step 4: Update .bt-status-dot.known colour

Find .bt-status-dot.known (~line 5274). The current value is background: #22c55e. Change to:

.bt-status-dot.known {
    background: #484f58;
}

(Green was misleading — "known" is neutral, not safe.)

  • Step 5: Rewrite createSimpleDeviceCard()

In bluetooth.js, replace the entire body of createSimpleDeviceCard(device) (~lines 13691511) with:

    function createSimpleDeviceCard(device) {
        const protocol = device.protocol || 'ble';
        const rssi = device.rssi_current;
        const inBaseline = device.in_baseline || false;
        const isNew = !inBaseline;
        const hasName = !!device.name;
        const isTracker = device.is_tracker === true;
        const trackerType = device.tracker_type;
        const trackerConfidence = device.tracker_confidence;
        const riskScore = device.risk_score || 0;
        const agentName = device._agent || 'Local';
        const seenBefore = device.seen_before === true;

        // Signal bar
        const rssiPercent = rssi != null ? Math.max(0, Math.min(100, ((rssi + 100) / 70) * 100)) : 0;
        const fillClass = rssi == null ? 'weak'
                        : rssi >= -60 ? 'strong'
                        : rssi >= -75 ? 'medium' : 'weak';

        const displayName = device.name || formatDeviceId(device.address);
        const name = escapeHtml(displayName);
        const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown'));
        const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : '';
        const seenCount = device.seen_count || 0;
        const searchIndex = [
            displayName, device.address, device.manufacturer_name,
            device.tracker_name, device.tracker_type, agentName
        ].filter(Boolean).join(' ').toLowerCase();

        // Protocol badge
        const protoBadge = protocol === 'ble'
            ? '<span class="bt-proto-badge ble">BLE</span>'
            : '<span class="bt-proto-badge classic">CLASSIC</span>';

        // Tracker badge
        let trackerBadge = '';
        if (isTracker) {
            const confColor = trackerConfidence === 'high' ? '#ef4444'
                            : trackerConfidence === 'medium' ? '#f97316' : '#eab308';
            const confBg = trackerConfidence === 'high' ? 'rgba(239,68,68,0.15)'
                         : trackerConfidence === 'medium' ? 'rgba(249,115,22,0.15)' : 'rgba(234,179,8,0.15)';
            const typeLabel = trackerType === 'airtag' ? 'AirTag'
                            : trackerType === 'tile' ? 'Tile'
                            : trackerType === 'samsung_smarttag' ? 'SmartTag'
                            : trackerType === 'findmy_accessory' ? 'FindMy'
                            : trackerType === 'chipolo' ? 'Chipolo' : 'TRACKER';
            trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor
                + ';font-size:9px;padding:1px 5px;border-radius:3px;font-weight:600;">' + typeLabel + '</span>';
        }

        // IRK badge
        const irkBadge = device.has_irk ? '<span class="bt-irk-badge">IRK</span>' : '';

        // Risk badge
        let riskBadge = '';
        if (riskScore >= 0.3) {
            const riskColor = riskScore >= 0.5 ? '#ef4444' : '#f97316';
            riskBadge = '<span class="bt-risk-badge" style="color:' + riskColor
                + ';font-size:8px;font-weight:600;">' + Math.round(riskScore * 100) + '% RISK</span>';
        }

        // MAC cluster badge
        const clusterBadge = device.mac_cluster_count > 1
            ? '<span class="bt-mac-cluster-badge">' + device.mac_cluster_count + ' MACs</span>'
            : '';

        // Flag badges (go to top-right, before status dot)
        const hFlags = device.heuristic_flags || [];
        let flagBadges = '';
        if (device.is_persistent || hFlags.includes('persistent'))
            flagBadges += '<span class="bt-flag-badge persistent">PERSIST</span>';
        if (device.is_beacon_like || hFlags.includes('beacon_like'))
            flagBadges += '<span class="bt-flag-badge beacon-like">BEACON</span>';
        if (device.is_strong_stable || hFlags.includes('strong_stable'))
            flagBadges += '<span class="bt-flag-badge strong-stable">STABLE</span>';

        // Status dot
        let statusDot;
        if (isTracker && trackerConfidence === 'high') {
            statusDot = '<span class="bt-status-dot tracker" style="background:#ef4444;"></span>';
        } else if (isNew) {
            statusDot = '<span class="bt-status-dot new"></span>';
        } else {
            statusDot = '<span class="bt-status-dot known"></span>';
        }

        // Bottom meta items
        const metaLabel = mfr || addr;  // already HTML-escaped above
        const distM = device.estimated_distance_m;
        const distStr = distM != null ? '~' + distM.toFixed(1) + 'm' : '';
        let metaHtml = '<span>' + metaLabel + '</span>';
        if (distStr) metaHtml += '<span>' + distStr + '</span>';
        metaHtml += '<span class="bt-row-rssi ' + fillClass + '">' + (rssi != null ? rssi : '—') + '</span>';
        if (seenBefore) metaHtml += '<span class="bt-history-badge">SEEN</span>';
        if (agentName !== 'Local')
            metaHtml += '<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">'
                + escapeHtml(agentName) + '</span>';

        // Left border colour
        const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444'
                          : isTracker ? '#f97316'
                          : rssi != null && rssi >= -60 ? 'var(--accent-green)'
                          : rssi != null && rssi >= -75 ? 'var(--accent-amber, #eab308)'
                          : 'var(--accent-red)';

        return '<div class="bt-device-row' + (isTracker ? ' is-tracker' : '') + '"'
            + ' data-bt-device-id="' + escapeAttr(device.device_id) + '"'
            + ' data-is-new="' + isNew + '"'
            + ' data-has-name="' + hasName + '"'
            + ' data-rssi="' + (rssi ?? -100) + '"'
            + ' data-is-tracker="' + isTracker + '"'
            + ' data-search="' + escapeAttr(searchIndex) + '"'
            + ' role="button" tabindex="0" data-keyboard-activate="true"'
            + ' style="border-left-color:' + borderColor + ';">'
            // Top line
            + '<div class="bt-row-top">'
                + '<div class="bt-row-top-left">'
                    + protoBadge
                    + '<span class="bt-row-name' + (hasName ? '' : ' bt-unnamed') + '">' + name + '</span>'
                    + trackerBadge + irkBadge + riskBadge + clusterBadge
                + '</div>'
                + '<div class="bt-row-top-right">'
                    + flagBadges + statusDot
                + '</div>'
            + '</div>'
            // Bottom line
            + '<div class="bt-row-bottom">'
                + '<div class="bt-signal-bar-wrap">'
                    + '<div class="bt-signal-track">'
                        + '<div class="bt-signal-fill ' + fillClass + '" style="width:' + rssiPercent.toFixed(1) + '%"></div>'
                    + '</div>'
                + '</div>'
                + '<div class="bt-row-meta">' + metaHtml + '</div>'
            + '</div>'
        + '</div>';
    }
  • Step 6: Verify manually

Reload and start a scan. Confirm:

  • Each device row has two lines: name + badges on top, signal bar + meta on bottom

  • Locate button is gone from rows; still present in the detail panel (right-click a device, check the detail panel at left)

  • Strong signal rows have green bar + green RSSI; medium amber; weak red

  • Tracker rows have red left border; AirTag/Tile labels show

  • Unnamed devices show address in italic grey

  • Selecting a row highlights it in cyan; detail panel populates

  • PERSIST, BEACON, STABLE flag badges appear top-right when set

  • Step 7: Run backend tests to confirm no regressions

pytest tests/test_bluetooth.py tests/test_bluetooth_api.py -v

Expected: all pass (frontend-only change, backend untouched).

  • Step 8: Commit
git add static/js/modes/bluetooth.js static/css/index.css
git commit -m "feat(bluetooth): WiFi-style 2-line device rows"