diff --git a/public/nodes.js b/public/nodes.js
index bea090341..8c9ca7d57 100644
--- a/public/nodes.js
+++ b/public/nodes.js
@@ -1109,17 +1109,18 @@
}
document.querySelector('#fullPathsSection h4').textContent = `Paths Through This Node (${pathData.totalPaths} unique, ${pathData.totalTransmissions} transmissions)`;
const COLLAPSE_LIMIT = 10;
+ const currentPubkey = n.public_key.toLowerCase();
function renderPaths(paths) {
return paths.map(p => {
const chain = p.hops.map(h => {
- const isThis = h.pubkey === n.public_key;
+ const isThis = h.pubkey && h.pubkey.toLowerCase() === currentPubkey;
if (window.HopDisplay) {
const entry = { name: h.name, pubkey: h.pubkey, ambiguous: h.ambiguous, conflicts: h.conflicts, totalGlobal: h.totalGlobal, totalRegional: h.totalRegional, globalFallback: h.globalFallback, unreliable: h.unreliable };
const html = HopDisplay.renderHop(h.prefix, entry);
return isThis ? html.replace('class="', 'class="hop-current ') : html;
}
const name = escapeHtml(h.name || h.prefix);
- const link = h.pubkey ? `${name}` : `${name}`;
+ const link = h.pubkey ? `${name}` : `${name}`;
return link;
}).join(' → ');
return `
@@ -2028,12 +2029,13 @@
document.querySelector('#pathsSection h4').textContent = `Paths Through This Node (${pathData.totalPaths} unique path${pathData.totalPaths !== 1 ? 's' : ''}, ${pathData.totalTransmissions} transmissions)`;
const COLLAPSE_LIMIT = 10;
const showAll = pathData.paths.length <= COLLAPSE_LIMIT;
+ const currentPubkey = n.public_key.toLowerCase();
function renderPaths(paths) {
return paths.map(p => {
const chain = p.hops.map(h => {
- const isThis = h.pubkey === n.public_key;
+ const isThis = h.pubkey && h.pubkey.toLowerCase() === currentPubkey;
const name = escapeHtml(h.name || h.prefix);
- const link = h.pubkey ? `
${name}` : `
${name}`;
+ const link = h.pubkey ? `
${name}` : `
${name}`;
return link;
}).join(' → ');
return `
diff --git a/public/style.css b/public/style.css
index ac1a9aadf..27fa83602 100644
--- a/public/style.css
+++ b/public/style.css
@@ -3145,7 +3145,20 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.hop-unreliable-btn { background: none; border: none; color: var(--status-yellow, #f59e0b); font-size: 13px;
cursor: help; vertical-align: middle; margin-left: 2px; padding: 0 2px; line-height: 1; }
.hop-global-fallback { border-bottom: 1px dashed var(--status-red); }
-.hop-current { font-weight: 700 !important; color: var(--link-color) !important; }
+.hop-current {
+ display: inline-block;
+ padding: 0 4px;
+ /* box-shadow, not outline: outline is this app's focus affordance (see
+ "Focus Indicators" above), so an outlined-but-unfocused hop reads as
+ focused, and a genuinely focused one would differ only in ring width and
+ a near-identical blue. A shadow ring draws the same box while leaving
+ :focus-visible unambiguous, and — like outline — it does not disturb the
+ shared renderer's dashed ambiguity border. */
+ box-shadow: 0 0 0 1px var(--link-color);
+ border-radius: 4px;
+ font-weight: 700 !important;
+ color: var(--link-color) !important;
+}
/* Self-loop subpath rows */
.subpath-selfloop { opacity: 0.6; }
diff --git a/test-issue-1146-path-link-contrast-e2e.js b/test-issue-1146-path-link-contrast-e2e.js
index 77a465fa6..43f24cd8c 100644
--- a/test-issue-1146-path-link-contrast-e2e.js
+++ b/test-issue-1146-path-link-contrast-e2e.js
@@ -14,15 +14,21 @@
* theme, then asserts the computed link colour vs. its background
* yields a contrast ratio ≥ 4.5:1.
*
- * Currently FAILS (link color resolves to rgb(0,0,238)).
- * After the style.css fix it PASSES.
+ * #1153 also checks that the selected node has a visible enclosing marker
+ * in an 18-hop chain, without marking a different node with the same prefix.
+ * Covers the desktop side/full views and mobile full view in both themes.
*
* Usage: BASE_URL=http://localhost:13581 node test-issue-1146-path-link-contrast-e2e.js
+ * Optional SCREENSHOT_DIR saves PNG evidence; use an ignored output directory.
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
+assert(['localhost', '127.0.0.1', '[::1]'].includes(new URL(BASE).hostname),
+ 'Browser regression tests must use a local server');
+const SCREENSHOT_DIR = process.env.SCREENSHOT_DIR;
+if (SCREENSHOT_DIR) require('fs').mkdirSync(SCREENSHOT_DIR, { recursive: true });
let passed = 0, failed = 0;
async function step(name, fn) {
@@ -83,20 +89,41 @@ async function effectiveBgFor(page, selector) {
console.log(`\n=== #1146 path-link contrast E2E against ${BASE} ===`);
- const hopPubkey = 'a1b2c3d4e5f60718293a4b5c6d7e8f9001122334455667788990aabbccddeeff';
+ const hopPubkey = 'a1'.repeat(32);
+ const targetName = 'Selected & relay';
+ let selectedPubkey;
+ let targetHopKey;
+ let siblingKey;
// Mock paths API for ANY node so test is deterministic.
await page.route('**/api/nodes/*/paths*', (route) => {
- route.fulfill({
+ selectedPubkey = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[3]);
+ // Full public-key identity must survive hex casing; a shared prefix alone
+ // must never qualify. Neither fixture node requires resolver changes.
+ targetHopKey = selectedPubkey === selectedPubkey.toUpperCase()
+ ? selectedPubkey.toLowerCase() : selectedPubkey.toUpperCase();
+ siblingKey = targetHopKey.slice(0, -1) + (targetHopKey.endsWith('0') ? '1' : '0');
+ const hops = Array.from({ length: 18 }, (_, i) => ({
+ pubkey: hopPubkey.slice(0, -2) + i.toString(16).padStart(2, '0'),
+ prefix: 'a1', name: 'Relay ' + (i + 1),
+ }));
+ hops[8] = { pubkey: targetHopKey, prefix: targetHopKey.slice(0, 2), name: targetName, unreliable: true, ambiguous: true };
+ hops[9] = {
+ pubkey: siblingKey, prefix: targetHopKey.slice(0, 2), name: 'Same-prefix sibling', ambiguous: true,
+ conflicts: [
+ { pubkey: targetHopKey, name: targetName, regional: true },
+ { pubkey: siblingKey, name: 'Same-prefix sibling', regional: true },
+ ],
+ };
+ hops[10] = { prefix: targetHopKey.slice(0, 2), name: 'Unresolved prefix' };
+ return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
totalPaths: 1,
totalTransmissions: 5,
paths: [{
- hops: [
- { pubkey: hopPubkey, prefix: 'a1', name: 'TestHop' },
- ],
+ hops,
count: 5,
lastSeen: new Date().toISOString(),
sampleHash: 'deadbeef00',
@@ -107,10 +134,10 @@ async function effectiveBgFor(page, selector) {
await step('Load nodes page and force dark theme', async () => {
await page.goto(BASE + '/#/nodes', { waitUntil: 'domcontentloaded' });
- await page.evaluate(() => {
- document.documentElement.setAttribute('data-theme', 'dark');
- });
await page.waitForSelector('#nodesBody tr[data-key]', { timeout: 15000 });
+ if (await page.locator('html').getAttribute('data-theme') !== 'dark') {
+ await page.locator('#darkModeToggle').click();
+ }
});
await step('Open side panel for first node and wait for paths', async () => {
@@ -121,7 +148,7 @@ async function effectiveBgFor(page, selector) {
const el = document.getElementById('pathsContent');
return el && el.querySelector('a[href^="#/nodes/"]');
},
- { timeout: 15000 }
+ null, { timeout: 15000 }
);
});
@@ -145,6 +172,117 @@ async function effectiveBgFor(page, selector) {
`covered by the .data-table td a rule and inherit UA blue.`);
});
+ async function assertCurrentHop(selector, hasHopDisplay) {
+ await page.waitForFunction((sel) => document.querySelector(sel)?.textContent.includes('Relay 18'), selector);
+ const targetSelector = selector + ' a[href="#/nodes/' + targetHopKey + '"]';
+ const result = await page.evaluate(({ selector, targetHopKey, siblingKey }) => {
+ const container = document.querySelector(selector);
+ const chain = container.firstElementChild.firstElementChild;
+ const target = chain.querySelector('a[href="#/nodes/' + targetHopKey + '"]');
+ const sibling = chain.querySelector('a[href="#/nodes/' + siblingKey + '"]');
+ target.scrollIntoView({ block: 'center' });
+ const style = getComputedStyle(target);
+ const rect = target.getBoundingClientRect();
+ const chainRect = chain.getBoundingClientRect();
+ const outline = parseFloat(style.outlineWidth) >= 1 && style.outlineStyle === 'solid';
+ const border = ['Top', 'Right', 'Bottom', 'Left'].every(side =>
+ parseFloat(style['border' + side + 'Width']) >= 1 && style['border' + side + 'Style'] === 'solid');
+ // A shadow ring counts too: outline is reserved for :focus-visible, so
+ // the marker deliberately uses box-shadow instead (see style.css).
+ const shadowMatch = /rgba?\([^)]*\)/.exec(style.boxShadow || '');
+ const shadow = style.boxShadow !== 'none' && !!shadowMatch;
+ return {
+ marked: Array.from(chain.querySelectorAll('.hop-current')).map(el => el.getAttribute('href')),
+ ring: outline || border || shadow,
+ ringColor: outline ? style.outlineColor : border ? style.borderTopColor : (shadowMatch && shadowMatch[0]),
+ outline, dashedBottom: style.borderBottomStyle === 'dashed' && parseFloat(style.borderBottomWidth) >= 1,
+ textColor: style.color, padding: parseFloat(style.paddingLeft),
+ text: target.textContent, injectedElements: target.childElementCount,
+ siblingMarked: sibling.classList.contains('hop-current'),
+ fits: rect.left >= chainRect.left - 1 && rect.right <= chainRect.right + 1,
+ fragments: target.getClientRects().length,
+ warning: !!chain.querySelector('.hop-unreliable-btn'),
+ conflict: !!chain.querySelector('.hop-conflict-btn'),
+ siblingAmbiguous: sibling.classList.contains('hop-ambiguous'),
+ hopCount: chain.children.length - chain.querySelectorAll('button').length,
+ };
+ }, { selector, targetHopKey, siblingKey });
+ assert(result.hopCount === 18, 'Expected all 18 hops in the rendered chain');
+ assert(result.ring, 'Selected hop must have a visible enclosing border or outline, beyond bold/color');
+ assert(result.marked.length === 1 && result.marked[0] === '#/nodes/' + targetHopKey,
+ 'Exactly the selected full public key must be marked, including mixed-case hex');
+ assert(!result.siblingMarked, 'Same-prefix sibling must not be marked as this node');
+ assert(result.padding > 0 && result.fits && result.fragments === 1,
+ 'Selected hop marker must enclose the name and fit within the wrapping chain');
+ assert(result.text === targetName && result.injectedElements === 0, 'Selected hop name must stay escaped');
+ const bg = parseRgb(await effectiveBgFor(page, targetSelector));
+ assert(contrast(parseRgb(result.ringColor), bg) >= 3, 'Selected hop ring must contrast with the active theme');
+ assert(contrast(parseRgb(result.textColor), bg) >= 4.5, 'Selected hop text must remain readable');
+ // The marker must not impersonate the focus ring. `outline` is this app's
+ // focus affordance (style.css "Focus Indicators"), so an unfocused marked
+ // hop drawing its own outline would read as focused, and a genuinely
+ // focused one would differ only in ring width — leaving a keyboard user
+ // unable to tell which hop of an 18-hop chain actually has focus.
+ // (:focus-visible itself is not asserted here: it needs real keyboard
+ // interaction, and the rule that provides it is not part of this change.)
+ assert(!result.outline,
+ 'The selected-hop marker must not use outline — that is reserved for :focus-visible');
+
+ if (hasHopDisplay) {
+ assert(result.warning && result.conflict && result.siblingAmbiguous,
+ 'Shared hop rendering must preserve unreliable and ambiguous indicators');
+ assert(result.ring && result.dashedBottom,
+ 'The selected ambiguous hop must keep its dashed bottom border alongside the enclosing marker ring');
+ }
+ if (SCREENSHOT_DIR) {
+ const theme = await page.locator('html').getAttribute('data-theme');
+ const filename = `${selector.slice(1)}-${page.viewportSize().width}-${theme}${hasHopDisplay ? '' : '-plain'}.png`;
+ await page.locator(selector).screenshot({ path: require('path').join(SCREENSHOT_DIR, filename) });
+ }
+ }
+
+ await step('#1153 desktop side panel: selected hop stands out in an 18-hop dark chain',
+ () => assertCurrentHop('#pathsContent', false));
+
+ for (const theme of ['light', 'dark']) {
+ if (await page.locator('html').getAttribute('data-theme') !== theme) {
+ await page.locator('#darkModeToggle').click();
+ }
+ await step('#1153 desktop side panel: ' + theme + ' theme', async () => {
+ await page.goto(BASE + '/#/nodes', { waitUntil: 'domcontentloaded' });
+ assert(await page.locator('html').getAttribute('data-theme') === theme, 'Expected the selected theme');
+ await page.locator('#nodesBody tr[data-key]').first().click();
+ await assertCurrentHop('#pathsContent', false);
+ });
+ await step('#1153 desktop full view: ' + theme + ' theme', async () => {
+ await page.locator('#nodesRight .node-detail-btn').click();
+ await assertCurrentHop('#fullPathsContent', true);
+ });
+ }
+
+ for (const theme of ['light', 'dark']) {
+ // The theme toggle lives in the desktop header; exercise the same theme
+ // at mobile width without depending on the unrelated navigation drawer.
+ await page.setViewportSize({ width: 1280, height: 900 });
+ if (await page.locator('html').getAttribute('data-theme') !== theme) {
+ await page.locator('#darkModeToggle').click();
+ }
+ await page.setViewportSize({ width: 390, height: 844 });
+ await step('#1153 mobile row click opens a readable full chain: ' + theme + ' theme', async () => {
+ await page.goto(BASE + '/#/nodes', { waitUntil: 'domcontentloaded' });
+ assert(await page.locator('html').getAttribute('data-theme') === theme, 'Expected the selected theme');
+ await page.locator('#nodesBody tr[data-key]').first().click();
+ await assertCurrentHop('#fullPathsContent', true);
+ });
+ }
+
+ await step('#1153 full-view fallback also marks only the selected node', async () => {
+ await page.route('**/hop-display.js*', route => route.fulfill({ contentType: 'text/javascript', body: '' }));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await assertCurrentHop('#fullPathsContent', false);
+ assert(await page.evaluate(() => !window.HopDisplay), 'Expected the real full-view fallback');
+ });
+
await browser.close();
console.log(`\n${passed} passed, ${failed} failed`);