feat(hash-color): bright vivid fill + dark outline + live feed/polyline surfaces (#951)

## Hash-Color: Bright Vivid Fill + Dark Outline + Extended Surfaces

Follow-up to #948 (merged). Revises the hash-color algorithm for better
perceptual discrimination and extends hash coloring to additional Live
page surfaces.

### Algorithm Changes (`public/hash-color.js`)
- **Hue**: bytes 0-1 (16-bit → 0-360°) — unchanged
- **Saturation**: byte 2 (55-95%) — NEW, was fixed 70%
- **Lightness**: byte 3 (light 50-65%, dark 55-72%) — NEW, was fixed
L=30/38/65
- **Outline** (`hashToOutline`): same-hue dark color (L=25% light, L=15%
dark) — NEW
- Sentinel threshold raised to 8 hex chars (need 4 bytes of entropy)
- Drops WCAG fill-darkening approach — outline carries contrast instead

### Live Page Updates (`public/live.js`)
- **Dot marker**: uses `hashToOutline()` for stroke (was TYPE_COLOR)
- **Polyline trace**: uses hash fill color (unified dot + trace by hash)
- **Feed items**: 4px `border-left` stripe matching packets table

### Test Updates
- `test-hash-color.js`: 32 tests (S variability, L variability, outline
< fill, same hue, pairwise distance)
- `test-e2e-playwright.js`: 2 new assertions (feed stripe, polyline hsl
stroke)

### Verification
- 20 real advert hashes from fixture DB: all produce unique hues (20/20)
- Pairwise HSL distance: avg=0.51, min=0.04
- Go server built and run against fixture DB — HTML serves updated
module
- VM sandbox render-check confirms distinct vivid fills with darker
outlines

Closes #946 §2.10/§2.11 scope extension.

---------

Co-authored-by: you <you@example.com>
Co-authored-by: Kpa-clawbot <bot@example.invalid>
This commit is contained in:
Kpa-clawbot
2026-05-01 01:53:04 -07:00
committed by GitHub
parent 3bd354338e
commit b9758111b0
4 changed files with 187 additions and 104 deletions
+39 -17
View File
@@ -1,5 +1,5 @@
/* hash-color.js — Deterministic HSL color from packet hash
* IIFE attaching window.HashColor = { hashToHsl }
* IIFE attaching window.HashColor = { hashToHsl, hashToOutline }
* Pure function: no DOM access, no state, works in Node vm.createContext sandbox.
*/
(function() {
@@ -7,42 +7,64 @@
/**
* Derive a deterministic HSL color string from a hex hash.
* @param {string|null|undefined} hashHex - Hex string (e.g. "a1b2c3...")
* Uses bytes 0-1 for hue, byte 2 for saturation, byte 3 for lightness.
* Produces bright vivid fills; contrast is provided by a dark outline (hashToOutline).
* @param {string|null|undefined} hashHex - Hex string (e.g. "a1b2c3d4...")
* @param {string} theme - "light" or "dark"
* @returns {string} CSS hsl() string
*/
function hashToHsl(hashHex, theme) {
if (!hashHex || hashHex.length < 4) {
if (!hashHex || hashHex.length < 8) {
return 'hsl(0, 0%, 50%)';
}
// First 2 bytes → hue (0-360)
var b0 = parseInt(hashHex.slice(0, 2), 16) || 0;
var b1 = parseInt(hashHex.slice(2, 4), 16) || 0;
var b2 = parseInt(hashHex.slice(4, 6), 16) || 0;
var b3 = parseInt(hashHex.slice(6, 8), 16) || 0;
// Hue: 0-360 from bytes 0-1 (16-bit)
var hue = Math.round(((b0 << 8) | b1) / 65535 * 360);
var S = 70;
// Saturation: 55-95% from byte 2
var S = 55 + Math.round(b2 / 255 * 40);
// Lightness: vivid range per theme from byte 3
// Light: 50-65%, Dark: 55-72%
var L;
if (theme === 'dark') {
L = 65;
L = 55 + Math.round(b3 / 255 * 17);
} else {
// Light theme: base L ensures WCAG ≥3.0 contrast against --content-bg (#f4f5f7, style.css:32)
// Green/cyan zone (hue 45-195) needs lower L due to high perceptual luminance
if (hue >= 45 && hue <= 195) {
L = 30;
} else {
L = 38;
}
L = 50 + Math.round(b3 / 255 * 15);
}
return 'hsl(' + hue + ', ' + S + '%, ' + L + '%)';
}
/**
* Derive a dark outline color (same hue) for contrast against backgrounds.
* @param {string|null|undefined} hashHex - Hex string
* @param {string} theme - "light" or "dark"
* @returns {string} CSS hsl() string
*/
function hashToOutline(hashHex, theme) {
if (!hashHex || hashHex.length < 8) {
return 'hsl(0, 0%, 30%)';
}
var b0 = parseInt(hashHex.slice(0, 2), 16) || 0;
var b1 = parseInt(hashHex.slice(2, 4), 16) || 0;
var hue = Math.round(((b0 << 8) | b1) / 65535 * 360);
// Dark outline: same hue, low lightness for contrast
if (theme === 'dark') {
return 'hsl(' + hue + ', 30%, 15%)';
}
return 'hsl(' + hue + ', 70%, 25%)';
}
// Export
if (typeof window !== 'undefined') {
window.HashColor = { hashToHsl: hashToHsl };
window.HashColor = { hashToHsl: hashToHsl, hashToOutline: hashToOutline };
} else if (typeof module !== 'undefined') {
module.exports = { hashToHsl: hashToHsl };
module.exports = { hashToHsl: hashToHsl, hashToOutline: hashToOutline };
}
})();
+17 -5
View File
@@ -23,6 +23,8 @@
let matrixMode = localStorage.getItem('live-matrix-mode') === 'true';
let matrixRain = localStorage.getItem('live-matrix-rain') === 'true';
let colorByHash = localStorage.getItem('meshcore-color-packets-by-hash') !== 'false';
/** Current theme string for hash-color functions. */
function _liveTheme() { return document.documentElement.dataset.theme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); }
let nodeFilterKeys = (localStorage.getItem('live-node-filter') || '').split(',').map(s => s.trim()).filter(Boolean);
let nodeFilterTotal = 0;
let nodeFilterShown = 0;
@@ -2704,13 +2706,14 @@
const mainOpacity = overrideOpacity ?? 0.8;
const isDashed = overrideOpacity != null;
// Hash-derived color for fill + contrail (when toggle ON and not ghost/dashed line)
// Hash-derived color for fill + contrail + outline (when toggle ON and not ghost/dashed line)
var hashFill = '#fff';
var hashOutline = color;
var contrailColor = color;
if (colorByHash && hash && !isDashed && window.HashColor) {
var theme = (document.documentElement.dataset.theme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'));
var hsl = HashColor.hashToHsl(hash, theme);
var hsl = HashColor.hashToHsl(hash, _liveTheme());
hashFill = hsl;
hashOutline = HashColor.hashToOutline(hash, _liveTheme());
contrailColor = hsl;
}
@@ -2719,12 +2722,13 @@
}).addTo(pathsLayer);
const line = L.polyline([from], {
color: color, weight: isDashed ? 1.5 : 2, opacity: mainOpacity, lineCap: 'round',
color: (colorByHash && hash && !isDashed && window.HashColor) ? hashFill : color,
weight: isDashed ? 1.5 : 2, opacity: mainOpacity, lineCap: 'round',
dashArray: isDashed ? '4 6' : null
}).addTo(pathsLayer);
const dot = L.circleMarker(from, {
radius: 3.5, fillColor: hashFill, fillOpacity: 1, color: color, weight: 1.5
radius: 3.5, fillColor: hashFill, fillOpacity: 1, color: hashOutline, weight: 1.5
}).addTo(animLayer);
let lastStep = performance.now();
@@ -2856,6 +2860,10 @@
item.setAttribute('tabindex', '0');
item.setAttribute('role', 'button');
item.style.cursor = 'pointer';
// Hash-color stripe for feed items (mirrors packets table border-left)
if (colorByHash && pkt.hash && window.HashColor) {
item.style.borderLeft = '4px solid ' + HashColor.hashToHsl(pkt.hash, _liveTheme());
}
// Channel color highlighting for GRP_TXT packets (#271)
var _cs = _getChannelStyle(pkt);
if (_cs) item.style.cssText += _cs;
@@ -2939,6 +2947,10 @@
item.setAttribute('role', 'button');
if (hash) item.setAttribute('data-hash', hash);
item.style.cursor = 'pointer';
// Hash-color stripe for feed items (mirrors packets table border-left)
if (colorByHash && hash && window.HashColor) {
item.style.borderLeft = '4px solid ' + HashColor.hashToHsl(hash, _liveTheme());
}
// Channel color highlighting for GRP_TXT packets (#271)
var _chanStyle = _getChannelStyle(pkt);
if (_chanStyle) item.style.cssText += _chanStyle;
+43
View File
@@ -2211,6 +2211,49 @@ async function run() {
await page.evaluate(() => localStorage.setItem('meshcore-color-packets-by-hash', 'true'));
});
// --- Live feed hash-color stripe ---
await test('Live feed items have border-left stripe when toggle ON', async () => {
await page.evaluate(() => localStorage.setItem('meshcore-color-packets-by-hash', 'true'));
await page.goto(BASE + '/#/live');
await page.waitForTimeout(3000); // allow feed to populate
const hasStripe = await page.evaluate(() => {
const items = document.querySelectorAll('.live-feed-item');
for (const item of items) {
if ((item.getAttribute('style') || item.style.cssText || '').includes('border-left')) return true;
}
return false;
});
// May not have live packets in fixture — skip if no feed items
const itemCount = await page.evaluate(() => document.querySelectorAll('.live-feed-item').length);
if (itemCount === 0) {
console.log(' (skipped — no live feed items in fixture)');
return;
}
assert(hasStripe, 'At least one .live-feed-item should have hash-color border-left stripe when toggle ON');
});
// --- Map polyline uses hash color ---
await test('Map trace polyline uses hash-derived color when toggle ON', async () => {
await page.evaluate(() => localStorage.setItem('meshcore-color-packets-by-hash', 'true'));
await page.goto(BASE + '/#/live');
await page.waitForTimeout(3000);
// Check if any polyline SVG path has an hsl stroke
const hasHslPolyline = await page.evaluate(() => {
const paths = document.querySelectorAll('path.leaflet-interactive');
for (const p of paths) {
const stroke = p.getAttribute('stroke') || '';
if (stroke.startsWith('hsl(')) return true;
}
return false;
});
const pathCount = await page.evaluate(() => document.querySelectorAll('path.leaflet-interactive').length);
if (pathCount === 0) {
console.log(' (skipped — no polyline paths on map in fixture)');
return;
}
assert(hasHslPolyline, 'At least one map polyline should have hsl() stroke color from hash');
});
await browser.close();
// Summary
+88 -82
View File
@@ -1,5 +1,6 @@
/* test-hash-color.js Unit tests for hash-color.js (vm.createContext sandbox)
* Tests: purity, theme split, yellow-zone clamp, sentinel, WCAG sweep
* Tests: purity, theme split, saturation variability, lightness variability,
* outline darker than fill, sentinel, perceptual distance
*/
'use strict';
const vm = require('vm');
@@ -24,6 +25,12 @@ function assert(cond, msg) {
else { failed++; console.error(' ✗ ' + msg); }
}
function parseHsl(str) {
const m = str.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/);
if (!m) return null;
return { h: parseInt(m[1]), s: parseInt(m[2]), l: parseInt(m[3]) };
}
// --- Purity: same input → same output ---
console.log('Purity:');
const r1 = HashColor.hashToHsl('a1b2c3d4', 'light');
@@ -34,110 +41,109 @@ assert(r1 === r3, 'Third call still identical (no internal state)');
// --- Theme split: light vs dark produce different L ---
console.log('Theme split:');
const light = HashColor.hashToHsl('ff00aabb', 'light');
const dark = HashColor.hashToHsl('ff00aabb', 'dark');
const light = HashColor.hashToHsl('ff00aa80', 'light');
const dark = HashColor.hashToHsl('ff00aa80', 'dark');
assert(light !== dark, 'Light and dark produce different colors for same hash');
// Extract L values
const lightL = parseInt(light.match(/(\d+)%\)$/)[1]);
const darkL = parseInt(dark.match(/(\d+)%\)$/)[1]);
assert(lightL <= 45, 'Light theme L ≤ 45% (got ' + lightL + ')');
assert(darkL >= 60, 'Dark theme L ≥ 60% (got ' + darkL + ')');
const lightP = parseHsl(light);
const darkP = parseHsl(dark);
assert(lightP.l >= 50 && lightP.l <= 65, 'Light theme L in [50,65] (got ' + lightP.l + ')');
assert(darkP.l >= 55 && darkP.l <= 72, 'Dark theme L in [55,72] (got ' + darkP.l + ')');
// --- Yellow-zone clamp: hue ∈ [45°, 75°] → L=45% in light mode ---
console.log('Yellow-zone clamp (hue 45-195 → L=30%):');
// Hue 60° → bytes: 60/360 * 65535 = 10922 = 0x2AAA → hex "2aaa"
const yellow = HashColor.hashToHsl('2aaa0000', 'light');
const yellowL = parseInt(yellow.match(/(\d+)%\)$/)[1]);
const yellowH = parseInt(yellow.match(/hsl\((\d+)/)[1]);
assert(yellowH >= 45 && yellowH <= 75, 'Yellow zone hue confirmed (' + yellowH + '°)');
assert(yellowL === 30, 'Yellow-zone L clamped to 30% in light (got ' + yellowL + ')');
// Same hash in dark should NOT clamp
const yellowDark = HashColor.hashToHsl('2aaa0000', 'dark');
const yellowDarkL = parseInt(yellowDark.match(/(\d+)%\)$/)[1]);
assert(yellowDarkL === 65, 'Yellow-zone NOT clamped in dark (got ' + yellowDarkL + ')');
// --- Saturation varies with byte 2 ---
console.log('Saturation variability (byte 2):');
const lowSat = HashColor.hashToHsl('000000ff', 'light'); // byte2=0x00
const highSat = HashColor.hashToHsl('0000ffff', 'light'); // byte2=0xff
const lowSatP = parseHsl(lowSat);
const highSatP = parseHsl(highSat);
assert(lowSatP.s === 55, 'byte2=0x00 → S=55% (got ' + lowSatP.s + ')');
assert(highSatP.s === 95, 'byte2=0xff → S=95% (got ' + highSatP.s + ')');
// Mid value
const midSat = HashColor.hashToHsl('00008000', 'light'); // byte2=0x80
const midSatP = parseHsl(midSat);
assert(midSatP.s > 55 && midSatP.s < 95, 'byte2=0x80 → S between 55 and 95 (got ' + midSatP.s + ')');
// --- Sentinel: null/empty hash ---
// --- Lightness varies with byte 3 ---
console.log('Lightness variability (byte 3):');
const lowL = HashColor.hashToHsl('00000000', 'light'); // byte3=0x00
const highL = HashColor.hashToHsl('000000ff', 'light'); // byte3=0xff
const lowLP = parseHsl(lowL);
const highLP = parseHsl(highL);
assert(lowLP.l === 50, 'byte3=0x00 light → L=50 (got ' + lowLP.l + ')');
assert(highLP.l === 65, 'byte3=0xff light → L=65 (got ' + highLP.l + ')');
const lowLD = HashColor.hashToHsl('00000000', 'dark');
const highLD = HashColor.hashToHsl('000000ff', 'dark');
assert(parseHsl(lowLD).l === 55, 'byte3=0x00 dark → L=55 (got ' + parseHsl(lowLD).l + ')');
assert(parseHsl(highLD).l === 72, 'byte3=0xff dark → L=72 (got ' + parseHsl(highLD).l + ')');
// --- Outline is darker than fill ---
console.log('Outline darker than fill:');
['a1b2c3d4', 'ff00aa80', '12345678', 'deadbeef'].forEach(h => {
['light', 'dark'].forEach(theme => {
const fill = parseHsl(HashColor.hashToHsl(h, theme));
const outline = parseHsl(HashColor.hashToOutline(h, theme));
assert(outline.l < fill.l, 'Outline L(' + outline.l + ') < Fill L(' + fill.l + ') for ' + h + '/' + theme);
});
});
// --- Outline same hue as fill ---
console.log('Outline same hue as fill:');
['a1b2c3d4', 'deadbeef'].forEach(h => {
const fill = parseHsl(HashColor.hashToHsl(h, 'light'));
const outline = parseHsl(HashColor.hashToOutline(h, 'light'));
assert(fill.h === outline.h, 'Hue matches: fill=' + fill.h + ' outline=' + outline.h + ' for ' + h);
});
// --- Sentinel: null/empty/short hash ---
console.log('Sentinel:');
assert(HashColor.hashToHsl(null, 'light') === 'hsl(0, 0%, 50%)', 'null → sentinel');
assert(HashColor.hashToHsl('', 'light') === 'hsl(0, 0%, 50%)', 'empty string → sentinel');
assert(HashColor.hashToHsl('ab', 'dark') === 'hsl(0, 0%, 50%)', 'too short (2 chars) → sentinel');
assert(HashColor.hashToHsl('abcdef', 'dark') === 'hsl(0, 0%, 50%)', '6 chars (need 8) → sentinel');
assert(HashColor.hashToHsl(undefined, 'dark') === 'hsl(0, 0%, 50%)', 'undefined → sentinel');
assert(HashColor.hashToOutline(null, 'light') === 'hsl(0, 0%, 30%)', 'null outline → sentinel');
// --- Variability: different hashes → different colors (anti-tautology) ---
console.log('Variability (anti-tautology):');
const colors = new Set();
['00000000', '80000000', 'ff000000', '00ff0000', 'ffff0000'].forEach(h => {
['00008080', '80008080', 'ff008080', '00ff8080', 'ffff8080'].forEach(h => {
colors.add(HashColor.hashToHsl(h, 'light'));
});
assert(colors.size >= 4, 'At least 4 distinct colors from 5 different hashes (got ' + colors.size + ')');
const darkColors = new Set();
['11110000', '55550000', '99990000', 'dddd0000'].forEach(h => {
darkColors.add(HashColor.hashToHsl(h, 'dark'));
});
assert(darkColors.size >= 3, 'At least 3 distinct dark colors from 4 hashes (got ' + darkColors.size + ')');
// Another variability: consecutive hashes differ
const c1 = HashColor.hashToHsl('01000000', 'light');
const c2 = HashColor.hashToHsl('02000000', 'light');
// Adjacent hashes differ
const c1 = HashColor.hashToHsl('01008080', 'light');
const c2 = HashColor.hashToHsl('02008080', 'light');
assert(c1 !== c2, 'Adjacent hashes produce different colors');
// --- WCAG contrast sweep ---
// Background constants from style.css:37 (light --content-bg) and style.css:61 (dark --content-bg)
console.log('WCAG contrast sweep (≥3.0 against --content-bg):');
function hexToRgb(hex) {
hex = hex.replace('#', '');
return [parseInt(hex.slice(0,2),16), parseInt(hex.slice(2,4),16), parseInt(hex.slice(4,6),16)];
// --- Perceptual distance: sample 50 hashes, compute pairwise HSL distance ---
console.log('Perceptual distance (50 sample hashes):');
function hslDistance(a, b) {
// Simple cylindrical distance: weight hue wrap, sat, lightness
var dh = Math.min(Math.abs(a.h - b.h), 360 - Math.abs(a.h - b.h)) / 180; // 0-1
var ds = Math.abs(a.s - b.s) / 100; // 0-1
var dl = Math.abs(a.l - b.l) / 100; // 0-1
return Math.sqrt(dh*dh + ds*ds + dl*dl);
}
function hslToRgb(h, s, l) {
s /= 100; l /= 100;
var c = (1 - Math.abs(2*l - 1)) * s;
var x = c * (1 - Math.abs((h/60) % 2 - 1));
var m = l - c/2;
var r, g, b;
if (h < 60) { r=c; g=x; b=0; }
else if (h < 120) { r=x; g=c; b=0; }
else if (h < 180) { r=0; g=c; b=x; }
else if (h < 240) { r=0; g=x; b=c; }
else if (h < 300) { r=x; g=0; b=c; }
else { r=c; g=0; b=x; }
return [Math.round((r+m)*255), Math.round((g+m)*255), Math.round((b+m)*255)];
const deterministicHashes = [];
for (var i = 0; i < 50; i++) {
var hex = ('0000000' + (i * 5347 + 12345).toString(16)).slice(-8);
deterministicHashes.push(hex);
}
function luminance(rgb) {
var a = rgb.map(function(v) { v /= 255; return v <= 0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4); });
return 0.2126*a[0] + 0.7152*a[1] + 0.0722*a[2];
const parsedColors = deterministicHashes.map(h => parseHsl(HashColor.hashToHsl(h, 'light')));
var distances = [];
for (var i = 0; i < parsedColors.length; i++) {
for (var j = i + 1; j < parsedColors.length; j++) {
distances.push(hslDistance(parsedColors[i], parsedColors[j]));
}
}
function contrastRatio(rgb1, rgb2) {
var l1 = luminance(rgb1), l2 = luminance(rgb2);
var lighter = Math.max(l1, l2), darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// Light bg: #f4f5f7 (style.css:33 --surface-0, referenced by --content-bg at line 37)
var lightBg = hexToRgb('#f4f5f7');
// Dark bg: #0f0f23 (style.css:57 --surface-0 dark, referenced by --content-bg at line 61)
var darkBg = hexToRgb('#0f0f23');
var wcagFails = [];
for (var hue = 0; hue < 360; hue += 15) {
// Simulate hashToHsl output for this hue
// Light theme
var lL = (hue >= 45 && hue <= 195) ? 30 : 38;
var lightRgb = hslToRgb(hue, 70, lL);
var lRatio = contrastRatio(lightRgb, lightBg);
if (lRatio < 3.0) wcagFails.push('light hue=' + hue + ' ratio=' + lRatio.toFixed(2));
// Dark theme
var darkRgb = hslToRgb(hue, 70, 65);
var dRatio = contrastRatio(darkRgb, darkBg);
if (dRatio < 3.0) wcagFails.push('dark hue=' + hue + ' ratio=' + dRatio.toFixed(2));
}
assert(wcagFails.length === 0, 'All hues pass WCAG ≥3.0 contrast' + (wcagFails.length ? ' FAILURES: ' + wcagFails.join('; ') : ''));
var avgDist = distances.reduce((a, b) => a + b, 0) / distances.length;
var minDist = Math.min(...distances);
console.log(' Avg pairwise HSL distance: ' + avgDist.toFixed(4));
console.log(' Min pairwise HSL distance: ' + minDist.toFixed(4));
assert(avgDist > 0.15, 'Average pairwise distance > 0.15 (got ' + avgDist.toFixed(4) + ')');
assert(minDist > 0.01, 'Min pairwise distance > 0.01 (got ' + minDist.toFixed(4) + ')');
// --- Summary ---
console.log('\n' + passed + ' passed, ' + failed + ' failed');