From 051c251e7fe7a46d8eb1384d4747abbec0b3a0e2 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Thu, 7 May 2026 06:16:30 -0700 Subject: [PATCH] fix(#1146): WCAG AA contrast for Paths Through This Node links in dark mode (#1159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red commit: a4ec258fb82f72b8d5da64492dfe9a5ff4241886 (CI run linked from `gh pr checks` once it starts) ## Problem "Paths Through This Node" entries in node detail (side panel #pathsContent and full-screen #fullPathsContent) render as `
` blocks, not tables. The existing rule ```css .node-detail-section .data-table td a, .node-full-card .data-table td a { color: var(--accent); } ``` (public/style.css:1231) only covers ``, so path-hop links inherit UA-default `rgb(0,0,238)`. On dark theme that's ~1.8–3.0:1 against `--card-bg: #1a1a2e` — well under the 4.5:1 WCAG AA body-text floor. ## Fix Add an explicit rule scoped to `#pathsContent` / `#fullPathsContent` that uses `var(--accent)` (matching the data-table pattern) plus a `:hover` to `var(--accent-hover)`. Tracks active theme + customizer overrides — no hard-coded colours. After: contrast measured at **6.19:1** in dark mode (link `rgb(74,158,255)` on `rgb(26,26,46)`). ## TDD - **Red commit** (`a4ec258`): adds `test-issue-1146-path-link-contrast-e2e.js` + wires it into the e2e-test job. Loads a node detail page, mocks `/paths`, forces `data-theme=dark`, computes WCAG luminance/contrast on the path-hop ``, asserts ≥ 4.5:1. Reverting only the CSS commit restores the failure. - **Green commit** (`5ad20fe`): the CSS fix. E2E assertion added: `test-issue-1146-path-link-contrast-e2e.js:120` Browser verified: local fixture run on `http://localhost:13591` (build of `cmd/server` with this branch's `public/`) — 3 passed, 0 failed. ## Files changed - `public/style.css` (+14 lines, scoped CSS rule + comment) - `test-issue-1146-path-link-contrast-e2e.js` (new, +132 lines) - `.github/workflows/deploy.yml` (+1 line, register the new E2E) ## Preflight `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master` → all gates pass, no warnings. Fixes #1146 --------- Co-authored-by: openclaw-bot --- .github/workflows/deploy.yml | 1 + public/style.css | 14 +++ test-issue-1146-path-link-contrast-e2e.js | 145 ++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 test-issue-1146-path-link-contrast-e2e.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9f09eb4c..d1c2e4d9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -235,6 +235,7 @@ jobs: 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-issue-1150-404-state-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-1146-path-link-contrast-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-rebrand-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-theme-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/style.css b/public/style.css index ff55eb61..28691ef6 100644 --- a/public/style.css +++ b/public/style.css @@ -1232,6 +1232,20 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } .node-full-card .data-table td a { color: var(--accent); } +/* #1146: "Paths Through This Node" entries render as
blocks, not + tables, so the rule above doesn't reach them. Without this, the path-hop + elements inherit the UA-default rgb(0,0,238) blue, which on the dark + card surface (--card-bg: #1a1a2e) computes to ~1.8-3.0:1 — well below + the 4.5:1 WCAG AA body-text minimum. Use --accent so the link tracks the + active theme (and customizer overrides) like the data-table links do. */ +.node-detail-section #pathsContent a, +.node-full-card #fullPathsContent a { + color: var(--accent); +} +.node-detail-section #pathsContent a:hover, +.node-full-card #fullPathsContent a:hover { + color: var(--accent-hover); +} .node-detail-section h4 { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-muted); margin-bottom: 8px; padding-bottom: 4px; diff --git a/test-issue-1146-path-link-contrast-e2e.js b/test-issue-1146-path-link-contrast-e2e.js new file mode 100644 index 00000000..484efbf4 --- /dev/null +++ b/test-issue-1146-path-link-contrast-e2e.js @@ -0,0 +1,145 @@ +/** + * #1146 — "Paths Through This Node" path-link contrast E2E. + * + * Bug: Path entries inside the node-detail "Paths Through This Node" + * section are rendered as
blocks, not a . The existing + * `.node-detail-section .data-table td a { color: var(--accent) }` + * rule (style.css:1231) doesn't apply, so the path-hop elements + * fall back to UA-default `rgb(0,0,238)` blue. On dark theme, that + * blue against `var(--card-bg)` (#1a1a2e) computes to ~3.0:1 — a + * WCAG AA failure (4.5:1 required for body text). + * + * This test loads a node detail page, mocks the /paths API to return + * a deterministic chain with at least one named hop, switches to dark + * 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. + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-1146-path-link-contrast-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +// WCAG 2.1 relative luminance + contrast ratio. +function srgbToLin(c) { + c = c / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} +function lum(rgb) { + return 0.2126 * srgbToLin(rgb[0]) + 0.7152 * srgbToLin(rgb[1]) + 0.0722 * srgbToLin(rgb[2]); +} +function contrast(fg, bg) { + const L1 = lum(fg), L2 = lum(bg); + const hi = Math.max(L1, L2), lo = Math.min(L1, L2); + return (hi + 0.05) / (lo + 0.05); +} +function parseRgb(s) { + // Accept "rgb(r, g, b)" or "rgba(r, g, b, a)". + const m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (!m) throw new Error('Cannot parse colour: ' + s); + return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]; +} +// Walk up parent chain to find the first non-transparent backgroundColor. +async function effectiveBgFor(page, selector) { + return await page.evaluate((sel) => { + let el = document.querySelector(sel); + if (!el) return null; + while (el) { + const cs = getComputedStyle(el); + const bg = cs.backgroundColor; + const m = bg && bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); + if (m) { + const a = m[4] !== undefined ? parseFloat(m[4]) : 1; + if (a > 0.01) return bg; + } + el = el.parentElement; + } + // Fallback: html background. + return getComputedStyle(document.documentElement).backgroundColor || 'rgb(255,255,255)'; + }, selector); +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + + console.log(`\n=== #1146 path-link contrast E2E against ${BASE} ===`); + + const hopPubkey = 'a1b2c3d4e5f60718293a4b5c6d7e8f9001122334455667788990aabbccddeeff'; + + // Mock paths API for ANY node so test is deterministic. + await page.route('**/api/nodes/*/paths*', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + totalPaths: 1, + totalTransmissions: 5, + paths: [{ + hops: [ + { pubkey: hopPubkey, prefix: 'a1', name: 'TestHop' }, + ], + count: 5, + lastSeen: new Date().toISOString(), + sampleHash: 'deadbeef00', + }], + }), + }); + }); + + 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 }); + }); + + await step('Open side panel for first node and wait for paths', async () => { + await page.click('#nodesBody tr[data-key]'); + await page.waitForSelector('#pathsContent', { timeout: 10000 }); + await page.waitForFunction( + () => { + const el = document.getElementById('pathsContent'); + return el && el.querySelector('a[href^="#/nodes/"]'); + }, + { timeout: 15000 } + ); + }); + + await step('Path link contrast (#pathsContent a) ≥ 4.5:1 in dark mode', async () => { + const linkColor = await page.$eval('#pathsContent a[href^="#/nodes/"]', (el) => getComputedStyle(el).color); + const bgColor = await effectiveBgFor(page, '#pathsContent a[href^="#/nodes/"]'); + const fg = parseRgb(linkColor); + const bg = parseRgb(bgColor); + const ratio = contrast(fg, bg); + console.log(` link=${linkColor} bg=${bgColor} ratio=${ratio.toFixed(2)}:1`); + assert(ratio >= 4.5, + `Expected contrast ≥ 4.5:1 (WCAG AA), got ${ratio.toFixed(2)}:1 ` + + `(link ${linkColor} on ${bgColor}). The path-link elements are not ` + + `covered by the .data-table td a rule and inherit UA blue.`); + }); + + await browser.close(); + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed === 0 ? 0 : 1); +})().catch((e) => { console.error(e); process.exit(1); });