Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@
"initTabBar": "readonly",
"invalidateApiCache": "readonly",
"isFavorite": "readonly",
"isPathHashSizeWarn": "readonly",
"isTransportRoute": "readonly",
"makeColumnsResizable": "readonly",
"makeRoleMarkerSVG": "readonly",
Expand All @@ -264,6 +265,7 @@
"pullReconnect": "readonly",
"qrcode": "readonly",
"registerPage": "readonly",
"renderPathHashSize": "readonly",
"renderVersionCard": "readonly",
"renderSkewBadge": "readonly",
"renderSkewSparkline": "readonly",
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<span class="' + cls + '"' + (o.title ? ' title="' + escapeHtml(o.title) + '"' : '') + '>' + label + '</span>';
}
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 ? '' : '<span class="sr-only"> — recommended: 2- or 3-byte path hash</span>';
return '<span class="' + cls + ' ' + o.block + '--warn path-hash-warn" title="' + tip + '">' +
'<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' +
label + rec + '</span>';
}
/**
* 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
Expand Down
9 changes: 7 additions & 2 deletions public/channels.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<span class="ch-path-hash-badge" title="Path hash size encoded in the sender’s packet header">Sent with: ' + size + '-byte</span>';
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) {
Expand Down
6 changes: 4 additions & 2 deletions public/customize-v2.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
};

Expand Down Expand Up @@ -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'
};

Expand All @@ -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'
};

Expand Down Expand Up @@ -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 ──
Expand Down
7 changes: 7 additions & 0 deletions public/nodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,13 @@
if (n.hash_size && Number(n.hash_size) >= 2) {
html += ` <span class="badge multibyte-badge" title="Node advertises multibyte hash path (firmware Feat1/Feat2)">Multibyte: ${Number(n.hash_size)}-byte</span>`;
}
// #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 += ` <a href="#/nodes/${encodeURIComponent(n.public_key)}?section=node-packets" class="badge" style="background:var(--status-yellow);color:#000;font-size:10px;cursor:pointer;text-decoration:none"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> variable hash size</a>`;
}
Expand Down
13 changes: 12 additions & 1 deletion public/packets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dd></dd> (#356 review round 2, finding 2).
function hashSizeDetailHtml(hashSize) {
const inner = renderPathHashSize(hashSize, hashSize + ' byte' + (hashSize !== 1 ? 's' : ''), { block: 'detail-hash-size' });
return inner ? '<dt>Hash Size</dt><dd>' + inner + '</dd>' : '';
}

async function renderDetail(panel, data, chosenObsId) {
const pkt = data.packet;
const observations = data.observations || [];
Expand Down Expand Up @@ -3893,7 +3903,7 @@
<dt>SNR / RSSI</dt><dd>${snr != null ? snr + ' dB' : '—'} / ${rssi != null ? rssi + ' dBm' : '—'}</dd>
<dt>Route Type</dt><dd>${routeTypeName(pkt.route_type)}</dd>
${pkt.scope_name != null ? `<dt>Scope</dt><dd>${pkt.scope_name !== '' ? escapeHtml(pkt.scope_name) : '<span style="color:var(--text-muted)">unknown scope</span>'}</dd>` : ''}
${hashSize ? `<dt>Hash Size</dt><dd>${hashSize} byte${hashSize !== 1 ? 's' : ''}</dd>` : ''}
${hashSizeDetailHtml(hashSize)}
<dt>Propagation</dt><dd>${propagationHtml}</dd>
${transportCodesRow}
${rawCustomRow}
Expand Down Expand Up @@ -4567,6 +4577,7 @@
reconcileVisibleCols,
getDetailPreview,
buildDetailMessageHtml,
hashSizeDetailHtml,
sortGroupChildren,
getPathHopCount,
renderDecodedPacket,
Expand Down
14 changes: 14 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions test-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions test-channels-observed-path-hash-size.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -181,6 +184,34 @@ test('sender badge uses only a valid encoded width', () => {
assert.strictEqual(senderBadge({ senderPathHashSize: '<img src=x onerror=alert(1)>' }), '');
});

// #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<span class="sr-only"> — 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</span>$'));
}
});

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' }];
Expand Down
22 changes: 22 additions & 0 deletions test-customizer-v2.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
32 changes: 32 additions & 0 deletions test-frontend-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading