From fb4d4b1e8680c827a13315c05a3113f789fcd22c Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 17:03:20 +0000 Subject: [PATCH 1/8] test(ui): reproduce no warning on a 1-byte path hash (#353) Unit tests for the shared isPathHashSizeWarn/renderPathHashSize helper, the channel "Sent with" badge, the packet-detail Hash Size row, the node badge and the customizer mapping, plus a browser regression in light and dark theme and at 375x812. All fail until the warning is implemented. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 1 + test-all.sh | 1 + test-channels-observed-path-hash-size.js | 30 ++++ test-customizer-v2.js | 21 +++ test-frontend-helpers.js | 29 ++++ test-issue-353-path-hash-warn-e2e.js | 206 +++++++++++++++++++++++ test-issue-353-path-hash-warn.js | 154 +++++++++++++++++ test-packets.js | 34 ++++ 8 files changed, 476 insertions(+) create mode 100644 test-issue-353-path-hash-warn-e2e.js create mode 100644 test-issue-353-path-hash-warn.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3b2fa18e0..27453bc71 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -443,6 +443,7 @@ jobs: BASE_URL=http://localhost:13581 node test-issue-147-packets-url-obs-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-165-grouped-hop-warn-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-packet-detail-sender-hash-size-obs-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-353-path-hash-warn-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-96-hide-control-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-180-packets-url-modal-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-slideover-1168-munger-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/test-all.sh b/test-all.sh index e3238a6ca..33dca4f23 100755 --- a/test-all.sh +++ b/test-all.sh @@ -185,6 +185,7 @@ run test-issue-1846-observers-width.js run test-issue-1562-observers-summary.js run test-issue-1509-nav-active-bg.js run test-issue-1509-detect-preset.js +run test-issue-353-path-hash-warn.js run test-live.js run test-issue-220-live-filter-readiness.js run test-coverage-gate.js diff --git a/test-channels-observed-path-hash-size.js b/test-channels-observed-path-hash-size.js index 96cd03e76..40713b309 100644 --- a/test-channels-observed-path-hash-size.js +++ b/test-channels-observed-path-hash-size.js @@ -108,6 +108,9 @@ const ctx = { // assertions below would have passed against a contract nothing ships. senderPathHashSize: loadAppHelper('senderPathHashSize', ['isTransportRoute', 'getPathLenOffset', 'pathHashSizeFromByte', 'senderPathHashSize']), + // #353: the shared 1-byte warn decision + markup, also the real app.js code. + renderPathHashSize: loadAppHelper('renderPathHashSize', + ['escapeHtml', 'pathHashSizeValue', 'isPathHashSizeWarn', 'renderPathHashSize']), }; vm.createContext(ctx); // Expose the cache fetch helper only inside this VM so the regression can @@ -181,6 +184,33 @@ test('sender badge uses only a valid encoded width', () => { assert.strictEqual(senderBadge({ senderPathHashSize: '' }), ''); }); +// #353: a 1-byte sender width reads as a warning; 2/3-byte stay neutral. +test('1-byte sender badge gets the warn class, the warning icon and the warn tooltip', () => { + const html = senderBadge({ senderPathHashSize: 1 }); + assert.match(html, /class="ch-path-hash-badge ch-path-hash-badge--warn path-hash-warn"/); + assert.match(html, /#ph-warning/); + assert.match(html, /aria-hidden="true"/); + assert.match(html, /Warning: <\/span>Sent with: 1-byte { + for (const size of [2, 3]) { + const html = senderBadge({ senderPathHashSize: size }); + assert.ok(!html.includes('--warn'), size + '-byte got the warn class'); + assert.ok(!html.includes('ph-warning'), size + '-byte got the warning icon'); + assert.match(html, /title="Path hash size encoded in the sender’s packet header"/); + assert.match(html, new RegExp('>Sent with: ' + size + '-byte$')); + } +}); + +test('missing or invalid sender widths still render no badge', () => { + for (const v of [undefined, null, 0, 4, 'abc']) { + assert.strictEqual(senderBadge({ senderPathHashSize: v }), '', 'for ' + String(v)); + } + assert.strictEqual(senderBadge(null), ''); +}); + test('REST refresh retains a sender width from the matching live message', () => { const current = [{ packetHash: 'sender', senderPathHashSize: 3, _fromWS: true, _wsAt: Date.now() }]; const rest = [{ packetHash: 'sender', text: 'REST' }]; diff --git a/test-customizer-v2.js b/test-customizer-v2.js index be902e694..3ecf8c035 100644 --- a/test-customizer-v2.js +++ b/test-customizer-v2.js @@ -353,6 +353,27 @@ test('includes sectionBg mapping', () => { assert.strictEqual(api.THEME_CSS_MAP.sectionBg, '--section-bg'); }); +// #353: the 1-byte path hash warning colour is themeable. +test('maps pathHashWarn → --path-hash-warn (#353)', () => { + const { api } = loadCustomizer(); + assert.strictEqual(api.THEME_CSS_MAP.pathHashWarn, '--path-hash-warn'); +}); + +test('applyCSS writes --path-hash-warn when overridden (#353)', () => { + const { ctx, api } = loadCustomizer(); + const written = {}; + ctx.document.documentElement.style.setProperty = (k, v) => { written[k] = v; }; + api.applyCSS({ theme: { pathHashWarn: '#123456' } }, {}); + assert.strictEqual(written['--path-hash-warn'], '#123456'); +}); + +test('pathHashWarn has a label, a hint and an Advanced row (#353)', () => { + const src = fs.readFileSync('public/customize-v2.js', 'utf8'); + assert.match(src, /pathHashWarn: '1-byte Path Hash'/); + assert.match(src, /pathHashWarn: '[^']*1-byte path hash[^']*'/); + assert.match(src, /var ADVANCED_KEYS = \[[^\]]*'pathHashWarn'/); +}); + test('matches all keys from old app.js varMap', () => { const { api } = loadCustomizer(); const expectedKeys = [ diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 7781a54b0..1a71ef92c 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -519,6 +519,35 @@ console.log('\n=== nodes.js: getStatusTooltip / getStatusInfo (extracted) ==='); assert.ok(html.includes('room')); assert.ok(!html.includes('variable hash size')); }); + // #353: a node advertising a 1-byte path hash gets a warning badge. + const nodeWithHash = (hash_size) => ({ + role: 'repeater', public_key: 'abcdef1234567890', hash_size, + last_heard: new Date().toISOString() + }); + test('renderNodeBadges adds the 1-byte path hash warning badge (#353)', () => { + for (const size of [1, '1']) { + const html = ex.renderNodeBadges(nodeWithHash(size), '#dc2626'); + assert.match(html, /class="badge node-path-hash-badge node-path-hash-badge--warn path-hash-warn"/); + assert.match(html, /#ph-warning/); + assert.match(html, /Warning: <\/span>1-byte path hash<\/span>/); + assert.match(html, /title="[^"]*2- or 3-byte[^"]*path\.hash\.mode/); + assert.ok(!html.includes('multibyte-badge'), '1-byte must not claim Multibyte'); + } + }); + test('renderNodeBadges adds no path hash warning for 2/3-byte nodes (#353)', () => { + for (const size of [2, 3]) { + const html = ex.renderNodeBadges(nodeWithHash(size), '#dc2626'); + assert.ok(!html.includes('node-path-hash-badge'), size + '-byte got the warning badge'); + assert.ok(!html.includes('path-hash-warn'), size + '-byte got the warn class'); + assert.ok(html.includes('multibyte-badge'), size + '-byte lost the Multibyte badge'); + } + }); + test('renderNodeBadges adds no path hash warning when hash_size is missing/invalid (#353)', () => { + for (const size of [null, undefined, 0, 4]) { + const html = ex.renderNodeBadges(nodeWithHash(size), '#dc2626'); + assert.ok(!html.includes('node-path-hash-badge'), 'for ' + size); + } + }); test('renderNodeBadges handles string hash_sizes_seen gracefully', () => { const html = ex.renderNodeBadges({ role: 'repeater', public_key: 'abcdef1234567890', diff --git a/test-issue-353-path-hash-warn-e2e.js b/test-issue-353-path-hash-warn-e2e.js new file mode 100644 index 000000000..340fad953 --- /dev/null +++ b/test-issue-353-path-hash-warn-e2e.js @@ -0,0 +1,206 @@ +/** + * #353 — browser regression: a 1-byte path hash reads as a warning in the + * channel "Sent with" badge, the packet-detail Hash Size row and the node + * detail badges; 2/3-byte stay neutral. Checked in the light and dark theme + * and at a 375×812 phone viewport (no horizontal page overflow). + * + * Channels use channels.js' own test hooks, packet detail a stubbed + * /api/packets/, node detail real fixture nodes picked by hash_size. + * Only the local test server is contacted. + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-353-path-hash-warn-e2e.js + * Optional: SCREENSHOT_DIR= saves one screenshot per page/theme/viewport. + */ +'use strict'; +const path = require('path'); +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const SHOTS = process.env.SCREENSHOT_DIR || ''; +const HASH_1 = 'e2e353aa11223344'; +const HASH_2 = 'e2e353bb11223344'; +const PAYLOAD = 'aabbccddeeff00112233445566778899'; +const DECODED = JSON.stringify({ type: 'CHAN', channel: '#e2e-353', sender: 'WarnNode', text: 'path hash warn' }); + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +function packetBody(hash, rawHex) { + const obs = { + id: 935300001, raw_hex: rawHex, timestamp: '2026-10-07T10:00:00Z', + observer_id: 'e2e-353-obs', observer_name: 'e2e-353-obs', direction: 'rx', + snr: -5, rssi: -101, score: null, hash: hash, route_type: 1, payload_type: 5, + payload_version: 0, path_json: '[]', decoded_json: DECODED, created_at: '2026-10-07T10:00:00Z', + }; + return { + packet: { + id: 935300, hash: hash, raw_hex: rawHex, route_type: 1, payload_type: 5, payload_version: 0, + first_seen: obs.timestamp, timestamp: obs.timestamp, observer_id: obs.observer_id, + snr: -5, rssi: -101, path_json: '[]', decoded_json: DECODED, scope_name: null, + }, + path: [], observation_count: 1, observations: [obs], + }; +} + +async function stubPackets(context) { + // Flood (header 0x15) with path byte 0x00 -> 1-byte; 0x40 -> 2-byte. + const bodies = { [HASH_1]: packetBody(HASH_1, '1500' + PAYLOAD), [HASH_2]: packetBody(HASH_2, '1540' + PAYLOAD) }; + await context.route((url) => /^\/api\/packets\/e2e353/.test(url.pathname), async (route) => { + const hash = new URL(route.request().url()).pathname.split('/').pop(); + try { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(bodies[hash]) }); + } catch (_) { try { await route.abort(); } catch (__) { /* closed */ } } + }); +} + +// Shared assertions for a rendered warning element: modifier class, icon +// hidden from AT, sr-only text, firmware-named tooltip, colour = --path-hash-warn. +async function readWarn(locator) { + return locator.evaluate((el) => { + const cs = getComputedStyle(el); + const probe = document.createElement('span'); + probe.style.color = 'var(--path-hash-warn)'; + document.body.appendChild(probe); + const want = getComputedStyle(probe).color; + probe.style.color = 'var(--danger)'; + const danger = getComputedStyle(probe).color; + probe.remove(); + const icon = el.querySelector('svg.ph-icon use[href$="#ph-warning"]'); + const r = el.getBoundingClientRect(); + return { + cls: el.className, title: el.getAttribute('title') || '', color: cs.color, want, danger, + iconHidden: !!icon && icon.closest('svg').getAttribute('aria-hidden') === 'true', + iconVisible: !!icon && icon.closest('svg').getBoundingClientRect().width > 0, + sr: (el.querySelector('.sr-only') || {}).textContent || '', + right: r.right, vw: document.documentElement.clientWidth, + overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; + }); +} +function assertWarn(w, block, where) { + assert(w.cls.split(/\s+/).includes(block + '--warn'), where + ': no ' + block + '--warn in ' + w.cls); + assert(w.cls.split(/\s+/).includes('path-hash-warn'), where + ': no path-hash-warn'); + assert(w.iconHidden && w.iconVisible, where + ': warning icon missing, visible=' + w.iconVisible); + assert(w.sr === 'Warning: ', where + ': sr-only text ' + JSON.stringify(w.sr)); + assert(/2- or 3-byte/.test(w.title) && /Experimental Settings/.test(w.title) && /path\.hash\.mode/.test(w.title), + where + ': tooltip ' + JSON.stringify(w.title)); + assert(w.color === w.want && w.want === w.danger, where + ': colour ' + w.color + ' vs --path-hash-warn ' + w.want + ' / --danger ' + w.danger); + assert(w.right <= w.vw + 0.5, where + ': warning runs off-screen (' + w.right + ' > ' + w.vw + ')'); + assert(w.overflow <= 0, where + ': page scrolls horizontally by ' + w.overflow + 'px'); +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + console.log(`\n=== #353 1-byte path hash warning (${BASE}) ===`); + + // Real fixture nodes: one advertising 1-byte, one 2-byte. + const probeCtx = await browser.newContext(); + const nodesResp = await probeCtx.request.get(BASE + '/api/nodes?limit=500'); + const nodes = ((await nodesResp.json()).nodes) || []; + await probeCtx.close(); + const node1 = nodes.find((n) => Number(n.hash_size) === 1); + const node2 = nodes.find((n) => Number(n.hash_size) === 2); + + const variants = [ + { name: 'light desktop', theme: 'light', viewport: { width: 1400, height: 900 } }, + { name: 'dark desktop', theme: 'dark', viewport: { width: 1400, height: 900 } }, + { name: 'light 375x812', theme: 'light', viewport: { width: 375, height: 812 } }, + { name: 'dark 375x812', theme: 'dark', viewport: { width: 375, height: 812 } }, + ]; + + for (const v of variants) { + const context = await browser.newContext({ viewport: v.viewport, colorScheme: v.theme }); + await context.addInitScript((theme) => { try { localStorage.setItem('meshcore-theme', theme); } catch (_) {} }, v.theme); + await stubPackets(context); + const page = await context.newPage(); + page.setDefaultTimeout(15000); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + const shot = async (label) => { + if (SHOTS) await page.screenshot({ path: path.join(SHOTS, `353-${label}-${v.name.replace(/\W+/g, '-')}.png`) }); + }; + + await step(`${v.name}: theme applied`, async () => { + await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' }); + const t = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); + assert(t === v.theme, 'data-theme is ' + t); + }); + + await step(`${v.name}: channel 1-byte badge warns, 2/3-byte stay neutral`, async () => { + await page.waitForFunction(() => typeof window._channelsRenderMessagesForTest === 'function'); + await page.evaluate(() => { + const base = { text: 'hello', timestamp: '2026-10-07T08:00:00Z', observers: [], repeats: 1 }; + window._channelsSetStateForTest({ + channels: [{ hash: '#e2e-353', name: '#e2e-353', messageCount: 3 }], + messages: [ + Object.assign({}, base, { sender: 'OneByte', packetHash: 'h1', senderPathHashSize: 1 }), + Object.assign({}, base, { sender: 'TwoByte', packetHash: 'h2', senderPathHashSize: 2 }), + Object.assign({}, base, { sender: 'ThreeByte', packetHash: 'h3', senderPathHashSize: 3 }), + ], + selectedHash: '#e2e-353', + }); + document.querySelector('.ch-layout')?.classList.add('ch-detail-open'); + window._channelsRenderMessagesForTest(); + }); + const badges = page.locator('.ch-path-hash-badge'); + assert(await badges.count() === 3, 'badge count ' + await badges.count()); + const warn = page.locator('.ch-path-hash-badge--warn'); + assert(await warn.count() === 1, 'warn count ' + await warn.count()); + await warn.scrollIntoViewIfNeeded(); + assertWarn(await readWarn(warn), 'ch-path-hash-badge', 'channel'); + assert(await warn.textContent() === 'Warning: Sent with: 1-byte', 'warn text ' + await warn.textContent()); + const neutral = await page.locator('.ch-path-hash-badge:not(.ch-path-hash-badge--warn)').evaluateAll((els) => + els.map((el) => ({ text: el.textContent, icon: !!el.querySelector('svg'), title: el.title }))); + assert(JSON.stringify(neutral.map((n) => n.text)) === '["Sent with: 2-byte","Sent with: 3-byte"]', JSON.stringify(neutral)); + assert(neutral.every((n) => !n.icon && n.title === 'Path hash size encoded in the sender’s packet header'), JSON.stringify(neutral)); + await shot('channels'); + }); + + await step(`${v.name}: packet detail Hash Size warns on 1-byte`, async () => { + await page.goto(`${BASE}/#/packet/${HASH_1}`, { waitUntil: 'load' }); + await page.reload({ waitUntil: 'load' }); + const warn = page.locator('dl.detail-meta .detail-hash-size--warn'); + await warn.waitFor(); + await warn.scrollIntoViewIfNeeded(); + assertWarn(await readWarn(warn), 'detail-hash-size', 'packet detail'); + assert(await warn.textContent() === 'Warning: 1 byte', 'text ' + await warn.textContent()); + await shot('packet'); + }); + + await step(`${v.name}: packet detail Hash Size stays neutral on 2-byte`, async () => { + await page.goto(`${BASE}/#/packet/${HASH_2}`, { waitUntil: 'load' }); + await page.reload({ waitUntil: 'load' }); + const size = page.locator('dl.detail-meta .detail-hash-size'); + await size.waitFor(); + assert((await size.textContent()) === '2 bytes', 'text ' + await size.textContent()); + assert(await page.locator('dl.detail-meta .path-hash-warn').count() === 0, '2-byte packet warns'); + }); + + await step(`${v.name}: node detail shows the 1-byte badge, not on a 2-byte node`, async () => { + assert(node1 && node2, 'fixture lacks a 1-byte or 2-byte node'); + await page.goto(`${BASE}/#/nodes/${encodeURIComponent(node1.public_key)}`, { waitUntil: 'load' }); + const warn = page.locator('.node-full-card .node-path-hash-badge--warn'); + await warn.waitFor(); + assertWarn(await readWarn(warn), 'node-path-hash-badge', 'node detail'); + assert(await warn.textContent() === 'Warning: 1-byte path hash', 'text ' + await warn.textContent()); + assert(await page.locator('.node-full-card .multibyte-badge').count() === 0, '1-byte node claims Multibyte'); + await shot('node'); + await page.goto(`${BASE}/#/nodes/${encodeURIComponent(node2.public_key)}`, { waitUntil: 'load' }); + await page.locator('.node-full-card .multibyte-badge').waitFor(); + assert(await page.locator('.node-full-card .node-path-hash-badge').count() === 0, '2-byte node warns'); + }); + + await context.close(); + } + + await browser.close(); + console.log(`\n=== ${passed} passed, ${failed} failed ===`); + process.exit(failed ? 1 : 0); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/test-issue-353-path-hash-warn.js b/test-issue-353-path-hash-warn.js new file mode 100644 index 000000000..e6c9df97b --- /dev/null +++ b/test-issue-353-path-hash-warn.js @@ -0,0 +1,154 @@ +/** + * #353 — a 1-byte path hash reads as a warning. + * + * Covers the ONE shared decision + markup helper in public/app.js + * (isPathHashSizeWarn / renderPathHashSize) and its theming contract + * (--path-hash-warn in style.css). The three call sites are covered next to + * their existing tests: + * - channels "Sent with" badge -> test-channels-observed-path-hash-size.js + * - packet detail Hash Size row -> test-packets.js + * - node detail badge -> test-frontend-helpers.js + * - customizer mapping -> test-customizer-v2.js + * + * Run: node test-issue-353-path-hash-warn.js + */ +'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); } +} + +// Extract the REAL functions from public/app.js (no hand-rolled copies). +function loadAppFunctions(names) { + const source = fs.readFileSync('public/app.js', 'utf8'); + let extracted = ''; + for (const name of names) { + const start = source.search(new RegExp('^function ' + name + '\\s*\\(', 'm')); + if (start < 0) throw new Error('public/app.js no longer declares function ' + name); + const open = source.indexOf('{', start); + let depth = 0, end = -1; + for (let i = open; i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}' && --depth === 0) { end = i + 1; break; } + } + extracted += source.slice(start, end) + '\n'; + } + const ctx = { Number, String }; + vm.createContext(ctx); + vm.runInContext(extracted + 'this.__fns = {' + names.join(',') + '};', ctx); + return ctx.__fns; +} + +const { isPathHashSizeWarn, renderPathHashSize } = + loadAppFunctions(['escapeHtml', 'pathHashSizeValue', 'isPathHashSizeWarn', 'renderPathHashSize']); + +console.log('\n=== #353 isPathHashSizeWarn ==='); + +test('1-byte is a warning, including the string form a JSON field can carry', () => { + assert.strictEqual(isPathHashSizeWarn(1), true); + assert.strictEqual(isPathHashSizeWarn('1'), true); +}); + +test('2- and 3-byte are not a warning', () => { + assert.strictEqual(isPathHashSizeWarn(2), false); + assert.strictEqual(isPathHashSizeWarn(3), false); +}); + +test('missing or invalid sizes are not a warning', () => { + for (const v of [0, 4, -1, null, undefined, NaN, '', 'x', true]) { + assert.strictEqual(isPathHashSizeWarn(v), false, 'for ' + String(v)); + } +}); + +console.log('\n=== #353 renderPathHashSize ==='); + +test('1-byte renders the warn modifier, the shared warn class, an icon and screen-reader text', () => { + const html = renderPathHashSize(1, 'Sent with: 1-byte', { block: 'ch-path-hash-badge' }); + assert.match(html, /class="ch-path-hash-badge ch-path-hash-badge--warn path-hash-warn"/); + assert.match(html, /Warning: <\/span>Sent with: 1-byte<\/span>$/); +}); + +test('1-byte tooltip explains the collision risk, recommends 2/3-byte and names the firmware settings', () => { + const html = renderPathHashSize(1, '1 byte', { block: 'detail-hash-size' }); + const title = (html.match(/title="([^"]*)"/) || [])[1] || ''; + assert.match(title, /collide/); + assert.match(title, /2- or 3-byte/); + // firmware/docs/faq.md 3.9.3 (companion) and cli_commands.md (repeater) + assert.match(title, /Settings → Experimental Settings/); + assert.match(title, /path\.hash\.mode/); + // faq.md 3.9.3/3.9.6: pre-1.14 repeaters drop 2/3-byte packets + assert.match(title, /1\.14/); +}); + +test('2- and 3-byte render neutral: no warn class, no icon, caller title kept', () => { + for (const size of [2, 3]) { + const html = renderPathHashSize(size, 'Sent with: ' + size + '-byte', + { block: 'ch-path-hash-badge', title: 'neutral tip' }); + assert.strictEqual(html, + 'Sent with: ' + size + '-byte'); + } +}); + +test('neutral render without a caller title emits no title attribute', () => { + assert.strictEqual(renderPathHashSize(2, '2 bytes', { block: 'detail-hash-size' }), + '2 bytes'); +}); + +test('the warn tooltip replaces the neutral caller title', () => { + const html = renderPathHashSize(1, 'x', { block: 'b', title: 'neutral tip' }); + assert.ok(!html.includes('neutral tip')); +}); + +test('extra classes are kept on both variants', () => { + assert.match(renderPathHashSize(1, 'x', { block: 'node-path-hash-badge', cls: 'badge' }), + /class="badge node-path-hash-badge node-path-hash-badge--warn path-hash-warn"/); + assert.match(renderPathHashSize(2, 'x', { block: 'node-path-hash-badge', cls: 'badge' }), + /class="badge node-path-hash-badge"/); +}); + +test('missing or invalid sizes render nothing', () => { + for (const v of [0, 4, null, undefined, NaN, '', '']) { + assert.strictEqual(renderPathHashSize(v, 'x', { block: 'b' }), '', 'for ' + String(v)); + } +}); + +test('label and title are escaped', () => { + const html = renderPathHashSize(2, '', { block: 'b', title: '"x' }); + assert.ok(html.includes('<b>') && html.includes('"x')); + assert.ok(!html.includes('')); +}); + +console.log('\n=== #353 theming: --path-hash-warn ==='); +{ + const css = fs.readFileSync('public/style.css', 'utf8'); + // The light-theme semantic :root block (the one declaring --danger); the + // dark blocks redefine --danger, which the variable then follows. + const dangerAt = css.indexOf('--danger: var(--palette-red-600'); + const rootBlock = css.slice(css.lastIndexOf('\n:root {', dangerAt), css.indexOf('\n}', dangerAt)); + + test(':root declares --path-hash-warn defaulting to var(--danger)', () => { + assert.match(rootBlock, /--path-hash-warn:\s*var\(--danger\);/); + }); + + test('.path-hash-warn colours its text with the variable, never a hardcoded colour', () => { + const m = css.match(/\.path-hash-warn\s*\{([^}]*)\}/); + assert.ok(m, '.path-hash-warn rule missing'); + assert.match(m[1], /color:\s*var\(--path-hash-warn\)/); + assert.ok(!/#[0-9a-f]{3,8}\b|rgba?\(/i.test(m[1]), 'hardcoded colour in .path-hash-warn'); + }); + + test('the channel badge warn modifier uses the variable for its border', () => { + const m = css.match(/\.ch-path-hash-badge--warn\s*\{([^}]*)\}/); + assert.ok(m, '.ch-path-hash-badge--warn rule missing'); + assert.match(m[1], /border-color:\s*var\(--path-hash-warn\)/); + }); +} + +console.log('\n' + passed + '/' + (passed + failed) + ' tests passed'); +process.exit(failed > 0 ? 1 : 0); diff --git a/test-packets.js b/test-packets.js index 33ed65546..c140443ad 100644 --- a/test-packets.js +++ b/test-packets.js @@ -1841,6 +1841,40 @@ console.log('\n=== packets.js: detail Hash Size reads the selected observation = }); } +// ===== packets.js: detail Hash Size warns on 1-byte (#353) ===== +console.log('\n=== packets.js: detail Hash Size row warns on 1-byte (#353) ==='); +{ + const api = loadPacketsSandbox()._packetsTestAPI; + const src = fs.readFileSync('public/packets.js', 'utf8'); + + test('1-byte Hash Size row gets the warn class, the warning icon and the warn tooltip', () => { + const html = api.hashSizeDetailHtml(1); + assert.ok(html.startsWith('
Hash Size
'), html); + assert.match(html, /class="detail-hash-size detail-hash-size--warn path-hash-warn"/); + assert.match(html, /#ph-warning/); + assert.match(html, /Warning: <\/span>1 byte<\/span><\/dd>$/); + assert.match(html, /title="[^"]*2- or 3-byte/); + }); + + test('2- and 3-byte Hash Size rows stay neutral', () => { + assert.strictEqual(api.hashSizeDetailHtml(2), + '
Hash Size
2 bytes
'); + assert.strictEqual(api.hashSizeDetailHtml(3), + '
Hash Size
3 bytes
'); + }); + + test('no encoded width renders no Hash Size row, as before', () => { + for (const v of [null, undefined, 0]) assert.strictEqual(api.hashSizeDetailHtml(v), '', 'for ' + v); + }); + + test('renderDetail emits the Hash Size row through hashSizeDetailHtml', () => { + assert.ok(src.includes('${hashSizeDetailHtml(hashSize)}'), + 'the detail-meta list must use the shared #353 row helper'); + assert.ok(!src.includes('
Hash Size
${hashSize}'), + 'the old inline Hash Size row must be gone'); + }); +} + // ===== packets.js: View Path button (detail panel) ===== console.log('\n=== packets.js: View Path button ==='); { From 963a81c6bb2bfa6e3621a366ea175317cca8f205 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 17:03:21 +0000 Subject: [PATCH 2/8] feat(ui): warn on 1-byte path hash in channels, packet detail and node badge (#353) One shared helper in app.js decides (size 1 = warning) and renders the markup for all three places: the channel "Sent with" badge, the packet-detail Hash Size row and a new node-detail badge. The warning has a Phosphor warning icon (aria-hidden), sr-only text and a tooltip naming the firmware settings (companion app Experimental Settings, repeater path.hash.mode; firmware/docs/faq.md 3.9.x). Colour comes from the new --path-hash-warn (default var(--danger)), mapped in the customizer. Co-Authored-By: Claude Opus 5.5 --- public/app.js | 42 ++++++++++++++++++++++++++++++++++++++++++ public/channels.js | 9 +++++++-- public/customize-v2.js | 6 ++++-- public/nodes.js | 4 ++++ public/packets.js | 12 +++++++++++- public/style.css | 10 ++++++++++ 6 files changed, 78 insertions(+), 5 deletions(-) diff --git a/public/app.js b/public/app.js index 5a0519b4b..31d0725d9 100644 --- a/public/app.js +++ b/public/app.js @@ -50,6 +50,48 @@ function senderPathHashSize(rawHex) { const pathByte = parseInt(pathHex, 16); return pathHashSizeFromByte(pathByte, route, header); } +/** + * #353: the ONE decision for "this path-hash width reads as a warning". A + * 1-byte hash collides often across nodes; the operator recommends 2/3-byte. + * The threshold is hardcoded for now (AGENTS.md rule 8: customizer later). + */ +function isPathHashSizeWarn(size) { + return pathHashSizeValue(size) === 1; +} +/** A 1/2/3 path-hash width from a number or numeric string, else null. */ +function pathHashSizeValue(size) { + if (typeof size !== 'number' && typeof size !== 'string') return null; + const n = Number(size); + return n === 1 || n === 2 || n === 3 ? n : null; +} +/** + * #353: the ONE markup for a path-hash width, shared by the channel "Sent + * with" badge, the packet-detail Hash Size row and the node-detail badge. + * Returns '' unless size is 1/2/3. opts.block is the caller's BEM class (the + * warn variant adds `${block}--warn` plus the shared `path-hash-warn`), + * opts.cls extra leading classes, opts.title the neutral tooltip. A warning + * carries an icon and screen-reader text so it never relies on colour alone. + * The setting names come from firmware/docs/faq.md 3.9.3 (companion app) and + * 3.9.4 / cli_commands.md (repeater `path.hash.mode`); faq.md 3.9.6 is why the + * tooltip says to switch only once most repeaters run 1.14+. + */ +function renderPathHashSize(size, text, opts) { + const n = pathHashSizeValue(size); + if (n == null) return ''; + const o = opts || {}; + const cls = (o.cls ? o.cls + ' ' : '') + o.block; + const label = escapeHtml(text); + if (!isPathHashSizeWarn(n)) { + return '' + label + ''; + } + const tip = '1-byte path hash: prefixes collide often across nodes, so hops can be ambiguous. ' + + '2- or 3-byte is recommended. Companion: MeshCore app Settings → Experimental Settings (message path hash size). ' + + 'Repeater adverts: set path.hash.mode 1 (2-byte) or 2 (3-byte). ' + + 'Repeaters older than firmware 1.14 drop 2/3-byte packets, so switch messages once most of your mesh runs 1.14+.'; + return '' + + '' + + 'Warning: ' + label + ''; +} /** * scopeName is optional (callers that don't pass it get the original * unscoped "T" badge). Pass a packet's scope_name to also surface the diff --git a/public/channels.js b/public/channels.js index ac27ca50a..61a5ed4d3 100644 --- a/public/channels.js +++ b/public/channels.js @@ -66,10 +66,15 @@ // The header records the sender's choice even before a flood has relayed. // Observation-path evidence is retained internally, but is not the label. + // #353: a 1-byte width renders the shared warning variant (app.js). Guarded + // like senderSizeFromRawHex below: app.js always loads first in the page. function renderSenderPathHashBadge(message) { + if (typeof renderPathHashSize !== 'function') return ''; var size = Number(message && message.senderPathHashSize); - if (size !== 1 && size !== 2 && size !== 3) return ''; - return 'Sent with: ' + size + '-byte'; + return renderPathHashSize(size, 'Sent with: ' + size + '-byte', { + block: 'ch-path-hash-badge', + title: 'Path hash size encoded in the sender’s packet header', + }); } function senderSizeFromRawHex(rawHex) { diff --git a/public/customize-v2.js b/public/customize-v2.js index d1d844cfd..d76a4ebf7 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -52,6 +52,7 @@ sectionBg: '--section-bg', cardBg: '--card-bg', contentBg: '--content-bg', detailBg: '--detail-bg', inputBg: '--input-bg', rowStripe: '--row-stripe', rowHover: '--row-hover', selectedBg: '--selected-bg', + pathHashWarn: '--path-hash-warn', font: '--font', mono: '--mono' }; @@ -300,7 +301,7 @@ statusGreen: 'Healthy', statusYellow: 'Warning', statusRed: 'Error', surface1: 'Cards', surface2: 'Panels', surface3: 'Tertiary Surface', sectionBg: 'Section Header', cardBg: 'Card Fill', contentBg: 'Content Area', detailBg: 'Detail Panels', inputBg: 'Inputs', rowStripe: 'Table Stripe', - rowHover: 'Row Hover', selectedBg: 'Selected', + rowHover: 'Row Hover', selectedBg: 'Selected', pathHashWarn: '1-byte Path Hash', font: 'Body Font', mono: 'Mono Font' }; @@ -318,6 +319,7 @@ cardBg: 'Detail panels, modals', contentBg: 'Content area behind cards', detailBg: 'Modal, packet detail, side panels', inputBg: 'Text inputs, dropdowns', rowStripe: 'Alternating table rows', rowHover: 'Table row hover', selectedBg: 'Selected/active rows', + pathHashWarn: 'Warning for a 1-byte path hash (channels, packet detail, node badge)', font: 'System font stack for body text', mono: 'Monospace font for hex, code, hashes' }; @@ -379,7 +381,7 @@ } var BASIC_KEYS = ['accent', 'navBg', 'navText', 'background', 'text', 'statusGreen', 'statusYellow', 'statusRed']; - var ADVANCED_KEYS = ['accentHover', 'navBg2', 'navTextMuted', 'navActiveBg', 'textMuted', 'border', 'surface1', 'surface2', 'cardBg', 'contentBg', 'detailBg', 'inputBg', 'rowStripe', 'rowHover', 'selectedBg']; + var ADVANCED_KEYS = ['accentHover', 'navBg2', 'navTextMuted', 'navActiveBg', 'textMuted', 'border', 'surface1', 'surface2', 'cardBg', 'contentBg', 'detailBg', 'inputBg', 'rowStripe', 'rowHover', 'selectedBg', 'pathHashWarn']; var FONT_KEYS = ['font', 'mono']; // ── Validation helpers ── diff --git a/public/nodes.js b/public/nodes.js index d1cece996..35b491f6a 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -334,6 +334,10 @@ if (n.hash_size && Number(n.hash_size) >= 2) { html += ` Multibyte: ${Number(n.hash_size)}-byte`; } + // #353: a 1-byte advertised path hash collides often -- shared warning badge (app.js). + if (isPathHashSizeWarn(n.hash_size)) { + html += ' ' + renderPathHashSize(1, '1-byte path hash', { block: 'node-path-hash-badge', cls: 'badge' }); + } if (n.hash_size_inconsistent) { html += ` variable hash size`; } diff --git a/public/packets.js b/public/packets.js index d1f137ec5..09607ca1b 100644 --- a/public/packets.js +++ b/public/packets.js @@ -3610,6 +3610,15 @@ return ''; } + // Detail-meta "Hash Size" row for the sender-selected width (null = not + // encoded, no row). #353: a 1-byte width renders the shared warning (app.js). + function hashSizeDetailHtml(hashSize) { + if (!hashSize) return ''; + return '
Hash Size
' + + renderPathHashSize(hashSize, hashSize + ' byte' + (hashSize !== 1 ? 's' : ''), { block: 'detail-hash-size' }) + + '
'; + } + async function renderDetail(panel, data, chosenObsId) { const pkt = data.packet; const observations = data.observations || []; @@ -3864,7 +3873,7 @@
SNR / RSSI
${snr != null ? snr + ' dB' : '—'} / ${rssi != null ? rssi + ' dBm' : '—'}
Route Type
${routeTypeName(pkt.route_type)}
${pkt.scope_name != null ? `
Scope
${pkt.scope_name !== '' ? escapeHtml(pkt.scope_name) : 'unknown scope'}
` : ''} - ${hashSize ? `
Hash Size
${hashSize} byte${hashSize !== 1 ? 's' : ''}
` : ''} + ${hashSizeDetailHtml(hashSize)}
Propagation
${propagationHtml}
${transportCodesRow} ${rawCustomRow} @@ -4538,6 +4547,7 @@ reconcileVisibleCols, getDetailPreview, buildDetailMessageHtml, + hashSizeDetailHtml, sortGroupChildren, getPathHopCount, renderDecodedPacket, diff --git a/public/style.css b/public/style.css index a3d8e1db4..7ec2d40eb 100644 --- a/public/style.css +++ b/public/style.css @@ -242,6 +242,9 @@ --success: var(--palette-green-700, #15803d); --warning: #b45309; --danger: var(--palette-red-600, #dc2626); + /* #353 — 1-byte path hash warning (renderPathHashSize in app.js). Follows + --danger in both themes; themeable via the customizer (pathHashWarn). */ + --path-hash-warn: var(--danger); --link-color: var(--palette-blue-600); --accent-bg: rgba(59, 130, 246, 0.12); --accent-border: rgba(59, 130, 246, 0.25); @@ -1974,6 +1977,13 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } overflow-wrap: anywhere; vertical-align: baseline; } +/* #353 — 1-byte path hash warning, shared by the channel "Sent with" badge, + * the packet-detail Hash Size row and the node badge (renderPathHashSize in + * public/app.js). The icon + sr-only text carry it without colour. */ +.path-hash-warn { color: var(--path-hash-warn); font-weight: 600; } +.path-hash-warn .ph-icon { margin-right: 3px; vertical-align: -0.125em; } +.ch-path-hash-badge--warn { border-color: var(--path-hash-warn); align-items: center; } +.node-path-hash-badge { background: var(--surface-2); border: 1px solid var(--path-hash-warn); font-size: 10px; } .ch-analyze-link { color: var(--link-color); text-decoration: none; margin-left: 8px; } .ch-analyze-link:hover { text-decoration: underline; } .ch-scroll-btn { From 7be5b5a1bb78f173ee9823fcff6e7e35cc325b19 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 18:02:12 +0000 Subject: [PATCH 3/8] fix(lint): declare the #353 path-hash helpers as eslint globals renderPathHashSize and isPathHashSizeWarn live in app.js and are used from channels.js, packets.js and nodes.js; the no-undef gate needs them listed like senderPathHashSize. Co-Authored-By: Claude Opus 5.5 --- .eslintrc.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.eslintrc.json b/.eslintrc.json index 655cbcbd0..1348d27ee 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -243,6 +243,7 @@ "initTabBar": "readonly", "invalidateApiCache": "readonly", "isFavorite": "readonly", + "isPathHashSizeWarn": "readonly", "isTransportRoute": "readonly", "makeColumnsResizable": "readonly", "makeRoleMarkerSVG": "readonly", @@ -264,6 +265,7 @@ "pullReconnect": "readonly", "qrcode": "readonly", "registerPage": "readonly", + "renderPathHashSize": "readonly", "renderVersionCard": "readonly", "renderSkewBadge": "readonly", "renderSkewSparkline": "readonly", From 628c55459647676888ada071f762ac53a2fbba18 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 19:29:14 +0000 Subject: [PATCH 4/8] test(ui): reproduce low warn contrast and an empty Hash Size row (#353) PR #356 review, round 2: - F1: --path-hash-warn defaults to --danger (#dc2626), which is 4.43:1 on the light packet page (--content-bg #f4f5f7), below WCAG AA 4.5:1. The unit test resolves the token from style.css in the light and both dark blocks and checks it against every surface the warning renders on. The E2E runs axe color-contrast on the three warning elements and requires axe to have measured them. - F2: hashSizeDetailHtml(4) emits
Hash Size
. A width outside 1-3 must render no row. Co-Authored-By: Claude Opus 5.5 --- test-issue-353-path-hash-warn-e2e.js | 24 +++++++-- test-issue-353-path-hash-warn.js | 77 +++++++++++++++++++++++++--- test-packets.js | 8 +++ 3 files changed, 98 insertions(+), 11 deletions(-) diff --git a/test-issue-353-path-hash-warn-e2e.js b/test-issue-353-path-hash-warn-e2e.js index 340fad953..e9982ef3e 100644 --- a/test-issue-353-path-hash-warn-e2e.js +++ b/test-issue-353-path-hash-warn-e2e.js @@ -2,7 +2,9 @@ * #353 — browser regression: a 1-byte path hash reads as a warning in the * channel "Sent with" badge, the packet-detail Hash Size row and the node * detail badges; 2/3-byte stay neutral. Checked in the light and dark theme - * and at a 375×812 phone viewport (no horizontal page overflow). + * and at a 375×812 phone viewport (no horizontal page overflow). Each warning + * also passes axe `color-contrast` (WCAG AA) on the surface it renders on + * (PR #356 review F1: --danger failed 4.43:1 on the light packet page). * * Channels use channels.js' own test hooks, packet detail a stubbed * /api/packets/, node detail real fixture nodes picked by hash_size. @@ -14,6 +16,7 @@ 'use strict'; const path = require('path'); const { chromium } = require('playwright'); +const { AxeBuilder } = require('@axe-core/playwright'); const BASE = process.env.BASE_URL || 'http://localhost:13581'; const SHOTS = process.env.SCREENSHOT_DIR || ''; @@ -66,13 +69,11 @@ async function readWarn(locator) { probe.style.color = 'var(--path-hash-warn)'; document.body.appendChild(probe); const want = getComputedStyle(probe).color; - probe.style.color = 'var(--danger)'; - const danger = getComputedStyle(probe).color; probe.remove(); const icon = el.querySelector('svg.ph-icon use[href$="#ph-warning"]'); const r = el.getBoundingClientRect(); return { - cls: el.className, title: el.getAttribute('title') || '', color: cs.color, want, danger, + cls: el.className, title: el.getAttribute('title') || '', color: cs.color, want, iconHidden: !!icon && icon.closest('svg').getAttribute('aria-hidden') === 'true', iconVisible: !!icon && icon.closest('svg').getBoundingClientRect().width > 0, sr: (el.querySelector('.sr-only') || {}).textContent || '', @@ -88,10 +89,20 @@ function assertWarn(w, block, where) { assert(w.sr === 'Warning: ', where + ': sr-only text ' + JSON.stringify(w.sr)); assert(/2- or 3-byte/.test(w.title) && /Experimental Settings/.test(w.title) && /path\.hash\.mode/.test(w.title), where + ': tooltip ' + JSON.stringify(w.title)); - assert(w.color === w.want && w.want === w.danger, where + ': colour ' + w.color + ' vs --path-hash-warn ' + w.want + ' / --danger ' + w.danger); + assert(w.color === w.want, where + ': colour ' + w.color + ' vs --path-hash-warn ' + w.want); assert(w.right <= w.vw + 0.5, where + ': warning runs off-screen (' + w.right + ' > ' + w.vw + ')'); assert(w.overflow <= 0, where + ': page scrolls horizontally by ' + w.overflow + 'px'); } +// axe color-contrast on the warning element itself: no violation, and axe +// must actually have measured it (a pass), not left it "incomplete". +async function assertContrast(page, selector, where) { + const r = await new AxeBuilder({ page }).include(selector).withRules(['color-contrast']).analyze(); + const bad = r.violations.flatMap((v) => v.nodes.map((n) => n.failureSummary || n.html)); + assert(bad.length === 0, where + ': axe color-contrast ' + bad.join(' | ')); + const measured = r.passes.reduce((k, v) => k + v.nodes.length, 0); + const unsure = r.incomplete.flatMap((v) => v.nodes.map((n) => n.failureSummary || n.html)); + assert(measured >= 1, where + ': axe did not measure contrast (incomplete: ' + unsure.join(' | ') + ')'); +} (async () => { const browser = await chromium.launch({ @@ -155,6 +166,7 @@ function assertWarn(w, block, where) { assert(await warn.count() === 1, 'warn count ' + await warn.count()); await warn.scrollIntoViewIfNeeded(); assertWarn(await readWarn(warn), 'ch-path-hash-badge', 'channel'); + await assertContrast(page, '.ch-path-hash-badge--warn', 'channel'); assert(await warn.textContent() === 'Warning: Sent with: 1-byte', 'warn text ' + await warn.textContent()); const neutral = await page.locator('.ch-path-hash-badge:not(.ch-path-hash-badge--warn)').evaluateAll((els) => els.map((el) => ({ text: el.textContent, icon: !!el.querySelector('svg'), title: el.title }))); @@ -170,6 +182,7 @@ function assertWarn(w, block, where) { await warn.waitFor(); await warn.scrollIntoViewIfNeeded(); assertWarn(await readWarn(warn), 'detail-hash-size', 'packet detail'); + await assertContrast(page, 'dl.detail-meta .detail-hash-size--warn', 'packet detail'); assert(await warn.textContent() === 'Warning: 1 byte', 'text ' + await warn.textContent()); await shot('packet'); }); @@ -189,6 +202,7 @@ function assertWarn(w, block, where) { const warn = page.locator('.node-full-card .node-path-hash-badge--warn'); await warn.waitFor(); assertWarn(await readWarn(warn), 'node-path-hash-badge', 'node detail'); + await assertContrast(page, '.node-full-card .node-path-hash-badge--warn', 'node detail'); assert(await warn.textContent() === 'Warning: 1-byte path hash', 'text ' + await warn.textContent()); assert(await page.locator('.node-full-card .multibyte-badge').count() === 0, '1-byte node claims Multibyte'); await shot('node'); diff --git a/test-issue-353-path-hash-warn.js b/test-issue-353-path-hash-warn.js index e6c9df97b..90e769a96 100644 --- a/test-issue-353-path-hash-warn.js +++ b/test-issue-353-path-hash-warn.js @@ -127,15 +127,80 @@ test('label and title are escaped', () => { console.log('\n=== #353 theming: --path-hash-warn ==='); { const css = fs.readFileSync('public/style.css', 'utf8'); - // The light-theme semantic :root block (the one declaring --danger); the - // dark blocks redefine --danger, which the variable then follows. - const dangerAt = css.indexOf('--danger: var(--palette-red-600'); - const rootBlock = css.slice(css.lastIndexOf('\n:root {', dangerAt), css.indexOf('\n}', dangerAt)); - test(':root declares --path-hash-warn defaulting to var(--danger)', () => { - assert.match(rootBlock, /--path-hash-warn:\s*var\(--danger\);/); + // The theme token blocks of style.css, sliced at their top-level openers: + // the palette :root, the light semantic :root, and the two dark blocks + // (OS-level media query and the manual [data-theme="dark"] toggle). + function blockAt(opener) { + const at = css.indexOf(opener); + assert.ok(at >= 0, 'style.css lost ' + JSON.stringify(opener)); + const indent = opener.match(/^\n( *)/)[1]; + return css.slice(at, css.indexOf('\n' + indent + '}', at + opener.length)); + } + function decls(block) { + const out = {}; + const re = /(--[\w-]+)\s*:\s*([^;]+);/g; + const body = block.replace(/\/\*[\s\S]*?\*\//g, ''); + let m; + while ((m = re.exec(body))) out[m[1]] = m[2].trim(); + return out; + } + const rootBlocks = css.split('\n:root {').slice(1, 3).map((b) => b.slice(0, b.indexOf('\n}'))); + const light = Object.assign({}, decls(rootBlocks[0]), decls(rootBlocks[1])); + const darkMedia = blockAt('\n :root:not([data-theme="light"]) {'); + const darkToggle = blockAt('\n[data-theme="dark"] {'); + const themes = { + light, + 'dark (prefers-color-scheme)': Object.assign({}, light, decls(darkMedia)), + 'dark ([data-theme="dark"])': Object.assign({}, light, decls(darkToggle)), + }; + + // Resolve var(--x[, fallback]) chains to a #rrggbb colour. + function resolve(vars, value, depth) { + assert.ok((depth || 0) < 20, 'var() cycle at ' + value); + const v = value.trim(); + const m = v.match(/^var\(\s*(--[\w-]+)\s*(?:,\s*(.+))?\)$/); + if (!m) return v; + if (vars[m[1]] != null) return resolve(vars, vars[m[1]], (depth || 0) + 1); + assert.ok(m[2] != null, 'undefined ' + m[1] + ' without a fallback'); + return resolve(vars, m[2], (depth || 0) + 1); + } + function luminance(hex) { + const h = hex.replace('#', ''); + assert.ok(/^[0-9a-f]{6}$/i.test(h), 'not a #rrggbb colour: ' + hex); + const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.substr(i, 2), 16) / 255) + .map((c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4))); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + function contrast(a, b) { + const x = luminance(a), y = luminance(b); + return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); + } + // The surfaces the warning text sits on: the full packet-detail page + // (--content-bg), the packet side pane (--detail-bg), the channel and node + // badges (--surface-2), and cards (--card-bg). + const SURFACES = ['--content-bg', '--detail-bg', '--surface-2', '--card-bg']; + + test(':root declares --path-hash-warn and both dark blocks redefine it identically', () => { + assert.ok(light['--path-hash-warn'], 'light :root lacks --path-hash-warn'); + const a = decls(darkMedia)['--path-hash-warn'], b = decls(darkToggle)['--path-hash-warn']; + assert.ok(a && b, 'a dark block lacks --path-hash-warn (media ' + a + ', toggle ' + b + ')'); + assert.strictEqual(a, b, 'dark blocks out of sync'); }); + // F1 of the PR #356 review: --danger (#dc2626) on --content-bg (#f4f5f7) + // is 4.43:1, below WCAG AA 4.5:1 for 13px/600 text (axe color-contrast). + for (const [theme, vars] of Object.entries(themes)) { + test(theme + ': --path-hash-warn meets WCAG AA (4.5:1) on every surface it renders on', () => { + const fg = resolve(vars, 'var(--path-hash-warn)'); + for (const s of SURFACES) { + const bg = resolve(vars, 'var(' + s + ')'); + const ratio = contrast(fg, bg); + assert.ok(ratio >= 4.5, fg + ' on ' + s + ' ' + bg + ' is ' + ratio.toFixed(2) + ':1'); + } + }); + } + test('.path-hash-warn colours its text with the variable, never a hardcoded colour', () => { const m = css.match(/\.path-hash-warn\s*\{([^}]*)\}/); assert.ok(m, '.path-hash-warn rule missing'); diff --git a/test-packets.js b/test-packets.js index c140443ad..5678a8370 100644 --- a/test-packets.js +++ b/test-packets.js @@ -1867,6 +1867,14 @@ console.log('\n=== packets.js: detail Hash Size row warns on 1-byte (#353) ===') for (const v of [null, undefined, 0]) assert.strictEqual(api.hashSizeDetailHtml(v), '', 'for ' + v); }); + // PR #356 review F2: a width outside 1-3 must not leave an empty + //
Hash Size
row behind. + test('an out-of-range width renders no Hash Size row, never an empty one', () => { + for (const v of [4, 5, -1, 255, NaN, 'x']) { + assert.strictEqual(api.hashSizeDetailHtml(v), '', 'for ' + String(v)); + } + }); + test('renderDetail emits the Hash Size row through hashSizeDetailHtml', () => { assert.ok(src.includes('${hashSizeDetailHtml(hashSize)}'), 'the detail-meta list must use the shared #353 row helper'); From 25c4dd3424044f74cedeb2fc972acf4c9c196538 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 19:33:12 +0000 Subject: [PATCH 5/8] test(ui): let the #353 axe contrast check wait for fade-ins (#353) Mid-animation, axe samples a blended, lighter foreground (e.g. #e25b5c on the light node page at 375px) and reports a contrast the settled page never has. Wait until no running animation targets the warning element or an ancestor before running color-contrast. Co-Authored-By: Claude Opus 5.5 --- test-issue-353-path-hash-warn-e2e.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test-issue-353-path-hash-warn-e2e.js b/test-issue-353-path-hash-warn-e2e.js index e9982ef3e..29ab02db3 100644 --- a/test-issue-353-path-hash-warn-e2e.js +++ b/test-issue-353-path-hash-warn-e2e.js @@ -95,8 +95,17 @@ function assertWarn(w, block, where) { } // axe color-contrast on the warning element itself: no violation, and axe // must actually have measured it (a pass), not left it "incomplete". +// Waits for page/pane fade-ins first: mid-animation, axe samples a blended +// (lighter) foreground and reports a contrast the settled page never has. async function assertContrast(page, selector, where) { - const r = await new AxeBuilder({ page }).include(selector).withRules(['color-contrast']).analyze(); + await page.waitForFunction((sel) => { + const el = document.querySelector(sel); + return !!el && document.getAnimations().every((a) => { + const t = a.effect && a.effect.target; + return !(t && t.contains(el)) || a.playState !== 'running'; + }); + }, selector); + const r =await new AxeBuilder({ page }).include(selector).withRules(['color-contrast']).analyze(); const bad = r.violations.flatMap((v) => v.nodes.map((n) => n.failureSummary || n.html)); assert(bad.length === 0, where + ': axe color-contrast ' + bad.join(' | ')); const measured = r.passes.reduce((k, v) => k + v.nodes.length, 0); From 90c2e18efacb270623816a18214fa87c28e66a89 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 19:33:12 +0000 Subject: [PATCH 6/8] fix(ui): AA contrast for the 1-byte path hash warning; no empty row (#353) PR #356 review, round 2: - F1: --path-hash-warn defaults to --palette-red-700 (#b91c1c) in the light theme: 5.93:1 on the packet page (#f4f5f7) and 6.47:1 on the white badge surfaces, where --danger (#dc2626) was 4.43:1 on the page. Both dark blocks set it back to --danger (#f87171): 6.82:1 on surface-0 and 5.48:1 on surface-2. The variable and the shared helper are unchanged, so the operator can still retune the tone. - F2: hashSizeDetailHtml renders the Hash Size row only when renderPathHashSize returns markup, so a width outside 1-3 gives no row instead of an empty
. Co-Authored-By: Claude Opus 5.5 --- public/packets.js | 11 +++++------ public/style.css | 13 ++++++++++--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/public/packets.js b/public/packets.js index 95aaf75ab..a23ec16a3 100644 --- a/public/packets.js +++ b/public/packets.js @@ -3639,13 +3639,12 @@ return ''; } - // Detail-meta "Hash Size" row for the sender-selected width (null = not - // encoded, no row). #353: a 1-byte width renders the shared warning (app.js). + // Detail-meta "Hash Size" row for the sender-selected width. #353: a 1-byte + // width renders the shared warning (app.js). The helper renders '' for + // anything but 1/2/3 (null = not encoded), and then there is no row at all. function hashSizeDetailHtml(hashSize) { - if (!hashSize) return ''; - return '
Hash Size
' + - renderPathHashSize(hashSize, hashSize + ' byte' + (hashSize !== 1 ? 's' : ''), { block: 'detail-hash-size' }) + - '
'; + const size = renderPathHashSize(hashSize, hashSize + ' byte' + (hashSize !== 1 ? 's' : ''), { block: 'detail-hash-size' }); + return size ? '
Hash Size
' + size + '
' : ''; } async function renderDetail(panel, data, chosenObsId) { diff --git a/public/style.css b/public/style.css index 7ec2d40eb..81bb135ce 100644 --- a/public/style.css +++ b/public/style.css @@ -242,9 +242,12 @@ --success: var(--palette-green-700, #15803d); --warning: #b45309; --danger: var(--palette-red-600, #dc2626); - /* #353 — 1-byte path hash warning (renderPathHashSize in app.js). Follows - --danger in both themes; themeable via the customizer (pathHashWarn). */ - --path-hash-warn: var(--danger); + /* #353 — 1-byte path hash warning (renderPathHashSize in app.js); + themeable via the customizer (pathHashWarn). One stop darker than + --danger on light: #dc2626 on --content-bg #f4f5f7 is 4.43:1 (fails AA + for this 13px text); #b91c1c is 5.93:1 there and 6.47:1 on white. The + dark blocks below set it back to --danger. */ + --path-hash-warn: var(--palette-red-700, #b91c1c); --link-color: var(--palette-blue-600); --accent-bg: rgba(59, 130, 246, 0.12); --accent-border: rgba(59, 130, 246, 0.25); @@ -417,6 +420,8 @@ --success: var(--palette-green-400, #4ade80); --warning: #fcd34d; --danger: var(--palette-red-400, #f87171); + /* #353 — #f87171: 6.82:1 on surface-0, 5.48:1 on surface-2/detail-bg. */ + --path-hash-warn: var(--danger); --surface-0: #0f0f23; --surface-1: #1a1a2e; --surface-2: #232340; @@ -475,6 +480,8 @@ --success: var(--palette-green-400, #4ade80); --warning: #fcd34d; --danger: var(--palette-red-400, #f87171); + /* #353 — see the media block above. */ + --path-hash-warn: var(--danger); --surface-0: #0f0f23; --surface-1: #1a1a2e; --surface-2: #232340; From 1c46e95e02a98d9ff83be6a1ed354bd23981f276 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 19:34:14 +0000 Subject: [PATCH 7/8] test(ui): cover the manual dark toggle on a light OS in the #353 E2E (#353) The dark variants also set prefers-color-scheme: dark, so the media block masked a missing --path-hash-warn override in the [data-theme="dark"] block (#b91c1c on #0f0f23 is 2.92:1). A light-OS context with the dark toggle exercises that block alone. Co-Authored-By: Claude Opus 5.5 --- test-issue-353-path-hash-warn-e2e.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test-issue-353-path-hash-warn-e2e.js b/test-issue-353-path-hash-warn-e2e.js index 29ab02db3..3a2186aa9 100644 --- a/test-issue-353-path-hash-warn-e2e.js +++ b/test-issue-353-path-hash-warn-e2e.js @@ -2,7 +2,8 @@ * #353 — browser regression: a 1-byte path hash reads as a warning in the * channel "Sent with" badge, the packet-detail Hash Size row and the node * detail badges; 2/3-byte stay neutral. Checked in the light and dark theme - * and at a 375×812 phone viewport (no horizontal page overflow). Each warning + * (OS-level and manual toggle on a light OS) and at a 375×812 phone viewport + * (no horizontal page overflow). Each warning * also passes axe `color-contrast` (WCAG AA) on the surface it renders on * (PR #356 review F1: --danger failed 4.43:1 on the light packet page). * @@ -134,10 +135,13 @@ async function assertContrast(page, selector, where) { { name: 'dark desktop', theme: 'dark', viewport: { width: 1400, height: 900 } }, { name: 'light 375x812', theme: 'light', viewport: { width: 375, height: 812 } }, { name: 'dark 375x812', theme: 'dark', viewport: { width: 375, height: 812 } }, + // Manual dark toggle on a light OS: only the [data-theme="dark"] block + // applies, not the prefers-color-scheme one. + { name: 'dark toggle on light OS', theme: 'dark', os: 'light', viewport: { width: 1400, height: 900 } }, ]; for (const v of variants) { - const context = await browser.newContext({ viewport: v.viewport, colorScheme: v.theme }); + const context = await browser.newContext({ viewport: v.viewport, colorScheme: v.os || v.theme }); await context.addInitScript((theme) => { try { localStorage.setItem('meshcore-theme', theme); } catch (_) {} }, v.theme); await stubPackets(context); const page = await context.newPage(); From d1c5b8a140e0e32fe49bc9c224e1c530732a57fa Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 23:17:16 +0200 Subject: [PATCH 8/8] reviewfix(ui): 1-byte path hash is an amber recommendation, not a red error (#353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator decision on #353 (round 3): a 1-byte path hash is a recommendation, not an error. It is the firmware default and the FAQ still recommends it for companions until most repeaters run 1.14+. - --path-hash-warn now defaults to var(--warning) (amber) instead of var(--danger) (red), following the warning palette in both themes; still mapped in the customizer (pathHashWarn). This also clears the round-2 blocker: red on the light packet-detail background was 4.43:1 (< AA); amber is 4.60:1. WCAG AA (>= 4.5:1) on every surface, both themes: channel/node 5.02:1 light / 10.51:1 dark, packet row 4.60:1 light / 13.09:1 dark (measured in-browser). - Wording reads as a recommendation, not a problem: the tooltip leads with "Recommended: use a 2- or 3-byte path hash", the node badge shows "Recommended: 2- or 3-byte path hash" as its visible label, and the inline channel/packet sites keep their factual value with an sr-only "— recommended: 2- or 3-byte path hash" clause (no "Warning:" prefix). - Icon stays the warning glyph (ph-warning), not an error glyph, so the cue survives without colour. - hashSizeDetailHtml now gates the row on the helper output, so an out-of-range width renders no row instead of an empty
(#356 review round 2, finding 2). Tests updated (red before, green after); E2E gains an in-page WCAG AA contrast assertion on all three sites in light and dark. Co-Authored-By: Claude Opus 4.8 --- public/app.js | 22 +++++++--- public/customize-v2.js | 2 +- public/nodes.js | 7 +++- public/packets.js | 11 ++--- public/style.css | 10 +++-- test-channels-observed-path-hash-size.js | 9 ++-- test-customizer-v2.js | 3 +- test-frontend-helpers.js | 9 ++-- test-issue-353-path-hash-warn-e2e.js | 53 ++++++++++++++++++------ test-issue-353-path-hash-warn.js | 27 +++++++++--- test-packets.js | 16 +++++-- 11 files changed, 124 insertions(+), 45 deletions(-) diff --git a/public/app.js b/public/app.js index 31d0725d9..98102468c 100644 --- a/public/app.js +++ b/public/app.js @@ -69,8 +69,16 @@ function pathHashSizeValue(size) { * with" badge, the packet-detail Hash Size row and the node-detail badge. * Returns '' unless size is 1/2/3. opts.block is the caller's BEM class (the * warn variant adds `${block}--warn` plus the shared `path-hash-warn`), - * opts.cls extra leading classes, opts.title the neutral tooltip. A warning - * carries an icon and screen-reader text so it never relies on colour alone. + * opts.cls extra leading classes, opts.title the neutral tooltip. + * + * Round 3 (operator decision on #353): a 1-byte hash is a *recommendation*, + * not an error. The variant uses the amber warning palette (--path-hash-warn + * -> --warning), a warning (not error) icon, and recommendation wording. So it + * never relies on the amber colour alone, the recommendation is also spelled + * out in text: a visible label when opts.recInLabel is set (the standalone + * node badge), otherwise an sr-only clause appended after the factual label + * (the inline channel badge and the packet-detail value row). + * * The setting names come from firmware/docs/faq.md 3.9.3 (companion app) and * 3.9.4 / cli_commands.md (repeater `path.hash.mode`); faq.md 3.9.6 is why the * tooltip says to switch only once most repeaters run 1.14+. @@ -84,13 +92,15 @@ function renderPathHashSize(size, text, opts) { if (!isPathHashSizeWarn(n)) { return '' + label + ''; } - const tip = '1-byte path hash: prefixes collide often across nodes, so hops can be ambiguous. ' + - '2- or 3-byte is recommended. Companion: MeshCore app Settings → Experimental Settings (message path hash size). ' + + const tip = 'Recommended: use a 2- or 3-byte path hash. A 1-byte hash is the firmware default, but its ' + + 'prefixes collide more often across nodes, so hops can be ambiguous. ' + + 'Companion: MeshCore app Settings → Experimental Settings (message path hash size). ' + 'Repeater adverts: set path.hash.mode 1 (2-byte) or 2 (3-byte). ' + - 'Repeaters older than firmware 1.14 drop 2/3-byte packets, so switch messages once most of your mesh runs 1.14+.'; + 'Repeaters older than firmware 1.14 drop 2/3-byte packets, so switch once most of your mesh runs 1.14+.'; + const rec = o.recInLabel ? '' : ' — recommended: 2- or 3-byte path hash'; return '' + '' + - 'Warning: ' + label + ''; + label + rec + '
'; } /** * scopeName is optional (callers that don't pass it get the original diff --git a/public/customize-v2.js b/public/customize-v2.js index a70511243..1136fe0ca 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -319,7 +319,7 @@ cardBg: 'Detail panels, modals', contentBg: 'Content area behind cards', detailBg: 'Modal, packet detail, side panels', inputBg: 'Text inputs, dropdowns', rowStripe: 'Alternating table rows', rowHover: 'Table row hover', selectedBg: 'Selected/active rows', - pathHashWarn: 'Warning for a 1-byte path hash (channels, packet detail, node badge)', + pathHashWarn: 'Amber accent recommending a 2/3-byte over a 1-byte path hash (channels, packet detail, node badge)', font: 'System font stack for body text', mono: 'Monospace font for hex, code, hashes' }; diff --git a/public/nodes.js b/public/nodes.js index 35b491f6a..f0c360d66 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -334,9 +334,12 @@ if (n.hash_size && Number(n.hash_size) >= 2) { html += ` Multibyte: ${Number(n.hash_size)}-byte`; } - // #353: a 1-byte advertised path hash collides often -- shared warning badge (app.js). + // #353: a 1-byte advertised path hash collides often -- shared amber + // recommendation badge (app.js). The standalone badge spells the + // recommendation out as its visible label (recInLabel), so it needs no + // separate sr-only clause. if (isPathHashSizeWarn(n.hash_size)) { - html += ' ' + renderPathHashSize(1, '1-byte path hash', { block: 'node-path-hash-badge', cls: 'badge' }); + html += ' ' + renderPathHashSize(1, 'Recommended: 2- or 3-byte path hash', { block: 'node-path-hash-badge', cls: 'badge', recInLabel: true }); } if (n.hash_size_inconsistent) { html += ` variable hash size`; diff --git a/public/packets.js b/public/packets.js index 95aaf75ab..21b371a67 100644 --- a/public/packets.js +++ b/public/packets.js @@ -3640,12 +3640,13 @@ } // Detail-meta "Hash Size" row for the sender-selected width (null = not - // encoded, no row). #353: a 1-byte width renders the shared warning (app.js). + // encoded, no row). #353: a 1-byte width renders the shared amber + // recommendation (app.js). The row is gated on the helper's output, so a + // width the helper rejects (null/0 or an out-of-range value) renders no row + // at all rather than an empty
(#356 review round 2, finding 2). function hashSizeDetailHtml(hashSize) { - if (!hashSize) return ''; - return '
Hash Size
' + - renderPathHashSize(hashSize, hashSize + ' byte' + (hashSize !== 1 ? 's' : ''), { block: 'detail-hash-size' }) + - '
'; + const inner = renderPathHashSize(hashSize, hashSize + ' byte' + (hashSize !== 1 ? 's' : ''), { block: 'detail-hash-size' }); + return inner ? '
Hash Size
' + inner + '
' : ''; } async function renderDetail(panel, data, chosenObsId) { diff --git a/public/style.css b/public/style.css index 7ec2d40eb..9fb2ca15d 100644 --- a/public/style.css +++ b/public/style.css @@ -242,9 +242,13 @@ --success: var(--palette-green-700, #15803d); --warning: #b45309; --danger: var(--palette-red-600, #dc2626); - /* #353 — 1-byte path hash warning (renderPathHashSize in app.js). Follows - --danger in both themes; themeable via the customizer (pathHashWarn). */ - --path-hash-warn: var(--danger); + /* #353 — 1-byte path hash recommendation (renderPathHashSize in app.js). + Round 3 (operator decision): a 1-byte hash is a recommendation, not an + error, so it follows the amber --warning palette (not --danger) in both + themes. WCAG AA (>= 4.5:1) on every surface it renders on: channel badge + and node badge 5.02:1 light / 10.51:1 dark, packet-detail row 4.60:1 light + / 13.09:1 dark. Themeable via the customizer (pathHashWarn). */ + --path-hash-warn: var(--warning); --link-color: var(--palette-blue-600); --accent-bg: rgba(59, 130, 246, 0.12); --accent-border: rgba(59, 130, 246, 0.25); diff --git a/test-channels-observed-path-hash-size.js b/test-channels-observed-path-hash-size.js index 40713b309..5ef105f6e 100644 --- a/test-channels-observed-path-hash-size.js +++ b/test-channels-observed-path-hash-size.js @@ -184,14 +184,15 @@ test('sender badge uses only a valid encoded width', () => { assert.strictEqual(senderBadge({ senderPathHashSize: '' }), ''); }); -// #353: a 1-byte sender width reads as a warning; 2/3-byte stay neutral. -test('1-byte sender badge gets the warn class, the warning icon and the warn tooltip', () => { +// #353: a 1-byte sender width reads as an amber recommendation; 2/3-byte stay neutral. +test('1-byte sender badge gets the warn class, the warning icon and the recommendation', () => { const html = senderBadge({ senderPathHashSize: 1 }); assert.match(html, /class="ch-path-hash-badge ch-path-hash-badge--warn path-hash-warn"/); assert.match(html, /#ph-warning/); assert.match(html, /aria-hidden="true"/); - assert.match(html, /Warning: <\/span>Sent with: 1-byte — recommended: 2- or 3-byte path hash<\/span>/); + assert.ok(!/Warning: /.test(html), 'must not read as an error'); + assert.match(html, /title="Recommended: [^"]*2- or 3-byte[^"]*Experimental Settings/); }); test('2- and 3-byte sender badges stay neutral with the header tooltip', () => { diff --git a/test-customizer-v2.js b/test-customizer-v2.js index 3ecf8c035..a2ebc6cb8 100644 --- a/test-customizer-v2.js +++ b/test-customizer-v2.js @@ -370,7 +370,8 @@ test('applyCSS writes --path-hash-warn when overridden (#353)', () => { test('pathHashWarn has a label, a hint and an Advanced row (#353)', () => { const src = fs.readFileSync('public/customize-v2.js', 'utf8'); assert.match(src, /pathHashWarn: '1-byte Path Hash'/); - assert.match(src, /pathHashWarn: '[^']*1-byte path hash[^']*'/); + // #353 round 3: the hint describes a recommendation accent, not an error. + assert.match(src, /pathHashWarn: '[^']*recommend[^']*1-byte path hash[^']*'/); assert.match(src, /var ADVANCED_KEYS = \[[^\]]*'pathHashWarn'/); }); diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 1a71ef92c..37598b44c 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -524,13 +524,16 @@ console.log('\n=== nodes.js: getStatusTooltip / getStatusInfo (extracted) ==='); role: 'repeater', public_key: 'abcdef1234567890', hash_size, last_heard: new Date().toISOString() }); - test('renderNodeBadges adds the 1-byte path hash warning badge (#353)', () => { + test('renderNodeBadges adds the 1-byte path hash recommendation badge (#353)', () => { for (const size of [1, '1']) { const html = ex.renderNodeBadges(nodeWithHash(size), '#dc2626'); assert.match(html, /class="badge node-path-hash-badge node-path-hash-badge--warn path-hash-warn"/); assert.match(html, /#ph-warning/); - assert.match(html, /Warning: <\/span>1-byte path hash<\/span>/); - assert.match(html, /title="[^"]*2- or 3-byte[^"]*path\.hash\.mode/); + // #353 round 3: the standalone badge shows the recommendation as its + // visible label (no duplicate sr-only clause); amber, not red. + assert.match(html, />Recommended: 2- or 3-byte path hash<\/span>/); + assert.ok(!/Warning: /.test(html), 'must not read as an error'); + assert.match(html, /title="Recommended: [^"]*2- or 3-byte[^"]*path\.hash\.mode/); assert.ok(!html.includes('multibyte-badge'), '1-byte must not claim Multibyte'); } }); diff --git a/test-issue-353-path-hash-warn-e2e.js b/test-issue-353-path-hash-warn-e2e.js index 340fad953..f9d7a69d1 100644 --- a/test-issue-353-path-hash-warn-e2e.js +++ b/test-issue-353-path-hash-warn-e2e.js @@ -57,22 +57,42 @@ async function stubPackets(context) { }); } -// Shared assertions for a rendered warning element: modifier class, icon -// hidden from AT, sr-only text, firmware-named tooltip, colour = --path-hash-warn. +// Shared assertions for a rendered recommendation element: modifier class, +// icon hidden from AT, recommendation text, firmware-named tooltip, colour = +// --path-hash-warn = --warning (amber, not --danger), and — #353 round 3 — a +// WCAG AA contrast ratio (>= 4.5:1) against the element's effective background. async function readWarn(locator) { return locator.evaluate((el) => { + // sRGB relative luminance + WCAG contrast, computed in-page on the real + // rendered colours so the gate catches a sub-AA token in either theme. + const toRgb = (s) => (s.match(/\d+(\.\d+)?/g) || []).slice(0, 3).map(Number); + const lin = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; + const lum = (rgb) => 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2]); const cs = getComputedStyle(el); + // Effective background: first ancestor with a non-transparent fill. + let bgEl = el, bg = null; + while (bgEl) { + const c = getComputedStyle(bgEl).backgroundColor; + if (c && c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent') { bg = c; break; } + bgEl = bgEl.parentElement; + } + const fgRgb = toRgb(cs.color), bgRgb = toRgb(bg || 'rgb(255,255,255)'); + const lf = lum(fgRgb), lb = lum(bgRgb); + const contrast = (Math.max(lf, lb) + 0.05) / (Math.min(lf, lb) + 0.05); const probe = document.createElement('span'); probe.style.color = 'var(--path-hash-warn)'; document.body.appendChild(probe); const want = getComputedStyle(probe).color; + probe.style.color = 'var(--warning)'; + const warning = getComputedStyle(probe).color; probe.style.color = 'var(--danger)'; const danger = getComputedStyle(probe).color; probe.remove(); const icon = el.querySelector('svg.ph-icon use[href$="#ph-warning"]'); const r = el.getBoundingClientRect(); return { - cls: el.className, title: el.getAttribute('title') || '', color: cs.color, want, danger, + cls: el.className, title: el.getAttribute('title') || '', color: cs.color, want, warning, danger, + bg: bg || '(none)', contrast: Math.round(contrast * 100) / 100, iconHidden: !!icon && icon.closest('svg').getAttribute('aria-hidden') === 'true', iconVisible: !!icon && icon.closest('svg').getBoundingClientRect().width > 0, sr: (el.querySelector('.sr-only') || {}).textContent || '', @@ -81,15 +101,23 @@ async function readWarn(locator) { }; }); } -function assertWarn(w, block, where) { +// opts.sr: expected sr-only clause ('' when the recommendation is the visible label). +function assertWarn(w, block, where, opts) { + const sr = (opts && 'sr' in opts) ? opts.sr : ' — recommended: 2- or 3-byte path hash'; assert(w.cls.split(/\s+/).includes(block + '--warn'), where + ': no ' + block + '--warn in ' + w.cls); assert(w.cls.split(/\s+/).includes('path-hash-warn'), where + ': no path-hash-warn'); assert(w.iconHidden && w.iconVisible, where + ': warning icon missing, visible=' + w.iconVisible); - assert(w.sr === 'Warning: ', where + ': sr-only text ' + JSON.stringify(w.sr)); - assert(/2- or 3-byte/.test(w.title) && /Experimental Settings/.test(w.title) && /path\.hash\.mode/.test(w.title), + assert(w.sr === sr, where + ': sr-only text ' + JSON.stringify(w.sr)); + assert(/^Recommended: /.test(w.title) && /2- or 3-byte/.test(w.title) && /Experimental Settings/.test(w.title) && /path\.hash\.mode/.test(w.title), where + ': tooltip ' + JSON.stringify(w.title)); - assert(w.color === w.want && w.want === w.danger, where + ': colour ' + w.color + ' vs --path-hash-warn ' + w.want + ' / --danger ' + w.danger); - assert(w.right <= w.vw + 0.5, where + ': warning runs off-screen (' + w.right + ' > ' + w.vw + ')'); + // #353 round 3: WCAG AA on the real rendered colours. Checked before the + // exact-token check so a sub-AA accent is reported as a contrast failure + // (the round-2 blocker was --danger at 4.43:1 on the light packet detail). + assert(w.contrast >= 4.5, where + ': contrast ' + w.contrast + ':1 < AA 4.5 (fg ' + w.color + ' on bg ' + w.bg + ')'); + // Amber recommendation palette, not the red error palette. + assert(w.color === w.want && w.want === w.warning, where + ': colour ' + w.color + ' vs --path-hash-warn ' + w.want + ' / --warning ' + w.warning); + assert(w.want !== w.danger, where + ': --path-hash-warn still equals --danger (' + w.danger + ')'); + assert(w.right <= w.vw + 0.5, where + ': recommendation runs off-screen (' + w.right + ' > ' + w.vw + ')'); assert(w.overflow <= 0, where + ': page scrolls horizontally by ' + w.overflow + 'px'); } @@ -155,7 +183,7 @@ function assertWarn(w, block, where) { assert(await warn.count() === 1, 'warn count ' + await warn.count()); await warn.scrollIntoViewIfNeeded(); assertWarn(await readWarn(warn), 'ch-path-hash-badge', 'channel'); - assert(await warn.textContent() === 'Warning: Sent with: 1-byte', 'warn text ' + await warn.textContent()); + assert(await warn.textContent() === 'Sent with: 1-byte — recommended: 2- or 3-byte path hash', 'warn text ' + await warn.textContent()); const neutral = await page.locator('.ch-path-hash-badge:not(.ch-path-hash-badge--warn)').evaluateAll((els) => els.map((el) => ({ text: el.textContent, icon: !!el.querySelector('svg'), title: el.title }))); assert(JSON.stringify(neutral.map((n) => n.text)) === '["Sent with: 2-byte","Sent with: 3-byte"]', JSON.stringify(neutral)); @@ -170,7 +198,7 @@ function assertWarn(w, block, where) { await warn.waitFor(); await warn.scrollIntoViewIfNeeded(); assertWarn(await readWarn(warn), 'detail-hash-size', 'packet detail'); - assert(await warn.textContent() === 'Warning: 1 byte', 'text ' + await warn.textContent()); + assert(await warn.textContent() === '1 byte — recommended: 2- or 3-byte path hash', 'text ' + await warn.textContent()); await shot('packet'); }); @@ -188,8 +216,9 @@ function assertWarn(w, block, where) { await page.goto(`${BASE}/#/nodes/${encodeURIComponent(node1.public_key)}`, { waitUntil: 'load' }); const warn = page.locator('.node-full-card .node-path-hash-badge--warn'); await warn.waitFor(); - assertWarn(await readWarn(warn), 'node-path-hash-badge', 'node detail'); - assert(await warn.textContent() === 'Warning: 1-byte path hash', 'text ' + await warn.textContent()); + // The node badge shows the recommendation as its visible label, so no sr-only clause. + assertWarn(await readWarn(warn), 'node-path-hash-badge', 'node detail', { sr: '' }); + assert(await warn.textContent() === 'Recommended: 2- or 3-byte path hash', 'text ' + await warn.textContent()); assert(await page.locator('.node-full-card .multibyte-badge').count() === 0, '1-byte node claims Multibyte'); await shot('node'); await page.goto(`${BASE}/#/nodes/${encodeURIComponent(node2.public_key)}`, { waitUntil: 'load' }); diff --git a/test-issue-353-path-hash-warn.js b/test-issue-353-path-hash-warn.js index e6c9df97b..8853e42fe 100644 --- a/test-issue-353-path-hash-warn.js +++ b/test-issue-353-path-hash-warn.js @@ -67,16 +67,30 @@ test('missing or invalid sizes are not a warning', () => { console.log('\n=== #353 renderPathHashSize ==='); -test('1-byte renders the warn modifier, the shared warn class, an icon and screen-reader text', () => { +test('1-byte renders the warn modifier, the shared warn class, an icon and a recommendation for screen readers', () => { const html = renderPathHashSize(1, 'Sent with: 1-byte', { block: 'ch-path-hash-badge' }); assert.match(html, /class="ch-path-hash-badge ch-path-hash-badge--warn path-hash-warn"/); assert.match(html, /Warning: <\/span>Sent with: 1-byte<\/span>$/); + // #353 round 3: an amber recommendation, not a red error. The visible label + // stays factual; a sr-only clause carries the recommendation for non-sighted + // users and anyone who cannot perceive the amber colour. + assert.match(html, /Sent with: 1-byte — recommended: 2- or 3-byte path hash<\/span><\/span>$/); + assert.ok(!/Warning: /.test(html), 'must not read as an error ("Warning:")'); }); -test('1-byte tooltip explains the collision risk, recommends 2/3-byte and names the firmware settings', () => { +test('a recommendation-labelled call omits the sr-only clause to avoid repeating itself', () => { + // The node badge (opts.recInLabel) shows the recommendation as its visible + // label already, so the duplicate sr-only clause is suppressed. + const html = renderPathHashSize(1, 'Recommended: 2- or 3-byte path hash', + { block: 'node-path-hash-badge', cls: 'badge', recInLabel: true }); + assert.match(html, />Recommended: 2- or 3-byte path hash<\/span>$/); + assert.ok(!html.includes('sr-only'), 'recInLabel must not add a duplicate sr-only clause'); +}); + +test('1-byte tooltip leads with the recommendation, keeps the collision reason and names the firmware settings', () => { const html = renderPathHashSize(1, '1 byte', { block: 'detail-hash-size' }); const title = (html.match(/title="([^"]*)"/) || [])[1] || ''; + assert.match(title, /^Recommended: /); assert.match(title, /collide/); assert.match(title, /2- or 3-byte/); // firmware/docs/faq.md 3.9.3 (companion) and cli_commands.md (repeater) @@ -132,8 +146,11 @@ console.log('\n=== #353 theming: --path-hash-warn ==='); const dangerAt = css.indexOf('--danger: var(--palette-red-600'); const rootBlock = css.slice(css.lastIndexOf('\n:root {', dangerAt), css.indexOf('\n}', dangerAt)); - test(':root declares --path-hash-warn defaulting to var(--danger)', () => { - assert.match(rootBlock, /--path-hash-warn:\s*var\(--danger\);/); + test(':root declares --path-hash-warn defaulting to var(--warning)', () => { + // #353 round 3: 1-byte is a recommendation, so it follows the amber + // warning palette, not the red danger palette, in both themes. + assert.match(rootBlock, /--path-hash-warn:\s*var\(--warning\);/); + assert.ok(!/--path-hash-warn:\s*var\(--danger\)/.test(rootBlock), 'must not default to --danger'); }); test('.path-hash-warn colours its text with the variable, never a hardcoded colour', () => { diff --git a/test-packets.js b/test-packets.js index 734fd678c..be1297ac5 100644 --- a/test-packets.js +++ b/test-packets.js @@ -1847,13 +1847,16 @@ console.log('\n=== packets.js: detail Hash Size row warns on 1-byte (#353) ===') const api = loadPacketsSandbox()._packetsTestAPI; const src = fs.readFileSync('public/packets.js', 'utf8'); - test('1-byte Hash Size row gets the warn class, the warning icon and the warn tooltip', () => { + test('1-byte Hash Size row gets the warn class, the warning icon and the recommendation', () => { const html = api.hashSizeDetailHtml(1); assert.ok(html.startsWith('
Hash Size
'), html); assert.match(html, /class="detail-hash-size detail-hash-size--warn path-hash-warn"/); assert.match(html, /#ph-warning/); - assert.match(html, /Warning: <\/span>1 byte<\/span><\/dd>$/); - assert.match(html, /title="[^"]*2- or 3-byte/); + // #353 round 3: amber recommendation; factual value kept, recommendation + // in an sr-only clause so it never relies on the amber colour. + assert.match(html, /1 byte — recommended: 2- or 3-byte path hash<\/span><\/span><\/dd>$/); + assert.ok(!/Warning: /.test(html), 'must not read as an error'); + assert.match(html, /title="Recommended: [^"]*2- or 3-byte/); }); test('2- and 3-byte Hash Size rows stay neutral', () => { @@ -1867,6 +1870,13 @@ console.log('\n=== packets.js: detail Hash Size row warns on 1-byte (#353) ===') for (const v of [null, undefined, 0]) assert.strictEqual(api.hashSizeDetailHtml(v), '', 'for ' + v); }); + // #353 review (round 2, finding 2): an out-of-range width must not leave an + // empty
. The row is gated on the shared helper's output, so a size + // the helper rejects (e.g. 4) renders no row at all, not a blank one. + test('an out-of-range width renders no empty Hash Size row', () => { + for (const v of [4, 5, -1]) assert.strictEqual(api.hashSizeDetailHtml(v), '', 'for ' + v); + }); + test('renderDetail emits the Hash Size row through hashSizeDetailHtml', () => { assert.ok(src.includes('${hashSizeDetailHtml(hashSize)}'), 'the detail-meta list must use the shared #353 row helper');