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
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ jobs:
BASE_URL=http://localhost:13581 node test-issue-1122-details-row-clamp-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1128-packets-layout-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-189-group-caret-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-254-affinity-toggle-mobile-aria-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1128-multi-viewport-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1136-live-region-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-live-multibyte-only-e2e.js 2>&1 | tee -a e2e-output.txt
Expand Down
3 changes: 3 additions & 0 deletions public/mobile-page-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
const SLOT_ID = 'navPageActions';

function isMobile() { return window.innerWidth <= MOBILE_BP; }
// #254: packets.js renders a group row as select-hash under this breakpoint,
// where the #1461 #7 redirect below makes activating it select.
window.MobilePageActions = { isMobile: isMobile };

function ensureSlot() {
let slot = document.getElementById(SLOT_ID);
Expand Down
45 changes: 39 additions & 6 deletions public/nodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,38 @@

// ─── End neighbor helpers ─────────────────────────────────────────────────

// #254: the Affinity Debug card (only shown with debugAffinity). Its heading is
// a disclosure button, with caret-right when collapsed and caret-down when
// expanded (#189), and aria-expanded on the button follows the body. The click
// is handled by onFullBodyClick, delegated from #nodeFullBody.
function affinityDebugCaretHtml(expanded) {
return '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-' + (expanded ? 'down' : 'right') + '"/></svg>';
}

function renderAffinityDebugCard() {
return `<div class="node-full-card" id="node-affinity-debug" style="display:none">
<h4><button type="button" class="affinity-debug-toggle" aria-expanded="false" aria-controls="affinityDebugBody"><span class="toggle-icon">${affinityDebugCaretHtml(false)}</span> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Affinity Debug</button></h4>
<div class="affinity-debug-body" id="affinityDebugBody" hidden>
<div id="affinityDebugContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading debug data…</div></div>
</div>
</div>`;
}

function toggleAffinityDebug(btn) {
const expanded = btn.getAttribute('aria-expanded') !== 'true';
btn.setAttribute('aria-expanded', String(expanded));
const body = btn.ownerDocument.getElementById(btn.getAttribute('aria-controls'));
if (body) body.hidden = !expanded;
const icon = btn.querySelector('.toggle-icon');
if (icon) icon.innerHTML = affinityDebugCaretHtml(expanded);
return expanded;
}

function onFullBodyClick(e) {
const btn = e.target.closest && e.target.closest('.affinity-debug-toggle');
if (btn) toggleAffinityDebug(btn);
}

let directNode = null; // set when navigating directly to #/nodes/:pubkey

let regionChangeHandler = null;
Expand All @@ -570,6 +602,9 @@
</div>
</div>`;
document.getElementById('nodeBackBtn').addEventListener('click', () => { location.hash = '#/nodes'; });
// Wired once here: loadFullNode re-renders the body (theme-refresh) and
// would stack listeners.
document.getElementById('nodeFullBody').addEventListener('click', onFullBodyClick);
loadFullNode(directNode);
// Escape to go back to nodes list
document.addEventListener('keydown', function nodesEsc(e) {
Expand Down Expand Up @@ -916,12 +951,7 @@
<div id="fullNeighborsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading neighbors…</div></div>
</div>

<div class="node-full-card" id="node-affinity-debug" style="display:none">
<h4 style="cursor:pointer" onclick="var body=this.parentElement.querySelector('.affinity-debug-body'); var hidden=body.style.display==='none'; body.style.display=hidden?'block':'none'; this.querySelector('.toggle-icon').innerHTML=body.style.display==='none'?'<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-down"/></svg>':'<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-up"/></svg>'"><span class="toggle-icon"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-down"/></svg></span> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Affinity Debug</h4>
<div class="affinity-debug-body" style="display:none">
<div id="affinityDebugContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading debug data…</div></div>
</div>
</div>
${renderAffinityDebugCard()}

<div class="node-full-card" id="fullPathsSection">
<h4>Paths Through This Node</h4>
Expand Down Expand Up @@ -2148,6 +2178,9 @@
window._nodesRenderNodeTimestampText = renderNodeTimestampText;
window._nodesGetStatusInfo = getStatusInfo;
window._nodesGetStatusTooltip = getStatusTooltip;
window._nodesRenderAffinityDebugCard = renderAffinityDebugCard;
window._nodesToggleAffinityDebug = toggleAffinityDebug;
window._nodesOnFullBodyClick = onFullBodyClick;

// #862: Expose search filter logic for testing
window._nodesMatchesSearch = function(node, query) {
Expand Down
33 changes: 32 additions & 1 deletion public/packets.js
Original file line number Diff line number Diff line change
Expand Up @@ -2404,6 +2404,29 @@
}
}

// #254: mobile-page-actions.js owns the mobile breakpoint and the #1461 #7
// redirect of a group-row click to select-hash. Without it nothing redirects.
function groupRowSelectsOnActivate() {
return !!(window.MobilePageActions && window.MobilePageActions.isMobile());
}

// A group row's action and aria-expanded depend on that breakpoint, so a
// resize that crosses it (e.g. a phone rotating) re-renders the visible rows.
let _groupRowsSelect = false;
let _groupRowModeTimer = null;
function _onGroupRowModeResize() {
clearTimeout(_groupRowModeTimer);
_groupRowModeTimer = setTimeout(() => {
const selects = groupRowSelectsOnActivate();
if (selects === _groupRowsSelect) return;
_groupRowsSelect = selects;
if (!_displayGrouped) return;
_lastVisibleStart = -1;
_lastVisibleEnd = -1;
renderVisibleRows();
}, 150);
}

// Build HTML for a single grouped packet row
function buildGroupRowHtml(p, entryIdx = -1) {
const isExpanded = expandedHashes.has(p.hash);
Expand Down Expand Up @@ -2440,8 +2463,12 @@
// channels.js, network-digest.js, analytics.js and route-view.js. The row that
// toggles reports its state in aria-expanded; a single-observation row cannot
// expand, so it has neither.
// #254: under the mobile breakpoint activating a group row selects it (#1461
// #7, the expand column is hidden there), so the row is select-hash and
// claims no expanded state.
const _grpToggles = !isSingle && !groupRowSelectsOnActivate();
const _grpCaret = isSingle ? '' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-' + (isExpanded ? 'down' : 'right') + '"/></svg>';
let html = `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row"${isSingle ? '' : ' aria-expanded="' + isExpanded + '"'}${_grpStyle ? ' style="' + _grpStyle + '"' : ''}>
let html = `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${_grpToggles ? 'toggle-select' : 'select-hash'}" data-value="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row"${_grpToggles ? ' aria-expanded="' + isExpanded + '"' : ''}${_grpStyle ? ' style="' + _grpStyle + '"' : ''}>
<td class="col-expand" style="text-align:center;cursor:pointer">${_grpCaret}</td>
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
<td class="col-time">${renderTimestampCell(p.latest)}</td>
Expand Down Expand Up @@ -4257,6 +4284,8 @@
_themeRefreshHandler = () => { if (typeof renderTableRows === 'function') renderTableRows(); };
window.addEventListener('theme-refresh', _themeRefreshHandler);
window.addEventListener('storage', _onStorageChange);
_groupRowsSelect = groupRowSelectsOnActivate();
window.addEventListener('resize', _onGroupRowModeResize);
var result = init(app, routeParam);
// Install channel color picker on packets table (M2, #271)
if (window.ChannelColorPicker) window.ChannelColorPicker.installPacketsTable();
Expand All @@ -4265,6 +4294,8 @@
destroy: function() {
if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; }
window.removeEventListener('storage', _onStorageChange);
window.removeEventListener('resize', _onGroupRowModeResize);
clearTimeout(_groupRowModeTimer);
return destroy();
}
});
Expand Down
8 changes: 8 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -2860,6 +2860,14 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
margin-bottom: 12px; border: 1px solid var(--border);
}
.node-full-card h4 { margin: 0 0 10px; font-size: 14px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; }
/* #254: the Affinity Debug heading is a disclosure button. It keeps the h4
* look and spans the heading, so the whole row toggles as before. */
.node-full-card h4 .affinity-debug-toggle {
display: flex; align-items: center; gap: 6px; width: 100%;
margin: 0; padding: 0; border: 0; background: none; cursor: pointer;
font: inherit; color: inherit; letter-spacing: inherit; text-transform: inherit; text-align: start;
}
@media (pointer: coarse) { .node-full-card h4 .affinity-debug-toggle { min-height: 48px; } }
.node-activity-list { display: flex; flex-direction: column; gap: 6px; }
.node-activity-item {
display: flex; gap: 8px; font-size: 13px; padding: 6px 0;
Expand Down
1 change: 1 addition & 0 deletions test-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ run test-top-routes-overlay.js
run test-important-links-byte-filter.js
run test-url-state.js
run test-node-adverts.js
run test-issue-254-affinity-debug-toggle.js
run test-perf-go-runtime.js
run test-channel-psk-ux.js
run test-channel-sidebar-layout.js
Expand Down
196 changes: 196 additions & 0 deletions test-issue-254-affinity-debug-toggle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/**
* #254: the node page's Affinity Debug card (shown only with debugAffinity, e.g.
* localStorage meshcore-affinity-debug=true) folds its body in and out.
*
* Its heading used to carry an inline onclick whose value held the Phosphor
* sprite markup with unescaped double quotes, so the browser cut the attribute
* short and the handler was a syntax error: the body could never open. Its
* carets were also reversed against the #189 convention (collapsed caret-right,
* expanded caret-down).
*
* Tests the real nodes.js in a vm sandbox:
* - the card renders a disclosure <button> (aria-expanded="false",
* aria-controls on the hidden body, caret-right) and no inline handler;
* - toggling opens (true, body shown, caret-down) and closes again;
* - the delegated click handler toggles from a click inside the button and
* ignores other clicks.
*
* Usage: node test-issue-254-affinity-debug-toggle.js
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');

let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(' ✅ ' + name); }
catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); }
}

function loadNodes() {
const noop = () => {};
const ctx = {
window: { addEventListener: noop, removeEventListener: noop, dispatchEvent: noop },
document: {
readyState: 'complete',
createElement: () => ({ id: '', textContent: '', innerHTML: '', style: {} }),
head: { appendChild: noop },
getElementById: () => null,
addEventListener: noop,
removeEventListener: noop,
querySelectorAll: () => [],
querySelector: () => null,
},
console, Date, Math, Array, Object, String, Number, JSON, RegExp, Error, TypeError,
Map, Set, Promise, URLSearchParams, parseInt, parseFloat, isNaN, isFinite,
encodeURIComponent, decodeURIComponent,
setTimeout: noop, clearTimeout: noop, setInterval: noop, clearInterval: noop,
fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }),
performance: { now: () => Date.now() },
localStorage: { getItem: () => null, setItem: noop, removeItem: noop },
location: { hash: '' },
CustomEvent: class CustomEvent {},
requestAnimationFrame: noop,
// Globals nodes.js reads at load time.
ROLE_COLORS: {}, ROLE_STYLE: {}, TYPE_COLORS: {},
getNodeStatus: () => 'active',
getHealthThresholds: () => ({ staleMs: 1, degradedMs: 2, silentMs: 3 }),
timeAgo: () => '', truncate: (s) => s, escapeHtml: (s) => String(s == null ? '' : s),
payloadTypeName: () => '', payloadTypeColor: () => '',
registerPage: noop,
RegionFilter: { init: noop, onChange: () => noop, getRegionParam: () => '' },
debouncedOnWS: () => null, onWS: noop, offWS: noop, debounce: (fn) => fn,
api: () => Promise.resolve({}), invalidateApiCache: noop,
CLIENT_TTL: { nodeList: 1, nodeDetail: 1, nodeHealth: 1 },
initTabBar: noop, getFavorites: () => [], favStar: () => '', bindFavStars: noop,
makeColumnsResizable: noop,
};
vm.createContext(ctx);
vm.runInContext(fs.readFileSync('public/nodes.js', 'utf8'), ctx, { filename: 'public/nodes.js' });
return ctx.window;
}

// Attributes of the first start tag in html that matches re.
function startTag(html, re) {
const m = re.exec(html);
assert(m, 'no start tag matching ' + re);
return m[0];
}
function attr(tag, name) {
const m = new RegExp('\\s' + name + '(?:="([^"]*)")?(?=[\\s>/])').exec(tag);
return m ? (m[1] === undefined ? '' : m[1]) : null;
}
function caretsIn(html) {
return (html.match(/#ph-caret-[a-z]+/g) || []).map((s) => s.slice('#ph-'.length));
}

// A minimal DOM for the rendered card: the toggle button, its .toggle-icon and
// the body it controls, seeded from the rendered markup.
function cardDom(html) {
const btnTag = startTag(html, /<button\b[^>]*class="[^"]*affinity-debug-toggle[^"]*"[^>]*>/);
const bodyId = attr(btnTag, 'aria-controls');
const bodyTag = startTag(html, new RegExp('<div\\b[^>]*\\sid="' + bodyId + '"[^>]*>'));
const iconHtml = /<span class="toggle-icon">([\s\S]*?)<\/span>/.exec(html);
assert(iconHtml, 'the button has a .toggle-icon');
const body = { id: bodyId, hidden: attr(bodyTag, 'hidden') !== null };
const icon = { innerHTML: iconHtml[1] };
const doc = { getElementById: (id) => (id === bodyId ? body : null) };
const attrs = { 'aria-expanded': attr(btnTag, 'aria-expanded'), 'aria-controls': bodyId };
const btn = {
ownerDocument: doc,
getAttribute: (k) => (k in attrs ? attrs[k] : null),
setAttribute: (k, v) => { attrs[k] = String(v); },
querySelector: (sel) => (sel === '.toggle-icon' ? icon : null),
closest: (sel) => (sel === '.affinity-debug-toggle' ? btn : null),
};
return { btn, body, icon, attrs };
}

const w = loadNodes();
const render = w._nodesRenderAffinityDebugCard;
const toggle = w._nodesToggleAffinityDebug;
const onClick = w._nodesOnFullBodyClick;

console.log('\n=== #254 Affinity Debug card: markup ===');

test('nodes.js exposes the card renderer, the toggle and the delegated click handler', () => {
assert.strictEqual(typeof render, 'function', '_nodesRenderAffinityDebugCard');
assert.strictEqual(typeof toggle, 'function', '_nodesToggleAffinityDebug');
assert.strictEqual(typeof onClick, 'function', '_nodesOnFullBodyClick');
});

const html = typeof render === 'function' ? render() : '';

test('the card is hidden until debugAffinity shows it', () => {
const card = startTag(html, /<div\b[^>]*\sid="node-affinity-debug"[^>]*>/);
assert(/display:\s*none/.test(attr(card, 'style') || ''), card);
});

test('no inline event handler anywhere in the card', () => {
const handlers = html.match(/\son[a-z]+\s*=/gi);
assert(!handlers, 'inline handler(s): ' + JSON.stringify(handlers));
});

test('the heading holds a disclosure button, collapsed, controlling the hidden body', () => {
const h4 = /<h4\b[^>]*>([\s\S]*?)<\/h4>/.exec(html);
assert(h4, 'the card has an <h4> heading');
const btnTag = startTag(h4[1], /<button\b[^>]*>/);
assert.strictEqual(attr(btnTag, 'type'), 'button', btnTag);
assert(/\baffinity-debug-toggle\b/.test(attr(btnTag, 'class') || ''), btnTag);
assert.strictEqual(attr(btnTag, 'aria-expanded'), 'false', btnTag);
const bodyId = attr(btnTag, 'aria-controls');
assert(bodyId, 'aria-controls names the body');
const bodyTag = startTag(html, new RegExp('<div\\b[^>]*\\sid="' + bodyId + '"[^>]*>'));
assert(/\baffinity-debug-body\b/.test(attr(bodyTag, 'class') || ''), bodyTag);
assert(attr(bodyTag, 'hidden') !== null, 'the body starts hidden: ' + bodyTag);
assert(/Affinity Debug/.test(h4[1]), 'the button is labelled Affinity Debug');
assert(/id="affinityDebugContent"/.test(html), 'the body keeps #affinityDebugContent for the loader');
});

test('collapsed shows exactly one caret, caret-right', () => {
const icon = /<span class="toggle-icon">([\s\S]*?)<\/span>/.exec(html);
assert(icon, 'the button has a .toggle-icon');
assert.deepStrictEqual(caretsIn(icon[1]), ['caret-right']);
assert.deepStrictEqual(caretsIn(html), ['caret-right'], 'no other caret in the card');
});

console.log('\n=== #254 Affinity Debug card: toggle ===');

test('the toggle opens the body: aria-expanded true, caret-down', () => {
const d = cardDom(html);
assert.strictEqual(toggle(d.btn), true, 'returns the new state');
assert.strictEqual(d.attrs['aria-expanded'], 'true');
assert.strictEqual(d.body.hidden, false, 'body shown');
assert.deepStrictEqual(caretsIn(d.icon.innerHTML), ['caret-down']);
});

test('a second toggle closes it again: aria-expanded false, caret-right', () => {
const d = cardDom(html);
toggle(d.btn);
assert.strictEqual(toggle(d.btn), false, 'returns the new state');
assert.strictEqual(d.attrs['aria-expanded'], 'false');
assert.strictEqual(d.body.hidden, true, 'body hidden');
assert.deepStrictEqual(caretsIn(d.icon.innerHTML), ['caret-right']);
});

test('the delegated click handler toggles from a click inside the button', () => {
const d = cardDom(html);
onClick({ target: { closest: (sel) => d.btn.closest(sel) } });
assert.strictEqual(d.attrs['aria-expanded'], 'true');
assert.strictEqual(d.body.hidden, false);
onClick({ target: { closest: (sel) => d.btn.closest(sel) } });
assert.strictEqual(d.attrs['aria-expanded'], 'false');
assert.strictEqual(d.body.hidden, true);
});

test('the delegated click handler ignores clicks elsewhere on the node page', () => {
const d = cardDom(html);
onClick({ target: { closest: () => null } });
onClick({ target: {} });
assert.strictEqual(d.attrs['aria-expanded'], 'false');
assert.strictEqual(d.body.hidden, true);
});

console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
Loading
Loading