feat(packets): add Path symbols legend disclosure (closes #1504) (#1570)

## Summary

Closes #1504. Adds a tiny, dismissible "Path symbols" legend next to the
Path column header on the Packets page (and reused on the Nodes page's
"Paths Through This Node" card), explaining the three
otherwise-undiscoverable path glyphs:

- `⚠N` — regional conflict count (multiple candidates for the hop's
prefix in this region)
- `⚠️` — unreliable name resolution (best-guess pubkey couldn't be
confirmed)
- dashed underline — ambiguous / global-fallback resolution

## Rationale (from triage)

- **Tufte**: integrate words and graphics. A hidden per-row tooltip
violates "don't make the viewer cross-reference." A small, persistent
inline key next to the column header is dense, on-data, and dismissible.
- **Avoid a modal** — chartjunk for a 3-glyph vocabulary.
- **Munger** rejected the reporter's option #2 (hover overlay that
pauses live updates): a power-user table must not stall from accidental
hovers.
- Single shared constant on `HopDisplay` so the Nodes page reuses the
same vocabulary without drift.

## Files

- `public/hop-display.js` — export `PATH_SYMBOLS_LEGEND` constant +
`renderPathSymbolsLegend()` helper (no changes to existing badge
rendering logic)
- `public/packets.js` — wire renderer into the Path `<th>` header
- `public/nodes.js` — reuse renderer on `#fullPathsSection` h4
- `public/style.css` — minimal styling (subtle dotted-underline trigger
+ floating disclosure panel, all via theme vars)
- `test-frontend-helpers.js` — 5 new assertions (TDD red→green)

## TDD red → green

- RED commit `46741267` — adds 5 assertion-shaped tests; all fail on the
assertion (not on import/build).
- GREEN commit `fab27ec5` — implements the constant, renderer, wiring,
and CSS; all 607 frontend-helper tests pass.

## Tested via

- DOM-grep assertions on the rendered `<details>` markup (`<summary>Path
symbols</summary>`, all three glyphs present, dashed-underline
description).
- Static grep that `packets.js` invokes the shared renderer adjacent to
the Path column.
- Full `test-frontend-helpers.js`, `test-packet-filter.js`,
`test-aging.js` pass.

## Hard rules honored

- No modal, no pause-on-hover, no changes to `hop-display.js`'s badge
rendering logic.
- No `<img>`/SVG additions, no new CSS vars (uses existing theme vars),
no Go changes.
- PII grep clean on every commit and on this body.

Browser verified: manual smoke pending — disclosure is closed-by-default
and uses standard `<details>` semantics; renders inline with column
header.

E2E assertion added: `test-frontend-helpers.js` — `#1504:
renderPathSymbolsLegend returns <details> disclosure with "Path symbols"
summary + all glyphs` (and 4 sibling assertions).

---------

