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/.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/.claude/rules/testing/playwright-tests.md b/.claude/rules/testing/playwright-tests.md
index 229087cc627..8cc3781a043 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`
@@ -77,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/axe-accessibility-architecture.md b/llm-docs/axe-accessibility-architecture.md
new file mode 100644
index 00000000000..4b0ee2a84c1
--- /dev/null
+++ b/llm-docs/axe-accessibility-architecture.md
@@ -0,0 +1,102 @@
+---
+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
+ - 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/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
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 |
diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md
index 4f38ae872a0..235d199858a 100644
--- a/news/changelog-1.9.md
+++ b/news/changelog-1.9.md
@@ -104,7 +104,13 @@ 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.
+- ([#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`
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");
}
diff --git a/src/format/html/format-html-axe.ts b/src/format/html/format-html-axe.ts
index b931cb1a459..7bdc33e49e3 100644
--- a/src/format/html/format-html-axe.ts
+++ b/src/format/html/format-html-axe.ts
@@ -4,43 +4,59 @@
* Copyright (C) 2020-2025 Posit Software, PBC
*/
-import { kIncludeInHeader } from "../../config/constants.ts";
-import { Format, FormatExtras } from "../../config/types.ts";
-import { TempContext } from "../../core/temp-types.ts";
+import { isHtmlDashboardOutput, isRevealjsOutput } from "../../config/format.ts";
+import {
+ Format,
+ FormatDependency,
+ FormatExtras,
+ kDependencies,
+} from "../../config/types.ts";
+import { formatResourcePath } from "../../core/resources.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,
+ format: Format,
options?: unknown,
): FormatExtras {
if (!options) return {};
- return {
- [kIncludeInHeader]: [
- temp.createFileFromString(
- ``,
- ),
- ],
- html: {
- "sass-bundles": [
- {
- key: "axe",
- dependency: "bootstrap",
- user: [{
- uses: "",
- defaults: "",
- functions: "",
- mixins: "",
- rules: `
+ // 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 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)
+ const baseRules = `
body div.quarto-axe-report {
position: fixed;
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; }
@@ -51,11 +67,95 @@ body div.quarto-axe-report {
text-decoration: underline;
cursor: pointer;
}
-
+
.quarto-axe-hover-highlight {
background-color: red;
- border: 1px solid $body-color;
-}`,
+ 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;
+ font-size: 0.55em;
+ h2 {
+ margin-bottom: 0.5em;
+ font-size: 1.8em;
+ }
+ div.quarto-axe-report {
+ position: static;
+ padding: 0;
+ border: none;
+ background-color: transparent;
+ max-height: none;
+ 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;
+ }
+}`
+ : "";
+
+ // Dashboard: report inside offcanvas sidebar (not fixed overlay)
+ const dashboardRules = isDashboard
+ ? `
+.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-dashboard .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;
+}
+.quarto-dashboard .quarto-axe-scanning {
+ padding: 1rem;
+ text-align: center;
+ opacity: 0.7;
+ font-style: italic;
+}`
+ : "";
+
+ return {
+ html: {
+ [kDependencies]: [
+ axeHtmlDependency(options),
+ ],
+ "sass-bundles": [
+ {
+ key: "axe",
+ dependency: sassDependency,
+ user: [{
+ uses: "",
+ defaults: `
+$body-color: #222 !default;
+$body-bg: #fff !default;
+$link-color: #2a76dd !default;
+`,
+ functions: "",
+ mixins: "",
+ rules: baseRules + revealjsRules + dashboardRules,
}],
},
],
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
diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js
index 88080850403..a1ee19816d0 100644
--- a/src/resources/formats/html/axe/axe-check.js
+++ b/src/resources/formats/html/axe/axe-check.js
@@ -42,6 +42,33 @@ 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");
+ 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" });
+ }
+ }
+
createViolationElement(violation) {
const violationElement = document.createElement("div");
@@ -58,41 +85,32 @@ 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";
targetElement.innerText = target;
nodeElement.appendChild(targetElement);
- nodeElement.addEventListener("mouseenter", () => {
- const element = document.querySelector(target);
- if (element) {
- element.scrollIntoView({ behavior: "smooth", block: "center" });
- 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));
- });
- nodeElement.appendChild(targetElement);
+ if (typeof Reveal !== "undefined") {
+ // RevealJS: click navigates to the slide and highlights briefly
+ nodeElement.addEventListener("click", () => {
+ const el = this.highlightTarget(target);
+ if (el) setTimeout(() => el.classList.remove("quarto-axe-hover-highlight"), 3000);
+ });
+ } else {
+ // 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);
}
return violationElement;
}
- report() {
+ createReportElement() {
const violations = this.axeResult.violations;
const reportElement = document.createElement("div");
reportElement.className = "quarto-axe-report";
@@ -105,7 +123,107 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter {
violations.forEach((violation) => {
reportElement.appendChild(this.createViolationElement(violation));
});
- document.querySelector("main").appendChild(reportElement);
+ 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 = "slide 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);
+ }
+
+ 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");
+ toggle.setAttribute("aria-label", "Toggle accessibility report");
+
+ 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()) {
+ this.createReportSlide();
+ } else {
+ return new Promise((resolve) => {
+ Reveal.on("ready", () => {
+ this.createReportSlide();
+ resolve();
+ });
+ });
+ }
+ } else if (document.body.classList.contains("quarto-dashboard")) {
+ this.createReportOffcanvas();
+ } else {
+ this.createReportOverlay();
+ }
}
}
@@ -117,29 +235,146 @@ 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;
}
- 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 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 }
+ // 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");
});
- const reporter = this.options === true ? new QuartoAxeConsoleReporter(result) : new reporters[this.options.output](result, this.options);
- reporter.report();
+ 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 runAxeScan() {
+ const saved = this.revealUnhideSlides();
+ try {
+ 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
+ // all tabster elements
+ "[data-tabster-dummy]"
+ ],
+ preload: { assets: ['cssom'], timeout: 50000 }
+ });
+ } 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.
+ // 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);
+ });
+
+ // 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);
+ }
+
+ try {
+ 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);
+ }
+ } 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.setAttribute("role", "alert");
+ 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");
+ }
+ }
+ }
+
+ async init() {
+ 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');
+ }
}
}
-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));
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();
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/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/docs/playwright/dashboard/.gitignore b/tests/docs/playwright/dashboard/.gitignore
new file mode 100644
index 00000000000..cc2dd8b1d3f
--- /dev/null
+++ b/tests/docs/playwright/dashboard/.gitignore
@@ -0,0 +1,4 @@
+*.html
+*_files/
+/.quarto/
+**/*.quarto_ipynb
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-accessibility-pages.qmd b/tests/docs/playwright/dashboard/axe-accessibility-pages.qmd
new file mode 100644
index 00000000000..3adc7b8a507
--- /dev/null
+++ b/tests/docs/playwright/dashboard/axe-accessibility-pages.qmd
@@ -0,0 +1,30 @@
+---
+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 {.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="Tab B"}
+This tab has only normal contrast text. No intentional violations here.
+:::
diff --git a/tests/docs/playwright/dashboard/axe-accessibility.qmd b/tests/docs/playwright/dashboard/axe-accessibility.qmd
new file mode 100644
index 00000000000..04514859128
--- /dev/null
+++ b/tests/docs/playwright/dashboard/axe-accessibility.qmd
@@ -0,0 +1,47 @@
+---
+title: "Sales Dashboard"
+format: dashboard
+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: #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-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/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/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-accessibility-dark.qmd b/tests/docs/playwright/revealjs/axe-accessibility-dark.qmd
new file mode 100644
index 00000000000..4dafc222c8a
--- /dev/null
+++ b/tests/docs/playwright/revealjs/axe-accessibility-dark.qmd
@@ -0,0 +1,19 @@
+---
+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/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/docs/playwright/revealjs/axe-accessibility.qmd b/tests/docs/playwright/revealjs/axe-accessibility.qmd
new file mode 100644
index 00000000000..cd2c446341a
--- /dev/null
+++ b/tests/docs/playwright/revealjs/axe-accessibility.qmd
@@ -0,0 +1,18 @@
+---
+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/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/.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/
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..fede2d9a135
--- /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-check"]', '#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/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-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-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-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-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/docs/smoke-all/accessibility/axe-html.qmd b/tests/docs/smoke-all/accessibility/axe-html.qmd
new file mode 100644
index 00000000000..188bcf0991b
--- /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-check"]', '#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..cdc416e40e7
--- /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-check"]', '#quarto-axe-checker-options']
+ - []
+ ensureFileRegexMatches:
+ - ['quarto-axe-checker-options']
+ - []
+---
+
+## Slide 1
+
+Content for accessibility checking.
+
+## Slide 2
+
+More content.
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
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..45a519e45f7
--- /dev/null
+++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts
@@ -0,0 +1,470 @@
+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.locator('[data-quarto-axe-complete]').waitFor({ 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;
+ // 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 — 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 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',
+ expectedViolation: 'link-name' },
+ { format: 'revealjs', outputMode: 'console', url: '/revealjs/axe-console.html',
+ expectedViolation: 'link-name' },
+ { 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' },
+ { 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',
+ 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' },
+ 'image-alt': { document: 'alternative text', console: 'alternative text' },
+};
+
+// -- Tests --
+
+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' });
+
+ 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();
+ await expect(axeReport).toContainText(violationText[expectedViolation].document);
+
+ // Report element is static (not fixed overlay)
+ await expect(axeReport).toHaveCSS('position', 'static');
+
+ } 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 });
+
+ // 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: 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);
+
+ // Verify report overlay CSS properties
+ await expect(axeReport).toHaveCSS('z-index', '9999');
+ await expect(axeReport).toHaveCSS('overflow-y', 'auto');
+
+ // Background must not be transparent
+ 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);
+ 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);
+ await page.goto(url, { waitUntil: 'networkidle' });
+ await waitForAxeCompletion(page);
+ const result = findAxeJsonResult(messages);
+ expect(result).toBeDefined();
+ expect(result!.violations.length).toBeGreaterThan(0);
+ expect(result!.violations.some(v => v.id === expectedViolation)).toBe(true);
+ }
+ });
+ }
+});
+
+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);
+ 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 });
+ await expect(reportSlide).toContainText(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 2 (index 1)
+ const imgTarget = reportSlide.locator('.quarto-axe-violation-target', { hasText: 'img' });
+ await imgTarget.click();
+
+ // After click, Reveal should have navigated to Slide 2 (index 1)
+ const afterClick = await page.evaluate(() => Reveal.getIndices().h);
+ expect(afterClick).toBe(1);
+
+ // The img element should have the highlight class
+ 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 });
+ 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
+ // 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 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/);
+ });
+});
+
+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 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 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/);
+ });
+});
+
+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.getByRole('tab', { name: '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);
+
+ // 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 }) => {
+ 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.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.getByRole('tab', { name: '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.getByRole('button', { name: 'Toggle sidebar' }).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.getByRole('button', { name: 'Toggle sidebar' }).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.getByRole('tab', { name: '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);
+ });
+
+ 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.getByRole('tab', { name: '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.getByRole('tab', { name: '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.getByRole('tab', { name: '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);
+ });
+});