From 3328ca4354aecc158d58395872d7945beae3f1a8 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Sun, 5 Apr 2026 00:03:17 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20channel=20color=20highlighting=20M1=20?= =?UTF-8?q?=E2=80=94=20core=20model=20+=20feed=20row=20(#271)=20(#607)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements M1 of the [channel color highlighting spec](docs/specs/channel-color-highlighting.md) for issue #271. Allows users to assign custom highlight colors to specific hash channels. When a `GRP_TXT` packet arrives with an assigned channel color, the feed row and packets table row get: - **4px colored left border** in the assigned color - **Subtle background tint** (color at 10% opacity) ## What's included ### `public/channel-colors.js` — Storage model - `ChannelColors.get(channel)` → hex color or null - `ChannelColors.set(channel, color)` — assign a color - `ChannelColors.remove(channel)` — clear assignment - `ChannelColors.getAll()` → all assignments - `ChannelColors.getRowStyle(typeName, channel)` → inline CSS string for row highlighting - Uses `localStorage` key `live-channel-colors` - Gracefully handles corrupt/missing localStorage data ### Feed row highlighting (`public/live.js`) - Both `addFeedItem` (live WS) and `addFeedItemDOM` (replay/DB load) apply channel color styles - Reads `decoded.payload.channelName` from the packet ### Packets table highlighting (`public/packets.js`) - `buildFlatRowHtml` and `buildGroupRowHtml` apply channel color styles to `` elements - Reads channel from `getParsedDecoded(p).channel` ### Tests (`test-channel-colors.js`) - 16 unit tests covering storage CRUD, edge cases (null, empty, corrupt data), and style generation - Tests verify only GRP_TXT/CHAN types get coloring, other types are unaffected ## Design decisions - **Only GRP_TXT/CHAN packets** — other types retain default `TYPE_COLORS` styling - **Channel color takes priority** over default type colors for row highlighting - **No UI for assigning colors yet** — that's M2 (right-click context menu + color picker) - **Storage key abstracted** behind functions to ease future migration if customizer rework (#288) lands - **10% opacity tint** (`#hexcolor` + `1a` suffix) ensures readability in both dark/light modes ## Performance - `getRowStyle()` is O(1) — single localStorage read + JSON parse per call - No per-packet API calls; all data is client-side - No impact on hot rendering paths beyond one localStorage read per row render Closes #271 (M1 only — further milestones in separate PRs) --------- Co-authored-by: you --- public/channel-colors.js | 109 ++++++++++++++++++++++++ public/index.html | 1 + public/live.js | 15 ++++ public/packets.js | 9 +- test-channel-colors.js | 173 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 public/channel-colors.js create mode 100644 test-channel-colors.js 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);