From 027045a1fdbc4e9150131fb63879a7c0cd386ca9 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 14 Sep 2026 17:57:36 +0200 Subject: [PATCH 1/3] feat(home): port upstream #2027 "Node page ->" link to My Mesh cards Ports the relevant part of upstream PR #2027 (Kpa-clawbot/CoreScope, head e5536d20fa8ed4e94b7d2abec481fa382dbf11c7): adds a "Node page ->" button to My Mesh cards, on both the normal card and the health-error card, plus flex-wrap on .mnc-actions so buttons wrap on narrow cards. Escaping/URL-encoding of the pubkey in navigation matches the existing Full health/View packets buttons. Local addition, not in upstream's diff: the card's keydown handler called e.preventDefault() on Enter/Space before delegating to the click handler (which already ignores .mnc-btn/.mnc-remove targets). Because a bubbled keydown's preventDefault() suppresses the native click-activation of a focused descendant @@ -356,6 +357,9 @@
${statusMsg}
+
+ +
`; } })); @@ -378,6 +382,7 @@ grid.querySelectorAll('.mnc-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); + if (btn.dataset.action === 'node') window.location.hash = '#/nodes/' + encodeURIComponent(btn.dataset.key); if (btn.dataset.action === 'health') loadHealth(btn.dataset.key); if (btn.dataset.action === 'packets') window.location.hash = '#/packets/' + btn.dataset.key; }); @@ -391,6 +396,12 @@ }; card.addEventListener('click', handler); card.addEventListener('keydown', (e) => { + // #2027 follow-up: a nested action/remove button's own Enter/Space + // activation must not be swallowed by the card's preventDefault() + // below. Bail out before it runs, the same target check `handler` + // already uses for the click case, so the button still fires its + // own click via the browser's native keyboard activation. + if (e.target.closest('.mnc-remove') || e.target.closest('.mnc-btn')) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handler(e); } }); }); diff --git a/test-issue-2027-my-mesh-node-page-e2e.js b/test-issue-2027-my-mesh-node-page-e2e.js new file mode 100644 index 000000000..567e0aca9 --- /dev/null +++ b/test-issue-2027-my-mesh-node-page-e2e.js @@ -0,0 +1,408 @@ +#!/usr/bin/env node +/* Port of upstream Kpa-clawbot/CoreScope#2027 (head e5536d20, "feat(home): + * link My Mesh node cards to the node page") plus a keyboard-activation fix + * found while browser-reproducing the reported finding (not in the upstream + * commit): the card's own keydown handler called preventDefault() + * unconditionally on Enter/Space before checking whether the event came + * from a nested `.mnc-btn`/`.mnc-remove`. A ` + @@ -358,7 +358,7 @@
${statusMsg}
- +
`; } diff --git a/test-issue-2027-my-mesh-node-page-e2e.js b/test-issue-2027-my-mesh-node-page-e2e.js index cf2dedc86..9211b0d60 100644 --- a/test-issue-2027-my-mesh-node-page-e2e.js +++ b/test-issue-2027-my-mesh-node-page-e2e.js @@ -47,7 +47,7 @@ const ORIGIN = 'http://127.0.0.1:18739'; const PUB = path.join(__dirname, 'public'); // Bump together with any added/removed step(): a run that finishes fewer // scenarios is reported as a failure, never as a pass. -const EXPECTED_STEPS = 24; +const EXPECTED_STEPS = 25; // Missing elements fail a scenario in seconds instead of Playwright's 30s. const DEFAULT_TIMEOUT_MS = 5000; @@ -395,6 +395,39 @@ function cardFor(page, pubkey) { }); } + // ---- 7b. The now-reachable Remove must be visible while focused ---- + // The keydown guard makes Remove genuinely keyboard-activatable. It is + // hover-revealed (opacity: 0), so without a focus rule a keyboard user + // would be operating an invisible destructive control (WCAG 2.4.7). + await step('keyboard-focused Remove is visible, and still hidden when unfocused', async () => { + const { page } = await newHarness(browser); + await page.setViewportSize({ width: 1280, height: 900 }); + await stub(page, '/nodes/' + encodeURIComponent(NORMAL_PK) + '/health', HEALTH_OK); + await renderMyMesh(page, [NORMAL_PK]); + const remove = cardFor(page, NORMAL_PK).locator('.mnc-remove'); + // .mnc-remove eases opacity over .15s, so wait for the transition to land + // and assert the meaningful contract (visible vs invisible) rather than an + // exact value an easing curve only approaches. + const awaitOpacity = async (visible) => { + await page.waitForFunction( + ([pk, wantVisible]) => { + const el = document.querySelector(`.my-node-card[data-key="${pk}"] .mnc-remove`); + if (!el) return false; + const o = parseFloat(getComputedStyle(el).opacity); + return wantVisible ? o > 0.9 : o < 0.1; + }, + [NORMAL_PK, visible], + ); + return parseFloat(await remove.evaluate((el) => getComputedStyle(el).opacity)); + }; + assert.ok(await awaitOpacity(false) < 0.1, 'Remove stays hidden while nothing on the card has focus'); + await remove.focus(); + assert.ok(await awaitOpacity(true) > 0.9, 'Remove becomes visible once it holds keyboard focus'); + await page.evaluate(() => document.activeElement.blur()); + assert.ok(await awaitOpacity(false) < 0.1, 'Remove hides again when focus leaves the card'); + await page.close(); + }); + // ---- 8. Narrow screen: buttons wrap, no horizontal overflow ---- await step('narrow card wraps the 3 action buttons without horizontal overflow', async () => { const { page } = await newHarness(browser);