diff --git a/public/analytics.js b/public/analytics.js
index 49a7b909..091ab0e7 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -6,6 +6,14 @@
const sf = (v, d) => (v != null ? v.toFixed(d) : '–'); // safe toFixed
function esc(s) { return s ? String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"') : ''; }
+ // --- Status color helpers (read from CSS variables for theme support) ---
+ function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
+ function statusGreen() { return cssVar('--status-green') || '#22c55e'; }
+ function statusYellow() { return cssVar('--status-yellow') || '#eab308'; }
+ function statusRed() { return cssVar('--status-red') || '#ef4444'; }
+ function accentColor() { return cssVar('--accent') || '#4a9eff'; }
+ function snrColor(snr) { return snr > 6 ? statusGreen() : snr > 0 ? statusYellow() : statusRed(); }
+
// --- SVG helpers ---
function sparkSvg(data, color, w = 120, h = 32) {
if (!data.length) return '';
@@ -247,8 +255,8 @@
// ===================== RF / SIGNAL =====================
function renderRF(el, rf) {
- const snrHist = histogram(rf.snrValues, 20, '#22c55e');
- const rssiHist = histogram(rf.rssiValues, 20, '#3b82f6');
+ const snrHist = histogram(rf.snrValues, 20, statusGreen());
+ const rssiHist = histogram(rf.rssiValues, 20, accentColor());
el.innerHTML = `
@@ -329,20 +337,21 @@
svg += `
${rssi}`;
}
// Quality zones
+ const _sg = statusGreen(), _sy = statusYellow(), _sr = statusRed();
const zones = [
- { label: 'Excellent', snr: [6, 15], rssi: [-80, -5], color: '#22c55e20' },
- { label: 'Good', snr: [0, 6], rssi: [-100, -80], color: '#f59e0b15' },
- { label: 'Weak', snr: [-12, 0], rssi: [-130, -100], color: '#ef444410' },
+ { label: 'Excellent', snr: [6, 15], rssi: [-80, -5], color: _sg + '20' },
+ { label: 'Good', snr: [0, 6], rssi: [-100, -80], color: _sy + '15' },
+ { label: 'Weak', snr: [-12, 0], rssi: [-130, -100], color: _sr + '10' },
];
// Define patterns for color-blind accessibility
svg += `
`;
- svg += ``;
- svg += ``;
- svg += ``;
+ svg += ``;
+ svg += ``;
+ svg += ``;
svg += ``;
const zonePatterns = { 'Excellent': 'pat-excellent', 'Good': 'pat-good', 'Weak': 'pat-weak' };
const zoneDash = { 'Excellent': '4,2', 'Good': '6,3', 'Weak': '2,2' };
- const zoneBorder = { 'Excellent': '#22c55e', 'Good': '#f59e0b', 'Weak': '#ef4444' };
+ const zoneBorder = { 'Excellent': _sg, 'Good': _sy, 'Weak': _sr };
zones.forEach(z => {
const x1 = pad + (z.snr[0] - snrMin) / (snrMax - snrMin) * (w - pad * 2);
const x2 = pad + (z.snr[1] - snrMin) / (snrMax - snrMin) * (w - pad * 2);
@@ -369,7 +378,7 @@
let html = '
| Type | Packets | Avg SNR | Min | Max | Distribution |
';
snrByType.forEach(t => {
const barPct = Math.max(((t.avg - (-12)) / 27) * 100, 2);
- const color = t.avg > 6 ? '#22c55e' : t.avg > 0 ? '#f59e0b' : '#ef4444';
+ const color = t.avg > 6 ? statusGreen() : t.avg > 0 ? statusYellow() : statusRed();
html += `
| ${t.name} |
${t.count} |
@@ -392,7 +401,7 @@
const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2);
return `${x},${y}`;
}).join(' ');
- svg += ``;
+ svg += ``;
// Packet count as area
const areaPts = data.map((d, i) => {
const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1));
@@ -411,7 +420,7 @@
svg += `${data[i].hour.slice(11)}h`;
}
svg += '';
- svg += `Avg SNRVolume
`;
+ svg += `Avg SNRVolume
`;
return svg;
}
@@ -526,7 +535,7 @@
const x = pad + (d.hops / maxHop) * (w - pad * 2);
const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2);
const r = Math.min(Math.sqrt(d.count) * 1.5, 12);
- const color = d.avgSnr > 6 ? '#22c55e' : d.avgSnr > 0 ? '#f59e0b' : '#ef4444';
+ const color = d.avgSnr > 6 ? statusGreen() : d.avgSnr > 0 ? statusYellow() : statusRed();
svg += ``;
svg += `${d.hops}h`;
});
@@ -927,13 +936,13 @@
${collisions.map(c => {
let badge, tooltip;
if (c.classification === 'local') {
- badge = '🏘️ Local';
+ badge = '🏘️ Local';
tooltip = 'Nodes close enough for direct RF — probably genuine prefix collision';
} else if (c.classification === 'regional') {
- badge = '⚡ Regional';
+ badge = '⚡ Regional';
tooltip = 'At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links';
} else if (c.classification === 'distant') {
- badge = '🌐 Distant';
+ badge = '🌐 Distant';
tooltip = 'Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
} else {
badge = '❓ Unknown';
@@ -993,7 +1002,7 @@
${routeDisplay}${hasSelfLoop ? ' 🔄' : ''} ${esc(prefixDisplay)} |
${s.count.toLocaleString()} |
${s.pct}% |
- |
+ |
`;
}).join('')}
`;
@@ -1093,7 +1102,7 @@
const dLon = (a.lon - b.lon) * 85;
const km = Math.sqrt(dLat*dLat + dLon*dLon);
total += km;
- const cls = km > 200 ? 'color:#ef4444;font-weight:bold' : km > 50 ? 'color:#f59e0b' : 'color:#22c55e';
+ const cls = km > 200 ? 'color:var(--status-red);font-weight:bold' : km > 50 ? 'color:var(--status-yellow)' : 'color:var(--status-green)';
dists.push(`
${km < 1 ? (km*1000).toFixed(0)+'m' : km.toFixed(1)+'km'} ${esc(a.name)} → ${esc(b.name)}
`);
} else {
dists.push(`
? ${esc(a.name)} → ${esc(b.name)} (no coords)
`);
@@ -1155,13 +1164,13 @@
const isEnd = i === 0 || i === nodesWithLoc.length - 1;
L.circleMarker(ll, {
radius: isEnd ? 8 : 5,
- color: isEnd ? (i === 0 ? '#22c55e' : '#ef4444') : '#f59e0b',
- fillColor: isEnd ? (i === 0 ? '#22c55e' : '#ef4444') : '#f59e0b',
+ color: isEnd ? (i === 0 ? statusGreen() : statusRed()) : statusYellow(),
+ fillColor: isEnd ? (i === 0 ? statusGreen() : statusRed()) : statusYellow(),
fillOpacity: 0.9, weight: 2
}).bindTooltip(n.name, { permanent: false }).addTo(map);
});
- L.polyline(latlngs, { color: '#f59e0b', weight: 3, dashArray: '8,6', opacity: 0.8 }).addTo(map);
+ L.polyline(latlngs, { color: statusYellow(), weight: 3, dashArray: '8,6', opacity: 0.8 }).addTo(map);
map.fitBounds(L.latLngBounds(latlngs).pad(0.3));
}
}
@@ -1207,15 +1216,15 @@
🔍 Network Status
-
${active}
+
${active}
🟢 Active
-
${degraded}
+
${degraded}
🟡 Degraded
-
${silent}
+
${silent}
🔴 Silent
@@ -1340,7 +1349,7 @@
if (data.distHistogram && data.distHistogram.bins) {
const buckets = data.distHistogram.bins.map(b => b.count);
const labels = data.distHistogram.bins.map(b => b.x.toFixed(1));
- html += `
Hop Distance Distribution
${barChart(buckets, labels, '#22c55e')}`;
+ html += `
Hop Distance Distribution
${barChart(buckets, labels, statusGreen())}`;
}
// Distance over time
diff --git a/public/live.css b/public/live.css
index 9db0257d..d1d7af3f 100644
--- a/public/live.css
+++ b/public/live.css
@@ -50,7 +50,7 @@
.live-beacon {
width: 8px;
height: 8px;
- background: #ef4444;
+ background: var(--status-red);
border-radius: 50%;
display: inline-block;
animation: beaconPulse 1.5s ease-in-out infinite;
@@ -541,7 +541,7 @@
}
.vcr-lcd-mode {
font-size: 0.65rem;
- color: #4ade80;
+ color: var(--status-green);
text-shadow: 0 0 6px rgba(74, 222, 128, 0.6);
font-weight: 700;
}
diff --git a/public/live.js b/public/live.js
index 66b98f10..9404e4c3 100644
--- a/public/live.js
+++ b/public/live.js
@@ -1,6 +1,10 @@
(function() {
'use strict';
+ // Status color helpers (read from CSS variables for theme support)
+ function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
+ function statusGreen() { return cssVar('--status-green') || '#22c55e'; }
+
let map, ws, nodesLayer, pathsLayer, animLayer, heatLayer;
let nodeMarkers = {};
let nodeData = {};
@@ -348,7 +352,7 @@
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
- drawLcdText(`${hh}:${mm}:${ss}`, '#4ade80');
+ drawLcdText(`${hh}:${mm}:${ss}`, statusGreen());
}
function updateVCRLcd() {
@@ -644,11 +648,11 @@
PACKET TYPES
- - Advert — Node advertisement
- - Message — Group text
- - Direct — Direct message
- - Request — Data request
- - Trace — Route trace
+ - Advert — Node advertisement
+ - Message — Group text
+ - Direct — Direct message
+ - Request — Data request
+ - Trace — Route trace
NODE ROLES
diff --git a/public/style.css b/public/style.css
index fa55a6de..ee42fe07 100644
--- a/public/style.css
+++ b/public/style.css
@@ -114,7 +114,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
70% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
}
-.live-dot.connected { background: #22c55e; animation: pulse-ring 2s ease-out infinite; }
+.live-dot.connected { background: var(--status-green); animation: pulse-ring 2s ease-out infinite; }
.nav-links { display: flex; align-items: center; gap: 4px; }
.nav-link {
@@ -1231,7 +1231,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.hop-conflict-dist { font-size: 11px; color: var(--text-muted); font-family: var(--mono); white-space: nowrap; }
.hop-conflict-pk { font-size: 10px; color: var(--text-muted); font-family: var(--mono); }
.hop-unreliable { opacity: 0.5; text-decoration: line-through; }
-.hop-global-fallback { border-bottom: 1px dashed #ef4444; }
+.hop-global-fallback { border-bottom: 1px dashed var(--status-red); }
.hop-current { font-weight: 700 !important; color: var(--accent) !important; }
/* Self-loop subpath rows */
@@ -1468,9 +1468,9 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
.perf-table td { padding: 5px 10px; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; }
.perf-table code { font-size: 12px; color: var(--text); }
.perf-table .perf-slow { background: rgba(239, 68, 68, 0.08); }
-.perf-table .perf-slow td { color: #ef4444; }
+.perf-table .perf-slow td { color: var(--status-red); }
.perf-table .perf-warn { background: rgba(251, 191, 36, 0.06); }
-.perf-table .perf-warn td { color: #f59e0b; }
+.perf-table .perf-warn td { color: var(--status-yellow); }
/* ─── Region filter bar ─── */
.region-filter-bar { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }