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",
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index d39d38b9c..f8ebc528d 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -447,6 +447,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-242-type-exclusions-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
diff --git a/public/app.js b/public/app.js
index 5a0519b4b..98102468c 100644
--- a/public/app.js
+++ b/public/app.js
@@ -50,6 +50,58 @@ 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.
+ *
+ * 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+.
+ */
+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 = '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 once most of your mesh runs 1.14+.';
+ const rec = o.recInLabel ? '' : ' — recommended: 2- or 3-byte path hash';
+ return '' +
+ '' +
+ label + rec + '';
+}
/**
* 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 54eedfc9c..1136fe0ca 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: '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'
};
@@ -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..f0c360d66 100644
--- a/public/nodes.js
+++ b/public/nodes.js
@@ -334,6 +334,13 @@
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 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, '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 98997e085..21b371a67 100644
--- a/public/packets.js
+++ b/public/packets.js
@@ -3639,6 +3639,16 @@
return '';
}
+ // Detail-meta "Hash Size" row for the sender-selected width (null = not
+ // 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) {
+ const inner = renderPathHashSize(hashSize, hashSize + ' byte' + (hashSize !== 1 ? 's' : ''), { block: 'detail-hash-size' });
+ return inner ? 'Hash Size' + inner + '' : '';
+ }
+
async function renderDetail(panel, data, chosenObsId) {
const pkt = data.packet;
const observations = data.observations || [];
@@ -3893,7 +3903,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}
@@ -4567,6 +4577,7 @@
reconcileVisibleCols,
getDetailPreview,
buildDetailMessageHtml,
+ hashSizeDetailHtml,
sortGroupChildren,
getPathHopCount,
renderDecodedPacket,
diff --git a/public/style.css b/public/style.css
index a3d8e1db4..9fb2ca15d 100644
--- a/public/style.css
+++ b/public/style.css
@@ -242,6 +242,13 @@
--success: var(--palette-green-700, #15803d);
--warning: #b45309;
--danger: var(--palette-red-600, #dc2626);
+ /* #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);
@@ -1974,6 +1981,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 {
diff --git a/test-all.sh b/test-all.sh
index 0fee6b9cc..3cfdc43a7 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -186,6 +186,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..5ef105f6e 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,34 @@ test('sender badge uses only a valid encoded width', () => {
assert.strictEqual(senderBadge({ senderPathHashSize: '
' }), '');
});
+// #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, /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', () => {
+ 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..a2ebc6cb8 100644
--- a/test-customizer-v2.js
+++ b/test-customizer-v2.js
@@ -353,6 +353,28 @@ 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'/);
+ // #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'/);
+});
+
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..37598b44c 100644
--- a/test-frontend-helpers.js
+++ b/test-frontend-helpers.js
@@ -519,6 +519,38 @@ 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 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/);
+ // #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');
+ }
+ });
+ 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..c6e6b8348
--- /dev/null
+++ b/test-issue-353-path-hash-warn-e2e.js
@@ -0,0 +1,273 @@
+/**
+ * #353 — browser regression: a 1-byte path hash reads as an amber
+ * recommendation (not a red error) 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 (OS-level and manual toggle on
+ * a light OS) and at a 375×812 phone viewport (no horizontal page overflow).
+ * The accent is --path-hash-warn = --warning (amber), not --danger, and each
+ * recommendation 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; amber is 4.60:1).
+ *
+ * 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 { AxeBuilder } = require('@axe-core/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 recommendation element: modifier class,
+// icon hidden from AT, recommendation text, firmware-named tooltip, and colour
+// = --path-hash-warn = --warning (amber, not --danger). The WCAG AA contrast
+// itself is checked by assertContrast() with real axe.
+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(--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, warning, 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,
+ };
+ });
+}
+// 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 === 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));
+ // #353 round 3: 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');
+}
+// Direct sRGB-luminance WCAG contrast of an element's text against its first
+// opaque ancestor background. Used as the fallback when axe leaves the element
+// "incomplete" (see assertContrast).
+async function measuredContrast(page, selector) {
+ return page.$eval(selector, (el) => {
+ 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 fg = toRgb(getComputedStyle(el).color);
+ 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 bgRgb = toRgb(bg || 'rgb(255,255,255)');
+ const lf = lum(fg), lb = lum(bgRgb);
+ return { ratio: Math.round((Math.max(lf, lb) + 0.05) / (Math.min(lf, lb) + 0.05) * 100) / 100, fg: getComputedStyle(el).color, bg: bg || '(none)' };
+ });
+}
+// 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.
+// When axe cannot settle a background (its "incomplete" overlap heuristic can
+// fire for a short inline badge sitting among others at a narrow viewport), it
+// is not a contrast failure — fall back to a direct WCAG computation on the
+// element's own text and its first opaque ancestor, which is what the user
+// actually sees, and still require AA (>= 4.5:1).
+async function assertContrast(page, selector, where) {
+ 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);
+ if (measured < 1) {
+ const m = await measuredContrast(page, selector);
+ assert(m.ratio >= 4.5, where + ': contrast ' + m.ratio + ':1 < AA 4.5 (fg ' + m.fg + ' on bg ' + m.bg + ')');
+ }
+}
+
+(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 } },
+ // 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.os || 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');
+ await assertContrast(page, '.ch-path-hash-badge--warn', 'channel');
+ 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));
+ 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');
+ await assertContrast(page, 'dl.detail-meta .detail-hash-size--warn', 'packet detail');
+ assert(await warn.textContent() === '1 byte — recommended: 2- or 3-byte path hash', '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();
+ // 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: '' });
+ await assertContrast(page, '.node-full-card .node-path-hash-badge--warn', 'node detail');
+ 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' });
+ 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..8853e42fe
--- /dev/null
+++ b/test-issue-353-path-hash-warn.js
@@ -0,0 +1,171 @@
+/**
+ * #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 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, /