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); + }); +});