dequelabs / dequelabs/axe-core
link-in-text-block: violation message does not explain why the link was matched as in-text-block
- Dominant language
- JavaScript
- Stars
- 7.5k
- Forks
- 933
- Avg merge
- 2d 13h
- Merged PRs (30d)
- 29
Description
#### 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 `` elements — same CSS,
same `` — produce different results solely because the surrounding
`` 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
1. Install dependencies:
```
npm install @axe-core/playwright @playwright/test playwright
```
2. Save the script below as `repro.mjs` and run:
```
node repro.mjs
```
3. Expected 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
```
```js
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 `
${CSS}
Test
${spanText}
| ${LINK_HREF}
`;
}
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`](https://github.com/dequelabs/axe-core/blob/develop/lib/commons/dom/is-in-text-block.js)
```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`](https://github.com/dequelabs/axe-core/blob/develop/lib/rules/link-in-text-block-matches.js)
and [`lib/commons/dom/is-in-text-block.js`](https://github.com/dequelabs/axe-core/blob/develop/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`**
```diff
+ 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`**
```diff
- "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.
Contributor guide
Research direction
Start with lib/commons/dom/is-in-text-block.js and lib/rules/link-in-text-block-matches.js to trace how the text-length comparison reaches the violation, then inspect lib/checks/color/link-in-text-block-style.json for message formatting. Run the provided Playwright reproduction and verify that violations explain the parent-text and link-text lengths while non-matching cases remain unaffected.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, playwright
- Domain
- accessibility, testing-qa
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100