dequelabs / dequelabs/axe-core

link-in-text-block: violation message does not explain why the link was matched as in-text-block

Open
#5,179 0 comments 0 reactions 0 assignees View on GitHub
rule metadata
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.