# Bluetooth UI Polish Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Apply the WiFi scanner's visual polish to the Bluetooth scanner: WiFi-style 2-line device rows, CSS animated radar sweep with trailing arc, and an enhanced device list header with scan indicator and sort controls. **Architecture:** Pure frontend — HTML structure in `templates/index.html`, styles in `static/css/index.css`, JS logic in `static/js/modes/bluetooth.js`, and the shared radar component `static/js/components/proximity-radar.js`. Each task is independently committable and leaves the UI functional. **Tech Stack:** Vanilla JS (ES6 IIFE module pattern), CSS animations, inline SVG, Flask/Jinja2 templates. --- ## Spec & reference - **Spec:** `docs/superpowers/specs/2026-03-27-bluetooth-ui-polish-design.md` - **Start the app for manual verification:** ```bash sudo -E venv/bin/python intercept.py # Open http://localhost:5050/?mode=bluetooth ``` ## File map | File | What changes | |---|---| | `static/js/components/proximity-radar.js` | `createSVG()` — add clip path + trailing arc group + CSS class; remove `animateSweep()` and its call; update `setPaused()` | | `static/css/index.css` | Add `.bt-radar-sweep` + `@keyframes bt-radar-rotate` (~line 4410); add `.bt-scan-indicator`, `.bt-scan-dot` (~line 4836); add `.bt-controls-row`, `.bt-sort-group`, `.bt-filter-group`, `.bt-sort-btn` (~line 4944); replace `.bt-device-row` and its children with 2-line structure (~line 5130) | | `templates/index.html` | Add `#btScanIndicator` to header (~line 1189); insert `.bt-controls-row` between signal strip and search (~line 1228); remove old `.bt-device-filters` div (lines 1231–1237) | | `static/js/modes/bluetooth.js` | Add `sortBy` state; add `initSortControls()`; add `renderAllDevices()`; update `initDeviceFilters()` to use new `#btFilterGroup`; update `setScanning()` to drive `#btScanIndicator`; remove `locateBtn` branch from `initListInteractions()`; rewrite `createSimpleDeviceCard()` | --- ## Task 1: Proximity Radar — CSS animation + trailing glow arc **Files:** - Modify: `static/js/components/proximity-radar.js` (lines 58–165) - Modify: `static/css/index.css` (~line 4410, after `.bt-radar-panel #btProximityRadar` block) ### Context `createSVG()` currently renders a `` and then calls `animateSweep()` which runs a `requestAnimationFrame` loop that mutates the line's `x2`/`y2` attributes each frame. We replace this with: - A `` containing two trailing arc `` elements and the sweep ``, all clipped to the radar circle - A CSS `@keyframes` rotation on `.bt-radar-sweep` (same approach as the WiFi radar's `.wifi-radar-sweep`) - `animateSweep()` deleted entirely - `setPaused()` updated to toggle `animationPlayState` instead of the `isPaused` flag check in `rotate()` **Geometry** (`CONFIG.size = 280`, so `center = 140`, `outerRadius = center − CONFIG.padding = 120`): - Sweep line: `x1=140 y1=140 x2=140 y2=20` (pointing up from centre) - Clip circle: `cx=140 cy=140 r=120` - 90° trailing arc (light): `M140,140 L140,20 A120,120 0 0,1 260,140 Z` - 60° trailing arc (denser): `M140,140 L140,20 A120,120 0 0,1 244,200 Z` _(these match the proportional geometry used by the WiFi radar at its scale)_ - [ ] **Step 1: Replace `createSVG()` sweep section** In `proximity-radar.js`, find and replace the lines that render the sweep and call `animateSweep` (the last 20 lines of `createSVG()`, roughly lines 97–134): Replace: ```js ``` With (inside the template literal): ```js ``` Also add `` to the `` block (before the closing ``): ```js ``` - [ ] **Step 2: Remove `animateSweep()` call and function** At the end of `createSVG()` (line ~133), remove: ```js // Add sweep animation animateSweep(); ``` Delete the entire `animateSweep()` function (lines 139–165): ```js /** * Animate the radar sweep line */ function animateSweep() { ... } ``` - [ ] **Step 3: Update `setPaused()` to use CSS animationPlayState** Replace the current `setPaused()` (line 494): ```js function setPaused(paused) { isPaused = paused; } ``` With: ```js function setPaused(paused) { isPaused = paused; const sweep = svg?.querySelector('.bt-radar-sweep'); if (sweep) sweep.style.animationPlayState = paused ? 'paused' : 'running'; } ``` - [ ] **Step 4: Add CSS animation to `index.css`** In `index.css`, find the line `.bt-radar-panel #btProximityRadar {` block (line ~4402). Add the following immediately after its closing `}` (after line ~4409): ```css /* Bluetooth radar — CSS sweep animation (replaces rAF loop in proximity-radar.js) */ .bt-radar-sweep { transform-origin: 140px 140px; animation: bt-radar-rotate 3s linear infinite; } @keyframes bt-radar-rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } ``` - [ ] **Step 5: Verify manually** Start the app (`sudo -E venv/bin/python intercept.py`), navigate to `/?mode=bluetooth`, and confirm: - Radar sweep rotates continuously with a trailing blue glow arc - Clicking "Pause" stops the rotation (the sweep group freezes) - Clicking the filter buttons (New Only / Strongest / Unapproved) still works - [ ] **Step 6: Commit** ```bash git add static/js/components/proximity-radar.js static/css/index.css git commit -m "feat(bluetooth): CSS animated radar sweep with trailing glow arc" ``` --- ## Task 2: Device list header — scan indicator + controls row **Files:** - Modify: `templates/index.html` (lines 1186–1237) - Modify: `static/css/index.css` (~lines 4836 and 4944) ### Context The header row (`wifi-device-list-header`) currently has title + count. We add a pulsing scan indicator (IDLE/SCANNING) right-aligned in that row. Between the signal distribution strip (`.bt-list-signal-strip`, ends ~line 1227) and the search toolbar (`.bt-device-toolbar`, line 1228) we insert a new `.bt-controls-row` with two halves: - Left: sort buttons (Signal / Name / Seen / Dist), contained in `#btSortGroup` - Right: filter buttons (All / New / Named / Strong / Trackers), contained in `#btFilterGroup` The old `.bt-device-filters` div (lines 1231–1237) is deleted entirely — filters move into the controls row. - [ ] **Step 1: Add scan indicator to the header** In `index.html`, find the `.wifi-device-list-header` block for BT (~line 1186): ```html
Bluetooth Devices
(0)
``` Replace with: ```html
Bluetooth Devices
(0)
IDLE
``` - [ ] **Step 2: Insert controls row, remove old filter div** In `index.html`, find the `.bt-device-toolbar` and `.bt-device-filters` block (lines 1228–1237): ```html
``` Replace with: ```html
Sort
``` - [ ] **Step 3: Add scan indicator CSS** In `index.css`, find `.bt-list-summary {` (~line 4837). Add the following immediately before it: ```css /* Bluetooth scan indicator (header) */ .bt-scan-indicator { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 10px; color: var(--text-dim); letter-spacing: 0.5px; } .bt-scan-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent-cyan); animation: bt-scan-pulse 1.2s ease-in-out infinite; } @keyframes bt-scan-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.7); } } .bt-scan-text { font-size: 10px; color: var(--text-dim); letter-spacing: 0.05em; } .bt-scan-text.active { color: var(--accent-cyan); font-weight: 600; } ``` - [ ] **Step 4: Add controls row CSS** In `index.css`, find `.bt-device-filters {` (~line 4933). Replace the entire `.bt-device-filters` block (lines 4933–4944) and the `.bt-filter-btn` blocks (lines 4946–4969) with: ```css /* Bluetooth controls row: sort + filter combined */ .bt-controls-row { display: flex; align-items: stretch; border-bottom: 1px solid var(--border-color); background: var(--bg-primary); flex-shrink: 0; position: sticky; top: 44px; z-index: 3; } .bt-sort-group { display: flex; align-items: center; gap: 2px; padding: 5px 10px; border-right: 1px solid var(--border-color); flex-shrink: 0; } .bt-filter-group { display: flex; align-items: center; gap: 3px; padding: 5px 8px; flex-wrap: wrap; } .bt-sort-label { font-size: 9px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; margin-right: 4px; } .bt-sort-btn { background: none; border: none; color: var(--text-dim); font-size: 10px; font-family: var(--font-mono); cursor: pointer; padding: 2px 6px; border-radius: 3px; transition: color 0.15s; } .bt-sort-btn:hover { color: var(--text-primary); } .bt-sort-btn.active { color: var(--accent-cyan); background: rgba(74,163,255,0.08); } .bt-filter-btn { padding: 3px 8px; font-size: 10px; font-family: var(--font-mono); background: none; border: 1px solid var(--border-color); border-radius: 3px; color: var(--text-dim); cursor: pointer; transition: all 0.15s; } .bt-filter-btn:hover { color: var(--text-primary); border-color: var(--border-light); } .bt-filter-btn.active { color: var(--accent-cyan); border-color: rgba(74,163,255,0.4); background: rgba(74,163,255,0.08); } ``` - [ ] **Step 5: Verify manually** Reload the app, navigate to `/?mode=bluetooth`. Confirm: - Header shows "IDLE" text right-aligned (no pulsing dot yet — JS wiring is Task 3) - Controls row appears between signal strip and search: "Sort Signal Name Seen Dist | All New Named Strong Trackers" - Filter and sort buttons are styled and visually clickable (they don't work yet — JS is Task 3) - Old `.bt-device-filters` div is gone - [ ] **Step 6: Commit** ```bash git add templates/index.html static/css/index.css git commit -m "feat(bluetooth): scan indicator and sort+filter controls row in device list header" ``` --- ## Task 3: JS wiring — scan indicator, sort, filter handler, locate branch cleanup **Files:** - Modify: `static/js/modes/bluetooth.js` - `setScanning()` (~line 984) - `initDeviceFilters()` (~line 130) - `init()` (~line 91) - `initListInteractions()` (~line 161) - Module-level state (~line 38) ### Context Four independent changes to `bluetooth.js`: 1. `setScanning()` drives `#btScanIndicator` (dot visible + "SCANNING" text when scanning) 2. `initDeviceFilters()` targets the new `#btFilterGroup` instead of `#btDeviceFilters` 3. New `initSortControls()` + `renderAllDevices()` functions, called from `init()` 4. The `locateBtn` branch in `initListInteractions()` is removed (no locate buttons in rows) - [ ] **Step 1: Add `sortBy` state variable** In `bluetooth.js`, find the module-level state block (~line 38, near `let currentDeviceFilter = 'all'`). Add: ```js let sortBy = 'rssi'; ``` Place it directly after `let currentDeviceFilter = 'all';`. - [ ] **Step 2: Update `setScanning()` to drive the scan indicator** In `bluetooth.js`, find `function setScanning(scanning)` (~line 984). At the end of the function body (after the `statusDot`/`statusText` block, around line 1010), add: ```js // Drive the per-panel scan indicator const scanDot = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-dot'); const scanText = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-text'); if (scanDot) scanDot.style.display = scanning ? 'inline-block' : 'none'; if (scanText) { scanText.textContent = scanning ? 'SCANNING' : 'IDLE'; scanText.classList.toggle('active', scanning); } ``` - [ ] **Step 3: Update `initDeviceFilters()` to use new container ID** In `bluetooth.js`, find `function initDeviceFilters()` (~line 130). Change: ```js const filterContainer = document.getElementById('btDeviceFilters'); ``` To: ```js const filterContainer = document.getElementById('btFilterGroup'); ``` (Everything else in the function — the click handler, search input listener — stays identical.) - [ ] **Step 4: Add `renderAllDevices()` function** In `bluetooth.js`, add the following new function after `renderDevice()` (~after line 1367): ```js /** * Re-render all devices in the current sort order, then re-apply the active filter. */ function renderAllDevices() { if (!deviceContainer) return; deviceContainer.innerHTML = ''; const sorted = [...devices.values()].sort((a, b) => { if (sortBy === 'rssi') return (b.rssi_current ?? -100) - (a.rssi_current ?? -100); if (sortBy === 'name') return (a.name || '\uFFFF').localeCompare(b.name || '\uFFFF'); if (sortBy === 'seen') return (b.seen_count || 0) - (a.seen_count || 0); if (sortBy === 'distance') return (a.estimated_distance_m ?? 9999) - (b.estimated_distance_m ?? 9999); return 0; }); sorted.forEach(device => renderDevice(device, false)); applyDeviceFilter(); if (selectedDeviceId) highlightSelectedDevice(selectedDeviceId); } ``` - [ ] **Step 5: Add `initSortControls()` function** In `bluetooth.js`, add the following new function after `initDeviceFilters()` (~after line 159): ```js function initSortControls() { const sortGroup = document.getElementById('btSortGroup'); if (!sortGroup) return; sortGroup.addEventListener('click', (e) => { const btn = e.target.closest('.bt-sort-btn'); if (!btn) return; const sort = btn.dataset.sort; if (!sort) return; sortBy = sort; sortGroup.querySelectorAll('.bt-sort-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); renderAllDevices(); }); } ``` - [ ] **Step 6: Call `initSortControls()` from `init()`** In `bluetooth.js`, find `function init()` (~line 91). After the line `initDeviceFilters();` (~line 120), add: ```js initSortControls(); ``` - [ ] **Step 7: Remove `locateBtn` branch from `initListInteractions()`** In `bluetooth.js`, find `function initListInteractions()` (~line 161). Remove these lines from the click handler: ```js const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]'); if (locateBtn) { event.preventDefault(); locateById(locateBtn.dataset.locateId); return; } ``` The click handler body should now go directly to: ```js const row = event.target.closest('.bt-device-row[data-bt-device-id]'); if (!row) return; selectDevice(row.dataset.btDeviceId); ``` - [ ] **Step 8: Verify manually** Reload. Navigate to `/?mode=bluetooth`. Start a scan. - Header shows pulsing dot + "SCANNING" text; stops when scan ends → "IDLE" - Sort buttons work: clicking "Name" re-orders the device list alphabetically; "Signal" puts strongest first - Filter buttons work: "New" shows only new devices; "Trackers" shows only trackers - Clicking a device row still opens the detail panel - [ ] **Step 9: Commit** ```bash git add static/js/modes/bluetooth.js git commit -m "feat(bluetooth): scan indicator, sort controls, updated filter handler" ``` --- ## Task 4: Device row rewrite — WiFi-style 2-line layout **Files:** - Modify: `static/js/modes/bluetooth.js` — `createSimpleDeviceCard()` (~line 1369) - Modify: `static/css/index.css` — `.bt-device-row` block (~lines 4790, 5130–5333) ### Context `createSimpleDeviceCard()` currently produces a 3-part layout (`.bt-row-main` / `.bt-row-secondary` / `.bt-row-actions`). We replace it with a 2-line WiFi-style layout: **Top line (`.bt-row-top`):** protocol badge + device name + tracker/IRK/risk/cluster badges (left); flag badges + status dot (right) **Bottom line (`.bt-row-bottom`):** full-width signal bar + flex meta row (manufacturer or address · distance · RSSI value) The locate button moves out of the row entirely (it exists in the detail panel, which is unchanged). The `.bt-status-dot.known` colour changes from green to grey (matching WiFi's "safe" colour logic — green was misleading for "known" devices). **CSS classes removed** (no longer emitted by JS, safe to delete): `.bt-row-main`, `.bt-row-left`, `.bt-row-right`, `.bt-rssi-container`, `.bt-rssi-bar-bg`, `.bt-rssi-bar`, `.bt-rssi-value`, `.bt-row-secondary`, `.bt-row-actions`, `.bt-row-actions .bt-locate-btn` (and its `:hover`, `:active`, `svg` variants) **CSS classes added:** `.bt-row-top`, `.bt-row-top-left`, `.bt-row-top-right`, `.bt-row-name`, `.bt-unnamed`, `.bt-signal-bar-wrap`, `.bt-signal-track`, `.bt-signal-fill` (+ `.strong`, `.medium`, `.weak`), `.bt-row-bottom`, `.bt-row-meta`, `.bt-row-rssi` (+ `.strong`, `.medium`, `.weak`) - [ ] **Step 1: Update `.bt-device-row` base CSS** In `index.css`, find `.bt-device-row {` (~line 5130). Replace the entire block: ```css .bt-device-row { display: flex; flex-direction: column; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-left: 4px solid #666; border-radius: 6px; padding: 10px 12px; margin-bottom: 6px; cursor: pointer; transition: all 0.15s ease; } ``` With: ```css .bt-device-row { display: flex; flex-direction: column; border-left: 3px solid transparent; padding: 9px 12px; cursor: pointer; border-bottom: 1px solid rgba(255, 255, 255, 0.03); transition: background 0.12s; } ``` - [ ] **Step 2: Update `.bt-device-row` interactive states** Find `.bt-device-row:last-child`, `.bt-device-row:hover`, `.bt-device-row:focus-visible` (~lines 5143–5155). Replace all three: ```css .bt-device-row:last-child { border-bottom: none; } .bt-device-row:hover { background: var(--bg-tertiary); } .bt-device-row:focus-visible { outline: 1px solid var(--accent-cyan); outline-offset: -1px; } ``` Also find `.bt-device-row.selected` (~line 4790). Replace: ```css .bt-device-row.selected { background: rgba(0, 212, 255, 0.1); border-color: var(--accent-cyan); } ``` With: ```css .bt-device-row.selected { background: rgba(74, 163, 255, 0.07); border-left-color: var(--accent-cyan) !important; } ``` - [ ] **Step 3: Remove old row-structure CSS, add new 2-line CSS** In `index.css`, find and delete the following blocks (lines ~5157–5333): - `.bt-row-main { … }` - `.bt-row-left { … }` - `.bt-row-right { … }` - `.bt-rssi-container { … }` - `.bt-rssi-bar-bg { … }` - `.bt-rssi-bar { … }` - `.bt-rssi-value { … }` - `.bt-row-secondary { … }` - `.bt-row-actions { … }` - `.bt-row-actions .bt-locate-btn { … }` (and the `:hover`, `:active`, `svg` variants) In their place, add: ```css /* Bluetooth device row — 2-line WiFi-style layout */ .bt-row-top { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 7px; } .bt-row-top-left { display: flex; align-items: center; gap: 5px; min-width: 0; flex: 1; overflow: hidden; } .bt-row-top-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; } .bt-row-name { font-size: 12px; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .bt-row-name.bt-unnamed { color: var(--text-dim); font-style: italic; } .bt-row-bottom { display: flex; align-items: center; gap: 8px; } .bt-signal-bar-wrap { flex: 1; } .bt-signal-track { height: 4px; background: var(--border-color); border-radius: 2px; overflow: hidden; } .bt-signal-fill { height: 100%; border-radius: 2px; transition: width 0.4s ease; } .bt-signal-fill.strong { background: linear-gradient(90deg, var(--accent-green), #88d49b); } .bt-signal-fill.medium { background: linear-gradient(90deg, var(--accent-green), var(--accent-orange)); } .bt-signal-fill.weak { background: linear-gradient(90deg, var(--accent-orange), var(--accent-red)); } .bt-row-meta { display: flex; align-items: center; gap: 6px; flex-shrink: 0; font-size: 10px; color: var(--text-dim); white-space: nowrap; } .bt-row-rssi { font-family: var(--font-mono); font-size: 10px; } .bt-row-rssi.strong { color: var(--accent-green); } .bt-row-rssi.medium { color: var(--accent-amber, #eab308); } .bt-row-rssi.weak { color: var(--accent-red); } ``` - [ ] **Step 4: Update `.bt-status-dot.known` colour** Find `.bt-status-dot.known` (~line 5274). The current value is `background: #22c55e`. Change to: ```css .bt-status-dot.known { background: #484f58; } ``` (Green was misleading — "known" is neutral, not safe.) - [ ] **Step 5: Rewrite `createSimpleDeviceCard()`** In `bluetooth.js`, replace the entire body of `createSimpleDeviceCard(device)` (~lines 1369–1511) with: ```js function createSimpleDeviceCard(device) { const protocol = device.protocol || 'ble'; const rssi = device.rssi_current; const inBaseline = device.in_baseline || false; const isNew = !inBaseline; const hasName = !!device.name; const isTracker = device.is_tracker === true; const trackerType = device.tracker_type; const trackerConfidence = device.tracker_confidence; const riskScore = device.risk_score || 0; const agentName = device._agent || 'Local'; const seenBefore = device.seen_before === true; // Signal bar const rssiPercent = rssi != null ? Math.max(0, Math.min(100, ((rssi + 100) / 70) * 100)) : 0; const fillClass = rssi == null ? 'weak' : rssi >= -60 ? 'strong' : rssi >= -75 ? 'medium' : 'weak'; const displayName = device.name || formatDeviceId(device.address); const name = escapeHtml(displayName); const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown')); const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : ''; const seenCount = device.seen_count || 0; const searchIndex = [ displayName, device.address, device.manufacturer_name, device.tracker_name, device.tracker_type, agentName ].filter(Boolean).join(' ').toLowerCase(); // Protocol badge const protoBadge = protocol === 'ble' ? 'BLE' : 'CLASSIC'; // 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 = '' + typeLabel + ''; } // IRK badge const irkBadge = device.has_irk ? 'IRK' : ''; // Risk badge let riskBadge = ''; if (riskScore >= 0.3) { const riskColor = riskScore >= 0.5 ? '#ef4444' : '#f97316'; riskBadge = '' + Math.round(riskScore * 100) + '% RISK'; } // MAC cluster badge const clusterBadge = device.mac_cluster_count > 1 ? '' + device.mac_cluster_count + ' MACs' : ''; // Flag badges (go to top-right, before status dot) const hFlags = device.heuristic_flags || []; let flagBadges = ''; if (device.is_persistent || hFlags.includes('persistent')) flagBadges += 'PERSIST'; if (device.is_beacon_like || hFlags.includes('beacon_like')) flagBadges += 'BEACON'; if (device.is_strong_stable || hFlags.includes('strong_stable')) flagBadges += 'STABLE'; // Status dot let statusDot; if (isTracker && trackerConfidence === 'high') { statusDot = ''; } else if (isNew) { statusDot = ''; } else { statusDot = ''; } // 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 = '' + metaLabel + ''; if (distStr) metaHtml += '' + distStr + ''; metaHtml += '' + (rssi != null ? rssi : '—') + ''; if (seenBefore) metaHtml += 'SEEN'; if (agentName !== 'Local') metaHtml += '' + escapeHtml(agentName) + ''; // 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 '
' // Top line + '
' + '
' + protoBadge + '' + name + '' + trackerBadge + irkBadge + riskBadge + clusterBadge + '
' + '
' + flagBadges + statusDot + '
' + '
' // Bottom line + '
' + '
' + '
' + '
' + '
' + '
' + '
' + metaHtml + '
' + '
' + '
'; } ``` - [ ] **Step 6: Verify manually** Reload and start a scan. Confirm: - Each device row has two lines: name + badges on top, signal bar + meta on bottom - Locate button is gone from rows; still present in the detail panel (right-click a device, check the detail panel at left) - Strong signal rows have green bar + green RSSI; medium amber; weak red - Tracker rows have red left border; AirTag/Tile labels show - Unnamed devices show address in italic grey - Selecting a row highlights it in cyan; detail panel populates - `PERSIST`, `BEACON`, `STABLE` flag badges appear top-right when set - [ ] **Step 7: Run backend tests to confirm no regressions** ```bash pytest tests/test_bluetooth.py tests/test_bluetooth_api.py -v ``` Expected: all pass (frontend-only change, backend untouched). - [ ] **Step 8: Commit** ```bash git add static/js/modes/bluetooth.js static/css/index.css git commit -m "feat(bluetooth): WiFi-style 2-line device rows" ```