From bca5977d025f50668c9996dd7fa10266598aded9 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 23 Dec 2025 16:59:24 +0100 Subject: [PATCH 01/35] Fix axe SCSS compilation for revealjs format Add !default fallback values for SCSS variables used by axe styling. For HTML+Bootstrap, these are overridden by the framework layer. For revealjs, these provide working defaults since sass-bundles compile separately from the revealjs theme. - src/format/html/format-html-axe.ts: Add -color, -color defaults - tests/docs/smoke-all/accessibility/: Add axe-html.qmd, axe-revealjs.qmd - tests/docs/playwright/: Add axe test documents - tests/integration/playwright/tests/: Add axe playwright specs --- src/format/html/format-html-axe.ts | 21 +++++++++++++---- .../playwright/html/axe-accessibility.qmd | 12 ++++++++++ .../playwright/revealjs/axe-accessibility.qmd | 16 +++++++++++++ .../docs/smoke-all/accessibility/axe-html.qmd | 21 +++++++++++++++++ .../smoke-all/accessibility/axe-revealjs.qmd | 23 +++++++++++++++++++ .../tests/html-axe-accessibility.spec.ts | 13 +++++++++++ .../tests/revealjs-axe-accessibility.spec.ts | 13 +++++++++++ 7 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 tests/docs/playwright/html/axe-accessibility.qmd create mode 100644 tests/docs/playwright/revealjs/axe-accessibility.qmd create mode 100644 tests/docs/smoke-all/accessibility/axe-html.qmd create mode 100644 tests/docs/smoke-all/accessibility/axe-revealjs.qmd create mode 100644 tests/integration/playwright/tests/html-axe-accessibility.spec.ts create mode 100644 tests/integration/playwright/tests/revealjs-axe-accessibility.spec.ts diff --git a/src/format/html/format-html-axe.ts b/src/format/html/format-html-axe.ts index b931cb1a459..505ae44ef86 100644 --- a/src/format/html/format-html-axe.ts +++ b/src/format/html/format-html-axe.ts @@ -5,17 +5,27 @@ */ import { kIncludeInHeader } from "../../config/constants.ts"; +import { isRevealjsOutput } from "../../config/format.ts"; import { Format, FormatExtras } from "../../config/types.ts"; import { TempContext } from "../../core/temp-types.ts"; import { encodeBase64 } from "../../deno_ral/encoding.ts"; export function axeFormatDependencies( - _format: Format, + format: Format, temp: TempContext, options?: unknown, ): FormatExtras { if (!options) return {}; + // Use reveal-theme for revealjs, bootstrap for other HTML formats. + // Note: For revealjs, sass-bundles compile separately from the theme + // (which compiles in format-reveal-theme.ts), so the !default values + // below are used instead of actual theme colors. This is a known + // limitation - see GitHub issue for architectural context. + const sassDependency = isRevealjsOutput(format.pandoc) + ? "reveal-theme" + : "bootstrap"; + return { [kIncludeInHeader]: [ temp.createFileFromString( @@ -28,10 +38,13 @@ export function axeFormatDependencies( "sass-bundles": [ { key: "axe", - dependency: "bootstrap", + dependency: sassDependency, user: [{ uses: "", - defaults: "", + defaults: ` +$body-color: #222 !default; +$link-color: #2a76dd !default; +`, functions: "", mixins: "", rules: ` @@ -51,7 +64,7 @@ body div.quarto-axe-report { text-decoration: underline; cursor: pointer; } - + .quarto-axe-hover-highlight { background-color: red; border: 1px solid $body-color; diff --git a/tests/docs/playwright/html/axe-accessibility.qmd b/tests/docs/playwright/html/axe-accessibility.qmd new file mode 100644 index 00000000000..0fa4db095ec --- /dev/null +++ b/tests/docs/playwright/html/axe-accessibility.qmd @@ -0,0 +1,12 @@ +--- +format: + html: + axe: + output: document +--- + +## HTML Document + +Content for axe accessibility testing. + +This text violates contrast rules: [insufficient contrast.]{style="color: #eee; background: #fff;"}. diff --git a/tests/docs/playwright/revealjs/axe-accessibility.qmd b/tests/docs/playwright/revealjs/axe-accessibility.qmd new file mode 100644 index 00000000000..60ddd667a28 --- /dev/null +++ b/tests/docs/playwright/revealjs/axe-accessibility.qmd @@ -0,0 +1,16 @@ +--- +format: + revealjs: + axe: + output: document +--- + +## Slide 1 + +Content for axe accessibility testing. + +This text violates contrast rules: [insufficient contrast.]{style="color: #eee; background: #fff;"}. + +## Slide 2 + +More content to check. diff --git a/tests/docs/smoke-all/accessibility/axe-html.qmd b/tests/docs/smoke-all/accessibility/axe-html.qmd new file mode 100644 index 00000000000..74f9b3a9f82 --- /dev/null +++ b/tests/docs/smoke-all/accessibility/axe-html.qmd @@ -0,0 +1,21 @@ +--- +title: "Axe Accessibility - HTML" +format: + html: + axe: true +_quarto: + tests: + html: + ensureHtmlElements: + - ['script[src*="axe"]', '#quarto-axe-checker-options'] + - [] + ensureFileRegexMatches: + - ['quarto-axe-checker-options'] + - [] +--- + +## HTML Document + +Content for accessibility checking. + +This is a regression test to ensure HTML format continues to work with axe enabled. diff --git a/tests/docs/smoke-all/accessibility/axe-revealjs.qmd b/tests/docs/smoke-all/accessibility/axe-revealjs.qmd new file mode 100644 index 00000000000..72a6720e171 --- /dev/null +++ b/tests/docs/smoke-all/accessibility/axe-revealjs.qmd @@ -0,0 +1,23 @@ +--- +title: "Axe Accessibility - Revealjs" +format: + revealjs: + axe: true +_quarto: + tests: + revealjs: + ensureHtmlElements: + - ['script[src*="axe"]', '#quarto-axe-checker-options'] + - [] + ensureFileRegexMatches: + - ['quarto-axe-checker-options'] + - [] +--- + +## Slide 1 + +Content for accessibility checking. + +## Slide 2 + +More content. diff --git a/tests/integration/playwright/tests/html-axe-accessibility.spec.ts b/tests/integration/playwright/tests/html-axe-accessibility.spec.ts new file mode 100644 index 00000000000..a63e1c66960 --- /dev/null +++ b/tests/integration/playwright/tests/html-axe-accessibility.spec.ts @@ -0,0 +1,13 @@ +import { test, expect } from '@playwright/test'; + +test('HTML - axe detects contrast violation and shows report', async ({ page }) => { + await page.goto('/html/axe-accessibility.html', { waitUntil: 'networkidle' }); + + // Wait for axe to run and produce report (output: document mode) + const axeReport = page.locator('.quarto-axe-report'); + await expect(axeReport).toBeVisible({ timeout: 10000 }); + + // Verify report contains violation information + const reportText = await axeReport.textContent(); + expect(reportText).toContain('color contrast'); +}); diff --git a/tests/integration/playwright/tests/revealjs-axe-accessibility.spec.ts b/tests/integration/playwright/tests/revealjs-axe-accessibility.spec.ts new file mode 100644 index 00000000000..8647eff9381 --- /dev/null +++ b/tests/integration/playwright/tests/revealjs-axe-accessibility.spec.ts @@ -0,0 +1,13 @@ +import { test, expect } from '@playwright/test'; + +test('Revealjs - axe detects contrast violation and shows report', async ({ page }) => { + await page.goto('/revealjs/axe-accessibility.html', { waitUntil: 'networkidle' }); + + // Wait for axe to run and produce report (output: document mode) + const axeReport = page.locator('.quarto-axe-report'); + await expect(axeReport).toBeVisible({ timeout: 10000 }); + + // Verify report contains violation information + const reportText = await axeReport.textContent(); + expect(reportText).toContain('color contrast'); +}); From 9b23ea736707567dab3c0d2dcfb6b1ae42c576dd Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 24 Feb 2026 17:21:57 +0100 Subject: [PATCH 02/35] Add comprehensive axe accessibility test coverage Test all axe output modes (console, json, document) across html, revealjs, and dashboard formats. HTML passes all modes. Revealjs tests are marked as expected-fail because axe-check.js is bundled in bootstrap-only quarto.js and never loads (#13781). Dashboard document mode is also expected-fail because the format has no
element for the document reporter. Add data-quarto-axe-complete attribute to axe-check.js as a deterministic completion signal for playwright tests. Consolidate 7 individual spec files into a single parameterized axe-accessibility.spec.ts following the html-math-katex.spec.ts pattern. --- .claude/rules/testing/playwright-tests.md | 34 +++++++ src/resources/formats/html/axe/axe-check.js | 1 + tests/docs/playwright/dashboard/.gitignore | 3 + .../dashboard/axe-accessibility.qmd | 12 +++ tests/docs/playwright/html/axe-console.qmd | 11 +++ tests/docs/playwright/html/axe-json.qmd | 12 +++ .../docs/playwright/revealjs/axe-console.qmd | 15 +++ tests/docs/playwright/revealjs/axe-json.qmd | 16 ++++ .../smoke-all/accessibility/axe-dashboard.qmd | 21 +++++ .../tests/axe-accessibility.spec.ts | 92 +++++++++++++++++++ .../tests/html-axe-accessibility.spec.ts | 13 --- .../tests/revealjs-axe-accessibility.spec.ts | 13 --- 12 files changed, 217 insertions(+), 26 deletions(-) create mode 100644 tests/docs/playwright/dashboard/.gitignore create mode 100644 tests/docs/playwright/dashboard/axe-accessibility.qmd create mode 100644 tests/docs/playwright/html/axe-console.qmd create mode 100644 tests/docs/playwright/html/axe-json.qmd create mode 100644 tests/docs/playwright/revealjs/axe-console.qmd create mode 100644 tests/docs/playwright/revealjs/axe-json.qmd create mode 100644 tests/docs/smoke-all/accessibility/axe-dashboard.qmd create mode 100644 tests/integration/playwright/tests/axe-accessibility.spec.ts delete mode 100644 tests/integration/playwright/tests/html-axe-accessibility.spec.ts delete mode 100644 tests/integration/playwright/tests/revealjs-axe-accessibility.spec.ts diff --git a/.claude/rules/testing/playwright-tests.md b/.claude/rules/testing/playwright-tests.md index 229087cc627..98feb76c33c 100644 --- a/.claude/rules/testing/playwright-tests.md +++ b/.claude/rules/testing/playwright-tests.md @@ -61,6 +61,40 @@ test("Feature description", async ({ page }) => { }); ``` +### Parameterized Tests + +When testing the same behavior across multiple formats or configurations, use `test.describe` with a test cases array instead of separate spec files. See `html-math-katex.spec.ts` and `axe-accessibility.spec.ts` for examples. + +```typescript +const testCases = [ + { format: 'html', url: '/html/feature.html' }, + { format: 'revealjs', url: '/revealjs/feature.html', shouldFail: 'reason (#issue)' }, +]; + +test.describe('Feature across formats', () => { + for (const { format, url, shouldFail } of testCases) { + test(`${format} — feature works`, async ({ page }) => { + if (shouldFail) test.fail(); + // shared test logic + }); + } +}); +``` + +**When to use:** Same assertion logic applied to multiple formats, output modes, or configurations. Reduces file count and centralizes shared helpers. + +### Expected Failures + +Use `test.fail()` to document known failures. Playwright inverts the result: the test passes if it fails, and flags if it unexpectedly passes (signaling the fix landed). + +```typescript +test('Feature that is known broken', async ({ page }) => { + // Brief explanation of why this fails and issue reference + test.fail(); + // ... normal test logic +}); +``` + ## Configuration - **Config file:** `playwright.config.ts` diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index 88080850403..c3b78eee5cf 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -132,6 +132,7 @@ class QuartoAxeChecker { }); const reporter = this.options === true ? new QuartoAxeConsoleReporter(result) : new reporters[this.options.output](result, this.options); reporter.report(); + document.body.setAttribute('data-quarto-axe-complete', 'true'); } } diff --git a/tests/docs/playwright/dashboard/.gitignore b/tests/docs/playwright/dashboard/.gitignore new file mode 100644 index 00000000000..2ae30d60d2b --- /dev/null +++ b/tests/docs/playwright/dashboard/.gitignore @@ -0,0 +1,3 @@ +*.html +*_files/ +.quarto/ diff --git a/tests/docs/playwright/dashboard/axe-accessibility.qmd b/tests/docs/playwright/dashboard/axe-accessibility.qmd new file mode 100644 index 00000000000..9eff466c67b --- /dev/null +++ b/tests/docs/playwright/dashboard/axe-accessibility.qmd @@ -0,0 +1,12 @@ +--- +format: + dashboard: + axe: + output: document +--- + +## Row + +Content for axe accessibility testing. + +This text violates contrast rules: [insufficient contrast.]{style="color: #eee; background: #fff;"}. diff --git a/tests/docs/playwright/html/axe-console.qmd b/tests/docs/playwright/html/axe-console.qmd new file mode 100644 index 00000000000..7e1e837b1a0 --- /dev/null +++ b/tests/docs/playwright/html/axe-console.qmd @@ -0,0 +1,11 @@ +--- +format: + html: + axe: true +--- + +## HTML Document + +Content for axe accessibility testing. + +This text violates contrast rules: [insufficient contrast.]{style="color: #eee; background: #fff;"}. diff --git a/tests/docs/playwright/html/axe-json.qmd b/tests/docs/playwright/html/axe-json.qmd new file mode 100644 index 00000000000..76aa2c67451 --- /dev/null +++ b/tests/docs/playwright/html/axe-json.qmd @@ -0,0 +1,12 @@ +--- +format: + html: + axe: + output: json +--- + +## HTML Document + +Content for axe accessibility testing. + +This text violates contrast rules: [insufficient contrast.]{style="color: #eee; background: #fff;"}. diff --git a/tests/docs/playwright/revealjs/axe-console.qmd b/tests/docs/playwright/revealjs/axe-console.qmd new file mode 100644 index 00000000000..55282e0c225 --- /dev/null +++ b/tests/docs/playwright/revealjs/axe-console.qmd @@ -0,0 +1,15 @@ +--- +format: + revealjs: + axe: true +--- + +## Slide 1 + +Content for axe accessibility testing. + +This text violates contrast rules: [insufficient contrast.]{style="color: #eee; background: #fff;"}. + +## Slide 2 + +More content to check. diff --git a/tests/docs/playwright/revealjs/axe-json.qmd b/tests/docs/playwright/revealjs/axe-json.qmd new file mode 100644 index 00000000000..681683707ba --- /dev/null +++ b/tests/docs/playwright/revealjs/axe-json.qmd @@ -0,0 +1,16 @@ +--- +format: + revealjs: + axe: + output: json +--- + +## Slide 1 + +Content for axe accessibility testing. + +This text violates contrast rules: [insufficient contrast.]{style="color: #eee; background: #fff;"}. + +## Slide 2 + +More content to check. diff --git a/tests/docs/smoke-all/accessibility/axe-dashboard.qmd b/tests/docs/smoke-all/accessibility/axe-dashboard.qmd new file mode 100644 index 00000000000..572bb46f589 --- /dev/null +++ b/tests/docs/smoke-all/accessibility/axe-dashboard.qmd @@ -0,0 +1,21 @@ +--- +title: "Axe Accessibility - Dashboard" +format: + dashboard: + axe: true +_quarto: + tests: + dashboard: + ensureHtmlElements: + - ['script[src*="axe"]', '#quarto-axe-checker-options'] + - [] + ensureFileRegexMatches: + - ['quarto-axe-checker-options'] + - [] +--- + +## Row + +Content for accessibility checking. + +This is a regression test to ensure dashboard format works with axe enabled. diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts new file mode 100644 index 00000000000..dd5d2e301c9 --- /dev/null +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -0,0 +1,92 @@ +import { test, expect, Page } from '@playwright/test'; + +// -- Helpers -- + +async function collectConsoleLogs(page: Page): Promise { + const messages: string[] = []; + page.on('console', msg => { + if (msg.type() === 'log') messages.push(msg.text()); + }); + return messages; +} + +async function waitForAxeCompletion(page: Page, timeout = 15000): Promise { + await page.waitForSelector('[data-quarto-axe-complete]', { timeout }); +} + +function findAxeJsonResult(messages: string[]): { violations: { id: string }[] } | undefined { + for (const m of messages) { + try { + const parsed = JSON.parse(m); + if (parsed.violations !== undefined) return parsed; + } catch { + // not JSON + } + } + return undefined; +} + +// -- Test matrix -- + +type OutputMode = 'document' | 'console' | 'json'; + +interface AxeTestCase { + format: string; + outputMode: OutputMode; + url: string; + shouldFail?: string; // reason for test.fail(), undefined if test should pass +} + +const testCases: AxeTestCase[] = [ + // HTML — all modes work (bootstrap loads axe-check.js, has
) + { format: 'html', outputMode: 'document', url: '/html/axe-accessibility.html' }, + { format: 'html', outputMode: 'console', url: '/html/axe-console.html' }, + { format: 'html', outputMode: 'json', url: '/html/axe-json.html' }, + + // RevealJS — axe-check.js never loads (bundled in bootstrap-only quarto.js) (#13781) + { format: 'revealjs', outputMode: 'document', url: '/revealjs/axe-accessibility.html', + shouldFail: 'axe-check.js bundled in quarto.js (bootstrap-only), never loads for revealjs (#13781)' }, + { format: 'revealjs', outputMode: 'console', url: '/revealjs/axe-console.html', + shouldFail: 'axe-check.js bundled in quarto.js (bootstrap-only), never loads for revealjs (#13781)' }, + { format: 'revealjs', outputMode: 'json', url: '/revealjs/axe-json.html', + shouldFail: 'axe-check.js bundled in quarto.js (bootstrap-only), never loads for revealjs (#13781)' }, + + // Dashboard — axe loads but document reporter fails (no
element) (#13781) + { format: 'dashboard', outputMode: 'document', url: '/dashboard/axe-accessibility.html', + shouldFail: 'Dashboard has no
element; document reporter fails on querySelector("main") (#13781)' }, +]; + +// -- Tests -- + +test.describe('Axe accessibility checking', () => { + for (const { format, outputMode, url, shouldFail } of testCases) { + test(`${format} — ${outputMode} mode detects contrast violation`, async ({ page }) => { + if (shouldFail) { + test.fail(); + } + + if (outputMode === 'document') { + await page.goto(url, { waitUntil: 'networkidle' }); + const axeReport = page.locator('.quarto-axe-report'); + await expect(axeReport).toBeVisible({ timeout: 10000 }); + const reportText = await axeReport.textContent(); + expect(reportText).toContain('color contrast'); + + } else if (outputMode === 'console') { + const messages = await collectConsoleLogs(page); + await page.goto(url, { waitUntil: 'networkidle' }); + await waitForAxeCompletion(page, shouldFail ? 5000 : 15000); + expect(messages.some(m => m.toLowerCase().includes('contrast'))).toBe(true); + + } else if (outputMode === 'json') { + const messages = await collectConsoleLogs(page); + await page.goto(url, { waitUntil: 'networkidle' }); + await waitForAxeCompletion(page, shouldFail ? 5000 : 15000); + const result = findAxeJsonResult(messages); + expect(result).toBeDefined(); + expect(result!.violations.length).toBeGreaterThan(0); + expect(result!.violations.some(v => v.id === 'color-contrast')).toBe(true); + } + }); + } +}); diff --git a/tests/integration/playwright/tests/html-axe-accessibility.spec.ts b/tests/integration/playwright/tests/html-axe-accessibility.spec.ts deleted file mode 100644 index a63e1c66960..00000000000 --- a/tests/integration/playwright/tests/html-axe-accessibility.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test('HTML - axe detects contrast violation and shows report', async ({ page }) => { - await page.goto('/html/axe-accessibility.html', { waitUntil: 'networkidle' }); - - // Wait for axe to run and produce report (output: document mode) - const axeReport = page.locator('.quarto-axe-report'); - await expect(axeReport).toBeVisible({ timeout: 10000 }); - - // Verify report contains violation information - const reportText = await axeReport.textContent(); - expect(reportText).toContain('color contrast'); -}); diff --git a/tests/integration/playwright/tests/revealjs-axe-accessibility.spec.ts b/tests/integration/playwright/tests/revealjs-axe-accessibility.spec.ts deleted file mode 100644 index 8647eff9381..00000000000 --- a/tests/integration/playwright/tests/revealjs-axe-accessibility.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test('Revealjs - axe detects contrast violation and shows report', async ({ page }) => { - await page.goto('/revealjs/axe-accessibility.html', { waitUntil: 'networkidle' }); - - // Wait for axe to run and produce report (output: document mode) - const axeReport = page.locator('.quarto-axe-report'); - await expect(axeReport).toBeVisible({ timeout: 10000 }); - - // Verify report contains violation information - const reportText = await axeReport.textContent(); - expect(reportText).toContain('color contrast'); -}); From a2b6a03f1d912575abdad9e3a923613f054caa79 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 24 Feb 2026 17:33:32 +0100 Subject: [PATCH 03/35] Await reporter.report() in axe-check.js for future-proofing All current reporters are synchronous, but awaiting is free and prevents the completion signal from firing early if a reporter becomes async in the future. --- src/resources/formats/html/axe/axe-check.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index c3b78eee5cf..5fb7d55a760 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -131,7 +131,7 @@ class QuartoAxeChecker { preload: { assets: ['cssom'], timeout: 50000 } }); const reporter = this.options === true ? new QuartoAxeConsoleReporter(result) : new reporters[this.options.output](result, this.options); - reporter.report(); + await reporter.report(); document.body.setAttribute('data-quarto-axe-complete', 'true'); } } From b48bf01fad29f3c7dae8965d2ff5565ef3ec0d9e Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 24 Feb 2026 18:05:13 +0100 Subject: [PATCH 04/35] Load axe-check.js as standalone module for all HTML formats axe accessibility checking only worked for bootstrap HTML because axe-check.js was imported by quarto.js, which only loads for bootstrap. Decouple axe-check.js from quarto.js so it loads independently via FormatDependency for all HTML formats (html, revealjs, dashboard). - Remove axe import and init() call from quarto.js - Add self-initialization to axe-check.js (runs when loaded as module) - Add FormatDependency in axeFormatDependencies to load axe-check.js as type="module" script when axe config is set - Fix document reporter to fall back to document.body when no
element exists (revealjs/dashboard layouts) - Update playwright tests: remove test.fail() markers, assert specific violations per format (color-contrast for html/dashboard, link-name for revealjs where CSS transforms prevent contrast detection) --- src/format/html/format-html-axe.ts | 12 +++- src/resources/formats/html/axe/axe-check.js | 8 ++- src/resources/formats/html/quarto.js | 2 - .../tests/axe-accessibility.spec.ts | 55 +++++++++++-------- 4 files changed, 50 insertions(+), 27 deletions(-) diff --git a/src/format/html/format-html-axe.ts b/src/format/html/format-html-axe.ts index 505ae44ef86..41bc369ffab 100644 --- a/src/format/html/format-html-axe.ts +++ b/src/format/html/format-html-axe.ts @@ -6,9 +6,11 @@ import { kIncludeInHeader } from "../../config/constants.ts"; import { isRevealjsOutput } from "../../config/format.ts"; -import { Format, FormatExtras } from "../../config/types.ts"; +import { Format, FormatExtras, kDependencies } from "../../config/types.ts"; +import { formatResourcePath } from "../../core/resources.ts"; import { TempContext } from "../../core/temp-types.ts"; import { encodeBase64 } from "../../deno_ral/encoding.ts"; +import { join } from "../../deno_ral/path.ts"; export function axeFormatDependencies( format: Format, @@ -35,6 +37,14 @@ export function axeFormatDependencies( ), ], html: { + [kDependencies]: [{ + name: "quarto-axe", + scripts: [{ + name: "axe-check.js", + path: formatResourcePath("html", join("axe", "axe-check.js")), + attribs: { type: "module" }, + }], + }], "sass-bundles": [ { key: "axe", diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index 5fb7d55a760..a52a7161755 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -105,7 +105,7 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { violations.forEach((violation) => { reportElement.appendChild(this.createViolationElement(violation)); }); - document.querySelector("main").appendChild(reportElement); + (document.querySelector("main") || document.body).appendChild(reportElement); } } @@ -143,4 +143,8 @@ export async function init() { const checker = new QuartoAxeChecker(jsonOptions); await checker.init(); } -} \ No newline at end of file +} + +// Self-initialize when loaded as a standalone module. +// ES modules are deferred, so the DOM is fully parsed when this runs. +init(); \ No newline at end of file diff --git a/src/resources/formats/html/quarto.js b/src/resources/formats/html/quarto.js index 84574cacad2..ee807684be1 100644 --- a/src/resources/formats/html/quarto.js +++ b/src/resources/formats/html/quarto.js @@ -1,5 +1,4 @@ import * as tabsets from "./tabsets/tabsets.js"; -import * as axe from "./axe/axe-check.js"; const sectionChanged = new CustomEvent("quarto-sectionChanged", { detail: {}, @@ -827,7 +826,6 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { }); tabsets.init(); -axe.init(); function throttle(func, wait) { let waiting = false; diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index dd5d2e301c9..eccd6bda606 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -34,58 +34,69 @@ interface AxeTestCase { format: string; outputMode: OutputMode; url: string; - shouldFail?: string; // reason for test.fail(), undefined if test should pass + // Expected violation ID. RevealJS CSS transforms prevent axe-core from + // computing color contrast, so revealjs tests check for a different violation. + expectedViolation: string; } const testCases: AxeTestCase[] = [ - // HTML — all modes work (bootstrap loads axe-check.js, has
) - { format: 'html', outputMode: 'document', url: '/html/axe-accessibility.html' }, - { format: 'html', outputMode: 'console', url: '/html/axe-console.html' }, - { format: 'html', outputMode: 'json', url: '/html/axe-json.html' }, + // HTML — bootstrap format, color contrast detected + { format: 'html', outputMode: 'document', url: '/html/axe-accessibility.html', + expectedViolation: 'color-contrast' }, + { format: 'html', outputMode: 'console', url: '/html/axe-console.html', + expectedViolation: 'color-contrast' }, + { format: 'html', outputMode: 'json', url: '/html/axe-json.html', + expectedViolation: 'color-contrast' }, - // RevealJS — axe-check.js never loads (bundled in bootstrap-only quarto.js) (#13781) + // RevealJS — axe-check.js loads as standalone module (#13781). + // 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', - shouldFail: 'axe-check.js bundled in quarto.js (bootstrap-only), never loads for revealjs (#13781)' }, + expectedViolation: 'link-name' }, { format: 'revealjs', outputMode: 'console', url: '/revealjs/axe-console.html', - shouldFail: 'axe-check.js bundled in quarto.js (bootstrap-only), never loads for revealjs (#13781)' }, + expectedViolation: 'link-name' }, { format: 'revealjs', outputMode: 'json', url: '/revealjs/axe-json.html', - shouldFail: 'axe-check.js bundled in quarto.js (bootstrap-only), never loads for revealjs (#13781)' }, + expectedViolation: 'link-name' }, - // Dashboard — axe loads but document reporter fails (no
element) (#13781) + // Dashboard — axe-check.js loads as standalone module, falls back to document.body (#13781) { format: 'dashboard', outputMode: 'document', url: '/dashboard/axe-accessibility.html', - shouldFail: 'Dashboard has no
element; document reporter fails on querySelector("main") (#13781)' }, + expectedViolation: 'color-contrast' }, ]; // -- Tests -- test.describe('Axe accessibility checking', () => { - for (const { format, outputMode, url, shouldFail } of testCases) { - test(`${format} — ${outputMode} mode detects contrast violation`, async ({ page }) => { - if (shouldFail) { - test.fail(); - } - + for (const { format, outputMode, url, expectedViolation } of testCases) { + test(`${format} — ${outputMode} mode detects ${expectedViolation} violation`, async ({ page }) => { if (outputMode === 'document') { await page.goto(url, { waitUntil: 'networkidle' }); const axeReport = page.locator('.quarto-axe-report'); await expect(axeReport).toBeVisible({ timeout: 10000 }); const reportText = await axeReport.textContent(); - expect(reportText).toContain('color contrast'); + // Document reporter shows violation descriptions, not IDs. + // Map violation IDs to expected text in the report. + const expectedText = expectedViolation === 'color-contrast' + ? 'color contrast' + : 'discernible text'; + expect(reportText).toContain(expectedText); } else if (outputMode === 'console') { const messages = await collectConsoleLogs(page); await page.goto(url, { waitUntil: 'networkidle' }); - await waitForAxeCompletion(page, shouldFail ? 5000 : 15000); - expect(messages.some(m => m.toLowerCase().includes('contrast'))).toBe(true); + await waitForAxeCompletion(page); + const expectedText = expectedViolation === 'color-contrast' + ? 'contrast' + : 'discernible text'; + expect(messages.some(m => m.toLowerCase().includes(expectedText))).toBe(true); } else if (outputMode === 'json') { const messages = await collectConsoleLogs(page); await page.goto(url, { waitUntil: 'networkidle' }); - await waitForAxeCompletion(page, shouldFail ? 5000 : 15000); + await waitForAxeCompletion(page); const result = findAxeJsonResult(messages); expect(result).toBeDefined(); expect(result!.violations.length).toBeGreaterThan(0); - expect(result!.violations.some(v => v.id === 'color-contrast')).toBe(true); + expect(result!.violations.some(v => v.id === expectedViolation)).toBe(true); } }); } From ccf29e39efd9ad68a03b2ffac7d54661bb63be31 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 25 Feb 2026 11:14:32 +0100 Subject: [PATCH 05/35] Add smoke-all tests for axe conditional inclusion Add negative tests verifying axe dependencies are absent when axe is not configured or explicitly disabled (axe: false). Fix CSS selector in all axe smoke-all tests to use script[src*="axe-check"] instead of script[src*="axe"] to avoid false matches on output filenames. Regenerate esbuild-analysis-cache.json to reflect removal of axe-check.js import from quarto.js (stale cache was causing unconditional inclusion). --- .../formats/html/esbuild-analysis-cache.json | 11 +++-------- .../smoke-all/accessibility/axe-dashboard.qmd | 2 +- .../accessibility/axe-disabled-html.qmd | 17 +++++++++++++++++ .../accessibility/axe-false-html.qmd | 19 +++++++++++++++++++ .../docs/smoke-all/accessibility/axe-html.qmd | 2 +- .../smoke-all/accessibility/axe-revealjs.qmd | 2 +- 6 files changed, 42 insertions(+), 11 deletions(-) create mode 100644 tests/docs/smoke-all/accessibility/axe-disabled-html.qmd create mode 100644 tests/docs/smoke-all/accessibility/axe-false-html.qmd diff --git a/src/resources/formats/html/esbuild-analysis-cache.json b/src/resources/formats/html/esbuild-analysis-cache.json index 35225503b2b..16f8062bbc3 100644 --- a/src/resources/formats/html/esbuild-analysis-cache.json +++ b/src/resources/formats/html/esbuild-analysis-cache.json @@ -2,7 +2,7 @@ "quarto.js": { "inputs": { "quarto.js": { - "bytes": 26885, + "bytes": 26830, "imports": [], "format": "esm" } @@ -14,21 +14,16 @@ "path": "./tabsets/tabsets.js", "kind": "import-statement", "external": true - }, - { - "path": "./axe/axe-check.js", - "kind": "import-statement", - "external": true } ], "exports": [], "entryPoint": "quarto.js", "inputs": { "quarto.js": { - "bytesInOutput": 22368 + "bytesInOutput": 22313 } }, - "bytes": 22368 + "bytes": 22313 } } } diff --git a/tests/docs/smoke-all/accessibility/axe-dashboard.qmd b/tests/docs/smoke-all/accessibility/axe-dashboard.qmd index 572bb46f589..fede2d9a135 100644 --- a/tests/docs/smoke-all/accessibility/axe-dashboard.qmd +++ b/tests/docs/smoke-all/accessibility/axe-dashboard.qmd @@ -7,7 +7,7 @@ _quarto: tests: dashboard: ensureHtmlElements: - - ['script[src*="axe"]', '#quarto-axe-checker-options'] + - ['script[src*="axe-check"]', '#quarto-axe-checker-options'] - [] ensureFileRegexMatches: - ['quarto-axe-checker-options'] diff --git a/tests/docs/smoke-all/accessibility/axe-disabled-html.qmd b/tests/docs/smoke-all/accessibility/axe-disabled-html.qmd new file mode 100644 index 00000000000..13310a814f2 --- /dev/null +++ b/tests/docs/smoke-all/accessibility/axe-disabled-html.qmd @@ -0,0 +1,17 @@ +--- +title: "No Axe - HTML" +format: html +_quarto: + tests: + html: + ensureHtmlElements: + - [] + - ['script[src*="axe-check"]', '#quarto-axe-checker-options'] + ensureFileRegexMatches: + - [] + - ['axe-check\.js', 'quarto-axe-checker-options'] +--- + +## HTML Document + +Render without axe config — no axe dependencies should be present. diff --git a/tests/docs/smoke-all/accessibility/axe-false-html.qmd b/tests/docs/smoke-all/accessibility/axe-false-html.qmd new file mode 100644 index 00000000000..6d81b16b158 --- /dev/null +++ b/tests/docs/smoke-all/accessibility/axe-false-html.qmd @@ -0,0 +1,19 @@ +--- +title: "Axe False - HTML" +format: + html: + axe: false +_quarto: + tests: + html: + ensureHtmlElements: + - [] + - ['script[src*="axe-check"]', '#quarto-axe-checker-options'] + ensureFileRegexMatches: + - [] + - ['axe-check\.js', 'quarto-axe-checker-options'] +--- + +## HTML Document + +Render with axe: false — no axe dependencies should be present. diff --git a/tests/docs/smoke-all/accessibility/axe-html.qmd b/tests/docs/smoke-all/accessibility/axe-html.qmd index 74f9b3a9f82..188bcf0991b 100644 --- a/tests/docs/smoke-all/accessibility/axe-html.qmd +++ b/tests/docs/smoke-all/accessibility/axe-html.qmd @@ -7,7 +7,7 @@ _quarto: tests: html: ensureHtmlElements: - - ['script[src*="axe"]', '#quarto-axe-checker-options'] + - ['script[src*="axe-check"]', '#quarto-axe-checker-options'] - [] ensureFileRegexMatches: - ['quarto-axe-checker-options'] diff --git a/tests/docs/smoke-all/accessibility/axe-revealjs.qmd b/tests/docs/smoke-all/accessibility/axe-revealjs.qmd index 72a6720e171..cdc416e40e7 100644 --- a/tests/docs/smoke-all/accessibility/axe-revealjs.qmd +++ b/tests/docs/smoke-all/accessibility/axe-revealjs.qmd @@ -7,7 +7,7 @@ _quarto: tests: revealjs: ensureHtmlElements: - - ['script[src*="axe"]', '#quarto-axe-checker-options'] + - ['script[src*="axe-check"]', '#quarto-axe-checker-options'] - [] ensureFileRegexMatches: - ['quarto-axe-checker-options'] From 39a63874ad72f4123a67763595d73b3541534d32 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 25 Feb 2026 11:35:41 +0100 Subject: [PATCH 06/35] Add idempotency guard to axe init and use violation text lookup map Prevent double initialization if axe-check.js is imported after self-initializing. Remove unused export. Replace inline conditionals in playwright tests with a violation text lookup map for clarity. --- src/resources/formats/html/axe/axe-check.js | 8 ++++++-- .../tests/axe-accessibility.spec.ts | 19 +++++++++---------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index a52a7161755..65e2a8410b0 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -136,7 +136,11 @@ class QuartoAxeChecker { } } -export async function init() { +let initialized = false; + +async function init() { + if (initialized) return; + initialized = true; const opts = document.querySelector("#quarto-axe-checker-options"); if (opts) { const jsonOptions = JSON.parse(atob(opts.textContent)); @@ -147,4 +151,4 @@ export 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(); \ No newline at end of file +init(); diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index eccd6bda606..457a83e6524 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -63,6 +63,13 @@ const testCases: AxeTestCase[] = [ expectedViolation: 'color-contrast' }, ]; +// Map axe violation IDs to the text that appears in document/console reporters. +// Document reporter shows the full description; console reporter shows a shorter form. +const violationText: Record = { + 'color-contrast': { document: 'color contrast', console: 'contrast' }, + 'link-name': { document: 'discernible text', console: 'discernible text' }, +}; + // -- Tests -- test.describe('Axe accessibility checking', () => { @@ -73,21 +80,13 @@ test.describe('Axe accessibility checking', () => { const axeReport = page.locator('.quarto-axe-report'); await expect(axeReport).toBeVisible({ timeout: 10000 }); const reportText = await axeReport.textContent(); - // Document reporter shows violation descriptions, not IDs. - // Map violation IDs to expected text in the report. - const expectedText = expectedViolation === 'color-contrast' - ? 'color contrast' - : 'discernible text'; - expect(reportText).toContain(expectedText); + expect(reportText).toContain(violationText[expectedViolation].document); } else if (outputMode === 'console') { const messages = await collectConsoleLogs(page); await page.goto(url, { waitUntil: 'networkidle' }); await waitForAxeCompletion(page); - const expectedText = expectedViolation === 'color-contrast' - ? 'contrast' - : 'discernible text'; - expect(messages.some(m => m.toLowerCase().includes(expectedText))).toBe(true); + expect(messages.some(m => m.toLowerCase().includes(violationText[expectedViolation].console))).toBe(true); } else if (outputMode === 'json') { const messages = await collectConsoleLogs(page); From 8c116590387f0da631513731c4c6b5de1fa3d391 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 25 Feb 2026 11:42:57 +0100 Subject: [PATCH 07/35] Guard violationText lookup against missing keys in axe tests --- tests/integration/playwright/tests/axe-accessibility.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index 457a83e6524..2612e575459 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -75,6 +75,9 @@ const violationText: Record = { test.describe('Axe accessibility checking', () => { for (const { format, outputMode, url, expectedViolation } of testCases) { test(`${format} — ${outputMode} mode detects ${expectedViolation} violation`, async ({ page }) => { + expect(violationText[expectedViolation], + `Missing violationText entry for "${expectedViolation}"`).toBeDefined(); + if (outputMode === 'document') { await page.goto(url, { waitUntil: 'networkidle' }); const axeReport = page.locator('.quarto-axe-report'); From c4fa15a144e46fc005e20390ab800b845163113a Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 26 Feb 2026 13:01:31 +0100 Subject: [PATCH 08/35] Fix axe report CSS: z-index, background, scroll for all HTML formats The axe document reporter overlay was unreadable: no z-index (hidden behind RevealJS UI and dashboard cards), transparent background (text invisible on dark themes), and no height constraint (overflows viewport). Uses var(--r-css-prop, $sass-variable) pattern to bridge RevealJS (CSS custom properties from exposer.scss) and Bootstrap (Sass-compiled theme values) theming systems. Adds Playwright tests for CSS properties and a RevealJS dark theme test case to verify the cross-format theming bridge. Updates the dashboard test doc with a multi-card layout using value boxes and cards. --- src/format/html/format-html-axe.ts | 8 ++- .../dashboard/axe-accessibility.qmd | 49 ++++++++++++++++--- .../revealjs/axe-accessibility-dark.qmd | 17 +++++++ .../tests/axe-accessibility.spec.ts | 15 ++++++ 4 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 tests/docs/playwright/revealjs/axe-accessibility-dark.qmd diff --git a/src/format/html/format-html-axe.ts b/src/format/html/format-html-axe.ts index 41bc369ffab..51e565042c0 100644 --- a/src/format/html/format-html-axe.ts +++ b/src/format/html/format-html-axe.ts @@ -53,6 +53,7 @@ export function axeFormatDependencies( uses: "", defaults: ` $body-color: #222 !default; +$body-bg: #fff !default; $link-color: #2a76dd !default; `, functions: "", @@ -63,7 +64,12 @@ body div.quarto-axe-report { bottom: 3rem; right: 3rem; padding: 1rem; - border: 1px solid $body-color; + border: 1px solid var(--r-main-color, $body-color); + z-index: 9999; + background-color: var(--r-background-color, $body-bg); + color: var(--r-main-color, $body-color); + max-height: 50vh; + overflow-y: auto; } .quarto-axe-violation-help { padding-left: 0.5rem; } diff --git a/tests/docs/playwright/dashboard/axe-accessibility.qmd b/tests/docs/playwright/dashboard/axe-accessibility.qmd index 9eff466c67b..04514859128 100644 --- a/tests/docs/playwright/dashboard/axe-accessibility.qmd +++ b/tests/docs/playwright/dashboard/axe-accessibility.qmd @@ -1,12 +1,47 @@ --- -format: - dashboard: - axe: - output: document +title: "Sales Dashboard" +format: dashboard +axe: + output: document --- -## Row +## Row {height=30%} -Content for axe accessibility testing. +### Column -This text violates contrast rules: [insufficient contrast.]{style="color: #eee; background: #fff;"}. +::: {.valuebox title="Total Revenue" color="primary"} +$1.2M +::: + +### Column + +::: {.valuebox title="Active Users" color="success"} +8,432 +::: + +### Column + +::: {.valuebox title="Conversion Rate" color="warning"} +3.2% +::: + +## Row {height=70%} + +### Column {width=60%} + +::: {.card title="Monthly Revenue"} +This card shows [monthly revenue trends]{style="color: #eee; background: #fff;"} across all regions. + +Additional analysis text to fill the card with content. The dashboard layout should fill the viewport. +::: + +### Column {width=40%} + +::: {.card title="Top Products"} +| Product | Sales | +|---------|-------| +| Widget A | $450K | +| Widget B | $320K | +| Widget C | $280K | +| Widget D | $150K | +::: diff --git a/tests/docs/playwright/revealjs/axe-accessibility-dark.qmd b/tests/docs/playwright/revealjs/axe-accessibility-dark.qmd new file mode 100644 index 00000000000..00ea7ba7fad --- /dev/null +++ b/tests/docs/playwright/revealjs/axe-accessibility-dark.qmd @@ -0,0 +1,17 @@ +--- +format: + revealjs: + theme: dark + axe: + output: document +--- + +## Slide 1 + +Content for axe accessibility testing on dark theme. + +This text violates contrast rules: [insufficient contrast.]{style="color: #333; background: #222;"}. + +## Slide 2 + +More content to check. diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index 2612e575459..ecbffce785e 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -58,6 +58,11 @@ const testCases: AxeTestCase[] = [ { format: 'revealjs', outputMode: 'json', url: '/revealjs/axe-json.html', expectedViolation: 'link-name' }, + // 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' }, + // 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' }, @@ -85,6 +90,16 @@ test.describe('Axe accessibility checking', () => { const reportText = await axeReport.textContent(); expect(reportText).toContain(violationText[expectedViolation].document); + // Verify report overlay CSS properties + await expect(axeReport).toHaveCSS('z-index', '9999'); + await expect(axeReport).toHaveCSS('overflow-y', 'auto'); + + // Background must not be transparent + const bgColor = await axeReport.evaluate(el => + window.getComputedStyle(el).getPropertyValue('background-color') + ); + expect(bgColor).not.toBe('rgba(0, 0, 0, 0)'); + } else if (outputMode === 'console') { const messages = await collectConsoleLogs(page); await page.goto(url, { waitUntil: 'networkidle' }); From 23ea4e0712e1b91c040e11469cbae6212bb77461 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 26 Feb 2026 14:11:23 +0100 Subject: [PATCH 09/35] Add Sass theming documentation for cross-format styling Rules file auto-loads when touching Sass-related files and flags the key constraint: RevealJS sass-bundles compile separately from the theme. llm-docs reference covers the full compilation pipeline, CSS custom property bridge, and debugging techniques. --- .claude/rules/formats/sass-theming.md | 22 +++ llm-docs/sass-theming-architecture.md | 211 ++++++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 .claude/rules/formats/sass-theming.md create mode 100644 llm-docs/sass-theming-architecture.md diff --git a/.claude/rules/formats/sass-theming.md b/.claude/rules/formats/sass-theming.md new file mode 100644 index 00000000000..2d08f263e43 --- /dev/null +++ b/.claude/rules/formats/sass-theming.md @@ -0,0 +1,22 @@ +--- +paths: + - "src/core/sass*" + - "src/format/html/format-html-scss*" + - "src/format/html/format-html-axe*" + - "src/format/reveal/format-reveal-theme*" + - "src/format/dashboard/format-dashboard-shared*" + - "src/resources/formats/**/*.scss" +--- + +# Sass Theming + +RevealJS sass-bundles compile separately from the theme (`format-reveal-theme.ts`), +so theme variables aren't in scope. Use CSS custom properties from `exposer.scss`: +`--r-background-color`, `--r-main-color`, `--r-heading-color`, etc. + +For cross-format CSS (works in both Bootstrap and RevealJS): +```scss +background-color: var(--r-background-color, $body-bg); +``` + +Read `llm-docs/sass-theming-architecture.md` for full compilation pipeline details. diff --git a/llm-docs/sass-theming-architecture.md b/llm-docs/sass-theming-architecture.md new file mode 100644 index 00000000000..7b516c92841 --- /dev/null +++ b/llm-docs/sass-theming-architecture.md @@ -0,0 +1,211 @@ +--- +main_commit: ee0f68be1 +analyzed_date: 2026-02-26 +key_files: + - src/core/sass.ts + - src/format/html/format-html-scss.ts + - src/format/reveal/format-reveal-theme.ts + - src/format/dashboard/format-dashboard-shared.ts + - src/command/render/pandoc-html.ts + - src/resources/formats/revealjs/reveal/css/theme/template/exposer.scss +--- + +# Sass Theming Architecture + +How Quarto compiles Sass for HTML-based formats and the architectural constraints that affect cross-format styling. + +## Sass Bundle Structure + +Each `SassBundle` has five layer types compiled in specific order: + +1. **uses** — `@use` directives for Sass modules +2. **functions** — Sass functions +3. **defaults** — Variables with `!default` flag +4. **mixins** — Reusable Sass mixins +5. **rules** — CSS rules and selectors + +Bundles are grouped by `dependency` field and compiled together. + +## Compilation Order + +**Source:** `src/core/sass.ts`, `compileSass()` + +``` +Uses: framework → quarto → user +Functions: framework → quarto → user +Defaults: user → quarto (REVERSED) → framework (REVERSED) +Mixins: framework → quarto → user +Rules: framework → quarto → user +``` + +Defaults are reversed because Sass `!default` means "set only if not already defined" — first definition wins. User defaults come first so they take priority. + +## Bootstrap Pipeline (HTML, Dashboard) + +**Key file:** `src/format/html/format-html-scss.ts`, `layerQuartoScss()` + +All sass-bundles with `dependency: "bootstrap"` compile in a single Sass invocation together with: +- Bootstrap framework layer (variables, functions, mixins, rules) +- Quarto's `_bootstrap-variables.scss` defaults +- User theme customizations +- YAML metadata variables (via `pandocVariablesToThemeDefaults()`) + +Because everything compiles together, theme variables like `$body-bg` and `$body-color` are in scope for all sass-bundle rules. + +### Key variables available + +| Variable | Default | Purpose | +|----------|---------|---------| +| `$body-bg` | `#fff` | Page background | +| `$body-color` | `#212529` | Main text color | +| `$link-color` | varies | Link color | +| `$border-color` | varies | Border color | +| `$card-bg` | varies | Card background (Dashboard) | + +Dark mode: Variables adjust automatically via Bootstrap's dark mode system. + +Dashboard uses Bootstrap theming — extends `htmlFormat()` in `format-dashboard.ts` and sets `dependency: "bootstrap"` in `format-dashboard-shared.ts`. + +### YAML metadata mapping + +**Source:** `pandocVariablesToThemeDefaults()` in `format-html-scss.ts` + +| YAML key | Sass variable | +|----------|---------------| +| `backgroundcolor` | `$body-bg` | +| `fontcolor` | `$body-color` | +| `linkcolor` | `$link-color` | +| `mainfont` | `$font-family-base` | +| `monofont` | `$font-family-code` | + +## RevealJS Pipeline (Two-Pass Compilation) + +RevealJS theme and sass-bundles compile in **separate invocations** of the Sass compiler. + +### Why two passes? + +RevealJS theme compilation happens during format resolution (`format-reveal-theme.ts`), before Pandoc rendering starts. Sass-bundles come from `formatExtras()` during Pandoc rendering, after the theme is already compiled. This ordering means bundles can't compile with the theme — the theme context no longer exists when bundles are processed. + +The solution: `exposer.scss` bridges the gap by exporting theme values as CSS custom properties at runtime. + +### Pass 1 — Theme compilation + +**Key file:** `src/format/reveal/format-reveal-theme.ts`, `revealTheme()` + +- Compiles the chosen theme (built-in or custom `.scss`) with user customizations +- All theme variables (`$body-bg`, `$backgroundColor`, etc.) are in scope +- `exposer.scss` runs, setting CSS custom properties on `:root` +- Output: `quarto-{hash}.css` theme file + +### Pass 2 — Sass-bundle compilation + +**Key file:** `src/command/render/pandoc-html.ts` + +- Processes sass-bundles with `dependency: "reveal-theme"` +- **Completely separate Sass context** — theme variables from Pass 1 are NOT in scope +- Bundles have their own variables (uses, functions, defaults, mixins, rules) but nothing from the theme compilation +- Any `!default` values in the bundle's defaults are the actual values used, regardless of what the theme set + +### Bridging the gap: CSS custom properties + +RevealJS themes expose variables at runtime via `exposer.scss`: + +**Source:** `src/resources/formats/revealjs/reveal/css/theme/template/exposer.scss` + +| CSS Custom Property | Sass Source | Type | +|---------------------|-------------|------| +| `--r-background-color` | `$backgroundColor` | Color | +| `--r-main-color` | `$mainColor` | Color | +| `--r-heading-color` | `$headingColor` | Color | +| `--r-link-color` | `$linkColor` | Color | +| `--r-link-color-dark` | `darken($linkColor, 15%)` | Color | +| `--r-overlay-element-bg-color` | `$overlayElementBgColor` | Raw RGB (e.g., `240, 240, 240`) | +| `--r-overlay-element-fg-color` | `$overlayElementFgColor` | Raw RGB | + +Note: Overlay element variables store raw RGB values, not hex. Use as: `rgba(var(--r-overlay-element-bg-color), 0.95)`. + +### RevealJS theme variables + +**Source:** `src/resources/formats/revealjs/quarto.scss` + +```scss +$body-bg: #fff !default; +$body-color: #222 !default; +$backgroundColor: $body-bg !default; +$mainColor: $body-color !default; +``` + +Dark themes (e.g., `themes/dark.scss`) override these: +```scss +$body-bg: #191919 !default; +$body-color: #fff !default; +``` + +## Cross-Format CSS Pattern + +For sass-bundles that must produce correct CSS across both Bootstrap and RevealJS: + +```scss +background-color: var(--r-background-color, $body-bg); +color: var(--r-main-color, $body-color); +``` + +- **RevealJS:** `--r-background-color` exists (set by exposer.scss at runtime) → uses theme value +- **Bootstrap:** `--r-background-color` doesn't exist → CSS fallback → uses `$body-bg` (compiled from theme) + +Both formats get the correct theme color, but via different mechanisms: runtime CSS custom property (RevealJS) vs compile-time Sass variable (Bootstrap). + +## Debugging + +### Save compiled SCSS + +```bash +export QUARTO_SAVE_SCSS=/tmp/debug +quarto render document.qmd +# Inspect /tmp/debug-1.scss for layer boundaries +``` + +The saved file includes `// quarto-scss-analysis-annotation` comments showing which layer contributed each section. + +### Verify final variable values + +Add to theme or sass-bundle rules: + +```scss +:root { + --debug-body-bg: #{$body-bg}; + --debug-body-color: #{$body-color}; +} +``` + +Inspect computed CSS custom properties in browser DevTools. + +## Variable Resolution Example + +Given: RevealJS document with `theme: dark` and an axe sass-bundle defining `$body-bg: #fff !default` + +**Pass 1 (theme):** +- `dark.scss`: `$body-bg: #191919 !default` → `$backgroundColor: #191919` +- `exposer.scss`: `--r-background-color: #191919` on `:root` + +**Pass 2 (axe bundle — separate Sass invocation):** +- Bundle defaults: `$body-bg: #fff !default` → resolves to `#fff` (theme value is NOT in scope) +- Bundle rules: `background-color: var(--r-background-color, #fff)` +- CSS output: `background-color: var(--r-background-color, #fff)` + +**Browser runtime:** +- `--r-background-color` is `#191919` (from theme CSS in Pass 1) → report gets dark background +- The `#fff` Sass fallback is never used because the CSS custom property takes precedence + +## Key Source Files + +| File | Role | +|------|------| +| `src/core/sass.ts` | Sass compilation pipeline, `compileSass()` | +| `src/format/html/format-html-scss.ts` | Bootstrap layer composition, `layerQuartoScss()` | +| `src/format/reveal/format-reveal-theme.ts` | RevealJS theme compilation, `revealTheme()` | +| `src/format/dashboard/format-dashboard-shared.ts` | Dashboard sass layer (Bootstrap dependency) | +| `src/command/render/pandoc-html.ts` | Sass-bundle grouping and compilation | +| `src/resources/formats/revealjs/quarto.scss` | RevealJS Sass defaults and mappings | +| `src/resources/formats/revealjs/reveal/css/theme/template/exposer.scss` | CSS custom property exposure | +| `src/resources/formats/html/bootstrap/_bootstrap-variables.scss` | Quarto Bootstrap variable defaults | From 526b9325f9158b0887d4d966e758d72868a5463b Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 26 Feb 2026 14:33:18 +0100 Subject: [PATCH 10/35] Verify dark theme uses CSS bridge, not Sass fallback Adds format-specific assertion for revealjs-dark test case: background must not be rgb(255, 255, 255), ensuring the CSS custom property bridge resolves to the theme color instead of the compile-time Sass fallback. --- tests/integration/playwright/tests/axe-accessibility.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index ecbffce785e..ad2134de545 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -100,6 +100,11 @@ test.describe('Axe accessibility checking', () => { ); expect(bgColor).not.toBe('rgba(0, 0, 0, 0)'); + // Dark theme: verify CSS custom property bridge uses theme color, not Sass fallback + if (format === 'revealjs-dark') { + expect(bgColor).not.toBe('rgb(255, 255, 255)'); + } + } else if (outputMode === 'console') { const messages = await collectConsoleLogs(page); await page.goto(url, { waitUntil: 'networkidle' }); From 9ade887df13269686aca5d8d201ca820731b9ba6 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 26 Feb 2026 14:36:10 +0100 Subject: [PATCH 11/35] Update revealjs axe test to expect report as dedicated slide RevealJS document mode now expects the axe report inside a section.quarto-axe-report-slide with scrollable class and static positioning, rather than a fixed overlay with z-index: 9999. HTML/dashboard document mode assertions unchanged. --- .../tests/axe-accessibility.spec.ts | 54 ++++++++++++------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index ad2134de545..6017a4aec67 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -85,24 +85,42 @@ test.describe('Axe accessibility checking', () => { if (outputMode === 'document') { await page.goto(url, { waitUntil: 'networkidle' }); - const axeReport = page.locator('.quarto-axe-report'); - await expect(axeReport).toBeVisible({ timeout: 10000 }); - const reportText = await axeReport.textContent(); - expect(reportText).toContain(violationText[expectedViolation].document); - - // Verify report overlay CSS properties - await expect(axeReport).toHaveCSS('z-index', '9999'); - await expect(axeReport).toHaveCSS('overflow-y', 'auto'); - - // Background must not be transparent - const bgColor = await axeReport.evaluate(el => - window.getComputedStyle(el).getPropertyValue('background-color') - ); - expect(bgColor).not.toBe('rgba(0, 0, 0, 0)'); - - // Dark theme: verify CSS custom property bridge uses theme color, not Sass fallback - if (format === 'revealjs-dark') { - expect(bgColor).not.toBe('rgb(255, 255, 255)'); + + if (format.startsWith('revealjs')) { + // RevealJS: report appears as a dedicated slide, not a fixed overlay + const reportSlide = page.locator('section.quarto-axe-report-slide'); + await expect(reportSlide).toBeAttached({ timeout: 10000 }); + await expect(reportSlide).toHaveClass(/scrollable/); + + // Slide has a title + const title = reportSlide.locator('h2'); + await expect(title).toHaveText('Accessibility Report'); + + // Report content is inside the slide + const axeReport = reportSlide.locator('.quarto-axe-report'); + await expect(axeReport).toBeAttached(); + const reportText = await axeReport.textContent(); + expect(reportText).toContain(violationText[expectedViolation].document); + + // Report element is static (not fixed overlay) + await expect(axeReport).toHaveCSS('position', 'static'); + + } else { + // HTML/Dashboard: report appears as a fixed overlay + const axeReport = page.locator('.quarto-axe-report'); + await expect(axeReport).toBeVisible({ timeout: 10000 }); + const reportText = await axeReport.textContent(); + expect(reportText).toContain(violationText[expectedViolation].document); + + // Verify report overlay CSS properties + await expect(axeReport).toHaveCSS('z-index', '9999'); + await expect(axeReport).toHaveCSS('overflow-y', 'auto'); + + // Background must not be transparent + const bgColor = await axeReport.evaluate(el => + window.getComputedStyle(el).getPropertyValue('background-color') + ); + expect(bgColor).not.toBe('rgba(0, 0, 0, 0)'); } } else if (outputMode === 'console') { From a69266ac006900b51b9426dc813c750aab0e8a87 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 26 Feb 2026 14:39:21 +0100 Subject: [PATCH 12/35] Add revealjs axe report as dedicated slide with Reveal API navigation In RevealJS, the axe accessibility report now appears as a scrollable slide appended to the end of the deck instead of a fixed overlay. Hover-to-highlight uses Reveal.slide() to navigate to the offending slide rather than scrollIntoView(). Runtime detection (typeof Reveal !== 'undefined') selects the path; HTML and dashboard formats continue using the fixed overlay unchanged. After Reveal.sync(), re-navigate to the current slide to trigger visibility class assignment on the new slide (sync alone doesn't call updateSlides which sets past/present/future classes). Also fix hover highlight border to use red instead of $body-color, which was invisible on dark RevealJS themes. --- src/format/html/format-html-axe.ts | 80 +++++++++++++-------- src/resources/formats/html/axe/axe-check.js | 58 ++++++++++++++- 2 files changed, 106 insertions(+), 32 deletions(-) diff --git a/src/format/html/format-html-axe.ts b/src/format/html/format-html-axe.ts index 51e565042c0..1ef2aab73ab 100644 --- a/src/format/html/format-html-axe.ts +++ b/src/format/html/format-html-axe.ts @@ -24,9 +24,55 @@ export function axeFormatDependencies( // (which compiles in format-reveal-theme.ts), so the !default values // below are used instead of actual theme colors. This is a known // limitation - see GitHub issue for architectural context. - const sassDependency = isRevealjsOutput(format.pandoc) - ? "reveal-theme" - : "bootstrap"; + const isRevealjs = isRevealjsOutput(format.pandoc); + const sassDependency = isRevealjs ? "reveal-theme" : "bootstrap"; + + // Base overlay rules shared by all formats (also serves as fallback for revealjs) + const baseRules = ` +body div.quarto-axe-report { + position: fixed; + bottom: 3rem; + right: 3rem; + padding: 1rem; + border: 1px solid var(--r-main-color, $body-color); + z-index: 9999; + background-color: var(--r-background-color, $body-bg); + color: var(--r-main-color, $body-color); + max-height: 50vh; + overflow-y: auto; +} + +.quarto-axe-violation-help { padding-left: 0.5rem; } +.quarto-axe-violation-selector { padding-left: 1rem; } +.quarto-axe-violation-target { + padding: 0.5rem; + color: $link-color; + text-decoration: underline; + cursor: pointer; +} + +.quarto-axe-hover-highlight { + background-color: red; + border: 2px solid red; +}`; + + // RevealJS: override overlay styles when report is embedded as a slide + const revealjsRules = isRevealjs + ? ` +.reveal .slides section.quarto-axe-report-slide { + text-align: left; + h2 { margin-bottom: 1rem; } + div.quarto-axe-report { + position: static; + padding: 0; + border: none; + background-color: transparent; + max-height: none; + overflow-y: visible; + z-index: auto; + } +}` + : ""; return { [kIncludeInHeader]: [ @@ -58,33 +104,7 @@ $link-color: #2a76dd !default; `, functions: "", mixins: "", - rules: ` -body div.quarto-axe-report { - position: fixed; - bottom: 3rem; - right: 3rem; - padding: 1rem; - border: 1px solid var(--r-main-color, $body-color); - z-index: 9999; - background-color: var(--r-background-color, $body-bg); - color: var(--r-main-color, $body-color); - max-height: 50vh; - overflow-y: auto; -} - -.quarto-axe-violation-help { padding-left: 0.5rem; } -.quarto-axe-violation-selector { padding-left: 1rem; } -.quarto-axe-violation-target { - padding: 0.5rem; - color: $link-color; - text-decoration: underline; - cursor: pointer; -} - -.quarto-axe-hover-highlight { - background-color: red; - border: 1px solid $body-color; -}`, + rules: baseRules + revealjsRules, }], }, ], diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index 65e2a8410b0..429c844a032 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -42,6 +42,18 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { super(axeResult, options); } + navigateToElement(element) { + if (typeof Reveal !== "undefined") { + const section = element.closest("section"); + if (section) { + const indices = Reveal.getIndices(section); + Reveal.slide(indices.h, indices.v); + } + } else { + element.scrollIntoView({ behavior: "smooth", block: "center" }); + } + } + createViolationElement(violation) { const violationElement = document.createElement("div"); @@ -69,7 +81,7 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { nodeElement.addEventListener("mouseenter", () => { const element = document.querySelector(target); if (element) { - element.scrollIntoView({ behavior: "smooth", block: "center" }); + this.navigateToElement(element); element.classList.add("quarto-axe-hover-highlight"); setTimeout(() => { element.style.border = ""; @@ -92,7 +104,7 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { return violationElement; } - report() { + createReportElement() { const violations = this.axeResult.violations; const reportElement = document.createElement("div"); reportElement.className = "quarto-axe-report"; @@ -105,8 +117,50 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { violations.forEach((violation) => { reportElement.appendChild(this.createViolationElement(violation)); }); + return reportElement; + } + + createReportOverlay() { + const reportElement = this.createReportElement(); (document.querySelector("main") || document.body).appendChild(reportElement); } + + createReportSlide() { + const slidesContainer = document.querySelector(".reveal .slides"); + if (!slidesContainer) { + this.createReportOverlay(); + return; + } + + const section = document.createElement("section"); + section.className = "quarto-axe-report-slide scrollable"; + + const title = document.createElement("h2"); + title.textContent = "Accessibility Report"; + section.appendChild(title); + + section.appendChild(this.createReportElement()); + slidesContainer.appendChild(section); + + // sync() registers the new slide but doesn't update visibility classes. + // Re-navigating to the current slide triggers the visibility update so + // the report slide gets the correct past/present/future class. + const indices = Reveal.getIndices(); + Reveal.sync(); + Reveal.slide(indices.h, indices.v); + } + + report() { + if (typeof Reveal !== "undefined") { + if (Reveal.isReady()) { + this.createReportSlide(); + } else { + Reveal.on("ready", () => this.createReportSlide()); + } + } else { + this.createReportOverlay(); + } + } } const reporters = { From 03ff36a3abe27db00165f6cb0738a4b632f040b2 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 26 Feb 2026 16:08:31 +0100 Subject: [PATCH 13/35] Scan all RevealJS slides for axe violations and improve report UX RevealJS sets hidden/aria-hidden on non-visible slides, causing axe to only check the first slide. Temporarily remove these attributes before axe.run() and restore them after, so all slides get checked. Change violation target interaction from hover-to-navigate (jarring) to click-to-navigate for RevealJS. HTML/dashboard keep hover behavior. Add slide class to report section for scrollable CSS compatibility. Reduce font size on report slide for readability. Add playwright tests for cross-slide scanning (image-alt detected), presentation state restoration, and click-to-navigate behavior. --- src/format/html/format-html-axe.ts | 13 ++- src/resources/formats/html/axe/axe-check.js | 81 ++++++++++++++----- .../revealjs/axe-accessibility-dark.qmd | 2 + .../playwright/revealjs/axe-accessibility.qmd | 2 + .../tests/axe-accessibility.spec.ts | 66 +++++++++++++++ 5 files changed, 142 insertions(+), 22 deletions(-) diff --git a/src/format/html/format-html-axe.ts b/src/format/html/format-html-axe.ts index 1ef2aab73ab..5ba0ec0fd8f 100644 --- a/src/format/html/format-html-axe.ts +++ b/src/format/html/format-html-axe.ts @@ -61,7 +61,11 @@ body div.quarto-axe-report { ? ` .reveal .slides section.quarto-axe-report-slide { text-align: left; - h2 { margin-bottom: 1rem; } + font-size: 0.55em; + h2 { + margin-bottom: 0.5em; + font-size: 1.8em; + } div.quarto-axe-report { position: static; padding: 0; @@ -71,6 +75,13 @@ body div.quarto-axe-report { overflow-y: visible; z-index: auto; } + .quarto-axe-violation-description { + margin-top: 0.6em; + font-weight: bold; + } + .quarto-axe-violation-target { + font-size: 0.9em; + } }` : ""; diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index 429c844a032..aa44d2b0da9 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -78,25 +78,35 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { targetElement.className = "quarto-axe-violation-target"; targetElement.innerText = target; nodeElement.appendChild(targetElement); - nodeElement.addEventListener("mouseenter", () => { - const element = document.querySelector(target); - if (element) { - this.navigateToElement(element); - element.classList.add("quarto-axe-hover-highlight"); - setTimeout(() => { - element.style.border = ""; - }, 2000); - } - }); - nodeElement.addEventListener("mouseleave", () => { - const element = document.querySelector(target); - if (element) { - element.classList.remove("quarto-axe-hover-highlight"); - } - }); - nodeElement.addEventListener("click", () => { - console.log(document.querySelector(target)); - }); + const isReveal = typeof Reveal !== "undefined"; + if (isReveal) { + // RevealJS: click navigates to the slide and highlights the element + nodeElement.addEventListener("click", () => { + const element = document.querySelector(target); + if (element) { + this.navigateToElement(element); + element.classList.add("quarto-axe-hover-highlight"); + setTimeout(() => { + element.classList.remove("quarto-axe-hover-highlight"); + }, 3000); + } + }); + } else { + // HTML/Dashboard: hover scrolls to and highlights the element + nodeElement.addEventListener("mouseenter", () => { + const element = document.querySelector(target); + if (element) { + this.navigateToElement(element); + element.classList.add("quarto-axe-hover-highlight"); + } + }); + nodeElement.addEventListener("mouseleave", () => { + const element = document.querySelector(target); + if (element) { + element.classList.remove("quarto-axe-hover-highlight"); + } + }); + } nodeElement.appendChild(targetElement); } nodesElement.appendChild(nodeElement); @@ -133,7 +143,7 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { } const section = document.createElement("section"); - section.className = "quarto-axe-report-slide scrollable"; + section.className = "slide quarto-axe-report-slide scrollable"; const title = document.createElement("h2"); title.textContent = "Accessibility Report"; @@ -173,8 +183,36 @@ class QuartoAxeChecker { constructor(opts) { this.options = opts; } + // In RevealJS, only the current slide is accessible to axe-core because + // non-visible slides have hidden and aria-hidden attributes. Temporarily + // remove these so axe can check all slides, then restore them. + revealUnhideSlides() { + const slides = document.querySelectorAll(".reveal .slides > section"); + if (slides.length === 0) return null; + const saved = []; + slides.forEach((s) => { + saved.push({ + el: s, + hidden: s.hasAttribute("hidden"), + ariaHidden: s.getAttribute("aria-hidden"), + }); + s.removeAttribute("hidden"); + s.removeAttribute("aria-hidden"); + }); + return saved; + } + + revealRestoreSlides(saved) { + if (!saved) return; + saved.forEach(({ el, hidden, ariaHidden }) => { + if (hidden) el.setAttribute("hidden", ""); + if (ariaHidden !== null) el.setAttribute("aria-hidden", ariaHidden); + }); + } + async init() { const axe = (await import("https://cdn.skypack.dev/pin/axe-core@v4.10.3-aVOFXWsJaCpVrtv89pCa/mode=imports,min/optimized/axe-core.js")).default; + const saved = this.revealUnhideSlides(); const result = await axe.run({ exclude: [ // https://github.com/microsoft/tabster/issues/288 @@ -182,8 +220,9 @@ class QuartoAxeChecker { // all tabster elements "[data-tabster-dummy]" ], - preload: { assets: ['cssom'], timeout: 50000 } + preload: { assets: ['cssom'], timeout: 50000 } }); + this.revealRestoreSlides(saved); const reporter = this.options === true ? new QuartoAxeConsoleReporter(result) : new reporters[this.options.output](result, this.options); await reporter.report(); document.body.setAttribute('data-quarto-axe-complete', 'true'); diff --git a/tests/docs/playwright/revealjs/axe-accessibility-dark.qmd b/tests/docs/playwright/revealjs/axe-accessibility-dark.qmd index 00ea7ba7fad..0d75ea566a8 100644 --- a/tests/docs/playwright/revealjs/axe-accessibility-dark.qmd +++ b/tests/docs/playwright/revealjs/axe-accessibility-dark.qmd @@ -12,6 +12,8 @@ Content for axe accessibility testing on dark theme. This text violates contrast rules: [insufficient contrast.]{style="color: #333; background: #222;"}. + + ## Slide 2 More content to check. diff --git a/tests/docs/playwright/revealjs/axe-accessibility.qmd b/tests/docs/playwright/revealjs/axe-accessibility.qmd index 60ddd667a28..3f55df3ae82 100644 --- a/tests/docs/playwright/revealjs/axe-accessibility.qmd +++ b/tests/docs/playwright/revealjs/axe-accessibility.qmd @@ -11,6 +11,8 @@ Content for axe accessibility testing. This text violates contrast rules: [insufficient contrast.]{style="color: #eee; background: #fff;"}. + + ## Slide 2 More content to check. diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index 6017a4aec67..6f0041a3df5 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -73,6 +73,7 @@ const testCases: AxeTestCase[] = [ const violationText: Record = { 'color-contrast': { document: 'color contrast', console: 'contrast' }, 'link-name': { document: 'discernible text', console: 'discernible text' }, + 'image-alt': { document: 'alternative text', console: 'alternative text' }, }; // -- Tests -- @@ -141,3 +142,68 @@ test.describe('Axe accessibility checking', () => { }); } }); + +test.describe('RevealJS axe — cross-slide scanning and state restoration', () => { + const revealjsUrl = '/revealjs/axe-accessibility.html'; + + test('detects image-alt violation on non-visible slide', async ({ page }) => { + await page.goto(revealjsUrl, { waitUntil: 'networkidle' }); + const reportSlide = page.locator('section.quarto-axe-report-slide'); + await expect(reportSlide).toBeAttached({ timeout: 10000 }); + const reportText = await reportSlide.textContent(); + expect(reportText).toContain(violationText['image-alt'].document); + }); + + test('restores presentation state after axe completes', async ({ page }) => { + await page.goto(revealjsUrl, { waitUntil: 'networkidle' }); + await waitForAxeCompletion(page); + + // Reveal state should be valid: on slide 0 (first slide) + const state = await page.evaluate(() => Reveal.getState()); + expect(state.indexh).toBe(0); + + // Non-present slides must have hidden and aria-hidden restored + const slideState = await page.evaluate(() => { + return Reveal.getSlides().map((s: Element) => ({ + id: s.id || s.className.substring(0, 30), + isPresent: s.classList.contains('present'), + hidden: s.hasAttribute('hidden'), + ariaHidden: s.getAttribute('aria-hidden'), + })); + }); + + for (const slide of slideState) { + if (slide.isPresent) { + expect(slide.hidden, `Present slide "${slide.id}" should not be hidden`).toBe(false); + expect(slide.ariaHidden, `Present slide "${slide.id}" should not have aria-hidden`).toBeNull(); + } else { + expect(slide.hidden, `Non-present slide "${slide.id}" should be hidden`).toBe(true); + expect(slide.ariaHidden, `Non-present slide "${slide.id}" should have aria-hidden`).toBe('true'); + } + } + }); + + test('click navigates to slide containing violation element', async ({ page }) => { + await page.goto(revealjsUrl, { waitUntil: 'networkidle' }); + const reportSlide = page.locator('section.quarto-axe-report-slide'); + await expect(reportSlide).toBeAttached({ timeout: 10000 }); + + // Navigate to the report slide (last slide) and wait for transition + await page.evaluate(() => { + Reveal.slide(Reveal.getTotalSlides() - 1); + }); + await expect(reportSlide).toHaveClass(/present/); + + // Click the img violation target — the img is on Slide 1 (index 0) + const imgTarget = reportSlide.locator('.quarto-axe-violation-target', { hasText: 'img' }); + await imgTarget.click(); + + // After click, Reveal should have navigated to Slide 1 (index 0) + const afterClick = await page.evaluate(() => Reveal.getIndices().h); + expect(afterClick).toBe(0); + + // The img element should have the highlight class + const highlightedImg = page.locator('img.quarto-axe-hover-highlight'); + await expect(highlightedImg).toBeAttached({ timeout: 3000 }); + }); +}); From 7df6262959b32b4199324ab506bea43a9d4173a8 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 26 Feb 2026 17:42:00 +0100 Subject: [PATCH 14/35] Fix cross-slide scanning test and minor axe-check.js issues Move from Slide 1 to Slide 2 in revealjs test docs so the cross-slide scanning test validates that revealUnhideSlides() actually works (previously the img was on the visible slide, passing without it). Add scrollIntoView fallback in navigateToElement when closest("section") returns null. Remove redundant appendChild call. --- src/resources/formats/html/axe/axe-check.js | 3 ++- tests/docs/playwright/revealjs/axe-accessibility-dark.qmd | 4 ++-- tests/docs/playwright/revealjs/axe-accessibility.qmd | 4 ++-- .../integration/playwright/tests/axe-accessibility.spec.ts | 6 +++--- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index aa44d2b0da9..afddfc5afca 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -48,6 +48,8 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { if (section) { const indices = Reveal.getIndices(section); Reveal.slide(indices.h, indices.v); + } else { + element.scrollIntoView({ behavior: "smooth", block: "center" }); } } else { element.scrollIntoView({ behavior: "smooth", block: "center" }); @@ -107,7 +109,6 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { } }); } - nodeElement.appendChild(targetElement); } nodesElement.appendChild(nodeElement); } diff --git a/tests/docs/playwright/revealjs/axe-accessibility-dark.qmd b/tests/docs/playwright/revealjs/axe-accessibility-dark.qmd index 0d75ea566a8..4dafc222c8a 100644 --- a/tests/docs/playwright/revealjs/axe-accessibility-dark.qmd +++ b/tests/docs/playwright/revealjs/axe-accessibility-dark.qmd @@ -12,8 +12,8 @@ Content for axe accessibility testing on dark theme. This text violates contrast rules: [insufficient contrast.]{style="color: #333; background: #222;"}. - - ## Slide 2 More content to check. + + diff --git a/tests/docs/playwright/revealjs/axe-accessibility.qmd b/tests/docs/playwright/revealjs/axe-accessibility.qmd index 3f55df3ae82..cd2c446341a 100644 --- a/tests/docs/playwright/revealjs/axe-accessibility.qmd +++ b/tests/docs/playwright/revealjs/axe-accessibility.qmd @@ -11,8 +11,8 @@ Content for axe accessibility testing. This text violates contrast rules: [insufficient contrast.]{style="color: #eee; background: #fff;"}. - - ## Slide 2 More content to check. + + diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index 6f0041a3df5..517b86f4306 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -194,13 +194,13 @@ test.describe('RevealJS axe — cross-slide scanning and state restoration', () }); await expect(reportSlide).toHaveClass(/present/); - // Click the img violation target — the img is on Slide 1 (index 0) + // Click the img violation target — the img is on Slide 2 (index 1) const imgTarget = reportSlide.locator('.quarto-axe-violation-target', { hasText: 'img' }); await imgTarget.click(); - // After click, Reveal should have navigated to Slide 1 (index 0) + // After click, Reveal should have navigated to Slide 2 (index 1) const afterClick = await page.evaluate(() => Reveal.getIndices().h); - expect(afterClick).toBe(0); + expect(afterClick).toBe(1); // The img element should have the highlight class const highlightedImg = page.locator('img.quarto-axe-hover-highlight'); From 5e26dacdef9fd3ad6253f1d295ec5781fd37c959 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 26 Feb 2026 18:26:58 +0100 Subject: [PATCH 15/35] Fix deferred report timing, vertical slide scanning, and axe error safety Make report() return a Promise for the deferred Reveal path so data-quarto-axe-complete is only set after the report slide exists. Unhide all section elements (not just top-level) before axe.run() so vertical slides are also scanned. RevealJS manages hidden/aria-hidden on nested sections independently from their parents. Wrap axe.run() in try/finally to restore slide state if axe throws. Add tests for completion signal invariant and vertical slide scanning. --- src/resources/formats/html/axe/axe-check.js | 33 ++++++++++++------- .../revealjs/axe-accessibility-vertical.qmd | 25 ++++++++++++++ .../tests/axe-accessibility.spec.ts | 17 ++++++++++ 3 files changed, 63 insertions(+), 12 deletions(-) create mode 100644 tests/docs/playwright/revealjs/axe-accessibility-vertical.qmd diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index afddfc5afca..567daa9dff5 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -166,7 +166,12 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { if (Reveal.isReady()) { this.createReportSlide(); } else { - Reveal.on("ready", () => this.createReportSlide()); + return new Promise((resolve) => { + Reveal.on("ready", () => { + this.createReportSlide(); + resolve(); + }); + }); } } else { this.createReportOverlay(); @@ -188,7 +193,7 @@ class QuartoAxeChecker { // non-visible slides have hidden and aria-hidden attributes. Temporarily // remove these so axe can check all slides, then restore them. revealUnhideSlides() { - const slides = document.querySelectorAll(".reveal .slides > section"); + const slides = document.querySelectorAll(".reveal .slides section"); if (slides.length === 0) return null; const saved = []; slides.forEach((s) => { @@ -214,16 +219,20 @@ class QuartoAxeChecker { async init() { const axe = (await import("https://cdn.skypack.dev/pin/axe-core@v4.10.3-aVOFXWsJaCpVrtv89pCa/mode=imports,min/optimized/axe-core.js")).default; const saved = this.revealUnhideSlides(); - const result = await axe.run({ - exclude: [ - // https://github.com/microsoft/tabster/issues/288 - // MS has claimed they won't fix this, so we need to add an exclusion to - // all tabster elements - "[data-tabster-dummy]" - ], - preload: { assets: ['cssom'], timeout: 50000 } - }); - this.revealRestoreSlides(saved); + let result; + try { + result = await axe.run({ + exclude: [ + // https://github.com/microsoft/tabster/issues/288 + // MS has claimed they won't fix this, so we need to add an exclusion to + // all tabster elements + "[data-tabster-dummy]" + ], + preload: { assets: ['cssom'], timeout: 50000 } + }); + } finally { + this.revealRestoreSlides(saved); + } const reporter = this.options === true ? new QuartoAxeConsoleReporter(result) : new reporters[this.options.output](result, this.options); await reporter.report(); document.body.setAttribute('data-quarto-axe-complete', 'true'); diff --git a/tests/docs/playwright/revealjs/axe-accessibility-vertical.qmd b/tests/docs/playwright/revealjs/axe-accessibility-vertical.qmd new file mode 100644 index 00000000000..cdb590150bb --- /dev/null +++ b/tests/docs/playwright/revealjs/axe-accessibility-vertical.qmd @@ -0,0 +1,25 @@ +--- +format: + revealjs: + navigation-mode: vertical + axe: + output: document +--- + +# Stack 1 + +## Visible Slide + +Content on the first vertical slide. + +## Hidden Vertical Slide + +This vertical slide is not visible on load. + + + +# Stack 2 + +## Another Slide + +More content to check. diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index 517b86f4306..a08767a6c9b 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -146,6 +146,15 @@ test.describe('Axe accessibility checking', () => { test.describe('RevealJS axe — cross-slide scanning and state restoration', () => { const revealjsUrl = '/revealjs/axe-accessibility.html'; + test('report slide exists when completion signal fires', async ({ page }) => { + await page.goto(revealjsUrl, { waitUntil: 'networkidle' }); + await waitForAxeCompletion(page); + const reportExists = await page.evaluate(() => + document.querySelector('section.quarto-axe-report-slide') !== null + ); + expect(reportExists).toBe(true); + }); + test('detects image-alt violation on non-visible slide', async ({ page }) => { await page.goto(revealjsUrl, { waitUntil: 'networkidle' }); const reportSlide = page.locator('section.quarto-axe-report-slide'); @@ -206,4 +215,12 @@ test.describe('RevealJS axe — cross-slide scanning and state restoration', () const highlightedImg = page.locator('img.quarto-axe-hover-highlight'); await expect(highlightedImg).toBeAttached({ timeout: 3000 }); }); + + test('detects image-alt violation on hidden vertical slide', async ({ page }) => { + await page.goto('/revealjs/axe-accessibility-vertical.html', { waitUntil: 'networkidle' }); + const reportSlide = page.locator('section.quarto-axe-report-slide'); + await expect(reportSlide).toBeAttached({ timeout: 10000 }); + const reportText = await reportSlide.textContent(); + expect(reportText).toContain(violationText['image-alt'].document); + }); }); From 95a1bbaa8f268372cf59298446d1bdfc04b7b4c9 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 26 Feb 2026 18:36:27 +0100 Subject: [PATCH 16/35] Use web-first Playwright assertions throughout axe tests Replace textContent() + toContain() with toContainText() for auto-retrying assertions. Replace page.evaluate() DOM check with toBeAttached(). Replace page.waitForSelector with locator.waitFor(). Replace evaluate(getComputedStyle) with not.toHaveCSS(). Add descriptive message to console output assertion. --- .../tests/axe-accessibility.spec.ts | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index a08767a6c9b..b999bbe56d1 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -11,7 +11,7 @@ async function collectConsoleLogs(page: Page): Promise { } async function waitForAxeCompletion(page: Page, timeout = 15000): Promise { - await page.waitForSelector('[data-quarto-axe-complete]', { timeout }); + await page.locator('[data-quarto-axe-complete]').waitFor({ timeout }); } function findAxeJsonResult(messages: string[]): { violations: { id: string }[] } | undefined { @@ -100,8 +100,7 @@ test.describe('Axe accessibility checking', () => { // Report content is inside the slide const axeReport = reportSlide.locator('.quarto-axe-report'); await expect(axeReport).toBeAttached(); - const reportText = await axeReport.textContent(); - expect(reportText).toContain(violationText[expectedViolation].document); + await expect(axeReport).toContainText(violationText[expectedViolation].document); // Report element is static (not fixed overlay) await expect(axeReport).toHaveCSS('position', 'static'); @@ -110,25 +109,23 @@ test.describe('Axe accessibility checking', () => { // HTML/Dashboard: report appears as a fixed overlay const axeReport = page.locator('.quarto-axe-report'); await expect(axeReport).toBeVisible({ timeout: 10000 }); - const reportText = await axeReport.textContent(); - expect(reportText).toContain(violationText[expectedViolation].document); + await expect(axeReport).toContainText(violationText[expectedViolation].document); // Verify report overlay CSS properties await expect(axeReport).toHaveCSS('z-index', '9999'); await expect(axeReport).toHaveCSS('overflow-y', 'auto'); // Background must not be transparent - const bgColor = await axeReport.evaluate(el => - window.getComputedStyle(el).getPropertyValue('background-color') - ); - expect(bgColor).not.toBe('rgba(0, 0, 0, 0)'); + await expect(axeReport).not.toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); } } else if (outputMode === 'console') { const messages = await collectConsoleLogs(page); await page.goto(url, { waitUntil: 'networkidle' }); await waitForAxeCompletion(page); - expect(messages.some(m => m.toLowerCase().includes(violationText[expectedViolation].console))).toBe(true); + const expectedText = violationText[expectedViolation].console; + expect(messages.some(m => m.toLowerCase().includes(expectedText)), + `Expected console output to contain "${expectedText}"`).toBe(true); } else if (outputMode === 'json') { const messages = await collectConsoleLogs(page); @@ -149,18 +146,14 @@ test.describe('RevealJS axe — cross-slide scanning and state restoration', () test('report slide exists when completion signal fires', async ({ page }) => { await page.goto(revealjsUrl, { waitUntil: 'networkidle' }); await waitForAxeCompletion(page); - const reportExists = await page.evaluate(() => - document.querySelector('section.quarto-axe-report-slide') !== null - ); - expect(reportExists).toBe(true); + await expect(page.locator('section.quarto-axe-report-slide')).toBeAttached(); }); test('detects image-alt violation on non-visible slide', async ({ page }) => { await page.goto(revealjsUrl, { waitUntil: 'networkidle' }); const reportSlide = page.locator('section.quarto-axe-report-slide'); await expect(reportSlide).toBeAttached({ timeout: 10000 }); - const reportText = await reportSlide.textContent(); - expect(reportText).toContain(violationText['image-alt'].document); + await expect(reportSlide).toContainText(violationText['image-alt'].document); }); test('restores presentation state after axe completes', async ({ page }) => { @@ -220,7 +213,6 @@ test.describe('RevealJS axe — cross-slide scanning and state restoration', () await page.goto('/revealjs/axe-accessibility-vertical.html', { waitUntil: 'networkidle' }); const reportSlide = page.locator('section.quarto-axe-report-slide'); await expect(reportSlide).toBeAttached({ timeout: 10000 }); - const reportText = await reportSlide.textContent(); - expect(reportText).toContain(violationText['image-alt'].document); + await expect(reportSlide).toContainText(violationText['image-alt'].document); }); }); From 41f173f57014d3b3d02ea1ef2bd13471031f599b Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 10:28:01 +0100 Subject: [PATCH 17/35] Use Bootstrap offcanvas sidebar for dashboard axe report Dashboards now display the axe accessibility report in an offcanvas sidebar instead of a fixed overlay that covered dashboard cards. The offcanvas uses backdrop:false and scroll:true so dashboard content stays interactive and hover-to-highlight works. A floating toggle button allows closing and reopening the report panel. --- src/format/html/format-html-axe.ts | 29 ++++++++- src/resources/formats/html/axe/axe-check.js | 52 ++++++++++++++++ .../tests/axe-accessibility.spec.ts | 62 ++++++++++++++++++- 3 files changed, 141 insertions(+), 2 deletions(-) diff --git a/src/format/html/format-html-axe.ts b/src/format/html/format-html-axe.ts index 5ba0ec0fd8f..b3b889a98f0 100644 --- a/src/format/html/format-html-axe.ts +++ b/src/format/html/format-html-axe.ts @@ -85,6 +85,33 @@ body div.quarto-axe-report { }` : ""; + // Dashboard: report inside offcanvas sidebar (not fixed overlay) + const dashboardRules = ` +.quarto-dashboard .offcanvas.quarto-axe-offcanvas { + .quarto-axe-report { + position: static; + padding: 0; + border: none; + background-color: transparent; + max-height: none; + overflow-y: visible; + z-index: auto; + } +} +.quarto-axe-toggle { + position: fixed; + bottom: 1rem; + right: 1rem; + z-index: 1040; + border-radius: 50%; + width: 3rem; + height: 3rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; +}`; + return { [kIncludeInHeader]: [ temp.createFileFromString( @@ -115,7 +142,7 @@ $link-color: #2a76dd !default; `, functions: "", mixins: "", - rules: baseRules + revealjsRules, + rules: baseRules + revealjsRules + dashboardRules, }], }, ], diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index 567daa9dff5..6591d0928b8 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -161,6 +161,56 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { Reveal.slide(indices.h, indices.v); } + createReportOffcanvas() { + const offcanvas = document.createElement("div"); + offcanvas.className = "offcanvas offcanvas-end quarto-axe-offcanvas"; + offcanvas.id = "quarto-axe-offcanvas"; + offcanvas.tabIndex = -1; + offcanvas.setAttribute("aria-labelledby", "quarto-axe-offcanvas-label"); + offcanvas.setAttribute("data-bs-scroll", "true"); + offcanvas.setAttribute("data-bs-backdrop", "false"); + + const header = document.createElement("div"); + header.className = "offcanvas-header"; + + const title = document.createElement("h5"); + title.className = "offcanvas-title"; + title.id = "quarto-axe-offcanvas-label"; + title.textContent = "Accessibility Report"; + header.appendChild(title); + + const closeBtn = document.createElement("button"); + closeBtn.type = "button"; + closeBtn.className = "btn-close"; + closeBtn.setAttribute("data-bs-dismiss", "offcanvas"); + closeBtn.setAttribute("aria-label", "Close"); + header.appendChild(closeBtn); + + offcanvas.appendChild(header); + + const body = document.createElement("div"); + body.className = "offcanvas-body"; + body.appendChild(this.createReportElement()); + offcanvas.appendChild(body); + + document.body.appendChild(offcanvas); + + const toggle = document.createElement("button"); + toggle.className = "btn btn-dark quarto-axe-toggle"; + toggle.type = "button"; + toggle.setAttribute("data-bs-toggle", "offcanvas"); + toggle.setAttribute("data-bs-target", "#quarto-axe-offcanvas"); + toggle.setAttribute("aria-controls", "quarto-axe-offcanvas"); + + const icon = document.createElement("i"); + icon.className = "bi bi-universal-access"; + toggle.appendChild(icon); + + document.body.appendChild(toggle); + + new bootstrap.Offcanvas(offcanvas).show(); + } + report() { if (typeof Reveal !== "undefined") { if (Reveal.isReady()) { @@ -173,6 +223,8 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { }); }); } + } else if (document.body.classList.contains("quarto-dashboard")) { + this.createReportOffcanvas(); } else { this.createReportOverlay(); } diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index b999bbe56d1..3f9002e1395 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -105,8 +105,25 @@ test.describe('Axe accessibility checking', () => { // Report element is static (not fixed overlay) await expect(axeReport).toHaveCSS('position', 'static'); + } else if (format === 'dashboard') { + // Dashboard: report appears in Bootstrap offcanvas sidebar + const offcanvas = page.locator('#quarto-axe-offcanvas'); + await expect(offcanvas).toBeVisible({ timeout: 10000 }); + + // Report content is inside the offcanvas + const axeReport = offcanvas.locator('.quarto-axe-report'); + await expect(axeReport).toBeAttached(); + await expect(axeReport).toContainText(violationText[expectedViolation].document); + + // Toggle button exists + const toggle = page.locator('.quarto-axe-toggle'); + await expect(toggle).toBeVisible(); + + // Report is static inside offcanvas (not fixed overlay) + await expect(axeReport).toHaveCSS('position', 'static'); + } else { - // HTML/Dashboard: report appears as a fixed overlay + // HTML: report appears as a fixed overlay const axeReport = page.locator('.quarto-axe-report'); await expect(axeReport).toBeVisible({ timeout: 10000 }); await expect(axeReport).toContainText(violationText[expectedViolation].document); @@ -216,3 +233,46 @@ test.describe('RevealJS axe — cross-slide scanning and state restoration', () await expect(reportSlide).toContainText(violationText['image-alt'].document); }); }); + +test.describe('Dashboard axe — offcanvas interaction and highlight', () => { + const dashboardUrl = '/dashboard/axe-accessibility.html'; + + test('offcanvas can be closed and reopened via toggle button', async ({ page }) => { + await page.goto(dashboardUrl, { waitUntil: 'networkidle' }); + const offcanvas = page.locator('#quarto-axe-offcanvas'); + await expect(offcanvas).toBeVisible({ timeout: 10000 }); + + // Close via close button + await offcanvas.locator('.btn-close').click(); + await expect(offcanvas).not.toBeVisible(); + + // Toggle button should still be visible + const toggle = page.locator('.quarto-axe-toggle'); + await expect(toggle).toBeVisible(); + + // Reopen via toggle + await toggle.click(); + await expect(offcanvas).toBeVisible(); + }); + + test('hover highlights the corresponding dashboard element', async ({ page }) => { + await page.goto(dashboardUrl, { waitUntil: 'networkidle' }); + const offcanvas = page.locator('#quarto-axe-offcanvas'); + await expect(offcanvas).toBeVisible({ timeout: 10000 }); + + // Find the first violation target in the offcanvas and get its CSS selector text + const target = offcanvas.locator('.quarto-axe-violation-target').first(); + const selector = await target.textContent(); + + // Hover the target + await target.hover(); + + // The corresponding element in the dashboard should get highlight class + const highlighted = page.locator(`${selector}.quarto-axe-hover-highlight`); + await expect(highlighted).toBeAttached({ timeout: 3000 }); + + // Move mouse away — highlight should be removed + await page.mouse.move(0, 0); + await expect(highlighted).not.toBeAttached(); + }); +}); From d864c68317a46a23404ad0d5bfdd8fd208576823 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 10:34:19 +0100 Subject: [PATCH 18/35] Add aria-label to toggle button and scope toggle styles to dashboard Toggle button now has aria-label for screen readers, and .quarto-axe-toggle CSS is scoped under .quarto-dashboard for consistency with the offcanvas styles. --- src/format/html/format-html-axe.ts | 2 +- src/resources/formats/html/axe/axe-check.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/format/html/format-html-axe.ts b/src/format/html/format-html-axe.ts index b3b889a98f0..a368d28095e 100644 --- a/src/format/html/format-html-axe.ts +++ b/src/format/html/format-html-axe.ts @@ -98,7 +98,7 @@ body div.quarto-axe-report { z-index: auto; } } -.quarto-axe-toggle { +.quarto-dashboard .quarto-axe-toggle { position: fixed; bottom: 1rem; right: 1rem; diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index 6591d0928b8..b627c814abb 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -201,6 +201,7 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { toggle.setAttribute("data-bs-toggle", "offcanvas"); toggle.setAttribute("data-bs-target", "#quarto-axe-offcanvas"); toggle.setAttribute("aria-controls", "quarto-axe-offcanvas"); + toggle.setAttribute("aria-label", "Toggle accessibility report"); const icon = document.createElement("i"); icon.className = "bi bi-universal-access"; From 7d9007677064260c6339761bfb122d319c15f6d8 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 10:57:01 +0100 Subject: [PATCH 19/35] Extract highlight/unhighlight helpers in axe-check.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deduplicate the interaction logic in createViolationElement — both the RevealJS click path and the HTML/Dashboard hover path shared querySelector + navigateToElement + classList add/remove. Now uses highlightTarget() and unhighlightTarget() helpers. --- src/resources/formats/html/axe/axe-check.js | 45 +++++++++------------ 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index b627c814abb..e38793721ec 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -42,6 +42,19 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { super(axeResult, options); } + highlightTarget(target) { + const element = document.querySelector(target); + if (!element) return null; + this.navigateToElement(element); + element.classList.add("quarto-axe-hover-highlight"); + return element; + } + + unhighlightTarget(target) { + const element = document.querySelector(target); + if (element) element.classList.remove("quarto-axe-hover-highlight"); + } + navigateToElement(element) { if (typeof Reveal !== "undefined") { const section = element.closest("section"); @@ -80,34 +93,16 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { targetElement.className = "quarto-axe-violation-target"; targetElement.innerText = target; nodeElement.appendChild(targetElement); - const isReveal = typeof Reveal !== "undefined"; - if (isReveal) { - // RevealJS: click navigates to the slide and highlights the element + if (typeof Reveal !== "undefined") { + // RevealJS: click navigates to the slide and highlights briefly nodeElement.addEventListener("click", () => { - const element = document.querySelector(target); - if (element) { - this.navigateToElement(element); - element.classList.add("quarto-axe-hover-highlight"); - setTimeout(() => { - element.classList.remove("quarto-axe-hover-highlight"); - }, 3000); - } + const el = this.highlightTarget(target); + if (el) setTimeout(() => el.classList.remove("quarto-axe-hover-highlight"), 3000); }); } else { - // HTML/Dashboard: hover scrolls to and highlights the element - nodeElement.addEventListener("mouseenter", () => { - const element = document.querySelector(target); - if (element) { - this.navigateToElement(element); - element.classList.add("quarto-axe-hover-highlight"); - } - }); - nodeElement.addEventListener("mouseleave", () => { - const element = document.querySelector(target); - if (element) { - element.classList.remove("quarto-axe-hover-highlight"); - } - }); + // HTML/Dashboard: hover highlights while mouse is over the target + nodeElement.addEventListener("mouseenter", () => this.highlightTarget(target)); + nodeElement.addEventListener("mouseleave", () => this.unhighlightTarget(target)); } } nodesElement.appendChild(nodeElement); From b7f18c9a61bab3f8305e65b92e5fb5ef55db5785 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 11:46:46 +0100 Subject: [PATCH 20/35] Gate dashboard SCSS rules behind isDashboard conditional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the pattern used for revealjsRules — only emit dashboard- specific CSS when rendering a dashboard format, not for all HTML formats. Uses format.identifier["base-format"] since dashboards use pandoc.to="html" (not "dashboard"). --- src/format/html/format-html-axe.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/format/html/format-html-axe.ts b/src/format/html/format-html-axe.ts index a368d28095e..354f6ac86fa 100644 --- a/src/format/html/format-html-axe.ts +++ b/src/format/html/format-html-axe.ts @@ -5,7 +5,7 @@ */ import { kIncludeInHeader } from "../../config/constants.ts"; -import { isRevealjsOutput } from "../../config/format.ts"; +import { isHtmlDashboardOutput, isRevealjsOutput } from "../../config/format.ts"; import { Format, FormatExtras, kDependencies } from "../../config/types.ts"; import { formatResourcePath } from "../../core/resources.ts"; import { TempContext } from "../../core/temp-types.ts"; @@ -25,6 +25,7 @@ export function axeFormatDependencies( // below are used instead of actual theme colors. This is a known // limitation - see GitHub issue for architectural context. const isRevealjs = isRevealjsOutput(format.pandoc); + const isDashboard = isHtmlDashboardOutput(format.identifier["base-format"]); const sassDependency = isRevealjs ? "reveal-theme" : "bootstrap"; // Base overlay rules shared by all formats (also serves as fallback for revealjs) @@ -86,7 +87,8 @@ body div.quarto-axe-report { : ""; // Dashboard: report inside offcanvas sidebar (not fixed overlay) - const dashboardRules = ` + const dashboardRules = isDashboard + ? ` .quarto-dashboard .offcanvas.quarto-axe-offcanvas { .quarto-axe-report { position: static; @@ -110,7 +112,8 @@ body div.quarto-axe-report { align-items: center; justify-content: center; font-size: 1.25rem; -}`; +}` + : ""; return { [kIncludeInHeader]: [ From 202a3e6ac8e0c97c81fb4a7c8a56c61321b44d90 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 11:53:57 +0100 Subject: [PATCH 21/35] Add usage comment to isHtmlDashboardOutput Document that callers must pass format.identifier["base-format"], not format.pandoc.to, since dashboards use the HTML pandoc writer. --- src/config/format.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/config/format.ts b/src/config/format.ts index c5daf557757..b8adee18a2b 100644 --- a/src/config/format.ts +++ b/src/config/format.ts @@ -86,6 +86,8 @@ export function isHtmlSlideOutput(format: string | FormatPandoc) { ].some((fmt) => isFormatTo(format, fmt)); } +// Dashboard uses pandoc.to="html", so pass format.identifier["base-format"] +// (not format.pandoc.to) when checking from a Format object. export function isHtmlDashboardOutput(format?: string) { return format === "dashboard" || format?.endsWith("-dashboard"); } From 3748350726bda09c1faf6c0e9ad943cf54c2d914 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 14:48:59 +0100 Subject: [PATCH 22/35] Re-trigger axe scan on dashboard tab/page/sidebar change When dashboard content visibility changes (page switch, card tabset, sidebar toggle, or browser back/forward), the axe report becomes stale. Listen for shown.bs.tab, bslib.sidebar, and popstate events to re-run axe.run() and replace the offcanvas body with updated results. A generation counter discards stale scan results when the user acts faster than axe completes. --- src/format/html/format-html-axe.ts | 6 + src/resources/formats/html/axe/axe-check.js | 60 ++++++++- .../dashboard/axe-accessibility-pages.qmd | 26 ++++ .../tests/axe-accessibility.spec.ts | 116 +++++++++++++++++- 4 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 tests/docs/playwright/dashboard/axe-accessibility-pages.qmd diff --git a/src/format/html/format-html-axe.ts b/src/format/html/format-html-axe.ts index 354f6ac86fa..fe6e3daeb91 100644 --- a/src/format/html/format-html-axe.ts +++ b/src/format/html/format-html-axe.ts @@ -112,6 +112,12 @@ body div.quarto-axe-report { align-items: center; justify-content: center; font-size: 1.25rem; +} +.quarto-dashboard .quarto-axe-scanning { + padding: 1rem; + text-align: center; + opacity: 0.7; + font-style: italic; }` : ""; diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index e38793721ec..a72d748c161 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -236,6 +236,8 @@ const reporters = { class QuartoAxeChecker { constructor(opts) { this.options = opts; + this.axe = null; + this.scanGeneration = 0; } // In RevealJS, only the current slide is accessible to axe-core because // non-visible slides have hidden and aria-hidden attributes. Temporarily @@ -264,12 +266,10 @@ class QuartoAxeChecker { }); } - async init() { - const axe = (await import("https://cdn.skypack.dev/pin/axe-core@v4.10.3-aVOFXWsJaCpVrtv89pCa/mode=imports,min/optimized/axe-core.js")).default; + async runAxeScan() { const saved = this.revealUnhideSlides(); - let result; try { - result = await axe.run({ + return await this.axe.run({ exclude: [ // https://github.com/microsoft/tabster/issues/288 // MS has claimed they won't fix this, so we need to add an exclusion to @@ -281,9 +281,61 @@ class QuartoAxeChecker { } finally { this.revealRestoreSlides(saved); } + } + + setupDashboardRescan() { + // Page tabs and card tabsets both fire shown.bs.tab on the document + document.addEventListener("shown.bs.tab", () => this.rescanDashboard()); + + // Browser back/forward — showPage() toggles classes without firing shown.bs.tab + window.addEventListener("popstate", () => { + setTimeout(() => this.rescanDashboard(), 50); + }); + + // bslib sidebar open/close — fires with bubbles:true after transition ends + document.addEventListener("bslib.sidebar", () => this.rescanDashboard()); + } + + async rescanDashboard() { + const gen = ++this.scanGeneration; + + document.body.removeAttribute("data-quarto-axe-complete"); + + const body = document.querySelector("#quarto-axe-offcanvas .offcanvas-body"); + if (body) { + body.innerHTML = ""; + const scanning = document.createElement("div"); + scanning.className = "quarto-axe-scanning"; + scanning.textContent = "Scanning\u2026"; + body.appendChild(scanning); + } + + const result = await this.runAxeScan(); + + if (gen !== this.scanGeneration) return; + + const reporter = new QuartoAxeDocumentReporter(result, this.options); + const reportElement = reporter.createReportElement(); + + if (body) { + body.innerHTML = ""; + body.appendChild(reportElement); + } + + document.body.setAttribute("data-quarto-axe-complete", "true"); + } + + async init() { + this.axe = (await import("https://cdn.skypack.dev/pin/axe-core@v4.10.3-aVOFXWsJaCpVrtv89pCa/mode=imports,min/optimized/axe-core.js")).default; + const result = await this.runAxeScan(); const reporter = this.options === true ? new QuartoAxeConsoleReporter(result) : new reporters[this.options.output](result, this.options); await reporter.report(); document.body.setAttribute('data-quarto-axe-complete', 'true'); + + if (document.body.classList.contains("quarto-dashboard") && + this.options !== true && this.options.output === "document") { + this.setupDashboardRescan(); + } } } diff --git a/tests/docs/playwright/dashboard/axe-accessibility-pages.qmd b/tests/docs/playwright/dashboard/axe-accessibility-pages.qmd new file mode 100644 index 00000000000..9e4bcbb9f81 --- /dev/null +++ b/tests/docs/playwright/dashboard/axe-accessibility-pages.qmd @@ -0,0 +1,26 @@ +--- +title: "Multi-Page Dashboard" +format: dashboard +axe: + output: document +--- + +# Global Sidebar {.sidebar} + +Sidebar content with [low contrast text]{#sidebar-contrast style="color: #eee; background: #fff;"} that triggers a color-contrast violation. + +# Page 1 + +## Row + +::: {.card title="Page 1 Content"} +This page has [low contrast text]{#page1-contrast style="color: #eee; background: #fff;"} that triggers a color-contrast violation. +::: + +# Page 2 + +## Row + +::: {.card title="Page 2 Content"} +This page has only normal contrast text. No intentional violations here. +::: diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index 3f9002e1395..362bc654c91 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -66,6 +66,10 @@ const testCases: AxeTestCase[] = [ // 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' }, + + // Dashboard with pages — multi-page dashboard with global sidebar + { format: 'dashboard-pages', outputMode: 'document', url: '/dashboard/axe-accessibility-pages.html', + expectedViolation: 'color-contrast' }, ]; // Map axe violation IDs to the text that appears in document/console reporters. @@ -105,7 +109,7 @@ test.describe('Axe accessibility checking', () => { // Report element is static (not fixed overlay) await expect(axeReport).toHaveCSS('position', 'static'); - } else if (format === 'dashboard') { + } else if (format.startsWith('dashboard')) { // Dashboard: report appears in Bootstrap offcanvas sidebar const offcanvas = page.locator('#quarto-axe-offcanvas'); await expect(offcanvas).toBeVisible({ timeout: 10000 }); @@ -276,3 +280,113 @@ test.describe('Dashboard axe — offcanvas interaction and highlight', () => { await expect(highlighted).not.toBeAttached(); }); }); + +test.describe('Dashboard axe — re-scan on visibility change', () => { + const pagesUrl = '/dashboard/axe-accessibility-pages.html'; + + // Helper: collect violation target IDs from the offcanvas report + async function getViolationTargetIds(page: Page): Promise { + return page.evaluate(() => { + const targets = document.querySelectorAll('#quarto-axe-offcanvas .quarto-axe-violation-target'); + return Array.from(targets).map(t => t.textContent || ''); + }); + } + + test('re-scans when switching to Page 2 — page 1 violations disappear', async ({ page }) => { + await page.goto(pagesUrl, { waitUntil: 'networkidle' }); + await waitForAxeCompletion(page); + + // Initial scan should include #page1-contrast (Page 1 is active) + const initialTargets = await getViolationTargetIds(page); + expect(initialTargets.some(t => t.includes('#page1-contrast')), + 'Initial scan should detect #page1-contrast').toBe(true); + + // Switch to Page 2 — remove completion signal first so we detect the NEXT scan + await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); + await page.locator('a[data-bs-target="#page-2"]').click(); + await waitForAxeCompletion(page); + + // After rescan, #page1-contrast should be gone (Page 1 is now hidden) + const afterTargets = await getViolationTargetIds(page); + expect(afterTargets.some(t => t.includes('#page1-contrast')), + 'After switching to Page 2, #page1-contrast should not be detected').toBe(false); + + // Sidebar violation should still be present (sidebar is visible on all pages) + expect(afterTargets.some(t => t.includes('#sidebar-contrast')), + '#sidebar-contrast should still be detected').toBe(true); + }); + + test('switching back to Page 1 restores page 1 violations', async ({ page }) => { + await page.goto(pagesUrl, { waitUntil: 'networkidle' }); + await waitForAxeCompletion(page); + + // Switch to Page 2 + await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); + await page.locator('a[data-bs-target="#page-2"]').click(); + await waitForAxeCompletion(page); + + // Switch back to Page 1 + await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); + await page.locator('a[data-bs-target="#page-1"]').click(); + await waitForAxeCompletion(page); + + // #page1-contrast should be back + const targets = await getViolationTargetIds(page); + expect(targets.some(t => t.includes('#page1-contrast')), + 'After switching back to Page 1, #page1-contrast should be detected again').toBe(true); + }); + + test('re-scans on sidebar toggle — collapsed sidebar hides violations', async ({ page }) => { + await page.goto(pagesUrl, { waitUntil: 'networkidle' }); + await waitForAxeCompletion(page); + + // Initial scan should include #sidebar-contrast + const initialTargets = await getViolationTargetIds(page); + expect(initialTargets.some(t => t.includes('#sidebar-contrast')), + 'Initial scan should detect #sidebar-contrast').toBe(true); + + // Collapse the sidebar + await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); + await page.locator('.collapse-toggle').click(); + await waitForAxeCompletion(page); + + // After collapse, #sidebar-contrast should be gone (sidebar is hidden) + const collapsedTargets = await getViolationTargetIds(page); + expect(collapsedTargets.some(t => t.includes('#sidebar-contrast')), + 'After collapsing sidebar, #sidebar-contrast should not be detected').toBe(false); + + // Expand the sidebar again + await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); + await page.locator('.collapse-toggle').click(); + await waitForAxeCompletion(page); + + // #sidebar-contrast should be back + const expandedTargets = await getViolationTargetIds(page); + expect(expandedTargets.some(t => t.includes('#sidebar-contrast')), + 'After expanding sidebar, #sidebar-contrast should be detected again').toBe(true); + }); + + test('back/forward navigation triggers rescan', async ({ page }) => { + await page.goto(pagesUrl, { waitUntil: 'networkidle' }); + await waitForAxeCompletion(page); + + // Switch to Page 2 (this pushes history via Bootstrap tab) + await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); + await page.locator('a[data-bs-target="#page-2"]').click(); + await waitForAxeCompletion(page); + + // Verify Page 2 is active and page1-contrast is gone + const page2Targets = await getViolationTargetIds(page); + expect(page2Targets.some(t => t.includes('#page1-contrast'))).toBe(false); + + // Go back — this triggers popstate, which should rescan + await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); + await page.goBack(); + await waitForAxeCompletion(page); + + // Page 1 should be active again, with its violations restored + const backTargets = await getViolationTargetIds(page); + expect(backTargets.some(t => t.includes('#page1-contrast')), + 'After going back, #page1-contrast should be detected again').toBe(true); + }); +}); From 52c7fc1bc00c91f1b9b2a364a7022af77479b298 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 15:02:41 +0100 Subject: [PATCH 23/35] Add card tabset rescan test and violations to dashboard fixture Page 2 now contains a card tabset with a low-contrast violation in Tab A and clean content in Tab B. Tests verify that switching card tabs within a page triggers a rescan and correctly hides/shows violations for the active tab. --- .../dashboard/axe-accessibility-pages.qmd | 10 +++-- .../tests/axe-accessibility.spec.ts | 39 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/tests/docs/playwright/dashboard/axe-accessibility-pages.qmd b/tests/docs/playwright/dashboard/axe-accessibility-pages.qmd index 9e4bcbb9f81..3adc7b8a507 100644 --- a/tests/docs/playwright/dashboard/axe-accessibility-pages.qmd +++ b/tests/docs/playwright/dashboard/axe-accessibility-pages.qmd @@ -19,8 +19,12 @@ This page has [low contrast text]{#page1-contrast style="color: #eee; background # Page 2 -## Row +## Row {.tabset} + +::: {.card title="Tab A"} +This tab has [low contrast text]{#tab-a-contrast style="color: #eee; background: #fff;"} that triggers a color-contrast violation. +::: -::: {.card title="Page 2 Content"} -This page has only normal contrast text. No intentional violations here. +::: {.card title="Tab B"} +This tab has only normal contrast text. No intentional violations here. ::: diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index 362bc654c91..6a163c35055 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -314,6 +314,10 @@ test.describe('Dashboard axe — re-scan on visibility change', () => { // Sidebar violation should still be present (sidebar is visible on all pages) expect(afterTargets.some(t => t.includes('#sidebar-contrast')), '#sidebar-contrast should still be detected').toBe(true); + + // Card tabset Tab A is active by default on Page 2 — its violation should appear + expect(afterTargets.some(t => t.includes('#tab-a-contrast')), + '#tab-a-contrast should be detected (Tab A is active on Page 2)').toBe(true); }); test('switching back to Page 1 restores page 1 violations', async ({ page }) => { @@ -389,4 +393,39 @@ test.describe('Dashboard axe — re-scan on visibility change', () => { expect(backTargets.some(t => t.includes('#page1-contrast')), 'After going back, #page1-contrast should be detected again').toBe(true); }); + + test('re-scans on card tabset switch — hidden tab violations disappear', async ({ page }) => { + await page.goto(pagesUrl, { waitUntil: 'networkidle' }); + await waitForAxeCompletion(page); + + // Switch to Page 2 where the card tabset lives + await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); + await page.locator('a[data-bs-target="#page-2"]').click(); + await waitForAxeCompletion(page); + + // Tab A is active by default — #tab-a-contrast should be present + const tabATargets = await getViolationTargetIds(page); + expect(tabATargets.some(t => t.includes('#tab-a-contrast')), + 'Tab A active: #tab-a-contrast should be detected').toBe(true); + + // Switch to Tab B within the card tabset + await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); + await page.locator('a[data-bs-toggle="tab"][data-value="Tab B"]').click(); + await waitForAxeCompletion(page); + + // Tab A is now hidden — #tab-a-contrast should be gone + const tabBTargets = await getViolationTargetIds(page); + expect(tabBTargets.some(t => t.includes('#tab-a-contrast')), + 'Tab B active: #tab-a-contrast should not be detected').toBe(false); + + // Switch back to Tab A + await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); + await page.locator('a[data-bs-toggle="tab"][data-value="Tab A"]').click(); + await waitForAxeCompletion(page); + + // #tab-a-contrast should reappear + const restoredTargets = await getViolationTargetIds(page); + expect(restoredTargets.some(t => t.includes('#tab-a-contrast')), + 'Tab A restored: #tab-a-contrast should be detected again').toBe(true); + }); }); From d9baa8c4c25cb7742614307e61ba03e4a06a5eee Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 15:08:29 +0100 Subject: [PATCH 24/35] Use role-based Playwright selectors in dashboard rescan tests Replace CSS attribute selectors with getByRole() for page tabs, card tabset tabs, and sidebar toggle button. More readable and resilient to markup changes. --- .../playwright/tests/axe-accessibility.spec.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index 6a163c35055..a3f27637db6 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -303,7 +303,7 @@ test.describe('Dashboard axe — re-scan on visibility change', () => { // Switch to Page 2 — remove completion signal first so we detect the NEXT scan await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); - await page.locator('a[data-bs-target="#page-2"]').click(); + await page.getByRole('tab', { name: 'Page 2' }).click(); await waitForAxeCompletion(page); // After rescan, #page1-contrast should be gone (Page 1 is now hidden) @@ -326,12 +326,12 @@ test.describe('Dashboard axe — re-scan on visibility change', () => { // Switch to Page 2 await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); - await page.locator('a[data-bs-target="#page-2"]').click(); + await page.getByRole('tab', { name: 'Page 2' }).click(); await waitForAxeCompletion(page); // Switch back to Page 1 await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); - await page.locator('a[data-bs-target="#page-1"]').click(); + await page.getByRole('tab', { name: 'Page 1' }).click(); await waitForAxeCompletion(page); // #page1-contrast should be back @@ -351,7 +351,7 @@ test.describe('Dashboard axe — re-scan on visibility change', () => { // Collapse the sidebar await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); - await page.locator('.collapse-toggle').click(); + await page.getByRole('button', { name: 'Toggle sidebar' }).click(); await waitForAxeCompletion(page); // After collapse, #sidebar-contrast should be gone (sidebar is hidden) @@ -361,7 +361,7 @@ test.describe('Dashboard axe — re-scan on visibility change', () => { // Expand the sidebar again await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); - await page.locator('.collapse-toggle').click(); + await page.getByRole('button', { name: 'Toggle sidebar' }).click(); await waitForAxeCompletion(page); // #sidebar-contrast should be back @@ -376,7 +376,7 @@ test.describe('Dashboard axe — re-scan on visibility change', () => { // Switch to Page 2 (this pushes history via Bootstrap tab) await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); - await page.locator('a[data-bs-target="#page-2"]').click(); + await page.getByRole('tab', { name: 'Page 2' }).click(); await waitForAxeCompletion(page); // Verify Page 2 is active and page1-contrast is gone @@ -400,7 +400,7 @@ test.describe('Dashboard axe — re-scan on visibility change', () => { // Switch to Page 2 where the card tabset lives await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); - await page.locator('a[data-bs-target="#page-2"]').click(); + await page.getByRole('tab', { name: 'Page 2' }).click(); await waitForAxeCompletion(page); // Tab A is active by default — #tab-a-contrast should be present @@ -410,7 +410,7 @@ test.describe('Dashboard axe — re-scan on visibility change', () => { // Switch to Tab B within the card tabset await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); - await page.locator('a[data-bs-toggle="tab"][data-value="Tab B"]').click(); + await page.getByRole('tab', { name: 'Tab B' }).click(); await waitForAxeCompletion(page); // Tab A is now hidden — #tab-a-contrast should be gone @@ -420,7 +420,7 @@ test.describe('Dashboard axe — re-scan on visibility change', () => { // Switch back to Tab A await page.evaluate(() => document.body.removeAttribute('data-quarto-axe-complete')); - await page.locator('a[data-bs-toggle="tab"][data-value="Tab A"]').click(); + await page.getByRole('tab', { name: 'Tab A' }).click(); await waitForAxeCompletion(page); // #tab-a-contrast should reappear From d85d4ca38aa65df2c94348797e3e5990bf6c41e9 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 15:17:36 +0100 Subject: [PATCH 25/35] Add error handling to rescanDashboard() for axe.run() failures Wrap the scan in try/catch/finally so a failed axe.run() shows an error message instead of leaving "Scanning..." stuck forever. The completion attribute is always set in finally (guarded by generation counter) to unblock any waiters. --- src/resources/formats/html/axe/axe-check.js | 32 +++++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index a72d748c161..1963f7be1cd 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -310,19 +310,33 @@ class QuartoAxeChecker { body.appendChild(scanning); } - const result = await this.runAxeScan(); + try { + const result = await this.runAxeScan(); - if (gen !== this.scanGeneration) return; + if (gen !== this.scanGeneration) return; - const reporter = new QuartoAxeDocumentReporter(result, this.options); - const reportElement = reporter.createReportElement(); + const reporter = new QuartoAxeDocumentReporter(result, this.options); + const reportElement = reporter.createReportElement(); - if (body) { - body.innerHTML = ""; - body.appendChild(reportElement); + if (body) { + body.innerHTML = ""; + body.appendChild(reportElement); + } + } catch (error) { + console.error("Axe rescan failed:", error); + if (gen !== this.scanGeneration) return; + if (body) { + body.innerHTML = ""; + const msg = document.createElement("div"); + msg.className = "quarto-axe-scanning"; + msg.textContent = "Accessibility scan failed. See console for details."; + body.appendChild(msg); + } + } finally { + if (gen === this.scanGeneration) { + document.body.setAttribute("data-quarto-axe-complete", "true"); + } } - - document.body.setAttribute("data-quarto-axe-complete", "true"); } async init() { From 7bc2a3a83dc8facc8a9cbd05065cc1bb862c2e24 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 15:30:27 +0100 Subject: [PATCH 26/35] Add role="alert" to rescan error message for screen reader announcement --- src/resources/formats/html/axe/axe-check.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index 1963f7be1cd..e09312429a6 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -329,6 +329,7 @@ class QuartoAxeChecker { body.innerHTML = ""; const msg = document.createElement("div"); msg.className = "quarto-axe-scanning"; + msg.setAttribute("role", "alert"); msg.textContent = "Accessibility scan failed. See console for details."; body.appendChild(msg); } From 2e368459fa9d5d85c794d2e42dbab53c61f4b709 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 15:57:26 +0100 Subject: [PATCH 27/35] Add comment explaining popstate 50ms delay in setupDashboardRescan --- src/resources/formats/html/axe/axe-check.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index e09312429a6..3de36d935c5 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -287,7 +287,9 @@ class QuartoAxeChecker { // Page tabs and card tabsets both fire shown.bs.tab on the document document.addEventListener("shown.bs.tab", () => this.rescanDashboard()); - // Browser back/forward — showPage() toggles classes without firing shown.bs.tab + // Browser back/forward — showPage() toggles classes without firing shown.bs.tab. + // The 50ms delay lets the dashboard finish toggling .active classes on tab panes + // before axe scans, so the correct page content is visible. window.addEventListener("popstate", () => { setTimeout(() => this.rescanDashboard(), 50); }); From 4de89640e7fe2a399ccd242d9c7a3e1693b260f3 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 16:53:45 +0100 Subject: [PATCH 28/35] Harden axe-check.js init(), fix multi-node event bug, normalize options Three fixes from code review: 1. Wrap init() in try/catch/finally so data-quarto-axe-complete is always set even if CDN import or initial scan fails, preventing Playwright test hangs. 2. Move nodeElement creation inside the violation.nodes loop so each node gets its own .quarto-axe-violation-selector div. Previously all targets shared one div and event listeners accumulated across nodes. 3. Normalize `axe: true` to `{output: "console"}` in the QuartoAxeChecker constructor, eliminating boolean-vs-object checks in init() and setupDashboardRescan(). Also adds gitignore for smoke-all/dashboard render artifacts and axe accessibility architecture doc in llm-docs. --- llm-docs/axe-accessibility-architecture.md | 101 ++++++++++++++++++++ src/resources/formats/html/axe/axe-check.js | 30 +++--- tests/docs/playwright/dashboard/.gitignore | 3 +- tests/docs/smoke-all/dashboard/.gitignore | 4 + 4 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 llm-docs/axe-accessibility-architecture.md create mode 100644 tests/docs/smoke-all/dashboard/.gitignore diff --git a/llm-docs/axe-accessibility-architecture.md b/llm-docs/axe-accessibility-architecture.md new file mode 100644 index 00000000000..4242c103342 --- /dev/null +++ b/llm-docs/axe-accessibility-architecture.md @@ -0,0 +1,101 @@ +--- +main_commit: ee0f68be1 +analyzed_date: 2026-02-27 +key_files: + - src/format/html/format-html.ts + - src/resources/formats/html/axe/axe-check.js + - src/resources/formats/html/axe/axe-check.scss + - src/resources/formats/revealjs/axe/axe-check.scss + - src/resources/formats/dashboard/axe/axe-check.scss +--- + +# Axe Accessibility Checking Architecture + +Quarto's axe-core integration spans three layers: build-time TypeScript, compile-time SCSS, and runtime JavaScript. Each layer operates at a fundamentally different stage, which is why they detect formats differently. + +## Three-Layer Overview + +### Layer 1: Build-time TypeScript (format detection + dependency injection) + +**File:** `src/format/html/format-html.ts` + +TypeScript code detects the output format and conditionally injects `axe-check.js` + `axe-check.css` as a `FormatDependency`. The format detection uses the Quarto format system (`isHtmlOutput`, `isRevealJsOutput`, `isDashboardOutput`). + +Key responsibilities: +- Read `axe` option from document metadata +- Inject axe files as `FormatDependency` (copies JS/CSS into output) +- Encode options as base64 JSON into a `` from breaking the HTML parser. + +### Layer 2: Compile-time SCSS (format-specific styling) + +**Files:** +- `src/resources/formats/html/axe/axe-check.scss` — HTML/dashboard styles +- `src/resources/formats/revealjs/axe/axe-check.scss` — RevealJS-specific styles + +SCSS files provide format-specific visual styling. RevealJS compiles its sass-bundles separately from the theme, so theme variables aren't in scope. The CSS custom property bridge (`--quarto-axe-*` variables set in HTML themes, consumed in RevealJS via `var()` with `!default` fallbacks) works around this architectural constraint. + +### Layer 3: Runtime JavaScript (scanning + reporting) + +**File:** `src/resources/formats/html/axe/axe-check.js` + +Single JS file handles all formats at runtime. Format detection uses DOM inspection: +- `typeof Reveal !== "undefined"` → RevealJS +- `document.body.classList.contains("quarto-dashboard")` → Dashboard +- Otherwise → standard HTML + +Key classes: +- `QuartoAxeChecker` — Orchestrates scanning. Loads axe-core from CDN, runs scans, creates reporters. +- `QuartoAxeDocumentReporter` — Format-specific DOM report (overlay, slide, or offcanvas) +- `QuartoAxeConsoleReporter` — Logs violations to browser console +- `QuartoAxeJsonReporter` — Dumps full axe result as JSON to console + +## Format-Specific Behavior + +### HTML (standard) +- Report: overlay appended to `
` (or ``) +- Interaction: hover highlights violation elements +- Rescan: none (static page) + +### RevealJS +- Report: dedicated `
` slide appended to `.reveal .slides` +- Interaction: click navigates to the slide containing the violation +- Scan prep: temporarily removes `hidden`/`aria-hidden` from all slides so axe can inspect them +- Rescan: none (slides are static) + +### Dashboard +- Report: Bootstrap offcanvas sidebar with toggle button +- Interaction: hover highlights violation elements +- Rescan: triggered by `shown.bs.tab` (page/card tabs), `popstate` (back/forward), `bslib.sidebar` (sidebar toggle) +- Generation counter prevents stale scan results from overwriting newer ones + +## Adding a New HTML Format + +If a new HTML-based format is added that needs axe support: + +1. **TypeScript** (`format-html.ts`): Add format detection to the axe dependency injection logic +2. **SCSS**: Create format-specific styles if the report placement differs. If the format uses Bootstrap themes, the CSS custom property bridge handles colors automatically. +3. **JavaScript** (`axe-check.js`): + - Add format detection in `QuartoAxeDocumentReporter.report()` — this determines which `createReport*()` method is called + - Implement a `createReport*()` method for the format's DOM structure + - If the format has dynamic content changes, add rescan triggers in `setupDashboardRescan()` (or create a format-specific equivalent) +4. **Tests**: Add Playwright test fixtures (`.qmd` files in `tests/docs/playwright//`) and parameterized test cases in `axe-accessibility.spec.ts` + +## Key Design Decisions + +### CDN loading +axe-core (~600KB) is loaded from `cdn.skypack.dev` at runtime rather than bundled. This keeps the Quarto distribution small since axe is a dev-only feature. Tradeoff: requires internet, fails silently in offline/CSP environments. + +### Options encoding +Options are base64-encoded in a `` from breaking HTML parsing. The runtime decodes with `atob()`. + +### Generation counter (dashboard rescan) +Rapid tab switches can queue multiple `axe.run()` calls. The `scanGeneration` counter ensures only the latest scan's results are displayed. Earlier scans complete but their results are discarded if a newer scan was started. + +## Known Limitations + +- **SCSS compilation**: RevealJS sass-bundles compile separately from themes. CSS custom properties bridge this gap, but direct SCSS variable sharing isn't possible without pipeline changes. +- **CDN dependency**: No offline fallback. See `quarto-cli-2u4f` for documentation task. +- **Popstate delay**: 50ms `setTimeout` for dashboard back/forward navigation. See `quarto-cli-1fdf` for improvement task. +- **Multi-main elements**: `createReportOverlay()` uses `document.querySelector('main')` which returns the first match. Multiple `
` elements is invalid HTML, so this is acceptable. diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index 3de36d935c5..a1ee19816d0 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -85,9 +85,9 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { const nodesElement = document.createElement("div"); nodesElement.className = "quarto-axe-violation-nodes"; violationElement.appendChild(nodesElement); - const nodeElement = document.createElement("div"); - nodeElement.className = "quarto-axe-violation-selector"; for (const node of violation.nodes) { + const nodeElement = document.createElement("div"); + nodeElement.className = "quarto-axe-violation-selector"; for (const target of node.target) { const targetElement = document.createElement("span"); targetElement.className = "quarto-axe-violation-target"; @@ -235,7 +235,8 @@ const reporters = { class QuartoAxeChecker { constructor(opts) { - this.options = opts; + // Normalize boolean shorthand: axe: true → {output: "console"} + this.options = opts === true ? { output: "console" } : opts; this.axe = null; this.scanGeneration = 0; } @@ -343,15 +344,20 @@ class QuartoAxeChecker { } async init() { - this.axe = (await import("https://cdn.skypack.dev/pin/axe-core@v4.10.3-aVOFXWsJaCpVrtv89pCa/mode=imports,min/optimized/axe-core.js")).default; - const result = await this.runAxeScan(); - const reporter = this.options === true ? new QuartoAxeConsoleReporter(result) : new reporters[this.options.output](result, this.options); - await reporter.report(); - document.body.setAttribute('data-quarto-axe-complete', 'true'); - - if (document.body.classList.contains("quarto-dashboard") && - this.options !== true && this.options.output === "document") { - this.setupDashboardRescan(); + try { + this.axe = (await import("https://cdn.skypack.dev/pin/axe-core@v4.10.3-aVOFXWsJaCpVrtv89pCa/mode=imports,min/optimized/axe-core.js")).default; + const result = await this.runAxeScan(); + const reporter = new reporters[this.options.output](result, this.options); + await reporter.report(); + + if (document.body.classList.contains("quarto-dashboard") && + this.options.output === "document") { + this.setupDashboardRescan(); + } + } catch (error) { + console.error("Axe accessibility check failed:", error); + } finally { + document.body.setAttribute('data-quarto-axe-complete', 'true'); } } } diff --git a/tests/docs/playwright/dashboard/.gitignore b/tests/docs/playwright/dashboard/.gitignore index 2ae30d60d2b..cc2dd8b1d3f 100644 --- a/tests/docs/playwright/dashboard/.gitignore +++ b/tests/docs/playwright/dashboard/.gitignore @@ -1,3 +1,4 @@ *.html *_files/ -.quarto/ +/.quarto/ +**/*.quarto_ipynb diff --git a/tests/docs/smoke-all/dashboard/.gitignore b/tests/docs/smoke-all/dashboard/.gitignore new file mode 100644 index 00000000000..cc2dd8b1d3f --- /dev/null +++ b/tests/docs/smoke-all/dashboard/.gitignore @@ -0,0 +1,4 @@ +*.html +*_files/ +/.quarto/ +**/*.quarto_ipynb From 98d568754b3797226fab246b1a05ce8422172a37 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 17:48:42 +0100 Subject: [PATCH 29/35] Refactor axe to use FormatDependency head field Replaces manual temp file creation with FormatDependency head field for injecting axe configuration script tag. Eliminates unnecessary temp file handling and leverages existing dependency injection system. Changes: - Extract axeHtmlDependency() function returning FormatDependency with head field - Remove temp parameter from axeFormatDependencies() - Remove kIncludeInHeader usage in favor of head field - Update call site to remove temp argument The dependency system processes head content automatically, collecting all HTML into a single temp file during processHtmlDependencies(). Output is functionally identical. --- .claude/rules/formats/html-dependencies.md | 25 +++++++++++++ llm-docs/axe-accessibility-architecture.md | 1 + src/format/html/format-html-axe.ts | 42 ++++++++++++---------- src/format/html/format-html.ts | 2 +- 4 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 .claude/rules/formats/html-dependencies.md diff --git a/.claude/rules/formats/html-dependencies.md b/.claude/rules/formats/html-dependencies.md new file mode 100644 index 00000000000..ca0ed8ca113 --- /dev/null +++ b/.claude/rules/formats/html-dependencies.md @@ -0,0 +1,25 @@ +--- +paths: + - "src/format/html/**/*" +--- + +# HTML FormatDependency Pattern + +**Reference:** `axeHtmlDependency()` in `src/format/html/format-html-axe.ts` + +## Use `head` Field for Dynamic HTML + +```typescript +function myHtmlDependency(config: unknown): FormatDependency { + return { + name: "my-feature", + head: ``, + scripts: [{ name: "file.js", path: formatResourcePath(...) }], + }; +} +``` + +- Use `head` for inline/dynamic content (config scripts, meta tags) +- Use `scripts`/`stylesheets` fields for external files +- Don't create temp files manually with `temp.createFileFromString()` +- Base64-encode JSON in script tags (prevents `` parser issues) diff --git a/llm-docs/axe-accessibility-architecture.md b/llm-docs/axe-accessibility-architecture.md index 4242c103342..4b0ee2a84c1 100644 --- a/llm-docs/axe-accessibility-architecture.md +++ b/llm-docs/axe-accessibility-architecture.md @@ -3,6 +3,7 @@ main_commit: ee0f68be1 analyzed_date: 2026-02-27 key_files: - src/format/html/format-html.ts + - src/format/html/format-html-axe.ts - src/resources/formats/html/axe/axe-check.js - src/resources/formats/html/axe/axe-check.scss - src/resources/formats/revealjs/axe/axe-check.scss diff --git a/src/format/html/format-html-axe.ts b/src/format/html/format-html-axe.ts index fe6e3daeb91..7bdc33e49e3 100644 --- a/src/format/html/format-html-axe.ts +++ b/src/format/html/format-html-axe.ts @@ -4,17 +4,33 @@ * Copyright (C) 2020-2025 Posit Software, PBC */ -import { kIncludeInHeader } from "../../config/constants.ts"; import { isHtmlDashboardOutput, isRevealjsOutput } from "../../config/format.ts"; -import { Format, FormatExtras, kDependencies } from "../../config/types.ts"; +import { + Format, + FormatDependency, + FormatExtras, + kDependencies, +} from "../../config/types.ts"; import { formatResourcePath } from "../../core/resources.ts"; -import { TempContext } from "../../core/temp-types.ts"; import { encodeBase64 } from "../../deno_ral/encoding.ts"; import { join } from "../../deno_ral/path.ts"; +function axeHtmlDependency(options: unknown): FormatDependency { + return { + name: "quarto-axe", + head: ``, + scripts: [{ + name: "axe-check.js", + path: formatResourcePath("html", join("axe", "axe-check.js")), + attribs: { type: "module" }, + }], + }; +} + export function axeFormatDependencies( format: Format, - temp: TempContext, options?: unknown, ): FormatExtras { if (!options) return {}; @@ -122,22 +138,10 @@ body div.quarto-axe-report { : ""; return { - [kIncludeInHeader]: [ - temp.createFileFromString( - ``, - ), - ], html: { - [kDependencies]: [{ - name: "quarto-axe", - scripts: [{ - name: "axe-check.js", - path: formatResourcePath("html", join("axe", "axe-check.js")), - attribs: { type: "module" }, - }], - }], + [kDependencies]: [ + axeHtmlDependency(options), + ], "sass-bundles": [ { key: "axe", diff --git a/src/format/html/format-html.ts b/src/format/html/format-html.ts index 305de562c8c..942b74b66cf 100644 --- a/src/format/html/format-html.ts +++ b/src/format/html/format-html.ts @@ -248,7 +248,7 @@ export async function htmlFormatExtras( scssOptions?: HtmlFormatScssOptions, ): Promise { const configurableExtras: FormatExtras[] = [ - axeFormatDependencies(format, temp, format.metadata[kAxe]), + axeFormatDependencies(format, format.metadata[kAxe]), ]; // note whether we are targeting bootstrap From 8042384a3ee9f3f25cc0a4ec91255cb5483053b4 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 17:50:38 +0100 Subject: [PATCH 30/35] Add gitignore for accessibility test artifacts --- tests/docs/smoke-all/accessibility/.gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 tests/docs/smoke-all/accessibility/.gitignore diff --git a/tests/docs/smoke-all/accessibility/.gitignore b/tests/docs/smoke-all/accessibility/.gitignore new file mode 100644 index 00000000000..0a60a0663a7 --- /dev/null +++ b/tests/docs/smoke-all/accessibility/.gitignore @@ -0,0 +1,2 @@ +*.html +*_files/ From 692a2aa0241b7a8ba8d3583f28c4cca5b871dfa2 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 17:56:07 +0100 Subject: [PATCH 31/35] Add changelog entry for revealjs axe accessibility fix (#13781) --- news/changelog-1.9.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 4f38ae872a0..442dd8773b5 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -104,6 +104,7 @@ All changes included in 1.9: ### `revealjs` - ([#13722](https://github.com/quarto-dev/quarto-cli/issues/13722)): Fix `light-content` / `dark-content` SCSS rules not included in Reveal.js format. (author: @mcanouil) +- ([#13781](https://github.com/quarto-dev/quarto-cli/issues/13781)): Fix `axe` accessibility checks failing with SCSS compilation error for revealjs format. Axe now loads as a standalone module for all HTML formats with format-specific report placement. - ([#13852](https://github.com/quarto-dev/quarto-cli/issues/13852)): Scroll-view options (`scrollSnap`, `scrollProgress`, `scrollActivationWidth`, `scrollLayout`) are now rendered in the Pandoc template instead of injected at runtime. Custom revealjs templates may need updating to include the scroll-view configuration block. ### `ipynb` From 0282b84cd07ee652fb9855c31e5a80305cca61a6 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 17:58:52 +0100 Subject: [PATCH 32/35] Add changelog entries for axe report in revealjs and dashboard (#14125) --- news/changelog-1.9.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 442dd8773b5..235d199858a 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -106,6 +106,11 @@ All changes included in 1.9: - ([#13722](https://github.com/quarto-dev/quarto-cli/issues/13722)): Fix `light-content` / `dark-content` SCSS rules not included in Reveal.js format. (author: @mcanouil) - ([#13781](https://github.com/quarto-dev/quarto-cli/issues/13781)): Fix `axe` accessibility checks failing with SCSS compilation error for revealjs format. Axe now loads as a standalone module for all HTML formats with format-specific report placement. - ([#13852](https://github.com/quarto-dev/quarto-cli/issues/13852)): Scroll-view options (`scrollSnap`, `scrollProgress`, `scrollActivationWidth`, `scrollLayout`) are now rendered in the Pandoc template instead of injected at runtime. Custom revealjs templates may need updating to include the scroll-view configuration block. +- ([#14125](https://github.com/quarto-dev/quarto-cli/pull/14125)): Add format-specific `axe` accessibility report for revealjs when using `axe: {output: document}`. Report appears as a dedicated slide appended to the deck with hover-to-highlight navigation to offending slides. + +### `dashboard` + +- ([#14125](https://github.com/quarto-dev/quarto-cli/pull/14125)): Add format-specific `axe` accessibility report for dashboards when using `axe: {output: document}`. Report appears as a Bootstrap offcanvas sidebar with automatic rescan when switching pages, card tabsets, or toggling the sidebar. ### `ipynb` From a00be0c3739f9b2b767e0b359eceeccd2b0fdcea Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 19:03:51 +0100 Subject: [PATCH 33/35] Add comprehensive axe accessibility test coverage Fills test coverage gaps identified in PR #14125 review: - Dashboard console/json output modes (3 test cases) - Dashboard dark theme CSS custom property handling - HTML hover interaction and element highlighting - Negative tests for revealjs/dashboard (4 smoke-all tests) Updates hover interaction tests to use Playwright best practices: - Use .toHaveClass() instead of string interpolation - Use .first() to handle non-unique selectors (e.g., "span") All tests passing (75 Playwright, 9 smoke-all) across all browsers. --- .../dashboard/axe-accessibility-dark.qmd | 49 +++++++++++++++++++ .../docs/playwright/dashboard/axe-console.qmd | 47 ++++++++++++++++++ tests/docs/playwright/dashboard/axe-json.qmd | 47 ++++++++++++++++++ .../accessibility/axe-disabled-dashboard.qmd | 23 +++++++++ .../accessibility/axe-disabled-revealjs.qmd | 21 ++++++++ .../accessibility/axe-false-dashboard.qmd | 25 ++++++++++ .../accessibility/axe-false-revealjs.qmd | 23 +++++++++ .../tests/axe-accessibility.spec.ts | 43 ++++++++++++++-- 8 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 tests/docs/playwright/dashboard/axe-accessibility-dark.qmd create mode 100644 tests/docs/playwright/dashboard/axe-console.qmd create mode 100644 tests/docs/playwright/dashboard/axe-json.qmd create mode 100644 tests/docs/smoke-all/accessibility/axe-disabled-dashboard.qmd create mode 100644 tests/docs/smoke-all/accessibility/axe-disabled-revealjs.qmd create mode 100644 tests/docs/smoke-all/accessibility/axe-false-dashboard.qmd create mode 100644 tests/docs/smoke-all/accessibility/axe-false-revealjs.qmd diff --git a/tests/docs/playwright/dashboard/axe-accessibility-dark.qmd b/tests/docs/playwright/dashboard/axe-accessibility-dark.qmd new file mode 100644 index 00000000000..cd96c69ca2d --- /dev/null +++ b/tests/docs/playwright/dashboard/axe-accessibility-dark.qmd @@ -0,0 +1,49 @@ +--- +title: "Sales Dashboard" +format: + dashboard: + theme: darkly + axe: + output: document +--- + +## Row {height=30%} + +### Column + +::: {.valuebox title="Total Revenue" color="primary"} +$1.2M +::: + +### Column + +::: {.valuebox title="Active Users" color="success"} +8,432 +::: + +### Column + +::: {.valuebox title="Conversion Rate" color="warning"} +3.2% +::: + +## Row {height=70%} + +### Column {width=60%} + +::: {.card title="Monthly Revenue"} +This card shows [monthly revenue trends]{style="color: #333; background: #222;"} across all regions. + +Additional analysis text to fill the card with content. The dashboard layout should fill the viewport. +::: + +### Column {width=40%} + +::: {.card title="Top Products"} +| Product | Sales | +|---------|-------| +| Widget A | $450K | +| Widget B | $320K | +| Widget C | $280K | +| Widget D | $150K | +::: diff --git a/tests/docs/playwright/dashboard/axe-console.qmd b/tests/docs/playwright/dashboard/axe-console.qmd new file mode 100644 index 00000000000..37ba334f3d8 --- /dev/null +++ b/tests/docs/playwright/dashboard/axe-console.qmd @@ -0,0 +1,47 @@ +--- +title: "Sales Dashboard" +format: dashboard +axe: + output: console +--- + +## Row {height=30%} + +### Column + +::: {.valuebox title="Total Revenue" color="primary"} +$1.2M +::: + +### Column + +::: {.valuebox title="Active Users" color="success"} +8,432 +::: + +### Column + +::: {.valuebox title="Conversion Rate" color="warning"} +3.2% +::: + +## Row {height=70%} + +### Column {width=60%} + +::: {.card title="Monthly Revenue"} +This card shows [monthly revenue trends]{style="color: #eee; background: #fff;"} across all regions. + +Additional analysis text to fill the card with content. The dashboard layout should fill the viewport. +::: + +### Column {width=40%} + +::: {.card title="Top Products"} +| Product | Sales | +|---------|-------| +| Widget A | $450K | +| Widget B | $320K | +| Widget C | $280K | +| Widget D | $150K | +::: diff --git a/tests/docs/playwright/dashboard/axe-json.qmd b/tests/docs/playwright/dashboard/axe-json.qmd new file mode 100644 index 00000000000..1167786a3e6 --- /dev/null +++ b/tests/docs/playwright/dashboard/axe-json.qmd @@ -0,0 +1,47 @@ +--- +title: "Sales Dashboard" +format: dashboard +axe: + output: json +--- + +## Row {height=30%} + +### Column + +::: {.valuebox title="Total Revenue" color="primary"} +$1.2M +::: + +### Column + +::: {.valuebox title="Active Users" color="success"} +8,432 +::: + +### Column + +::: {.valuebox title="Conversion Rate" color="warning"} +3.2% +::: + +## Row {height=70%} + +### Column {width=60%} + +::: {.card title="Monthly Revenue"} +This card shows [monthly revenue trends]{style="color: #eee; background: #fff;"} across all regions. + +Additional analysis text to fill the card with content. The dashboard layout should fill the viewport. +::: + +### Column {width=40%} + +::: {.card title="Top Products"} +| Product | Sales | +|---------|-------| +| Widget A | $450K | +| Widget B | $320K | +| Widget C | $280K | +| Widget D | $150K | +::: diff --git a/tests/docs/smoke-all/accessibility/axe-disabled-dashboard.qmd b/tests/docs/smoke-all/accessibility/axe-disabled-dashboard.qmd new file mode 100644 index 00000000000..6dfc4886234 --- /dev/null +++ b/tests/docs/smoke-all/accessibility/axe-disabled-dashboard.qmd @@ -0,0 +1,23 @@ +--- +title: "No Axe - Dashboard" +format: dashboard +_quarto: + tests: + dashboard: + ensureHtmlElements: + - [] + - ['script[src*="axe-check"]', '#quarto-axe-checker-options'] + ensureFileRegexMatches: + - [] + - ['axe-check\.js', 'quarto-axe-checker-options'] +--- + +## Row + +### Column + +::: {.card title="Test Card"} +Render without axe config — no axe dependencies should be present. + +This document verifies that axe is disabled by default for Dashboard format. +::: diff --git a/tests/docs/smoke-all/accessibility/axe-disabled-revealjs.qmd b/tests/docs/smoke-all/accessibility/axe-disabled-revealjs.qmd new file mode 100644 index 00000000000..da0e3952d47 --- /dev/null +++ b/tests/docs/smoke-all/accessibility/axe-disabled-revealjs.qmd @@ -0,0 +1,21 @@ +--- +title: "No Axe - RevealJS" +format: revealjs +_quarto: + tests: + revealjs: + ensureHtmlElements: + - [] + - ['script[src*="axe-check"]', '#quarto-axe-checker-options'] + ensureFileRegexMatches: + - [] + - ['axe-check\.js', 'quarto-axe-checker-options'] +--- + +## Slide 1 + +Render without axe config — no axe dependencies should be present. + +## Slide 2 + +This document verifies that axe is disabled by default for RevealJS format. diff --git a/tests/docs/smoke-all/accessibility/axe-false-dashboard.qmd b/tests/docs/smoke-all/accessibility/axe-false-dashboard.qmd new file mode 100644 index 00000000000..82dd86611d3 --- /dev/null +++ b/tests/docs/smoke-all/accessibility/axe-false-dashboard.qmd @@ -0,0 +1,25 @@ +--- +title: "Axe False - Dashboard" +format: + dashboard: + axe: false +_quarto: + tests: + dashboard: + ensureHtmlElements: + - [] + - ['script[src*="axe-check"]', '#quarto-axe-checker-options'] + ensureFileRegexMatches: + - [] + - ['axe-check\.js', 'quarto-axe-checker-options'] +--- + +## Row + +### Column + +::: {.card title="Test Card"} +Render with axe: false — no axe dependencies should be present. + +This document verifies that axe can be explicitly disabled for Dashboard format. +::: diff --git a/tests/docs/smoke-all/accessibility/axe-false-revealjs.qmd b/tests/docs/smoke-all/accessibility/axe-false-revealjs.qmd new file mode 100644 index 00000000000..071a7f6f95c --- /dev/null +++ b/tests/docs/smoke-all/accessibility/axe-false-revealjs.qmd @@ -0,0 +1,23 @@ +--- +title: "Axe False - RevealJS" +format: + revealjs: + axe: false +_quarto: + tests: + revealjs: + ensureHtmlElements: + - [] + - ['script[src*="axe-check"]', '#quarto-axe-checker-options'] + ensureFileRegexMatches: + - [] + - ['axe-check\.js', 'quarto-axe-checker-options'] +--- + +## Slide 1 + +Render with axe: false — no axe dependencies should be present. + +## Slide 2 + +This document verifies that axe can be explicitly disabled for RevealJS format. diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index a3f27637db6..2cc1ffe00d9 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -66,6 +66,14 @@ const testCases: AxeTestCase[] = [ // 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' }, + { 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' }, // Dashboard with pages — multi-page dashboard with global sidebar { format: 'dashboard-pages', outputMode: 'document', url: '/dashboard/axe-accessibility-pages.html', @@ -272,12 +280,41 @@ test.describe('Dashboard axe — offcanvas interaction and highlight', () => { await target.hover(); // The corresponding element in the dashboard should get highlight class - const highlighted = page.locator(`${selector}.quarto-axe-hover-highlight`); - await expect(highlighted).toBeAttached({ timeout: 3000 }); + // Use .first() since selector may match multiple elements + const element = page.locator(selector).first(); + await expect(element).toHaveClass(/quarto-axe-hover-highlight/, { timeout: 3000 }); + + // Move mouse away — highlight should be removed + await page.mouse.move(0, 0); + await expect(element).not.toHaveClass(/quarto-axe-hover-highlight/); + }); +}); + +test.describe('HTML axe — hover interaction and highlight', () => { + const htmlUrl = '/html/axe-accessibility.html'; + + test('hover highlights the corresponding page element', async ({ page }) => { + await page.goto(htmlUrl, { waitUntil: 'networkidle' }); + + // Wait for axe to complete + const axeReport = page.locator('.quarto-axe-report'); + await expect(axeReport).toBeVisible({ timeout: 10000 }); + + // Find the first violation target and get its CSS selector text + const target = axeReport.locator('.quarto-axe-violation-target').first(); + const selector = await target.textContent(); + + // Hover the target (event bubbles to parent with mouseenter listener) + await target.hover(); + + // The corresponding element on the page should get highlight class + // Use .first() since selector may match multiple elements (e.g., "span") + const element = page.locator(selector).first(); + await expect(element).toHaveClass(/quarto-axe-hover-highlight/, { timeout: 3000 }); // Move mouse away — highlight should be removed await page.mouse.move(0, 0); - await expect(highlighted).not.toBeAttached(); + await expect(element).not.toHaveClass(/quarto-axe-hover-highlight/); }); }); From 4affb36be86b76b12c93bc109ca1a3fdcca4eb20 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 19:43:02 +0100 Subject: [PATCH 34/35] Clarify hover test implementation comments Add explanatory comments to hover interaction tests: - Explain .first() usage (axe-core may produce non-unique selectors) - Clarify mouse.move(0,0) intent (clear hover state) Tests verify integration (hover triggers highlight) not selector uniqueness. --- .../playwright/tests/axe-accessibility.spec.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index 2cc1ffe00d9..45a519e45f7 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -280,11 +280,12 @@ test.describe('Dashboard axe — offcanvas interaction and highlight', () => { await target.hover(); // The corresponding element in the dashboard should get highlight class - // Use .first() since selector may match multiple elements + // Use .first() since axe-core may produce non-unique selectors (e.g., "span"). + // This tests integration (hover triggers highlight) not selector uniqueness. const element = page.locator(selector).first(); await expect(element).toHaveClass(/quarto-axe-hover-highlight/, { timeout: 3000 }); - // Move mouse away — highlight should be removed + // Move mouse to top-left corner (away from all elements) to clear hover state await page.mouse.move(0, 0); await expect(element).not.toHaveClass(/quarto-axe-hover-highlight/); }); @@ -308,11 +309,12 @@ test.describe('HTML axe — hover interaction and highlight', () => { await target.hover(); // The corresponding element on the page should get highlight class - // Use .first() since selector may match multiple elements (e.g., "span") + // Use .first() since axe-core may produce non-unique selectors (e.g., "span"). + // This tests integration (hover triggers highlight) not selector uniqueness. const element = page.locator(selector).first(); await expect(element).toHaveClass(/quarto-axe-hover-highlight/, { timeout: 3000 }); - // Move mouse away — highlight should be removed + // Move mouse to top-left corner (away from all elements) to clear hover state await page.mouse.move(0, 0); await expect(element).not.toHaveClass(/quarto-axe-hover-highlight/); }); From bfd181f36fea59eee41061edc2b2a2fc33df18d2 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Feb 2026 21:52:10 +0100 Subject: [PATCH 35/35] Document Playwright best practices from axe test development Add llm-docs/playwright-best-practices.md with patterns for writing reliable Playwright tests, derived from comprehensive axe accessibility test development (PR #14125). Covers web-first assertions, role-based selectors, handling non-unique selectors, completion signals, and parameterized testing. Refactor .claude/rules/testing/playwright-tests.md to brief reference format with link to detailed documentation, reducing auto-loaded context while preserving knowledge for explicit access. --- .claude/rules/testing/playwright-tests.md | 11 + llm-docs/playwright-best-practices.md | 367 ++++++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 llm-docs/playwright-best-practices.md diff --git a/.claude/rules/testing/playwright-tests.md b/.claude/rules/testing/playwright-tests.md index 98feb76c33c..8cc3781a043 100644 --- a/.claude/rules/testing/playwright-tests.md +++ b/.claude/rules/testing/playwright-tests.md @@ -111,6 +111,17 @@ uv run python -m http.server 8080 # Serves from tests/docs/playwright/ ``` +## Best Practices + +**For detailed examples and patterns, see [llm-docs/playwright-best-practices.md](../../llm-docs/playwright-best-practices.md)** + +Key patterns for reliable tests: + +- **Web-first assertions:** Use `expect(el).toContainText()`, `toBeAttached()`, `toHaveCSS()` instead of imperative DOM queries +- **Role-based selectors:** Prefer `getByRole('tab', { name: 'Page 2' })` over `locator('a[data-bs-target]')` +- **Non-unique selectors:** When using `.first()`, add comment explaining why selector may be non-unique and what you're testing +- **Completion signals:** Use `data-feature-complete` attributes in finally blocks instead of arbitrary delays + ## Utilities From `src/utils.ts`: diff --git a/llm-docs/playwright-best-practices.md b/llm-docs/playwright-best-practices.md new file mode 100644 index 00000000000..72d3669b45a --- /dev/null +++ b/llm-docs/playwright-best-practices.md @@ -0,0 +1,367 @@ +--- +main_commit: ee0f68be1 +analyzed_date: 2026-02-27 +key_files: + - tests/integration/playwright/tests/axe-accessibility.spec.ts + - tests/integration/playwright/tests/html-math-katex.spec.ts +--- + +# Playwright Testing Best Practices + +Best practices for writing reliable, maintainable Playwright tests in Quarto CLI, derived from comprehensive test development (axe-accessibility.spec.ts, 431 lines, 75 test cases across 3 formats). + +## Web-First Assertions + +**Always use Playwright's web-first assertions** - they auto-retry and are more reliable than imperative DOM queries. + +### Text Content + +```typescript +// ✅ Good - auto-retrying, declarative +await expect(element).toContainText('expected text'); + +// ❌ Bad - imperative, no auto-retry +const text = await element.textContent(); +expect(text).toContain('expected text'); +``` + +### Element Presence + +```typescript +// ✅ Good - built-in waiting +await expect(element).toBeAttached(); +await expect(element).toBeVisible(); + +// ❌ Bad - manual DOM check +const isAttached = await page.evaluate(() => + document.querySelector('.element') !== null +); +expect(isAttached).toBe(true); +``` + +### CSS Properties + +```typescript +// ✅ Good - direct assertion with auto-retry +await expect(element).toHaveCSS('background-color', 'rgb(255, 0, 0)'); +await expect(element).not.toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + +// ❌ Bad - manual getComputedStyle +const bgColor = await element.evaluate(el => + window.getComputedStyle(el).backgroundColor +); +expect(bgColor).toBe('rgb(255, 0, 0)'); +``` + +### Waiting for Elements + +```typescript +// ✅ Good - use locator methods +await element.waitFor({ state: 'visible' }); +await element.waitFor({ state: 'attached', timeout: 10000 }); + +// ❌ Bad - page-level selector waiting +await page.waitForSelector('.element'); +``` + +### Why Web-First Assertions? + +Web-first assertions automatically retry until: +- The condition is met (test passes) +- Timeout occurs (test fails with clear error) + +This handles timing issues gracefully without manual `waitFor()` calls or fixed delays. + +**Real-world impact from PR #14125:** Converting from imperative checks to web-first assertions eliminated multiple race conditions in cross-format testing where different formats loaded at different speeds. + +## Role-Based Selectors + +**Prefer semantic role-based selectors** over CSS attribute selectors. + +### Interactive Elements + +```typescript +// ✅ Good - semantic, resilient to markup changes +await page.getByRole('tab', { name: 'Page 2' }).click(); +await page.getByRole('button', { name: 'Toggle sidebar' }).click(); +await page.getByRole('heading', { name: 'Title' }).isVisible(); +await page.getByRole('navigation').locator('a', { hasText: 'Home' }).click(); + +// ❌ Bad - fragile, coupled to implementation +await page.locator('a[data-bs-target="#page-2"]').click(); +await page.locator('.collapse-toggle').click(); +await page.locator('h1.title').isVisible(); +await page.locator('nav a:has-text("Home")').click(); +``` + +### When to Use CSS Selectors + +Role-based selectors aren't always possible. Use CSS selectors for: +- Custom components without ARIA roles +- Testing implementation-specific classes (e.g., `.quarto-axe-report`) +- Dynamic content where role/name combinations are too generic + +```typescript +// Acceptable - testing specific implementation class +await page.locator('.quarto-axe-report').toBeVisible(); + +// Acceptable - no semantic role exists +await page.locator('section.quarto-axe-report-slide').toBeAttached(); +``` + +### Why Role-Based Selectors? + +1. **Readability:** `getByRole('tab', { name: 'Page 2' })` is self-documenting +2. **Accessibility:** If the selector works, the element is accessible +3. **Resilience:** CSS classes and attributes change; roles and labels are stable contracts +4. **Maintenance:** Easier to understand intent when reviewing tests + +**Real-world impact from PR #14125:** Dashboard rescan tests (8 test cases) initially used CSS attribute selectors like `a[data-bs-target="#page-2"]`. Refactoring to role-based selectors made tests readable without opening the HTML fixtures. + +## Handling Non-Unique Selectors + +When external tools produce selectors you don't control (e.g., axe-core returning generic "span"), use `.first()` with explanatory comments. + +### Pattern + +```typescript +// ✅ Good - explicit about why .first() is used +// Use .first() since axe-core may produce non-unique selectors (e.g., "span"). +// This tests integration (hover triggers highlight) not selector uniqueness. +const element = page.locator(selector).first(); +await expect(element).toHaveClass(/quarto-axe-hover-highlight/); + +// ❌ Bad - no explanation, unclear intent +const element = page.locator(selector).first(); +await expect(element).toHaveClass(/quarto-axe-hover-highlight/); +``` + +### When to Use `.first()` + +Use when: +- External tools generate selectors you don't control +- Test focuses on interaction/integration, not selector precision +- Selector is known to match multiple elements, but you only care about one + +**Always add comments** explaining: +1. Why the selector may be non-unique (e.g., "axe-core produces generic selectors") +2. What the test is actually verifying (e.g., "hover triggers highlight, not selector uniqueness") + +### Why Not Fix the Selector? + +In integration tests, you're often verifying end-to-end behavior with third-party libraries. The test validates that your integration code works correctly, not that the third-party library produces optimal selectors. + +**Real-world example from PR #14125:** + +```typescript +// axe-core returns CSS selectors like "span" for violations +// We test: "when hovering violation target, does page element get highlighted?" +// We don't test: "does axe produce unique selectors?" (that's axe's job) + +const target = reportSlide.locator('.quarto-axe-violation-target').first(); +await target.hover(); + +// Use .first() since axe-core may produce non-unique selectors (e.g., "span"). +// This tests integration (hover triggers highlight) not selector uniqueness. +const element = page.locator(selector).first(); +await expect(element).toHaveClass(/quarto-axe-hover-highlight/); +``` + +## Async Completion Signals + +For tests that wait for async operations (network requests, library initialization, processing), add deterministic completion signals instead of arbitrary delays or polling. + +### Pattern + +```typescript +// In application code: +async function init() { + try { + await loadLibrary(); + await processContent(); + } catch (error) { + console.error('Initialization failed:', error); + } finally { + // Always set completion signal, even if work fails + document.body.setAttribute('data-feature-complete', 'true'); + } +} + +// In test: +await page.goto('/page.html'); +await page.waitForSelector('[data-feature-complete]', { timeout: 15000 }); + +// Now you can safely assert on the results +await expect(page.locator('.result')).toBeVisible(); +``` + +### Error Handling + +The signal should **always** be set, even on failure: + +```typescript +// ✅ Good - completion signal set in finally block +async function processData() { + try { + await doAsyncWork(); + } catch (error) { + console.error('Processing failed:', error); + } finally { + document.body.setAttribute('data-processing-complete', 'true'); + } +} + +// ❌ Bad - signal only set on success +async function processData() { + try { + await doAsyncWork(); + document.body.setAttribute('data-processing-complete', 'true'); + } catch (error) { + console.error('Processing failed:', error); + } +} +``` + +### Why Not Use Fixed Delays? + +```typescript +// ❌ Bad - arbitrary delay, may be too short or unnecessarily long +await page.goto('/page.html'); +await page.waitForTimeout(5000); // Hope 5 seconds is enough? + +// ✅ Good - deterministic, completes as soon as ready +await page.goto('/page.html'); +await page.waitForSelector('[data-feature-complete]', { timeout: 15000 }); +``` + +### Why Completion Signals? + +1. **Deterministic:** Test knows exactly when async work is done +2. **Fast:** No waiting longer than necessary +3. **Clear failures:** Timeout means "work never completed" not "maybe we didn't wait long enough" +4. **Debuggable:** Missing attribute = work didn't finish or crashed + +**Real-world impact from PR #14125:** The axe accessibility tests initially had race conditions where tests would sometimes pass/fail depending on axe-core's CDN load speed. Adding `data-quarto-axe-complete` in a finally block made tests deterministic - they wait exactly as long as needed and fail clearly if axe never initializes. + +### Advanced: Generation Counters for Rescanning + +When operations can be triggered multiple times (e.g., rescanning on navigation), use generation counters to discard stale results: + +```typescript +let scanGeneration = 0; + +async function rescan() { + const currentGeneration = ++scanGeneration; + + try { + const results = await performScan(); + + // Discard stale results if user triggered another scan + if (currentGeneration !== scanGeneration) { + return; + } + + updateUI(results); + } finally { + // Only set completion for latest scan + if (currentGeneration === scanGeneration) { + document.body.setAttribute('data-scan-complete', 'true'); + } + } +} +``` + +**From PR #14125 dashboard rescan:** Users can switch tabs/pages faster than axe scans complete. Generation counters ensure old scans don't overwrite newer results. + +## Parameterized Tests + +When testing the same behavior across multiple formats or configurations, use `test.describe` with a test cases array instead of separate spec files. + +### Pattern + +```typescript +interface TestCase { + format: string; + url: string; + expectedViolation?: string; + shouldFail?: string; // Reason for expected failure +} + +const testCases: TestCase[] = [ + { format: 'html', url: '/html/feature.html', expectedViolation: 'color-contrast' }, + { format: 'revealjs', url: '/revealjs/feature.html', expectedViolation: 'link-name' }, + { + format: 'dashboard', + url: '/dashboard/feature.html', + shouldFail: 'Dashboard has no
element (#13781)' + }, +]; + +test.describe('Feature across formats', () => { + for (const { format, url, expectedViolation, shouldFail } of testCases) { + test(`${format} — feature detects ${expectedViolation}`, async ({ page }) => { + if (shouldFail) { + test.fail(); // Mark as expected failure + } + + await page.goto(url); + // Shared test logic + }); + } +}); +``` + +### When to Use Parameterized Tests + +Use when: +- Same assertion logic applied to multiple formats (html, revealjs, dashboard, pdf) +- Testing multiple output modes (console, json, document) +- Testing across configurations (themes, options, feature flags) + +**Benefits:** +- Reduces file count (1 spec file instead of 3-10) +- Centralizes shared helpers +- Easy to add new test cases +- Clear comparison of format differences + +**From PR #14125:** axe-accessibility.spec.ts tests 3 formats × 3 output modes = 9 base cases in a single 431-line file instead of 9 separate spec files. + +### Expected Failures with test.fail() + +Mark known failures explicitly: + +```typescript +test('Feature that is broken in revealjs', async ({ page }) => { + // RevealJS doesn't support this yet (#13781) + test.fail(); + + // Normal test logic - if this unexpectedly passes, Playwright will flag it + await page.goto('/revealjs/feature.html'); + await expect(page.locator('.feature')).toBeVisible(); +}); +``` + +**Why use test.fail():** +- Documents known issues in test suite +- Test passes when it fails (expected behavior) +- Test **fails** if it unexpectedly passes (signals the bug is fixed) +- Better than commenting out tests or skipping with test.skip() + +## Summary + +**Four key patterns for reliable Playwright tests:** + +1. **Web-first assertions** - `expect(el).toContainText()` not `expect(await el.textContent())` +2. **Role-based selectors** - `getByRole('tab', { name: 'Page 2' })` not `locator('a[data-bs-target]')` +3. **Explicit .first() comments** - Explain why and what you're testing +4. **Completion signals** - `data-feature-complete` in finally blocks, not arbitrary delays + +These patterns emerged from building comprehensive cross-format test coverage and debugging race conditions. They make tests: +- More reliable (fewer flaky failures) +- More readable (intent is clear) +- Easier to maintain (resilient to markup changes) +- Faster to debug (clear failure modes) + +**Reference implementations:** +- `tests/integration/playwright/tests/axe-accessibility.spec.ts` - 431 lines, 75 test cases +- `tests/integration/playwright/tests/html-math-katex.spec.ts` - Parameterized format testing