link-in-text-block: violation message does not explain why the link was matched as in-text-block
Expectation
When link-in-text-block reports a violation, the message should explain
why the link was considered to be inside a text block — i.e. what context
triggered the rule to apply at all.
Actual
The violation message only states:
"Ensure links are distinguished from surrounding text in a way that does not rely on color."
It does not explain why the link matched the rule in the first place.
The matches function (isInTextBlock) uses a length comparison:
parentText.length > widgetText.length. This threshold is invisible in the
output, making the result appear inconsistent or even random to the user.
Example: two HTML files with structurally identical <footer> elements — same CSS,
same <a> — produce different results solely because the surrounding
<span> text is one word longer in one file than in the other.
The violation fires when parentText (62 chars) exceeds LINK_HREF (50 chars),
and does not fire when parentText (48 chars) is shorter.
Nothing in the reported message hints at this.
How to Reproduce
Install dependencies:
npm install @axe-core/playwright @playwright/test playwrightSave the script below as
repro.mjsand run:node repro.mjsExpected output:
FAIL — parentText (span+pipe) longer than link text span text length : 62 link text length : 50 violation : link-in-text-block PASS — parentText (span+pipe) shorter than link text span text length : 48 link text length : 50 violation : none
import AxeBuilder from '@axe-core/playwright';
import { chromium } from '@playwright/test';
const CSS = `
footer a { color: #555; text-decoration: none; }
footer a:hover { text-decoration: underline; }
`;
const LINK_HREF = 'https://github.com/blaumohn/lebenslauf-web-vorlage';
const cases = [
{
label: 'FAIL — parentText (span+pipe) longer than link text',
span: '2026-06-18 Dani Y. | PHP-Lebenslauf-App inkl. CI/CD, Vollstack',
},
{
label: 'PASS — parentText (span+pipe) shorter than link text',
span: '2026-06-18 Alex B. | Preview der App von Dani Y.',
},
];
function buildPage(spanText) {
return `<!DOCTYPE html><html lang="de"><head>
<meta charset="utf-8">
<style>${CSS}</style>
</head><body>
<main><h1>Test</h1></main>
<footer>
<span>${spanText}</span>
| <a href="${LINK_HREF}" target="_blank" rel="noopener noreferrer">${LINK_HREF}</a>
</footer>
</body></html>`;
}
const browser = await chromium.launch();
const context = await browser.newContext();
for (const c of cases) {
const page = await context.newPage();
await page.setContent(buildPage(c.span));
await page.waitForSelector('body');
const results = await new AxeBuilder({ page })
.withRules(['link-in-text-block'])
.analyze();
const v = results.violations[0];
console.log(`\n${c.label}`);
console.log(`span text length : ${c.span.length}`);
console.log(`link text length : ${LINK_HREF.length}`);
console.log(`violation : ${v ? v.id : 'none'}`);
await page.close();
}
await browser.close();Additional context
The root cause is in isInTextBlock():
lib/commons/dom/is-in-text-block.js
return parentText.length > widgetText.length;This heuristic is never surfaced in the violation message.
A developer reading the report has no way to understand why one page
fails and the other passes without reading
lib/rules/link-in-text-block-matches.js
and lib/commons/dom/is-in-text-block.js.
Suggested improvement: expose the length comparison in the violation message.
lib/commons/dom/is-in-text-block.js
+ const inBlock = parentText.length > widgetText.length;
+ if (inBlock) {
+ node._axeInTextBlockLengths = {
+ parentTextLength: parentText.length,
+ widgetTextLength: widgetText.length
+ };
+ }
- return parentText.length > widgetText.length;
+ return inBlock;lib/checks/color/link-in-text-block-style.json
- "fail": "The link has no styling (such as underline) to distinguish it from the surrounding text"
+ "fail": {
+ "default": "The link has no styling (such as underline) to distinguish it from the surrounding text",
+ "inTextBlock": "The link has no styling to distinguish it from the surrounding text (surrounding text: ${data.parentTextLength} chars, link text: ${data.widgetTextLength} chars)"
+ }This would make the rule transparent and the results reproducible without source-level investigation.
axe-core versions tested: 4.11.4 and 4.12.1 (latest) — both reproduce the issue.
Source: dequelabs/axe-core