Skip to content
Merged
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
52 changes: 48 additions & 4 deletions 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.
export 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 Expand Up @@ -379,6 +419,10 @@ async function init() {
}
}

// Self-initialize when loaded as a standalone module.
// ES modules are deferred, so the DOM is fully parsed when this runs.
init();
// Self-initialize when loaded as a standalone module in a browser. ES modules
// are deferred, so the DOM is fully parsed when this runs. The `document` guard
// keeps the module side-effect-free when imported outside a browser (e.g. unit
// tests that exercise axeConformanceLevel directly).
if (typeof document !== "undefined") {
init();
}
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
113 changes: 113 additions & 0 deletions tests/unit/axe-conformance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* axe-conformance.test.ts
*
* Tests the WCAG conformance label our axe document reporter derives from a
* violation's axe-core `tags` array (https://github.com/quarto-dev/quarto-cli/issues/14604).
*
* These exercise our own formatting, not axe-core's rule data: every input is a
* hand-written `tags` array (the contract axe hands us), and assertions cover
* only how we turn those tags into a label. An axe-core upgrade that re-tags a
* rule therefore cannot break these tests.
*
* Copyright (C) 2020-2025 Posit Software, PBC
*/

import { unitTest } from "../test.ts";
import { assertEquals } from "testing/asserts";
import { axeConformanceLevel } from "../../src/resources/formats/html/axe/axe-check.js";

unitTest(
"axeConformanceLevel - best-practice tag yields Best Practice",
// deno-lint-ignore require-await
async () => {
assertEquals(
axeConformanceLevel(["best-practice", "cat.color"]),
"Best Practice",
);
},
);

unitTest(
"axeConformanceLevel - version+level with single criterion (issue #14604 example)",
// deno-lint-ignore require-await
async () => {
assertEquals(
axeConformanceLevel(["cat.text-alternatives", "wcag2a", "wcag111"]),
"WCAG 2.0 A (1.1.1)",
);
},
);

unitTest(
"axeConformanceLevel - level AA criterion",
// deno-lint-ignore require-await
async () => {
assertEquals(
axeConformanceLevel(["cat.color", "wcag2aa", "wcag143"]),
"WCAG 2.0 AA (1.4.3)",
);
},
);

unitTest(
"axeConformanceLevel - multiple criteria sort numerically, not lexically",
// deno-lint-ignore require-await
async () => {
// 1.4.3 must precede 1.4.10; a lexical sort would put "1.4.10" first.
assertEquals(
axeConformanceLevel(["wcag2aa", "wcag143", "wcag1410"]),
"WCAG 2.0 AA (1.4.3, 1.4.10)",
);
},
);

unitTest(
"axeConformanceLevel - criteria order is independent of tag order",
// deno-lint-ignore require-await
async () => {
assertEquals(
axeConformanceLevel(["wcag2a", "wcag412", "wcag244"]),
"WCAG 2.0 A (2.4.4, 4.1.2)",
);
},
);

unitTest(
"axeConformanceLevel - obsolete criterion is flagged",
// deno-lint-ignore require-await
async () => {
assertEquals(
axeConformanceLevel(["wcag2a-obsolete", "wcag411"]),
"Obsolete WCAG 2.0 A (4.1.1)",
);
},
);

unitTest(
"axeConformanceLevel - WCAG 2.1 and 2.2 versions",
// deno-lint-ignore require-await
async () => {
assertEquals(axeConformanceLevel(["wcag21aa"]), "WCAG 2.1 AA");
assertEquals(axeConformanceLevel(["wcag22aa"]), "WCAG 2.2 AA");
},
);

unitTest(
"axeConformanceLevel - no conformance tags falls back to empty string",
// deno-lint-ignore require-await
async () => {
// Caller renders the impact alone when this is "".
assertEquals(axeConformanceLevel(["cat.color"]), "");
},
);

unitTest(
"axeConformanceLevel - best-practice takes precedence over any WCAG tags",
// deno-lint-ignore require-await
async () => {
assertEquals(
axeConformanceLevel(["best-practice", "wcag2a", "wcag111"]),
"Best Practice",
);
},
);
Loading