diff --git a/public/channel-colors.js b/public/channel-colors.js
new file mode 100644
index 00000000..e0f2241b
--- /dev/null
+++ b/public/channel-colors.js
@@ -0,0 +1,109 @@
+/**
+ * Channel Color Highlighting — Storage Model (M1)
+ *
+ * localStorage key: 'live-channel-colors'
+ * Value: JSON object mapping channel names to hex colors
+ * e.g. { "#wardriving": "#ef4444", "#meshnet": "#3b82f6" }
+ *
+ * Only applies to GRP_TXT packets. Other types retain default styling.
+ */
+(function() {
+ 'use strict';
+
+ var STORAGE_KEY = 'live-channel-colors';
+
+ function _load() {
+ try {
+ return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
+ } catch (e) {
+ return {};
+ }
+ }
+
+ function _save(colors) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(colors));
+ }
+
+ /** Validate hex color format: #RGB or #RRGGBB */
+ var HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
+ function _isValidHex(color) {
+ return typeof color === 'string' && HEX_RE.test(color);
+ }
+
+ /** Normalize 3-digit hex to 6-digit: #abc → #aabbcc */
+ function _normalize(color) {
+ if (color.length === 4) {
+ return '#' + color[1] + color[1] + color[2] + color[2] + color[3] + color[3];
+ }
+ return color;
+ }
+
+ /**
+ * Get the assigned color for a channel, or null if unassigned.
+ * @param {string} channel - Channel name (e.g. "#test")
+ * @returns {string|null} Hex color or null
+ */
+ function getChannelColor(channel) {
+ if (!channel) return null;
+ var colors = _load();
+ return colors[channel] || null;
+ }
+
+ /**
+ * Assign a color to a channel.
+ * @param {string} channel - Channel name
+ * @param {string} color - Hex color (e.g. "#ef4444")
+ */
+ function setChannelColor(channel, color) {
+ if (!channel || !color) return;
+ if (!_isValidHex(color)) return;
+ var colors = _load();
+ colors[channel] = _normalize(color);
+ _save(colors);
+ }
+
+ /**
+ * Remove the color assignment for a channel.
+ * @param {string} channel - Channel name
+ */
+ function removeChannelColor(channel) {
+ if (!channel) return;
+ var colors = _load();
+ delete colors[channel];
+ _save(colors);
+ }
+
+ /**
+ * Get all channel-color assignments.
+ * @returns {Object} Map of channel name → hex color
+ */
+ function getAllChannelColors() {
+ return _load();
+ }
+
+ /**
+ * Compute inline style string for a feed row / table row based on channel color.
+ * Returns empty string if no channel color is assigned.
+ * @param {string} typeName - Packet type name (e.g. "GRP_TXT", "CHAN")
+ * @param {string|null} channel - Channel name from decoded payload
+ * @returns {string} Inline style string or empty
+ */
+ function getChannelRowStyle(typeName, channel) {
+ // Only GRP_TXT / CHAN packets get channel coloring
+ if (typeName !== 'GRP_TXT' && typeName !== 'CHAN') return '';
+ if (!channel) return '';
+ var color = getChannelColor(channel);
+ if (!color) return '';
+ // 4px left border + 10% opacity background tint
+ return 'border-left:4px solid ' + color + ';background:' + color + '1a;';
+ }
+
+ // Export to window for use by live.js and packets.js
+ window.ChannelColors = {
+ get: getChannelColor,
+ set: setChannelColor,
+ remove: removeChannelColor,
+ getAll: getAllChannelColors,
+ getRowStyle: getChannelRowStyle
+ };
+})();
diff --git a/public/index.html b/public/index.html
index 951601ce..1bc44c2a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -94,6 +94,7 @@
+
diff --git a/public/live.js b/public/live.js
index df6e027a..6addc50e 100644
--- a/public/live.js
+++ b/public/live.js
@@ -2495,6 +2495,15 @@
if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; }
}
+ /** Extract channel row style from a packet (shared by feed item builders). */
+ function _getChannelStyle(pkt) {
+ if (!window.ChannelColors) return '';
+ var d = pkt.decoded || {};
+ var h = d.header || {};
+ var p = d.payload || {};
+ return window.ChannelColors.getRowStyle(h.payloadTypeName || '', p.channelName || null);
+ }
+
function addFeedItemDOM(icon, typeName, payload, hops, color, pkt, feed) {
const text = payload.text || payload.name || '';
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
@@ -2505,6 +2514,9 @@
item.setAttribute('tabindex', '0');
item.setAttribute('role', 'button');
item.style.cursor = 'pointer';
+ // Channel color highlighting for GRP_TXT packets (#271)
+ var _cs = _getChannelStyle(pkt);
+ if (_cs) item.style.cssText += _cs;
item.innerHTML = `
${icon}
${typeName}
@@ -2573,6 +2585,9 @@
item.setAttribute('role', 'button');
if (hash) item.setAttribute('data-hash', hash);
item.style.cursor = 'pointer';
+ // Channel color highlighting for GRP_TXT packets (#271)
+ var _chanStyle = _getChannelStyle(pkt);
+ if (_chanStyle) item.style.cssText += _chanStyle;
item.innerHTML = `
${icon}
${typeName}
diff --git a/public/packets.js b/public/packets.js
index 0a1ae23c..b770635a 100644
--- a/public/packets.js
+++ b/public/packets.js
@@ -1124,7 +1124,10 @@
const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
const groupHashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
const isSingle = p.count <= 1;
- let html = `
+ // Channel color highlighting (#271)
+ const _grpDecoded = getParsedDecoded(p) || {};
+ const _grpChanStyle = window.ChannelColors ? window.ChannelColors.getRowStyle(_grpDecoded.type || groupTypeName, _grpDecoded.channel) : '';
+ let html = `
| ${isSingle ? '' : (isExpanded ? '▼' : '▶')} |
${groupRegion ? `${groupRegion}` : '—'} |
${renderTimestampCell(p.latest)} |
@@ -1174,11 +1177,13 @@
const region = p.observer_id ? (observerMap.get(p.observer_id)?.iata || '') : '';
const typeName = payloadTypeName(p.payload_type);
const typeClass = payloadTypeColor(p.payload_type);
+ // Channel color highlighting (#271)
+ const _chanStyle = window.ChannelColors ? window.ChannelColors.getRowStyle(decoded.type || typeName, decoded.channel) : '';
const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
const hashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
const pathStr = renderPath(pathHops, p.observer_id);
const detail = getDetailPreview(decoded);
- return `
+ return `
| ${region ? `${region}` : '—'} |
${renderTimestampCell(p.timestamp)} |
${truncate(p.hash || String(p.id), 8)} |
diff --git a/test-channel-colors.js b/test-channel-colors.js
new file mode 100644
index 00000000..97e078f6
--- /dev/null
+++ b/test-channel-colors.js
@@ -0,0 +1,173 @@
+/* Unit tests for channel color highlighting (M1) — #271 */
+'use strict';
+const vm = require('vm');
+const fs = require('fs');
+const assert = require('assert');
+
+let passed = 0, failed = 0;
+function test(name, fn) {
+ try {
+ fn();
+ passed++;
+ console.log(` ✅ ${name}`);
+ } catch (e) {
+ failed++;
+ console.log(` ❌ ${name}: ${e.message}`);
+ }
+}
+
+// Build minimal sandbox with localStorage mock
+function makeSandbox() {
+ const store = {};
+ const localStorage = {
+ getItem: function(k) { return store[k] !== undefined ? store[k] : null; },
+ setItem: function(k, v) { store[k] = String(v); },
+ removeItem: function(k) { delete store[k]; },
+ clear: function() { for (var k in store) delete store[k]; }
+ };
+ const ctx = {
+ window: {},
+ localStorage: localStorage,
+ console: console,
+ JSON: JSON,
+ };
+ ctx.window.ChannelColors = undefined;
+ vm.createContext(ctx);
+ const src = fs.readFileSync(__dirname + '/public/channel-colors.js', 'utf8');
+ vm.runInContext(src, ctx);
+ return ctx;
+}
+
+console.log('\n🎨 Channel Colors — Storage CRUD');
+
+test('getChannelColor returns null for unassigned channel', function() {
+ const ctx = makeSandbox();
+ assert.strictEqual(ctx.window.ChannelColors.get('#test'), null);
+});
+
+test('setChannelColor + getChannelColor round-trip', function() {
+ const ctx = makeSandbox();
+ ctx.window.ChannelColors.set('#sf', '#ef4444');
+ assert.strictEqual(ctx.window.ChannelColors.get('#sf'), '#ef4444');
+});
+
+test('setChannelColor overwrites existing color', function() {
+ const ctx = makeSandbox();
+ ctx.window.ChannelColors.set('#sf', '#ef4444');
+ ctx.window.ChannelColors.set('#sf', '#3b82f6');
+ assert.strictEqual(ctx.window.ChannelColors.get('#sf'), '#3b82f6');
+});
+
+test('removeChannelColor removes assignment', function() {
+ const ctx = makeSandbox();
+ ctx.window.ChannelColors.set('#test', '#ff0000');
+ ctx.window.ChannelColors.remove('#test');
+ assert.strictEqual(ctx.window.ChannelColors.get('#test'), null);
+});
+
+test('removeChannelColor on non-existent channel is no-op', function() {
+ const ctx = makeSandbox();
+ ctx.window.ChannelColors.remove('#nonexistent');
+ assert.deepStrictEqual(ctx.window.ChannelColors.getAll(), {});
+});
+
+test('getAllChannelColors returns all assignments', function() {
+ const ctx = makeSandbox();
+ ctx.window.ChannelColors.set('#a', '#111111');
+ ctx.window.ChannelColors.set('#b', '#222222');
+ const all = ctx.window.ChannelColors.getAll();
+ assert.strictEqual(JSON.stringify(all), JSON.stringify({ '#a': '#111111', '#b': '#222222' }));
+});
+
+test('getAllChannelColors returns empty object when none set', function() {
+ const ctx = makeSandbox();
+ assert.strictEqual(JSON.stringify(ctx.window.ChannelColors.getAll()), '{}');
+});
+
+test('handles corrupt localStorage gracefully', function() {
+ const ctx = makeSandbox();
+ ctx.localStorage.setItem('live-channel-colors', 'not-json{{{');
+ assert.strictEqual(ctx.window.ChannelColors.get('#test'), null);
+ assert.strictEqual(JSON.stringify(ctx.window.ChannelColors.getAll()), '{}');
+});
+
+test('set with null/empty channel is no-op', function() {
+ const ctx = makeSandbox();
+ ctx.window.ChannelColors.set('', '#ff0000');
+ ctx.window.ChannelColors.set(null, '#ff0000');
+ assert.strictEqual(JSON.stringify(ctx.window.ChannelColors.getAll()), '{}');
+});
+
+test('set rejects invalid hex colors', function() {
+ const ctx = makeSandbox();
+ ctx.window.ChannelColors.set('#ch', 'red');
+ ctx.window.ChannelColors.set('#ch', '#xyz');
+ ctx.window.ChannelColors.set('#ch', '#12345');
+ ctx.window.ChannelColors.set('#ch', '#1234567');
+ ctx.window.ChannelColors.set('#ch', 'ff0000');
+ assert.strictEqual(ctx.window.ChannelColors.get('#ch'), null);
+});
+
+test('set normalizes 3-digit hex to 6-digit', function() {
+ const ctx = makeSandbox();
+ ctx.window.ChannelColors.set('#ch', '#abc');
+ assert.strictEqual(ctx.window.ChannelColors.get('#ch'), '#aabbcc');
+});
+
+test('set accepts valid 6-digit hex', function() {
+ const ctx = makeSandbox();
+ ctx.window.ChannelColors.set('#ch', '#ef4444');
+ assert.strictEqual(ctx.window.ChannelColors.get('#ch'), '#ef4444');
+});
+
+test('get with null/empty channel returns null', function() {
+ const ctx = makeSandbox();
+ assert.strictEqual(ctx.window.ChannelColors.get(''), null);
+ assert.strictEqual(ctx.window.ChannelColors.get(null), null);
+});
+
+console.log('\n🎨 Channel Colors — Row Style Generation');
+
+test('getRowStyle returns empty string for non-GRP_TXT types', function() {
+ const ctx = makeSandbox();
+ ctx.window.ChannelColors.set('#test', '#ff0000');
+ assert.strictEqual(ctx.window.ChannelColors.getRowStyle('ADVERT', '#test'), '');
+ assert.strictEqual(ctx.window.ChannelColors.getRowStyle('TXT_MSG', '#test'), '');
+ assert.strictEqual(ctx.window.ChannelColors.getRowStyle('ACK', '#test'), '');
+});
+
+test('getRowStyle returns empty string for unassigned channel', function() {
+ const ctx = makeSandbox();
+ assert.strictEqual(ctx.window.ChannelColors.getRowStyle('GRP_TXT', '#unassigned'), '');
+});
+
+test('getRowStyle returns empty string for null channel', function() {
+ const ctx = makeSandbox();
+ assert.strictEqual(ctx.window.ChannelColors.getRowStyle('GRP_TXT', null), '');
+});
+
+test('getRowStyle returns border + background for assigned GRP_TXT channel', function() {
+ const ctx = makeSandbox();
+ ctx.window.ChannelColors.set('#sf', '#ef4444');
+ const style = ctx.window.ChannelColors.getRowStyle('GRP_TXT', '#sf');
+ assert.ok(style.includes('border-left:4px solid #ef4444'), 'should have left border');
+ assert.ok(style.includes('background:#ef44441a'), 'should have 10% opacity background');
+});
+
+test('getRowStyle works with CHAN type (alias for GRP_TXT)', function() {
+ const ctx = makeSandbox();
+ ctx.window.ChannelColors.set('#mesh', '#3b82f6');
+ const style = ctx.window.ChannelColors.getRowStyle('CHAN', '#mesh');
+ assert.ok(style.includes('border-left:4px solid #3b82f6'), 'should have left border');
+ assert.ok(style.includes('background:#3b82f61a'), 'should have background tint');
+});
+
+test('getRowStyle returns empty when channel has no assigned color', function() {
+ const ctx = makeSandbox();
+ ctx.window.ChannelColors.set('#other', '#ff0000');
+ assert.strictEqual(ctx.window.ChannelColors.getRowStyle('GRP_TXT', '#nope'), '');
+});
+
+// Summary
+console.log(`\n${passed} passed, ${failed} failed\n`);
+process.exit(failed ? 1 : 0);