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
10 changes: 6 additions & 4 deletions public/nodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? `<a href="#/nodes/${encodeURIComponent(h.pubkey)}" style="${isThis ? 'font-weight:700;color:var(--link-color, #3b82f6)' : ''}">${name}</a>` : `<span>${name}</span>`;
const link = h.pubkey ? `<a href="#/nodes/${encodeURIComponent(h.pubkey)}"${isThis ? ' class="hop-current"' : ''}>${name}</a>` : `<span>${name}</span>`;
return link;
}).join(' → ');
return `<div style="padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
Expand Down Expand Up @@ -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 ? `<a href="#/nodes/${encodeURIComponent(h.pubkey)}" style="${isThis ? 'font-weight:700;color:var(--link-color, #3b82f6)' : ''}">${name}</a>` : `<span>${name}</span>`;
const link = h.pubkey ? `<a href="#/nodes/${encodeURIComponent(h.pubkey)}"${isThis ? ' class="hop-current"' : ''}>${name}</a>` : `<span>${name}</span>`;
return link;
}).join(' → ');
return `<div style="padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
Expand Down
15 changes: 14 additions & 1 deletion public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
160 changes: 149 additions & 11 deletions test-issue-1146-path-link-contrast-e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 <node> & 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',
Expand All @@ -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 () => {
Expand All @@ -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 }
);
});

Expand All @@ -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`);
Expand Down
Loading