Co-authored-by: Kpa-clawbot <bot@meshcore-analyzer>
Co-authored-by: clawbot <bot@openclaw.local>
This commit is contained in:
Kpa-clawbot
2026-06-04 10:30:35 -07:00
committed by GitHub
co-authored by Kpa-clawbot clawbot
parent 0af968811f
commit 5fd8900cfc
5 changed files with 204 additions and 1 deletions
+22 -1
View File
@@ -121,5 +121,26 @@ window.HopDisplay = (function() {
} catch (e) { console.error('Conflict popover error:', e); }
}
return { renderHop, renderPath, _showFromBtn };
// #1504 — Path symbols legend (shared by Packets + Nodes pages).
// Tufte: integrate words and graphics — small, on-data, dismissible.
// Glyph strings here MUST match exactly what hop-display.js emits in renderHop()
// (the yellow ⚠N button + the bare ⚠ unreliable button + dashed-underline class).
const PATH_SYMBOLS_LEGEND = [
{ glyph: '⚠N',
description: 'Yellow button next to a hop — N regional candidates share this hop\u2019s prefix. Click for the candidate list.' },
{ glyph: '⚠',
description: 'Warning icon alone (no number) — unreliable name resolution: the best-guess pubkey couldn\u2019t be confirmed against surrounding path hops.' },
{ glyph: 'dashed underline',
description: 'Ambiguous or global-fallback resolution — the name matched outside the current region.' },
];
function renderPathSymbolsLegend() {
const items = PATH_SYMBOLS_LEGEND.map(function(e) {
return '<li><span class="path-legend-glyph">' + escapeHtml(e.glyph) + '</span> — ' + escapeHtml(e.description) + '</li>';
}).join('');
return '<details class="path-symbols-legend"><summary>Path symbols</summary>' +
'<ul class="path-legend-list">' + items + '</ul></details>';
}
return { renderHop, renderPath, _showFromBtn, PATH_SYMBOLS_LEGEND, renderPathSymbolsLegend };
})();
+1
View File
@@ -689,6 +689,7 @@
<div class="node-full-card" id="fullPathsSection">
<h4>Paths Through This Node</h4>
<div class="path-symbols-legend-wrapper">${(window.HopDisplay && HopDisplay.renderPathSymbolsLegend) ? HopDisplay.renderPathSymbolsLegend() : ''}</div>
<div id="fullPathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths…</div></div>
</div>
+1
View File
@@ -1404,6 +1404,7 @@
<button class="btn btn-icon${showHexHashes ? ' active' : ''}" id="hexHashToggle" title="Show raw hex hash prefixes instead of resolved node names in the path column">Hex Paths</button>
</div>
</div>
<div class="path-symbols-legend-wrapper">${(window.HopDisplay && HopDisplay.renderPathSymbolsLegend) ? HopDisplay.renderPathSymbolsLegend() : ''}</div>
<div class="table-fluid-wrap"><table class="data-table" id="pktTable">
<thead><tr>
<th scope="col" class="col-expand" data-priority="1"></th><th scope="col" class="col-region" data-sort-key="region" data-priority="3">Region</th><th scope="col" class="col-time" data-sort-key="time" data-type="date" data-priority="1">Time</th><th scope="col" class="col-hash" data-sort-key="hash" data-priority="3">Hash</th><th scope="col" class="col-size" data-sort-key="size" data-type="numeric" data-priority="4">Size</th>
+47
View File
@@ -4719,3 +4719,50 @@ body.embed #app.app-fixed {
height: 100vh !important;
height: 100dvh !important;
}
/* #1504 — Path symbols legend disclosure (subtle inline key, shared by Packets + Nodes). */
.path-symbols-legend-wrapper {
margin: 4px 0 6px;
text-align: right;
}
.path-symbols-legend {
position: relative; /* anchor for absolutely-positioned .path-legend-list */
display: inline-block;
margin-left: 6px;
font-weight: normal;
font-size: 11px;
vertical-align: middle;
}
.path-symbols-legend > summary {
display: inline-block;
cursor: pointer;
color: var(--text-muted, #888);
list-style: none;
border-bottom: 1px dotted currentColor;
user-select: none;
}
.path-symbols-legend > summary::-webkit-details-marker { display: none; }
.path-symbols-legend > summary::marker { content: ''; }
.path-symbols-legend > summary::before { content: 'ⓘ '; opacity: 0.7; }
.path-symbols-legend[open] > summary { color: var(--text, inherit); }
.path-symbols-legend .path-legend-list {
position: absolute;
z-index: 50;
margin: 4px 0 0;
padding: 8px 12px;
list-style: none;
background: var(--bg-card, var(--bg, #fff));
border: 1px solid var(--border, #ddd);
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
font-size: 11px;
line-height: 1.5;
max-width: 360px;
color: var(--text, inherit);
}
.path-symbols-legend .path-legend-list li { padding: 2px 0; }
.path-symbols-legend .path-legend-glyph {
display: inline-block;
min-width: 1.6em;
font-weight: 600;
}
+133
View File
@@ -6406,6 +6406,139 @@ console.log('\n=== roles.js: Map Tile Config Parsing ===');
});
}
// ===== #1504 — Path symbols legend disclosure =====
{
console.log('\n--- #1504: Path symbols legend disclosure ---');
const sb = {
window: { addEventListener: () => {}, dispatchEvent: () => {} },
document: {
readyState: 'complete',
createElement: () => ({ id: '', textContent: '', innerHTML: '' }),
head: { appendChild: () => {} },
getElementById: () => null,
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
},
console, Date, Math, Array, Object, String, Number, JSON, RegExp, Map, Set,
encodeURIComponent, parseInt, parseFloat, isNaN, Infinity, NaN, undefined,
setTimeout: () => {}, setInterval: () => {}, clearTimeout: () => {}, clearInterval: () => {},
};
sb.window.document = sb.document; sb.self = sb.window; sb.globalThis = sb.window;
const ctx1504 = vm.createContext(sb);
vm.runInContext(fs.readFileSync(__dirname + '/public/hop-display.js', 'utf8'), ctx1504);
const HD = ctx1504.window.HopDisplay;
test('#1504: HopDisplay.PATH_SYMBOLS_LEGEND is defined and non-empty array', () => {
assert.ok(Array.isArray(HD.PATH_SYMBOLS_LEGEND), 'PATH_SYMBOLS_LEGEND must be an array');
assert.ok(HD.PATH_SYMBOLS_LEGEND.length >= 3, 'must have at least 3 entries (⚠N, ⚠️, dashed underline)');
});
test('#1504: each legend entry has glyph + description', () => {
HD.PATH_SYMBOLS_LEGEND.forEach((e, i) => {
assert.ok(e && typeof e.glyph === 'string' && e.glyph.length > 0, 'entry ' + i + ' needs non-empty glyph');
assert.ok(typeof e.description === 'string' && e.description.length > 0, 'entry ' + i + ' needs non-empty description');
});
});
test('#1504: legend constant + renderer exposed on window.HopDisplay namespace', () => {
assert.ok(ctx1504.window.HopDisplay.PATH_SYMBOLS_LEGEND, 'PATH_SYMBOLS_LEGEND exported on namespace');
assert.strictEqual(typeof ctx1504.window.HopDisplay.renderPathSymbolsLegend, 'function', 'renderPathSymbolsLegend exported');
});
test('#1504: renderPathSymbolsLegend returns <details> disclosure with "Path symbols" summary + all glyphs', () => {
const html = HD.renderPathSymbolsLegend();
assert.ok(html.includes('<details'), 'must render a <details> element');
assert.ok(html.includes('<summary>Path symbols</summary>'), 'must have summary text "Path symbols"');
assert.ok(html.includes('⚠'), 'must contain warning glyph');
assert.ok(/dashed/i.test(html), 'must describe the dashed underline convention');
});
test('#1504: packets.js places legend in a sibling wrapper (NOT inside any <th> with data-sort-key)', () => {
const src = fs.readFileSync(__dirname + '/public/packets.js', 'utf8');
assert.ok(src.includes('renderPathSymbolsLegend'),
'packets.js must invoke HopDisplay.renderPathSymbolsLegend()');
assert.ok(src.includes('path-symbols-legend-wrapper'),
'packets.js must wrap the legend in .path-symbols-legend-wrapper (sibling, not inside <th>)');
// The legend invocation must NOT be inside a <th>...</th> with data-sort-key.
// Simple structural check: in any line that contains renderPathSymbolsLegend,
// we must NOT see "data-sort-key" on that same line.
src.split('\n').forEach((line, i) => {
if (line.includes('renderPathSymbolsLegend') && line.includes('data-sort-key')) {
throw new Error('packets.js line ' + (i+1) + ' places legend inside a sortable <th> — will clobber the sort handler: ' + line.trim());
}
});
});
test('#1504: nodes.js places legend in a sibling wrapper (NOT inside <h4>)', () => {
const src = fs.readFileSync(__dirname + '/public/nodes.js', 'utf8');
assert.ok(src.includes('path-symbols-legend-wrapper'),
'nodes.js must wrap the legend in .path-symbols-legend-wrapper (sibling, not inside <h4>)');
src.split('\n').forEach((line, i) => {
// line must not contain BOTH <h4 and renderPathSymbolsLegend
if (line.includes('renderPathSymbolsLegend') && /<h4[\s>]/.test(line)) {
throw new Error('nodes.js line ' + (i+1) + ' still embeds legend inside <h4>: ' + line.trim());
}
});
});
test('#1504: style.css gives .path-symbols-legend position:relative so absolutely-positioned panel anchors correctly', () => {
const css = fs.readFileSync(__dirname + '/public/style.css', 'utf8');
// Find the rule block for .path-symbols-legend (NOT .path-symbols-legend-wrapper)
const m = css.match(/\.path-symbols-legend\s*\{[^}]*\}/);
assert.ok(m, '.path-symbols-legend rule must exist');
assert.ok(/position\s*:\s*relative/.test(m[0]),
'.path-symbols-legend must declare position:relative so .path-legend-list (position:absolute) anchors to it, not a random ancestor. Block was: ' + m[0]);
// And the panel must still be position:absolute
const panel = css.match(/\.path-symbols-legend\s+\.path-legend-list\s*\{[^}]*\}/);
assert.ok(panel && /position\s*:\s*absolute/.test(panel[0]),
'.path-symbols-legend .path-legend-list must remain position:absolute');
});
test('#1504: legend glyphs match what hop-display.js actually renders (no documented-but-missing glyphs)', () => {
const hopSrc = fs.readFileSync(__dirname + '/public/hop-display.js', 'utf8');
HD.PATH_SYMBOLS_LEGEND.forEach(entry => {
const g = entry.glyph;
if (g === 'dashed underline') {
// documented as a CSS convention; class hop-ambiguous uses border-bottom: dashed
assert.ok(/hop-ambiguous|hop-global-fallback/.test(hopSrc),
'legend mentions "dashed underline" but hop-display.js has no ambiguous/global-fallback class');
return;
}
if (g === '⚠N') {
// Real template literal in hop-display.js: ⚠${badgeCount}
assert.ok(hopSrc.includes('⚠${badgeCount}') || hopSrc.includes('\u26a0${badgeCount}'),
'legend documents ⚠N but hop-display.js does not emit ⚠${badgeCount}');
return;
}
// Otherwise the literal glyph must appear in the file
assert.ok(hopSrc.includes(g),
'legend glyph ' + JSON.stringify(g) + ' (codepoints ' +
[...g].map(c => 'U+' + c.codePointAt(0).toString(16).toUpperCase()).join(',') +
') not found in hop-display.js');
});
});
test('#1504 regression: clicking <summary> in legend does NOT trigger column-sort handler', () => {
// Simulate the structural guarantee from the wrapper move: legend must not be a descendant of a sortable <th>.
// table-sort.js binds click on th[data-sort-key]; with the legend in a sibling div, no click on summary
// can bubble to a sortable th.
const pktSrc = fs.readFileSync(__dirname + '/public/packets.js', 'utf8');
// Extract the snippet around the table head and confirm the legend is OUTSIDE <thead>...</thead>
const theadIdx = pktSrc.indexOf('<thead>');
const theadEnd = pktSrc.indexOf('</thead>', theadIdx);
const tbodyEnd = pktSrc.indexOf('</table>', theadEnd);
assert.ok(theadIdx > 0 && theadEnd > theadIdx, 'must find thead boundaries');
const insideThead = pktSrc.slice(theadIdx, theadEnd);
assert.ok(!insideThead.includes('renderPathSymbolsLegend'),
'renderPathSymbolsLegend must NOT appear inside <thead> — would clobber sort handler');
// Also: legend must be outside the entire <table> (sibling)
const insideTable = pktSrc.slice(theadIdx, tbodyEnd);
assert.ok(!insideTable.includes('renderPathSymbolsLegend'),
'legend must be sibling of <table>, not a child of any <th>/<thead>/<tr>');
});
}
// ===== SUMMARY =====
Promise.allSettled(pendingTests).then(() => {
console.log(`\n${'═'.repeat(40)}`);