11 KiB
Bluetooth UI Polish — Apply WiFi Treatment
Date: 2026-03-27 Status: Approved Scope: Frontend only — CSS, JS, HTML. No backend changes.
Overview
Apply the same visual polish introduced in the WiFi scanner redesign to the Bluetooth scanner. Three areas are updated: device rows, proximity radar, and the device list header. The signal distribution strip is retained.
Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Row structure | Match WiFi 2-line layout closely | Visual consistency across modes |
| Locate button placement | Remove from rows, keep in detail panel only | Rows are already information-dense; locate is a detail-panel action |
| Radar sweep | CSS animation + trailing glow arc | Matches WiFi, replaces rAF loop in ProximityRadar |
| Header additions | Scan indicator + sort controls | Matches WiFi; sort by Signal / Name / Seen / Distance |
| Signal distribution strip | Keep | Provides a useful at-a-glance signal breakdown not present in WiFi |
| Sort/filter layout | Sort group + filter group on one combined controls row | Saves vertical space vs separate rows |
CSS Tokens
Reuse tokens already defined in index.css :root — no new tokens needed:
--bg-primary, --bg-secondary, --bg-tertiary, --bg-card, --border-color, --border-light, --text-primary, --text-secondary, --text-dim, --accent-cyan, --accent-cyan-dim, --accent-green, --accent-green-dim, --accent-red, --accent-red-dim, --accent-orange, --accent-amber-dim.
Hardcoded literals used in WiFi are reused here for consistency:
- Selected row tint:
rgba(74, 163, 255, 0.07) - Radar arc fill:
rgba(0, 180, 216, N)(same as WiFi's#00b4d8)
Component Designs
1. Device List Header
Scan indicator — new <div class="bt-scan-indicator" id="btScanIndicator"> added to .wifi-device-list-header (right-aligned via margin-left: auto):
<div class="bt-scan-indicator" id="btScanIndicator">
<span class="bt-scan-dot"></span>
<span class="bt-scan-text">IDLE</span>
</div>
.bt-scan-dot is 7×7px circle, animation: bt-scan-pulse 1.2s ease-in-out infinite (opacity/scale identical to WiFi). Dot hidden when idle (same as WiFi pattern).
setScanning(scanning) updated — add after the existing start/stop btn toggle:
const dot = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-dot');
const text = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-text');
if (dot) dot.style.display = scanning ? 'inline-block' : 'none';
if (text) text.textContent = scanning ? 'SCANNING' : 'IDLE';
2. Sort + Filter Controls Row
Replaces the separate .bt-device-toolbar (search stays above) and .bt-device-filters divs. A new combined controls row sits between the signal strip and the search input:
<div class="bt-controls-row">
<div class="bt-sort-group">
<span class="bt-sort-label">Sort</span>
<button class="bt-sort-btn active" data-sort="rssi">Signal</button>
<button class="bt-sort-btn" data-sort="name">Name</button>
<button class="bt-sort-btn" data-sort="seen">Seen</button>
<button class="bt-sort-btn" data-sort="distance">Dist</button>
</div>
<div class="bt-filter-group">
<button class="bt-filter-btn active" data-filter="all">All</button>
<button class="bt-filter-btn" data-filter="new">New</button>
<button class="bt-filter-btn" data-filter="named">Named</button>
<button class="bt-filter-btn" data-filter="strong">Strong</button>
<button class="bt-filter-btn" data-filter="trackers">Trackers</button>
</div>
</div>
Sort state: add let sortBy = 'rssi' to module-level state. Sort button click handler (bound in init()):
document.querySelectorAll('.bt-sort-btn').forEach(btn => {
btn.addEventListener('click', () => {
sortBy = btn.dataset.sort;
document.querySelectorAll('.bt-sort-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderAllDevices();
});
});
renderAllDevices() — new function that re-renders all devices in sorted order:
function renderAllDevices() {
if (!deviceContainer) return;
deviceContainer.innerHTML = '';
const sorted = [...devices.values()].sort((a, b) => {
if (sortBy === 'rssi') return (b.rssi_current ?? -100) - (a.rssi_current ?? -100);
if (sortBy === 'name') return (a.name || 'zzz').localeCompare(b.name || 'zzz');
if (sortBy === 'seen') return (b.seen_count || 0) - (a.seen_count || 0);
if (sortBy === 'distance') return (a.estimated_distance_m ?? 999) - (b.estimated_distance_m ?? 999);
return 0;
});
sorted.forEach(device => renderDevice(device, false));
applyDeviceFilter();
highlightSelectedDevice(selectedDeviceId);
}
3. Device Rows
createSimpleDeviceCard(device) is rewritten to produce WiFi-style 2-line rows. All existing data attributes and filter attributes are preserved.
Row HTML structure:
<div class="bt-device-row [is-tracker]"
data-bt-device-id="…"
data-is-new="…"
data-has-name="…"
data-rssi="…"
data-is-tracker="…"
data-search="…"
role="button" tabindex="0" data-keyboard-activate="true"
style="border-left-color: COLOR;">
<div class="bt-row-top">
<div class="bt-row-top-left">
<span class="bt-proto-badge [ble|classic]">BLE|CLASSIC</span>
<span class="bt-row-name [bt-unnamed]">NAME or ADDRESS</span>
<!-- conditional badges: tracker, IRK, risk, flags, cluster -->
</div>
<div class="bt-row-top-right">
<!-- conditional: flag badges -->
<span class="bt-status-dot [new|known|tracker]"></span>
</div>
</div>
<div class="bt-row-bottom">
<div class="bt-signal-bar-wrap">
<div class="bt-signal-track">
<div class="bt-signal-fill [strong|medium|weak]" style="width: N%"></div>
</div>
</div>
<div class="bt-row-meta">
<span>MANUFACTURER or —</span>
<span>~Xm</span> <!-- omitted if no distance -->
<span class="bt-row-rssi [strong|medium|weak]">−N</span>
</div>
</div>
</div>
Signal fill width: pct = rssi != null ? Math.max(0, Math.min(100, (rssi + 100) / 70 * 100)) : 0 (preserves existing BT formula — range is −100 to −30, not −100 to −20 as in WiFi).
Signal fill class thresholds (match existing getRssiColor breakpoints):
.strong(rssi ≥ −60):background: linear-gradient(90deg, var(--accent-green), #88d49b).medium(−60 > rssi ≥ −75):background: linear-gradient(90deg, var(--accent-green), var(--accent-orange)).weak(rssi < −75):background: linear-gradient(90deg, var(--accent-orange), var(--accent-red))
Left border colour:
- High-confidence tracker:
#ef4444 - Any tracker (lower confidence):
#f97316 - Non-tracker: result of
getRssiColor(rssi)(unchanged)
Unnamed device: Address shown in .bt-row-name.bt-unnamed with color: var(--text-dim); font-style: italic.
Badges moved to top-right (not top-left): PERSIST, BEACON, STABLE flag badges move to .bt-row-top-right (before status dot), keeping top-left clean. Tracker, IRK, risk, and cluster badges remain in .bt-row-top-left after the name.
Locate button: Removed from the row entirely. It exists only in #btDetailContent (already present, no change needed).
Selected row state:
.bt-device-row.selected {
background: rgba(74, 163, 255, 0.07);
border-left-color: var(--accent-cyan) !important;
}
highlightSelectedDevice() is unchanged — it adds/removes .selected by data-bt-device-id.
4. Proximity Radar
proximity-radar.js changes:
createSVG() — add trailing arc group and CSS sweep class:
- Replace
<line class="radar-sweep" …>with a<g class="bt-radar-sweep" clip-path="url(#radarClip)">containing:- Two trailing arc
<path>elements (90° and 60°) with low opacity fills - The sweep
<line>element
- Two trailing arc
- Add
<clipPath id="radarClip"><circle …/></clipPath>to<defs>to prevent arc overflow
animateSweep() — remove entirely. The CSS animation replaces it.
CSS in index.css (BT section):
.bt-radar-sweep {
transform-origin: CENTER_X px CENTER_Y px; /* 140px 140px — half of CONFIG.size */
animation: bt-radar-rotate 3s linear infinite;
}
@keyframes bt-radar-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
isPaused handling: when paused, add animation-play-state: paused to .bt-radar-sweep via a class toggle instead of the rAF check. setPaused(paused) updated:
const sweep = svg?.querySelector('.bt-radar-sweep');
if (sweep) sweep.style.animationPlayState = paused ? 'paused' : 'running';
Arc path geometry (center = 140, outerRadius = center − padding = 120):
- 90° arc endpoint:
(140 + 120, 140)→(260, 140) - 60° arc endpoint:
x = 140 + 120·sin(60°) ≈ 244,y = 140 − 120·cos(60°) + 120 = 200→(244, 200) - Sweep line:
x1=140 y1=140 x2=140 y2=20(straight up)
File Changes
| File | Change |
|---|---|
templates/index.html |
BT device list header: add #btScanIndicator; insert new .bt-controls-row (sort group + filter group) between .bt-list-signal-strip and .bt-device-toolbar; remove old standalone .bt-device-filters div; .bt-device-toolbar (search input) is kept unchanged |
static/js/modes/bluetooth.js |
Add sortBy state; add renderAllDevices(); rewrite createSimpleDeviceCard() for 2-line rows (no locate button); update setScanning() to drive #btScanIndicator; bind sort button listener in init(); remove the locateBtn branch from the delegated click handler in bindListListeners() (no .bt-locate-btn[data-locate-id] elements will exist in rows) |
static/js/components/proximity-radar.js |
createSVG(): add clip path + trailing arc group + CSS class on sweep; remove animateSweep() function and its call; update setPaused() to use animationPlayState |
static/css/index.css |
BT section: add .bt-scan-indicator, .bt-scan-dot + @keyframes bt-scan-pulse; replace .bt-row-main, .bt-row-left/right, .bt-rssi-*, .bt-row-actions with .bt-row-top/bottom, .bt-signal-*, .bt-row-meta, .bt-row-name; add .bt-sort-btn, .bt-controls-row, .bt-sort-group, .bt-filter-group; add .bt-radar-sweep + @keyframes bt-radar-rotate |
Out of Scope
- Sidebar panel (scanner config, export, baseline)
- Tracker detection panel (left column)
- Zone summary cards under radar
- Radar filter buttons (New Only / Strongest / Unapproved) and Pause button
- Device detail panel (
#btDetailContent) — already well-structured - Bluetooth locate mode
- Any backend / route changes