Files
intercept/docs/superpowers/specs/2026-03-27-bluetooth-ui-polish-design.md
2026-03-27 16:19:16 +00:00

11 KiB
Raw Blame History

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