mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-07 18:31:46 +00:00
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:
+39
-17
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user