Skip to content

axe: output: document: report never updates after page state changes  #14668

Description

@cwickham

Description

The interactive axe overlay (axe: output: document) is Quarto's tool for an author eyeballing accessibility while editing under quarto preview. It runs axe-core exactly once, at page load. But axe-core evaluates the rendered, visible DOM: color-contrast is computed from the colours on screen right now, and hidden elements (display: none) are skipped entirely. So two ordinary reader interactions invalidate the report, and the overlay gives no indication it's showing results for a state the page is no longer in:

  1. Toggling dark/light mode. With theme: {light: ..., dark: ...}, the initial scan runs in whichever scheme loads first. Toggle the scheme and every color-contrast result is potentially wrong. A full page reload re-scans in the persisted scheme, but nothing tells the author a reload is needed.
  2. Expanding collapsed content. Violations inside collapsed callouts, folded code, closed tabsets, etc. are invisible to the load-time scan. This case is worse than stale: expanding doesn't trigger a rescan, and a reload re-collapses the content — so these violations are unreachable by the overlay no matter what the author does.

Dashboards already solve this: setupDashboardRescan re-runs axe and rebuilds the report on shown.bs.tab, popstate, and bslib.sidebar — but it's gated to body.quarto-dashboard:

  • rescan machinery:
    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());
    }
  • dashboard-only gate:
    if (document.body.classList.contains("quarto-dashboard") &&
    this.options.output === "document") {
    this.setupDashboardRescan();
    }

Proposal: extend the same rescan to regular HTML documents when output: document:

  • rescan when the colour scheme toggles (the .quarto-color-scheme-toggle click handler / toggleColorMode);
  • rescan on Bootstrap state events, as dashboards do: shown.bs.collapse / hidden.bs.collapse (callouts, code folding), shown.bs.tab (tabsets), plus shown.bs.dropdown / shown.bs.modal / shown.bs.offcanvas where relevant;
  • and/or a manual "Rescan" button in the overlay as the catch-all, so the author can always refresh the report to match whatever state they've put the page in.

Most of the machinery exists; the work is ungating it and pointing the re-render at the overlay report element rather than the dashboard offcanvas.

This is deliberately scoped to the interactive overlay under quarto preview. Automated coverage of viewport/theme/interaction states (for output: json, CI, etc.) is a different problem I'll write up separately.

Steps to reproduce

---
title: "Axe overlay staleness repro"
format:
  html:
    theme:
      light: flatly
      dark: darkly
    axe:
      output: document
---

## Dark-mode-only contrast failure

<p style="color: #555555;">This paragraph is `#555555` — fine on flatly's white
background, but low contrast on darkly's dark background.</p>

## Collapsed callout hiding a violation

::: {.callout-note collapse="true" title="Expand me"}
<img src="figure.png" width="50" height="50">

The image above has no alt text — invisible to axe while collapsed.
:::

(figure.png is any image; it exists only to carry a missing alt.)

  1. quarto preview the document (the overlay needs http:; module scripts don't load from file:).
  2. Note the overlay report at the bottom of the page.
  3. Click the dark-mode toggle (top right). The page turns dark; the overlay doesn't change.
  4. Expand the callout. The overlay doesn't change.

Actual behavior

The overlay shows the load-time scan forever. Verified by driving headless Chrome over CDP and comparing the overlay's contents against a fresh in-page axe.run() after each interaction (Quarto 1.10.12):

State Overlay shows Fresh axe.run() finds
Load (light, collapsed) load-time results same as overlay
After dark toggle unchanged + color-contrast (2 nodes: the #555555 paragraph and its inline code)
After expanding callout unchanged + image-alt (the callout's image)

Reloading the page in dark mode (the scheme persists via localStorage) does surface the color-contrast violations — confirming they're real and that a fresh scan catches them — but the callout re-collapses on reload, so the image-alt violation can never appear in the overlay.

Expected behavior

The overlay report reflects the page state the author is looking at: it rescans (automatically on scheme toggle and expand/collapse events, or at minimum via a manual "Rescan" button) so that mode-specific contrast failures and violations inside collapsible content are catchable during interactive checking, the way they already are in dashboards.

Your environment

  • Quarto 1.10.12
  • macOS 26.5.1 (build 25F80), Chrome (headless, CDP) for verification

AI-assisted investigation, grounded in the rendered output and the quarto-cli source at commit 38ef483 (per CONTRIBUTING.md "Using AI tools to investigate").

Metadata

Metadata

Assignees

No one assigned

    Labels

    accessibilityhtmlIssues with HTML and related web technology (html/css/scss/js)

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions