diff --git a/public/hop-display.js b/public/hop-display.js index 73b23e90..c62f0f77 100644 --- a/public/hop-display.js +++ b/public/hop-display.js @@ -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 '
  • ' + escapeHtml(e.glyph) + ' — ' + escapeHtml(e.description) + '
  • '; + }).join(''); + return '
    Path symbols' + + '
    '; + } + + return { renderHop, renderPath, _showFromBtn, PATH_SYMBOLS_LEGEND, renderPathSymbolsLegend }; })(); diff --git a/public/nodes.js b/public/nodes.js index 8040eda8..3e2363a7 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -689,6 +689,7 @@

    Paths Through This Node

    +
    ${(window.HopDisplay && HopDisplay.renderPathSymbolsLegend) ? HopDisplay.renderPathSymbolsLegend() : ''}
    Loading paths…
    diff --git a/public/packets.js b/public/packets.js index f627e73d..b2b230dc 100644 --- a/public/packets.js +++ b/public/packets.js @@ -1404,6 +1404,7 @@ +
    ${(window.HopDisplay && HopDisplay.renderPathSymbolsLegend) ? HopDisplay.renderPathSymbolsLegend() : ''}
    diff --git a/public/style.css b/public/style.css index 76968f8e..760200a5 100644 --- a/public/style.css +++ b/public/style.css @@ -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; +} diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 57e8483c..b64644ff 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -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
    disclosure with "Path symbols" summary + all glyphs', () => { + const html = HD.renderPathSymbolsLegend(); + assert.ok(html.includes(' element'); + assert.ok(html.includes('Path symbols'), '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
    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 ... + const theadIdx = pktSrc.indexOf(''); + const theadEnd = pktSrc.indexOf('', theadIdx); + const tbodyEnd = pktSrc.indexOf('
    RegionTimeHashSize 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 )'); + // The legend invocation must NOT be inside a ... — will clobber the sort handler: ' + line.trim()); + } + }); + }); + + test('#1504: nodes.js places legend in a sibling wrapper (NOT inside

    )', () => { + 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

    )'); + src.split('\n').forEach((line, i) => { + // line must not contain BOTH

    ]/.test(line)) { + throw new Error('nodes.js line ' + (i+1) + ' still embeds legend inside

    : ' + 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 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

    . + // 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
    ', 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 — would clobber sort handler'); + // Also: legend must be outside the entire (sibling) + const insideTable = pktSrc.slice(theadIdx, tbodyEnd); + assert.ok(!insideTable.includes('renderPathSymbolsLegend'), + 'legend must be sibling of
    , not a child of any /'); + }); +} + // ===== SUMMARY ===== Promise.allSettled(pendingTests).then(() => { console.log(`\n${'═'.repeat(40)}`);
    /