diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index c4908d9192e..bf755fff5cc 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -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 diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index 32f306ce222..10c269809c2 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -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; @@ -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"); @@ -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(); +} diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index 45a519e45f7..14619746faa 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -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', @@ -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 ). { 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', @@ -61,11 +64,11 @@ 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', @@ -73,11 +76,11 @@ const testCases: AxeTestCase[] = [ // 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. @@ -91,7 +94,7 @@ const violationText: Record = { // -- 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(); @@ -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'); @@ -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(); @@ -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'); diff --git a/tests/unit/axe-conformance.test.ts b/tests/unit/axe-conformance.test.ts new file mode 100644 index 00000000000..adf9d270a51 --- /dev/null +++ b/tests/unit/axe-conformance.test.ts @@ -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", + ); + }, +);