Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/changelog-1.10.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ All changes included in 1.10:
## Accessibility

- ([#14468](https://github.com/quarto-dev/quarto-cli/issues/14468)): The `axe` accessibility report UI (HTML overlay, revealjs report slide, dashboard offcanvas) now uses its own theme-independent colors instead of inheriting from `brand` or theme. Keeps the report readable regardless of page styling, and stops `axe` from clobbering brand colors set via `_brand.yml`.
- ([#14604](https://github.com/quarto-dev/quarto-cli/issues/14604)): The `axe` accessibility report UI now shows each violation's WCAG conformance level (e.g. `WCAG 2.0 AA (1.4.3)`) or `Best Practice`, derived from the violation's axe-core tags.

## Formats

Expand Down
42 changes: 41 additions & 1 deletion src/resources/formats/html/axe/axe-check.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,40 @@
// Derive a human-readable WCAG conformance label from axe-core's `tags` array.
// Tags encode the version+level (`wcag2a`, `wcag21aa`), the specific success
// criteria (`wcag111` → 1.1.1), and `best-practice` for axe's own
// recommendations that aren't tied to any WCAG success criterion. Returns "" when
// no conformance tags are present so callers can fall back to the impact alone.
function axeConformanceLevel(tags) {
if (tags.includes("best-practice")) return "Best Practice";

// Version+level: wcag2a, wcag2aa, wcag21aa, wcag22aa, ... An `-obsolete`
// suffix (e.g. `wcag2a-obsolete` on the deprecated `duplicate-id` rule) marks
// a criterion that was withdrawn from later WCAG versions, e.g. SC 4.1.1,
// removed in WCAG 2.2. We surface the original level but flag it as obsolete
// so a withdrawn criterion isn't mistaken for a current conformance failure.
const versionTag = tags.find((t) => /^wcag\d+a+(-obsolete)?$/.test(t));
// Without a version+level tag there's no conformance level to report, so fall
// back to the impact alone rather than emitting a bare, level-less criterion.
if (!versionTag) return "";

const [, major, minor, level, obsolete] =
versionTag.match(/^wcag(\d)(\d?)(a+)(-obsolete)?$/);
let label = `WCAG ${major}.${minor || "0"} ${level.toUpperCase()}`;

// Success criteria: wcag111 → 1.1.1, wcag1410 → 1.4.10. Principle and
// guideline are always single digits; the remainder is the criterion number.
const criteria = tags
.filter((t) => /^wcag\d{3,}$/.test(t))
.map((t) => {
const d = t.slice(4);
return `${d[0]}.${d[1]}.${d.slice(2)}`;
})
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
if (criteria.length) {
label += ` (${criteria.join(", ")})`;
}
return obsolete ? `Obsolete ${label}` : label;
}

class QuartoAxeReporter {
constructor(axeResult, options) {
this.axeResult = axeResult;
Expand Down Expand Up @@ -74,7 +111,10 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter {

const descriptionElement = document.createElement("div");
descriptionElement.className = "quarto-axe-violation-description";
descriptionElement.innerText = `${violation.impact.replace(/^[a-z]/, match => match.toLocaleUpperCase())}: ${violation.description}`;
const impact = violation.impact.replace(/^[a-z]/, match => match.toLocaleUpperCase());
const level = axeConformanceLevel(violation.tags);
const prefix = level ? `${impact} · ${level}` : impact;
descriptionElement.innerText = `${prefix}: ${violation.description}`;
violationElement.appendChild(descriptionElement);

const helpElement = document.createElement("div");
Expand Down
32 changes: 25 additions & 7 deletions tests/integration/playwright/tests/axe-accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,15 @@ interface AxeTestCase {
// Expected violation ID. RevealJS CSS transforms prevent axe-core from
// computing color contrast, so revealjs tests check for a different violation.
expectedViolation: string;
// WCAG conformance label the document reporter derives from the violation's
// axe-core tags (#14604). Only the document reporter renders this.
expectedConformance?: string;
}

const testCases: AxeTestCase[] = [
// HTML — bootstrap format, color contrast detected
{ format: 'html', outputMode: 'document', url: '/html/axe-accessibility.html',
expectedViolation: 'color-contrast' },
expectedViolation: 'color-contrast', expectedConformance: 'WCAG 2.0 AA (1.4.3)' },
{ format: 'html', outputMode: 'console', url: '/html/axe-console.html',
expectedViolation: 'color-contrast' },
{ format: 'html', outputMode: 'json', url: '/html/axe-json.html',
Expand All @@ -52,7 +55,7 @@ const testCases: AxeTestCase[] = [
// RevealJS CSS transforms prevent axe-core from computing color contrast,
// so we check for link-name (slide-menu-button has unlabeled <a>).
{ format: 'revealjs', outputMode: 'document', url: '/revealjs/axe-accessibility.html',
expectedViolation: 'link-name' },
expectedViolation: 'link-name', expectedConformance: 'WCAG 2.0 A (2.4.4, 4.1.2)' },
{ format: 'revealjs', outputMode: 'console', url: '/revealjs/axe-console.html',
expectedViolation: 'link-name' },
{ format: 'revealjs', outputMode: 'json', url: '/revealjs/axe-json.html',
Expand All @@ -61,23 +64,23 @@ const testCases: AxeTestCase[] = [
// RevealJS dark theme — verifies CSS custom property bridge for theming.
// Report should use --r-background-color/#191919, not the Sass fallback #fff.
{ format: 'revealjs-dark', outputMode: 'document', url: '/revealjs/axe-accessibility-dark.html',
expectedViolation: 'link-name' },
expectedViolation: 'link-name', expectedConformance: 'WCAG 2.0 A (2.4.4, 4.1.2)' },

// Dashboard — axe-check.js loads as standalone module, falls back to document.body (#13781)
{ format: 'dashboard', outputMode: 'document', url: '/dashboard/axe-accessibility.html',
expectedViolation: 'color-contrast' },
expectedViolation: 'color-contrast', expectedConformance: 'WCAG 2.0 AA (1.4.3)' },
{ format: 'dashboard', outputMode: 'console', url: '/dashboard/axe-console.html',
expectedViolation: 'color-contrast' },
{ format: 'dashboard', outputMode: 'json', url: '/dashboard/axe-json.html',
expectedViolation: 'color-contrast' },

// Dashboard dark theme — verifies CSS custom property bridge for theming
{ format: 'dashboard-dark', outputMode: 'document', url: '/dashboard/axe-accessibility-dark.html',
expectedViolation: 'color-contrast' },
expectedViolation: 'color-contrast', expectedConformance: 'WCAG 2.0 AA (1.4.3)' },

// Dashboard with pages — multi-page dashboard with global sidebar
{ format: 'dashboard-pages', outputMode: 'document', url: '/dashboard/axe-accessibility-pages.html',
expectedViolation: 'color-contrast' },
expectedViolation: 'color-contrast', expectedConformance: 'WCAG 2.0 AA (1.4.3)' },
];

// Map axe violation IDs to the text that appears in document/console reporters.
Expand All @@ -91,7 +94,7 @@ const violationText: Record<string, { document: string; console: string }> = {
// -- Tests --

test.describe('Axe accessibility checking', () => {
for (const { format, outputMode, url, expectedViolation } of testCases) {
for (const { format, outputMode, url, expectedViolation, expectedConformance } of testCases) {
test(`${format} — ${outputMode} mode detects ${expectedViolation} violation`, async ({ page }) => {
expect(violationText[expectedViolation],
`Missing violationText entry for "${expectedViolation}"`).toBeDefined();
Expand All @@ -114,6 +117,11 @@ test.describe('Axe accessibility checking', () => {
await expect(axeReport).toBeAttached();
await expect(axeReport).toContainText(violationText[expectedViolation].document);

// Conformance level is derived from the violation's axe-core tags (#14604)
if (expectedConformance) {
await expect(axeReport).toContainText(expectedConformance);
}

// Report element is static (not fixed overlay)
await expect(axeReport).toHaveCSS('position', 'static');

Expand All @@ -127,6 +135,11 @@ test.describe('Axe accessibility checking', () => {
await expect(axeReport).toBeAttached();
await expect(axeReport).toContainText(violationText[expectedViolation].document);

// Conformance level is derived from the violation's axe-core tags (#14604)
if (expectedConformance) {
await expect(axeReport).toContainText(expectedConformance);
}

// Toggle button exists
const toggle = page.locator('.quarto-axe-toggle');
await expect(toggle).toBeVisible();
Expand All @@ -140,6 +153,11 @@ test.describe('Axe accessibility checking', () => {
await expect(axeReport).toBeVisible({ timeout: 10000 });
await expect(axeReport).toContainText(violationText[expectedViolation].document);

// Conformance level is derived from the violation's axe-core tags (#14604)
if (expectedConformance) {
await expect(axeReport).toContainText(expectedConformance);
}

// Verify report overlay CSS properties
await expect(axeReport).toHaveCSS('z-index', '9999');
await expect(axeReport).toHaveCSS('overflow-y', 'auto');
Expand Down
Loading