diff --git a/public/hop-display.js b/public/hop-display.js
index 1396177..19c8ba9 100644
--- a/public/hop-display.js
+++ b/public/hop-display.js
@@ -7,11 +7,58 @@ window.HopDisplay = (function() {
return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
+ // Dismiss any open conflict popover
+ function dismissPopover() {
+ const old = document.querySelector('.hop-conflict-popover');
+ if (old) old.remove();
+ }
+
+ // Global click handler to dismiss popovers
+ let _listenerAttached = false;
+ function ensureGlobalListener() {
+ if (_listenerAttached) return;
+ _listenerAttached = true;
+ document.addEventListener('click', (e) => {
+ if (!e.target.closest('.hop-conflict-popover') && !e.target.closest('.hop-conflict-btn')) {
+ dismissPopover();
+ }
+ });
+ }
+
+ function showConflictPopover(btn, h, conflicts, globalFallback) {
+ dismissPopover();
+ ensureGlobalListener();
+
+ const regional = conflicts.filter(c => c.regional);
+ const shown = regional.length > 0 ? regional : conflicts;
+
+ let html = `
`;
+ html += '';
+ for (const c of shown) {
+ const name = escapeHtml(c.name || c.pubkey?.slice(0, 16) || '?');
+ const dist = c.distKm != null ? `
${c.distKm}km` : '';
+ const pk = c.pubkey ? c.pubkey.slice(0, 12) + '…' : '';
+ html += `
+ ${name}
+ ${dist}
+ ${pk}
+ `;
+ }
+ html += '
';
+
+ const popover = document.createElement('div');
+ popover.className = 'hop-conflict-popover';
+ popover.innerHTML = html;
+ document.body.appendChild(popover);
+
+ // Position near the button
+ const rect = btn.getBoundingClientRect();
+ popover.style.top = (rect.bottom + window.scrollY + 4) + 'px';
+ popover.style.left = Math.max(8, Math.min(rect.left + window.scrollX - 60, window.innerWidth - 280)) + 'px';
+ }
+
/**
* Render a hop prefix as HTML with conflict info.
- * @param {string} h - hex hop prefix
- * @param {Object} [entry] - resolved hop entry from HopResolver or hopNameCache
- * @param {Object} [opts] - { link: true, truncate: 0 }
*/
function renderHop(h, entry, opts) {
opts = opts || {};
@@ -22,33 +69,20 @@ window.HopDisplay = (function() {
const pubkey = entry.pubkey || h;
const ambiguous = entry.ambiguous || false;
const conflicts = entry.conflicts || [];
- const totalGlobal = entry.totalGlobal || conflicts.length;
- const totalRegional = entry.totalRegional || 0;
const globalFallback = entry.globalFallback || false;
const unreliable = entry.unreliable || false;
const display = opts.hexMode ? h : (name ? escapeHtml(opts.truncate ? name.slice(0, opts.truncate) : name) : h);
- // Build tooltip — only show regional candidates (global is noise)
+ // Simple title for the hop link itself
let title = h;
- if (conflicts.length > 0) {
- const regional = conflicts.filter(c => c.regional);
- const shown = regional.length > 0 ? regional : conflicts; // fall back to all if no regional
- const lines = shown.map(c => {
- let line = c.name || c.pubkey?.slice(0, 12) || '?';
- if (c.distKm != null) line += ` (${c.distKm}km)`;
- return line;
- });
- const label = regional.length > 0 ? `${regional.length} in region` : `${conflicts.length} global`;
- title = `${h} — ${label}:\n${lines.join('\n')}`;
- }
- if (unreliable) title += '\n✗ Unreliable — too far from neighbors';
- if (globalFallback) title += '\n⚑ No regional candidates';
+ if (unreliable) title += ' — unreliable';
// Badge — only count regional conflicts
const regionalConflicts = conflicts.filter(c => c.regional);
const badgeCount = regionalConflicts.length > 0 ? regionalConflicts.length : (globalFallback ? conflicts.length : 0);
+ const conflictData = escapeHtml(JSON.stringify({ h, conflicts, globalFallback }));
const warnBadge = badgeCount > 1
- ? `⚠${badgeCount}`
+ ? ` `
: '';
const cls = [
@@ -60,16 +94,13 @@ window.HopDisplay = (function() {
].filter(Boolean).join(' ');
if (opts.link !== false) {
- return `${display}${warnBadge}`;
+ return `${display}${warnBadge}`;
}
- return `${display}${warnBadge}`;
+ return `${display}${warnBadge}`;
}
/**
* Render a full path as HTML.
- * @param {string[]} hops - array of hex prefixes
- * @param {Object} cache - hop name cache (hop → entry)
- * @param {Object} [opts] - { link: true, separator: ' → ' }
*/
function renderPath(hops, cache, opts) {
opts = opts || {};
@@ -78,5 +109,13 @@ window.HopDisplay = (function() {
return hops.filter(Boolean).map(h => renderHop(h, cache[h], opts)).join(sep);
}
- return { renderHop, renderPath };
+ // Called from inline onclick
+ function _showFromBtn(btn) {
+ try {
+ const data = JSON.parse(btn.dataset.conflict);
+ showConflictPopover(btn, data.h, data.conflicts, data.globalFallback);
+ } catch (e) { console.error('Conflict popover error:', e); }
+ }
+
+ return { renderHop, renderPath, _showFromBtn };
})();
diff --git a/public/index.html b/public/index.html
index 473548b..6a9ad31 100644
--- a/public/index.html
+++ b/public/index.html
@@ -22,7 +22,7 @@
-
+
-
+
diff --git a/public/style.css b/public/style.css
index 8422712..00e356c 100644
--- a/public/style.css
+++ b/public/style.css
@@ -1225,6 +1225,21 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
/* Ambiguous hop indicator */
.hop-ambiguous { border-bottom: 1px dashed #f59e0b; }
.hop-warn { font-size: 0.7em; margin-left: 2px; vertical-align: super; color: #f59e0b; }
+.hop-conflict-btn { background: #f59e0b; color: #000; border: none; border-radius: 4px; font-size: 11px;
+ font-weight: 700; padding: 1px 5px; cursor: pointer; vertical-align: middle; margin-left: 3px; line-height: 1.2; }
+.hop-conflict-btn:hover { background: #d97706; }
+.hop-conflict-popover { position: absolute; z-index: 9999; background: var(--surface-1); border: 1px solid var(--border);
+ border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.25); width: 260px; max-height: 300px; overflow-y: auto; }
+.hop-conflict-header { padding: 10px 12px; font-size: 12px; font-weight: 700; border-bottom: 1px solid var(--border);
+ color: var(--text-muted); }
+.hop-conflict-list { padding: 4px 0; }
+.hop-conflict-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; text-decoration: none;
+ color: var(--text); font-size: 13px; border-bottom: 1px solid var(--border); }
+.hop-conflict-item:last-child { border-bottom: none; }
+.hop-conflict-item:hover { background: var(--hover-bg); }
+.hop-conflict-name { font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.hop-conflict-dist { font-size: 11px; color: var(--text-muted); font-family: var(--mono); white-space: nowrap; }
+.hop-conflict-pk { font-size: 10px; color: var(--text-muted); font-family: var(--mono); }
.hop-unreliable { opacity: 0.5; text-decoration: line-through; }
.hop-global-fallback { border-bottom: 1px dashed #ef4444; }
.hop-current { font-weight: 700 !important; color: var(--accent) !important; }