diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 07cdfa14..3446d310 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -152,6 +152,7 @@ jobs:
node test-issue-1633-hide-1byte-hops.js
node test-issue-1668-m4-per-route.js
node test-a11y-axe-1668-selftest.js
+ node test-issue-1705-subpath-contrast.js
- name: š”ļø Preflight XSS gate ā actual --diff check (PR only)
# The fixture self-test above (test-preflight-xss-gate.js) only
@@ -406,6 +407,7 @@ jobs:
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 node test-issue-1705-subpath-contrast-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1147-section-order-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1151-orphan-separators-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1486-collapse-reopens-detail-e2e.js 2>&1 | tee -a e2e-output.txt
diff --git a/test-issue-1705-subpath-contrast-e2e.js b/test-issue-1705-subpath-contrast-e2e.js
new file mode 100644
index 00000000..34cf6844
--- /dev/null
+++ b/test-issue-1705-subpath-contrast-e2e.js
@@ -0,0 +1,210 @@
+#!/usr/bin/env node
+/* Issue #1705 ā WCAG AA contrast E2E guard for `.subpath-selected .hop-prefix`.
+ *
+ * Companion to test-issue-1705-subpath-contrast.js (parser-only). The
+ * parser test is a fast pre-CI smoke; it inspects DECLARED cascade in
+ * style.css text but cannot resolve real browser specificity, `!important`
+ * ordering, or `color: inherit` resolution. A higher-specificity
+ * `.subpath-selected .hop-prefix` rule could win in the real browser
+ * while the parser test still happily asserts the canonical token pair.
+ *
+ * This test loads public/style.css into a real (headless) Chromium and
+ * uses getComputedStyle() on a `
` with a
+ * `` child ā the exact DOM shape rendered by
+ * public/analytics.js. It then computes the sRGB-composited WCAG ratio
+ * between the resolved foreground and the resolved background in both
+ * light (`:root`) and dark (`[data-theme="dark"]`) themes and asserts
+ * >=4.5:1.
+ *
+ * Why this catches what the parser test cannot:
+ * - `getComputedStyle` resolves `color: inherit` through the real
+ * cascade (parser fakes it).
+ * - Any later rule that overrides `.subpath-selected` color/background
+ * at higher specificity is observed live (parser only sees the
+ * selector-shaped match).
+ * - rgba()/transparent foregrounds are composited the same way the
+ * browser would render them.
+ *
+ * Designed to be runnable in CI; degrades to SKIP if Chromium cannot
+ * launch (mirrors test-logo-theme-e2e.js policy). Set CHROMIUM_REQUIRE=1
+ * to convert SKIP -> FAIL.
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const { chromium } = require('playwright');
+
+const CSS_PATH = path.resolve(__dirname, 'public', 'style.css');
+
+function fail(msg) {
+ console.error(`test-issue-1705-subpath-contrast-e2e.js: FAIL ā ${msg}`);
+ process.exit(1);
+}
+
+// WCAG sRGB relative luminance + contrast.
+function relLum([r, g, b]) {
+ const f = (c) => {
+ c /= 255;
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
+ };
+ return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
+}
+function contrast(fg, bg) {
+ const L1 = relLum(fg), L2 = relLum(bg);
+ const [hi, lo] = L1 >= L2 ? [L1, L2] : [L2, L1];
+ return (hi + 0.05) / (lo + 0.05);
+}
+// Parse "rgb(r, g, b)" / "rgba(r, g, b, a)" from getComputedStyle.
+function parseRGB(s) {
+ const m = s && s.match(/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)\s*(?:,\s*([0-9.]+))?\s*\)$/);
+ if (!m) return null;
+ return [Math.round(+m[1]), Math.round(+m[2]), Math.round(+m[3]), m[4] != null ? parseFloat(m[4]) : 1];
+}
+function composite(fg, bg) {
+ const a = fg[3];
+ return [
+ Math.round(fg[0] * a + bg[0] * (1 - a)),
+ Math.round(fg[1] * a + bg[1] * (1 - a)),
+ Math.round(fg[2] * a + bg[2] * (1 - a)),
+ 1,
+ ];
+}
+const hex = (c) => `#${c.slice(0, 3).map(v => v.toString(16).padStart(2, '0')).join('')}`;
+
+async function main() {
+ if (!fs.existsSync(CSS_PATH)) {
+ fail(`style.css not found at ${CSS_PATH}`);
+ }
+
+ const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
+ let browser;
+ try {
+ browser = await chromium.launch({
+ headless: true,
+ executablePath: process.env.CHROMIUM_PATH || undefined,
+ args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
+ });
+ } catch (err) {
+ if (requireChromium) {
+ fail(`Chromium required but unavailable: ${err.message}`);
+ }
+ console.log(`test-issue-1705-subpath-contrast-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
+ process.exit(0);
+ }
+
+ // Reproduce the DOM rendered by public/analytics.js (renderTable +
+ // click handler that adds `.subpath-selected` to a ).
+ // The production table uses `.analytics-table` (see analytics.js
+ // renderSubpathsTable: ``), so the
+ // fixture below mirrors that exact class to exercise any rule scoped
+ // to `.analytics-table tr.subpath-selected .hop-prefix`.
+ const html = `
+
+
+ 1705 e2e
+
+
+
+ | # | Route | Count |
+
+
+ | 1 |
+ node-a ā node-b aa ā bb |
+ 42 |
+
+
+
+`;
+
+ let failures = 0;
+ try {
+ for (const theme of ['light', 'dark']) {
+ const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
+ const page = await context.newPage();
+ // Use file:// origin so the file:// stylesheet link is allowed.
+ await page.goto('about:blank');
+ await page.setContent(html, { waitUntil: 'load' });
+ // setContent loads with about:blank origin which cannot fetch file://.
+ // Inline the stylesheet contents instead ā same correctness because
+ // we read public/style.css directly off disk.
+ const cssText = fs.readFileSync(CSS_PATH, 'utf8');
+ await page.evaluate((css) => {
+ const styleEl = document.createElement('style');
+ styleEl.textContent = css;
+ document.head.appendChild(styleEl);
+ }, cssText);
+ // Apply the theme attribute the same way customize.js does.
+ await page.evaluate((t) => {
+ if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
+ else document.documentElement.removeAttribute('data-theme');
+ }, theme);
+ // Force a layout flush.
+ await page.evaluate(() => document.body.getBoundingClientRect());
+
+ const probe = await page.evaluate(() => {
+ const tr = document.querySelector('tr.subpath-selected');
+ const span = document.getElementById('probe-prefix');
+ if (!tr || !span) return { error: 'fixture DOM missing' };
+ const trStyle = getComputedStyle(tr);
+ const spanStyle = getComputedStyle(span);
+ return {
+ trBg: trStyle.backgroundColor,
+ trColor: trStyle.color,
+ spanColor: spanStyle.color,
+ spanBg: spanStyle.backgroundColor,
+ };
+ });
+ if (probe.error) fail(`[${theme}] ${probe.error}`);
+
+ const fgRaw = parseRGB(probe.spanColor);
+ const bgRaw = parseRGB(probe.trBg);
+ if (!fgRaw) fail(`[${theme}] could not parse computed span color "${probe.spanColor}"`);
+ if (!bgRaw) fail(`[${theme}] could not parse computed tr background "${probe.trBg}"`);
+
+ // If parent bg is transparent (no .subpath-selected background
+ // declared / overridden away), walk up to body to find an opaque
+ // ancestor. Browsers paint against the next opaque layer.
+ let bg = bgRaw;
+ if (bg[3] < 1) {
+ const opaque = await page.evaluate(() => {
+ let n = document.querySelector('tr.subpath-selected').parentElement;
+ while (n) {
+ const s = getComputedStyle(n).backgroundColor;
+ const m = s.match(/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)\s*(?:,\s*([0-9.]+))?\s*\)$/);
+ if (m && (m[4] == null || parseFloat(m[4]) >= 1)) return s;
+ n = n.parentElement;
+ }
+ // Fall back to white if we run off the top.
+ return 'rgb(255, 255, 255)';
+ });
+ bg = parseRGB(opaque) || [255, 255, 255, 1];
+ }
+ const fg = fgRaw[3] < 1 ? composite(fgRaw, bg) : fgRaw;
+ const ratio = contrast(fg, bg);
+ const ok = ratio >= 4.5;
+ console.log(
+ `${ok ? 'ā' : 'ā'} ${theme.padEnd(5)} computed span color=${probe.spanColor} on tr bg=${probe.trBg} ` +
+ `composed=${hex(fg)} on ${hex(bg)} ratio=${ratio.toFixed(2)}:1 (need ā„4.5)`
+ );
+ if (!ok) {
+ failures++;
+ console.error(
+ `#1705 regression: real browser getComputedStyle in ${theme} theme gives ${ratio.toFixed(2)}:1, below WCAG AA 4.5:1 ` +
+ `(composed ${hex(fg)} on ${hex(bg)})`
+ );
+ }
+ await context.close();
+ }
+ } finally {
+ await browser.close();
+ }
+
+ if (failures > 0) process.exit(1);
+ console.log(`\nā
PASS ā .subpath-selected .hop-prefix ā„4.5:1 in both themes (real browser getComputedStyle)`);
+}
+
+main().catch((err) => {
+ console.error(`test-issue-1705-subpath-contrast-e2e.js: ERROR ā ${err && err.stack || err}`);
+ process.exit(1);
+});
diff --git a/test-issue-1705-subpath-contrast.js b/test-issue-1705-subpath-contrast.js
new file mode 100644
index 00000000..5b20d083
--- /dev/null
+++ b/test-issue-1705-subpath-contrast.js
@@ -0,0 +1,260 @@
+/* Issue #1705 ā WCAG AA contrast regression for `.subpath-selected .hop-prefix`.
+ *
+ * The class historically used `color: rgba(255,255,255,0.6)` over a
+ * `.subpath-selected { background: var(--accent) }` surface. In dark theme
+ * the composited pixel was ~1.87:1 ā well under WCAG AA 4.5:1.
+ *
+ * This test:
+ * 1. Parses public/style.css and extracts `--accent`, `--accent-strong`,
+ * `--text-on-accent` per theme (with the same one-level var() resolver
+ * as test-issue-1668-m2-contrast.js).
+ * 2. Reads the declared `background` for `.subpath-selected` and the
+ * declared `color` for `.subpath-selected .hop-prefix`.
+ * 3. Properly composites rgba() foregrounds against the resolved
+ * background pixel before computing luminance (the historical
+ * a11y-probe miss called out in the #1705 issue body).
+ * 4. Asserts the composited fg-vs-bg contrast ratio is ā„4.5:1 in both
+ * `:root` (light) and `[data-theme="dark"]`.
+ *
+ * Red posture: with the legacy rule
+ * .subpath-selected .hop-prefix { color: rgba(255,255,255,0.6); }
+ * over `--accent` (#4a9eff in either theme), this test fails on the
+ * composited contrast assertion (ā1.87:1).
+ *
+ * Green posture: parent uses `--accent-strong` + `--text-on-accent`, and
+ * the child inherits, giving ā„4.5:1 on the composited pixel in both themes.
+ */
+'use strict';
+const fs = require('fs');
+const assert = require('assert');
+
+const CSS_PATH = 'public/style.css';
+const css = fs.readFileSync(CSS_PATH, 'utf8');
+
+// āā Token extraction (mirrors test-issue-1668-m2-contrast.js) āāāāāāāāāāāāā
+// MF5 (review r1): throw loudly when the block regex does not match the
+// scoped `selector { ... \n}` shape ā silent {} previously let the test
+// pass falsely if `style.css` were minified or its closing brace style
+// changed. Callers must pass a human-readable `selectorLabel` for the
+// error message.
+function extractBlockTokens(blockRegex, selectorLabel) {
+ const tokens = {};
+ const flagged = new RegExp(blockRegex.source, blockRegex.flags.includes('g') ? blockRegex.flags : blockRegex.flags + 'g');
+ let m;
+ let matched = false;
+ while ((m = flagged.exec(css)) !== null) {
+ matched = true;
+ const body = m[1];
+ const re = /^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/gim;
+ let mm;
+ while ((mm = re.exec(body)) !== null) {
+ tokens[mm[1]] = mm[2].trim();
+ }
+ }
+ if (!matched) {
+ throw new Error(
+ `extractBlockTokens: no closing brace found for selector ${selectorLabel || blockRegex.source} ` +
+ `(parser expects \` { ... \\n}\` shape; if style.css was minified or restructured, fix the parser).`
+ );
+ }
+ return tokens;
+}
+
+const lightTokens = extractBlockTokens(/:root\s*\{([\s\S]*?)\n\}/, ':root');
+const darkTokens = extractBlockTokens(/\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/, '[data-theme="dark"]');
+
+// MF5 defensive assertion ā if the CSS structure regresses to a shape
+// the parser can't read, fail at the top rather than silently emitting
+// "all colors look fine" because every token resolved to undefined.
+for (const tok of ['--accent', '--accent-strong', '--text-on-accent']) {
+ assert.ok(
+ lightTokens[tok],
+ `extractBlockTokens regression: expected :root to declare ${tok} but parser found none. ` +
+ `Either the CSS structure changed or extractBlockTokens needs updating.`
+ );
+}
+
+function resolveToken(name, theme) {
+ const map = theme === 'dark' ? { ...lightTokens, ...darkTokens } : lightTokens;
+ let val = map[name];
+ if (!val) return null;
+ for (let i = 0; i < 5; i++) {
+ const m = val.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,\s*([^)]+))?\)\s*$/);
+ if (!m) break;
+ const next = map[m[1]];
+ val = next || (m[2] ? m[2].trim() : null);
+ if (!val) return null;
+ }
+ return val;
+}
+
+// āā Color parsing (returns [r,g,b,a] with a in [0,1]) āāāāāāāāāāāāāāāāāāāāā
+function parseColor(s) {
+ if (!s) return null;
+ s = s.trim();
+ let m = s.match(/^#([0-9a-f]{3})$/i);
+ if (m) {
+ return [
+ parseInt(m[1][0] + m[1][0], 16),
+ parseInt(m[1][1] + m[1][1], 16),
+ parseInt(m[1][2] + m[1][2], 16),
+ 1,
+ ];
+ }
+ m = s.match(/^#([0-9a-f]{6})$/i);
+ if (m) {
+ return [parseInt(m[1].slice(0,2),16), parseInt(m[1].slice(2,4),16), parseInt(m[1].slice(4,6),16), 1];
+ }
+ m = s.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([0-9.]+))?\s*\)$/i);
+ if (m) return [+m[1], +m[2], +m[3], m[4] != null ? parseFloat(m[4]) : 1];
+ return null;
+}
+
+// āā Alpha compositing (sRGB straight-alpha over an opaque background) āāāāā
+// The historical audit probe miss: rgba(255,255,255,0.6) over #4a9eff is
+// NOT "white vs #4a9eff" ā it's the COMPOSITED pixel vs #4a9eff for the
+// purposes of WCAG. We compose then compare composed-fg to bg.
+function composite(fg, bg) {
+ const a = fg[3];
+ return [
+ Math.round(fg[0] * a + bg[0] * (1 - a)),
+ Math.round(fg[1] * a + bg[1] * (1 - a)),
+ Math.round(fg[2] * a + bg[2] * (1 - a)),
+ 1,
+ ];
+}
+
+function relLum([r,g,b]) {
+ const f = (c) => {
+ c /= 255;
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
+ };
+ return 0.2126*f(r) + 0.7152*f(g) + 0.0722*f(b);
+}
+
+function contrast(fg, bg) {
+ const L1 = relLum(fg), L2 = relLum(bg);
+ const [hi, lo] = L1 >= L2 ? [L1, L2] : [L2, L1];
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+// `.subpath-selected { background: ; color: ; ... }`
+// `.subpath-selected .hop-prefix { color: ; ... }`
+// MF7 (review r1): collect ALL matches for the property across every rule
+// whose selector matches `selectorRegex` and fail loudly if more than one
+// declaration is found, so a higher-specificity / later-in-cascade
+// override doesn't silently win over (or under) the one we're auditing.
+// The simplified parser here cannot model real CSS specificity; throwing
+// is the correct discipline ā a human resolves which one is the
+// intended ground truth.
+function extractDecl(selectorRegex, prop, selectorLabel) {
+ const re = new RegExp(selectorRegex.source + '\\s*\\{([^}]*)\\}', 'g');
+ const matches = [];
+ let m;
+ while ((m = re.exec(css)) !== null) {
+ const body = m[1];
+ const propRe = new RegExp('(?:^|[;\\s])' + prop + '\\s*:\\s*([^;]+?)\\s*(?:!important)?\\s*;', 'i');
+ const mm = body.match(propRe);
+ if (mm) matches.push({ rule: m[0].slice(0, 80).replace(/\s+/g, ' '), value: mm[1].trim() });
+ }
+ if (matches.length === 0) return null;
+ if (matches.length > 1) {
+ // Different values across rules ā ambiguous cascade. Identical values
+ // are still suspicious but not load-bearing for this audit.
+ const distinct = new Set(matches.map(x => x.value));
+ if (distinct.size > 1) {
+ throw new Error(
+ `extractDecl: multiple distinct \`${prop}\` declarations matched selector ${selectorLabel || selectorRegex.source} ā ` +
+ `parser cannot resolve CSS specificity. Matches:\n` +
+ matches.map(x => ` ${x.value} (in: ${x.rule}...)`).join('\n') +
+ `\nResolve by collapsing rules in CSS or by adding a Playwright getComputedStyle assertion.`
+ );
+ }
+ // Same value across N rules ā warn but proceed.
+ console.warn(` ā extractDecl: ${matches.length} rules declare \`${prop}\` = ${matches[0].value} for ${selectorLabel || selectorRegex.source} (identical, OK).`);
+ }
+ return matches[matches.length - 1].value;
+}
+
+// The parent selector ā `\\.subpath-selected` matches both
+// `.subpath-selected { ... }` and the qualified child rule, so anchor by
+// boundary on the closing of the class token.
+const parentBg = extractDecl(/(?:^|[\s>+~,])\.subpath-selected(?=\s*\{)/, 'background', '.subpath-selected');
+const parentColor = extractDecl(/(?:^|[\s>+~,])\.subpath-selected(?=\s*\{)/, 'color', '.subpath-selected');
+const childColor = extractDecl(/\.subpath-selected\s+\.hop-prefix(?=\s*\{)/, 'color', '.subpath-selected .hop-prefix');
+
+assert.ok(parentBg, '`.subpath-selected { background: ... }` not found in CSS');
+assert.ok(childColor, '`.subpath-selected .hop-prefix { color: ... }` not found in CSS');
+
+console.log(`\n#1705 .subpath-selected hop-prefix contrast audit\n${'ā'.repeat(60)}`);
+console.log(`parent background : ${parentBg}`);
+console.log(`parent color : ${parentColor || '(unset)'}`);
+console.log(`child color : ${childColor}`);
+
+// `var(--token, fallback)` ā resolve via theme map; if missing, use fallback.
+function resolveCssValue(raw, theme) {
+ raw = raw.trim();
+ const m = raw.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,\s*(.+))?\)$/i);
+ if (m) {
+ const tokVal = resolveToken(m[1], theme);
+ if (tokVal) return tokVal;
+ return m[2] ? resolveCssValue(m[2], theme) : null;
+ }
+ // strip trailing `!important` if any
+ return raw.replace(/\s*!important\s*$/i, '');
+}
+
+const THEMES = ['light', 'dark'];
+let failures = 0;
+for (const theme of THEMES) {
+ const bgRaw = resolveCssValue(parentBg, theme);
+ const bg = parseColor(bgRaw);
+ assert.ok(bg, `[${theme}] could not parse parent background "${bgRaw}"`);
+
+ // Child color may be `inherit`, in which case fall back to the parent's
+ // declared color. MF6 (review r1): do NOT default to white ā that
+ // historically hid a regression where the parent's color drifted from
+ // #fff to something else and the test still saw white-on-blue and
+ // passed. Walk the declared cascade: child ā parent ā throw if neither
+ // declares a color. If the user wants browser-cascade semantics,
+ // they can use the Playwright variant (test-issue-1705-subpath-contrast-e2e.js).
+ let childRaw = resolveCssValue(childColor, theme);
+ if (/^inherit$/i.test(childRaw)) {
+ if (!parentColor) {
+ throw new Error(
+ `[${theme}] child .hop-prefix color is \`inherit\` but no parent .subpath-selected color is declared in CSS. ` +
+ `Parser cannot walk past one level ā either declare a color on .subpath-selected, ` +
+ `or replace this parser test with the Playwright variant which uses real getComputedStyle.`
+ );
+ }
+ childRaw = resolveCssValue(parentColor, theme);
+ if (!childRaw) {
+ throw new Error(
+ `[${theme}] could not resolve parent color "${parentColor}" while expanding child \`inherit\` in ${theme} theme.`
+ );
+ }
+ }
+ const fgDeclared = parseColor(childRaw);
+ assert.ok(fgDeclared, `[${theme}] could not parse child color "${childRaw}"`);
+
+ // Alpha-composite the declared foreground over the resolved background
+ // pixel before computing contrast. This is the correctness fix the
+ // probe missed.
+ const fgComposed = fgDeclared[3] < 1 ? composite(fgDeclared, bg) : fgDeclared;
+ const ratio = contrast(fgComposed, bg);
+
+ const hex = (c) => `#${c.slice(0,3).map(v=>v.toString(16).padStart(2,'0')).join('')}`;
+ const ok = ratio >= 4.5;
+ console.log(
+ `${ok ? 'ā' : 'ā'} ${theme.padEnd(5)} hop-prefix composed=${hex(fgComposed)} on bg=${hex(bg)} ratio=${ratio.toFixed(2)}:1 (need ā„4.5)`
+ );
+ if (!ok) failures++;
+ assert.ok(
+ ok,
+ `#1705 regression: .subpath-selected .hop-prefix contrast in ${theme} theme is ${ratio.toFixed(2)}:1, below WCAG AA 4.5:1 (composed ${hex(fgComposed)} on ${hex(bg)})`
+ );
+}
+
+if (failures === 0) {
+ console.log(`\nā
PASS ā .subpath-selected .hop-prefix ā„4.5:1 in both themes`);
+}