diff --git a/.claude/rules/dev-tools/axe-scan.md b/.claude/rules/dev-tools/axe-scan.md new file mode 100644 index 00000000000..f0fc5857bc3 --- /dev/null +++ b/.claude/rules/dev-tools/axe-scan.md @@ -0,0 +1,21 @@ +--- +paths: + - "src/command/call/axe/**" + - "tests/unit/axe-*.test.ts" + - "tests/smoke/axe/**" + - "tests/docs/axe-scan/**" +--- + +# Axe Scan Command (`quarto call axe`) + +For the scanner's architecture — pipeline, mode discovery, signatures, +baseline semantics, exit codes, and the rationale behind each — see +`llm-docs/axe-scan-architecture.md`. + +For how to use the command (flags, baseline workflow, CI recipe), see +`dev-docs/axe-scan.md`. + +The signature normalization scheme is pinned by +`tests/unit/axe-signature.test.ts`: changing `normalizeSelector` or +`signatureOf` in a way that re-keys signatures requires bumping +`kSignatureScheme` in `schemas.ts` on purpose. diff --git a/.claude/rules/formats/html-dark-mode.md b/.claude/rules/formats/html-dark-mode.md new file mode 100644 index 00000000000..81b43cd7c2d --- /dev/null +++ b/.claude/rules/formats/html-dark-mode.md @@ -0,0 +1,22 @@ +--- +paths: + - "src/format/html/format-html-info*" + - "src/format/html/format-html-bootstrap*" + - "src/command/render/pandoc-html*" + - "src/resources/formats/html/templates/quarto-html-*body.ejs" + - "src/core/brand/**" + - "src/command/dev-call/axe/**" +--- + +# HTML Dark Mode + +`formatDarkMode(format)` (`src/format/html/format-html-info.ts`) is the single +predicate for "does this page have a dark mode" — `undefined` means no. +The `data-mode` link attribute measures compiled CSS darkness; it does not +declare the author's slot. The dark slot's stable identity is the +`quarto-color-alternate` class. + +For the full picture — configuration surface (`theme`, `brand`/`_brand.yml`), +rendered DOM markers, programmatic mode switching, and the known traps +(light-only brand, light-colored dark slots, `theme: darkly`) — see +`llm-docs/html-dark-mode-architecture.md`. diff --git a/dev-docs/axe-scan.md b/dev-docs/axe-scan.md new file mode 100644 index 00000000000..1d1f58d7386 --- /dev/null +++ b/dev-docs/axe-scan.md @@ -0,0 +1,192 @@ +# Scanning a site with `quarto call axe` + +`quarto call axe` scans a rendered Quarto site for accessibility +violations with axe-core. It drives headless Chrome over every page, at +desktop and mobile widths, in each colour mode the page ships. It groups +violations by root cause, compares them against a committed baseline, and +writes a report you can read, commit review comments from, or gate CI on. + +**Status: experimental.** The command is hidden (it does not show in +`quarto call` help) and makes no stability promise — flags, artifact shapes, +and semantics can change between prereleases. This page is contributor-facing +documentation; it graduates to quarto.org when the command is unhidden (the +same path the `axe:` render option's docs took). + +Prerequisite: a Chromium the scanner can find. `quarto install +chrome-headless-shell` is the reliable route; an installed system Chrome or +Edge also works. + +## When to reach for it + +The render-time `axe:` option checks the page you are looking at, in your +browser, while you author. This command checks the *site*: every page, both +viewports, light and dark, at audit time or in CI. Reach for it when you +want a site-wide inventory, a regression gate, or output an agent can work +through. + +## First scan + +```sh +quarto render +quarto call axe _site +``` + +The scan prints its matrix up front (`43 pages (40 light+dark, 3 default) × +2 viewports — 166 cells`), then one line per cell, then a findings summary. +Artifacts land at the project root (the nearest `_quarto.yml` at or above +the site dir): + +- `_axe-checks/findings.json` — every finding, machine-readable. +- `_axe-checks/report.md` — the human summary, GitHub-flavored markdown. +- `_axe-checks/README.md` — generated docs for the artifacts, including the + baseline how-to. Read this after a scan; it is written for whoever (or + whatever) has only the artifact directory in front of them. +- `_axe-checks/cells/` — raw axe output per page × viewport × mode cell. + +`_axe-checks/` ignores itself (`.gitignore` with `*`): it is a disposable +snapshot, not a thing to commit. + +### Reading report.md + +The report is GitHub-flavored markdown, made to be read where markdown +already renders: your editor's preview, GitHub, or the sticky PR comment +from the CI recipe below. Rendering it is optional. For a standalone HTML +view: + +```sh +quarto render _axe-checks/report.md +``` + +then open `_axe-checks/report.html` in your browser. The output lands +beside the report, inside the self-ignoring artifact directory. + +`quarto preview _axe-checks/report.md` does **not** work from inside a +project: `_`-prefixed directories are not project inputs, so preview stops +with `No output created by quarto render report.md`. Use render-then-open, +or pass `--report` a path inside your site source (see the flags table) to +render and preview the report as part of the site. + +A finding is one *root cause*, not one element: an alt-less image in a +shared include shows up as one finding with an instance count, not once per +page. Grouping keys on axe's element selector (normalized), so this holds +when the pages describe the element the same way — reliably true for +Quarto's own chrome and for repeated template output, and occasionally +wrong for an anonymous element whose surrounding DOM differs page to page +(axe then picks different selectors, and one cause splits into two +findings). Findings on many pages usually come from a shared source — a +template, the theme, Quarto's own chrome — and one fix clears them all. + +## Flags + +| flag | default | | +|---|---|---| +| `--pages ` | all `*.html` | comma-separated site-relative globs | +| `--exclude ` | — | skip globs, applied after `--pages` | +| `--max-pages ` | ∞ | deterministic cap (sorted, first n) | +| `--viewports ` | `1440x900,320x568` | | +| `--themes ` | `light,dark` | filters two-mode pages; one-mode pages always scan once | +| `--timeout ` | `30000` | per-cell budget | +| `--settle ` | `50` | extra delay after the page reports ready | +| `--fail-on ` | off | exit 1 on new findings at/above `minor`/`moderate`/`serious`/`critical` | +| `--report ` | `_axe-checks/report.md` | put the report elsewhere, e.g. inside your site source | + +The narrow default viewport is 320 CSS px — the width WCAG's reflow +criterion (SC 1.4.10) names, equivalent to 400% zoom on a 1280 px window — +so every rule runs against the reflowed mobile layout. + +A subset scan (`--pages`, `--exclude`, `--max-pages`) says so loudly in +every artifact: its counts describe the subset, not the site. + +## Running it as a post-render step + +The command works as a project `post-render` script, so a full render scans +itself: + +```yaml +project: + type: website + post-render: + - quarto call axe _site/ +``` + +The script runs from the project directory after the outputs are written, +so the relative site dir and the artifact anchor resolve exactly as they do +on the command line. Exit codes propagate: without `--fail-on`, findings +never fail the render; with `--fail-on`, a new finding at the threshold — +or an incomplete scan — fails `quarto render` itself, with the scan's error +line in the render output. This runs on every full render of the project, +which adds the scan's runtime to each render. + +## The baseline workflow + +The first scan of a real site reports findings you will not fix today: +upstream defects, deferred best-practice items, known false positives. The +baseline is the committed ledger of those decisions — `_axe-baseline.json` +at the project root, beside (not inside) `_axe-checks/`. + +1. Scan, then fix what you can from `report.md`. +2. For each finding you are accepting instead: copy it out of + `findings.json` into the baseline's `findings` array, trim it, and write + a `note` saying why. The generated `_axe-checks/README.md` documents the + entry shape and the scoping rules (`pages: []` accepts site-wide; + a listed `pages` re-alerts anywhere else). +3. Commit `_axe-baseline.json`. + +From then on, reports separate **new** findings from **baselined** ones, +and only new findings can fail CI. A baselined finding that escalates in +impact, or shows up on a page outside its scope, re-alerts as new. Entries +a full-site scan no longer sees are reported as stale — prune them by hand. + +There is deliberately no `--update-baseline`: every entry exists because +someone wrote it and said why. + +## Exit codes and the CI recipe + +| exit | meaning | +|---|---| +| `0` | scan complete; no new findings at/above `--fail-on` (when given) | +| `1` | complete scan, new findings at/above the `--fail-on` threshold | +| `2` | scan incomplete — a cell timed out or errored, no browser, nothing to scan. Takes precedence over 1: an incomplete scan never reads as a pass | + +A minimal GitHub Actions gate: + +```yaml +- uses: quarto-dev/quarto-actions/setup@v2 +- run: quarto install chrome-headless-shell --no-prompt +- run: quarto render +- run: quarto call axe _site --fail-on serious +``` + +`report.md` is GitHub-flavored markdown, so posting it as a PR comment is +workflow configuration, not tooling. A sticky comment beats a plain one: +it updates in place on each push instead of stacking a new comment per run: + +```yaml +- if: always() + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: axe + path: _axe-checks/report.md +``` + +(`if: always()` keeps the comment current when `--fail-on` fails the job; +`header` keys the comment so other sticky comments are untouched. GitHub +caps comment bodies at 65,536 characters; a whole-site report on a large +site can exceed it — trim or attach as an artifact instead.) + +## Reading the output as an agent + +Point the agent at `_axe-checks/` and let it read the generated `README.md` +first. Finding ids are stable across runs (`image-alt-6e3b76`), so "fix +`image-alt-6e3b76`" is a well-defined instruction, and each finding's +`occurrences[]` carries real selectors and HTML excerpts. Fixes belong in +Quarto *source* (`.qmd`, `_quarto.yml`, `_brand.yml`, theme `.scss`) — never +in the rendered site directory. + +## Where the pieces are documented + +- How it works and why: `llm-docs/axe-scan-architecture.md`. +- What the artifacts mean, baseline entry shape: the generated + `_axe-checks/README.md` (regenerated every scan, always matches the build + that wrote it). +- Render-time single-page checking: the `axe:` HTML format option. diff --git a/llm-docs/axe-scan-architecture.md b/llm-docs/axe-scan-architecture.md new file mode 100644 index 00000000000..6708adca0bd --- /dev/null +++ b/llm-docs/axe-scan-architecture.md @@ -0,0 +1,289 @@ +--- +main_commit: abc6a78ed +analyzed_date: 2026-08-27 +key_files: + - src/command/call/axe/cmd.ts + - src/command/call/axe/config.ts + - src/command/call/axe/discover.ts + - src/command/call/axe/scan.ts + - src/command/call/axe/aggregate.ts + - src/command/call/axe/schemas.ts + - src/command/call/axe/conformance.ts + - src/command/call/axe/report.ts + - src/command/call/axe/readme.ts +--- + +# Axe Scan Architecture (`quarto call axe`) + +`quarto call axe ` scans an already-rendered site for +accessibility violations. It serves the site dir locally, drives headless +Chrome over raw CDP, and runs quarto-cli's vendored axe-core against every +page × viewport × colour-mode cell. It groups violations by root-cause +signature, reconciles them against a hand-written baseline, and writes +`findings.json`, `report.md`, and a generated `README.md`. + +The command is a hidden prototype under `call` (hidden while experimental — +it does not show in `quarto call` help). It validates the +semantics for a future public `quarto axe` command. How to *use* it is in +`dev-docs/axe-scan.md`; this document explains how it works and why. + +Two sibling documents cover adjacent systems: the render-time `axe:` overlay +(`llm-docs/axe-accessibility-architecture.md`) and the dark-mode plumbing the +mode axis is built on (`llm-docs/html-dark-mode-architecture.md`). + +## Pipeline + +``` +discover.ts scan.ts aggregate.ts report.ts / readme.ts +pages + modes → cells (raw axe → findings.json → report.md, README.md +redirect stubs payloads, on disk) (the contract) +``` + +`cmd.ts` orchestrates: it resolves the artifact anchor, reads the baseline, +serves the site dir (`handleHttpRequests` from `src/core/http-server.ts`), +launches Chrome, chains the stages, prints the summary, and maps the result +to an exit code. Each stage also works standalone, so tests feed captured +cell payloads straight into the aggregate stage without a browser. + +The one architectural change from the quarto-web harness this was ported +from: axe is injected at scan time (`formatResourcePath("html", +"axe/axe.min.js")`), not by a render-time hook. Any rendered site scans +as-is, offline, on one known axe version. + +**Default viewports** (`kDefaultViewports`, config.ts): `1440x900` renders +the desktop chrome (full navbar, sidebar, margin TOC); `320x568` renders +the reflowed mobile chrome (hamburger, off-canvas). The narrow width is the +one viewport WCAG names — SC 1.4.10 Reflow forbids 2-D scrolling at 320 CSS +px, the 400%-zoom equivalent of a 1280 window. axe has no automated reflow +rule; the point is that the *whole ruleset* runs against the reflowed +layout, and overflow the reflow failure creates surfaces indirectly (e.g. +`scrollable-region-focusable`). The pair is one viewport per chrome regime, +not device fidelity — any width in the same Bootstrap breakpoint band +exercises nearly identical markup. (The narrow default was `390x844`, the +iPhone 12-14 logical size inherited from the harness, until 2026-08-28.) + +## Discovery: the matrix is known before the browser launches + +`discoverPages` walks `` for `*.html` (skipping `_axe-checks` and +`site_libs`), applies `--pages`, then `--exclude`, then `--max-pages` (sorted +first, so the cap is deterministic). It reads each surviving file once and +classifies it: + +- **Redirect stubs** (`aliases:` meta-refresh pages, `_redirects` scripts) + are site furniture, not content. They are set aside and *recorded* — in + the console, in `findings.json` under `redirects[]` — never silently + dropped. The scan-time redirect guard (below) stays the fail-closed net + for redirects this sniff cannot see. +- **Pages** get their colour modes sniffed from the HTML. The marker for + "this page has two modes" is the inline before-body script that defines + `window.quartoToggleColorScheme`. Quarto emits that script iff + `formatDarkMode(format) !== undefined` — the single upstream predicate. + The sniff matches the *definition* (`window.quartoToggleColorScheme =`), + not the bare name, so documentation about the toggle does not + false-trigger. The emitting template + (`quarto-html-before-body.ejs`) carries a pointer back at the sniffer. + +A two-mode page contributes `light` and `dark` cells — the author's two +slots. A one-mode page contributes one `default` cell, whatever colour its +single presentation is; a dark-coloured single theme (`theme: darkly`) is an +*annotation* (`darkColoured`), never a cell name. The alternate-stylesheet +links are not a usable marker: a light-only `_brand.yml` emits them with no +dark mode behind them (fixture: `tests/docs/axe-scan/sites/brand-light-only`). + +`--themes` filters the discovered matrix. It prunes the light/dark pair on +two-mode pages; `default` cells always stay, so `--themes light` means "one +cell per page". A filter that matches zero cells is an error, not an empty +scan. + +## The scan stage: raw CDP, fail-closed cells + +The driver is a ~150-line CDP client (`CdpClient` in `scan.ts`): send a +command, await its result, wait for one event. The scanner needs six CDP +methods, so there is no wrapper library. Puppeteer-core is the named +fallback if raw CDP gets painful. quarto-cli's existing wrapper +(`src/core/cri/cri.ts`, which drives Chrome for mermaid) was read and +declined: it exposes navigate/query/screenshot only — no emulation, no +`awaitPromise`, no per-command timeout, and a hung `axe.run()` would hang +forever. That leaves two Chrome launchers in the tree; `cri.ts` cross-refers +here, and unifying them is tracked as follow-up work. Browser discovery is +shared: `getBrowserExecutablePath()` (`src/core/puppeteer.ts`) encodes +`QUARTO_CHROMIUM` → installed `chrome-headless-shell` → system Chrome/Edge. + +**Cells fail closed.** A timeout, an evaluation error, a payload that is not +an axe result, or a page that moved is an infrastructure failure in the +output and the exit code — never a pass. Per cell, `scanCell`: + +1. Sets viewport and `prefers-color-scheme` emulation. +2. On a two-mode page, seeds `localStorage["quarto-color-scheme"]` + (`"alternate"` = the dark slot) via `Page.addScriptToEvaluateOnNewDocument`, + which runs before parse. The before-body script applies a stored value + before first paint, and an explicit stored value wins in both + `respect-user-color-scheme` settings — so this *selects* the mode + deterministically. No toggle click, no cross-cell leak. +3. Navigates, waits for load, then for readiness (webfonts + two animation + frames, capped at 2s), then `--settle` on top. +4. Verifies the document is still the requested page (`redirectTarget`), and + that the seeded mode took (`quarto-light`/`quarto-dark` body class). +5. Injects the vendored axe source and awaits `axe.run(document, + { resultTypes: ["violations"] })` — all under the per-cell `--timeout`. + After a timeout the tab is reset to `about:blank` so a hung run cannot + bleed into the next cell. + +Raw payloads land in `_axe-checks/cells/____.json` as +they complete. Slugs map `/` to `_`; two pages whose slugs collide get a +short path-hash suffix (`pageSlugs`). Page paths are percent-encoded per +segment before navigation. + +## Aggregation: signatures, findings.json + +`aggregate.ts` turns ok cells into findings grouped by **root-cause +signature**, so one defect repeated by shared or generated code is one +finding with a multiplicity count. Across pages this is a same-root-cause +*heuristic*: axe picks a minimal unique selector per page, so an anonymous +element in different DOM contexts can normalize to different signatures and +split one cause into two findings (fixture-verified for the collapse case: +the shared include in `sites/findings` yields `img` on both pages). The +baseline's `pages` scoping exists because the heuristic can also be wrong +the other way — identical signatures on different pages that are unrelated +elements. + +The signature is hybrid (`signatureOf`): for `color-contrast` it is the +colour pair (`color-contrast :: #767676 on #ffffff`) — the root cause lives +in the theme, not the element. Everything else keys on the normalized +selector (`normalizeSelector`): `nth-child` stripped, volatile attributes +(`href`, `id`, `style`, …) dropped, other attribute *values* kept with digit +runs wildcarded, trailing instance ids collapsed (`#cb12-1` → `#cb`). +Keeping values matters: stripping them once reduced +`div[data-bs-target=".callout-4-contents"]` to a selector every Bootstrap +collapse matched, so one accepted defect suppressed unrelated failures +site-wide. The scheme has known warts (it eats citation-key years, truncates +digit-final hex hashes, and `[id="x"]` vs `#x` disagree); they are pinned by +`tests/unit/axe-signature.test.ts` and deliberately deferred — fixing them +re-keys every signature. + +`findings.json` is the contract everything else reads. Two version fields +guard it (`schemas.ts`): + +- `version` (`kFindingsVersion`) — the field *shape*. Additive fields ship + as `nullish` without a bump so old files keep validating; breaking changes + bump it. +- `signatureScheme` (`kSignatureScheme`) — the normalizer. A normalizer + change re-keys every signature while every field name survives; without + this field that is indistinguishable from "everything fixed, equally many + new problems". Bump it whenever `normalizeSelector` or `signatureOf` + changes existing signatures. + +A finding's `id` is `-`: stable across runs and +machines, so "fix `image-alt-6e3b76`" is a well-defined instruction. Paths +in `findings.json` (`config.siteDir`, `baseline.file`) are emitted relative +to the artifact anchor, so the file says the same thing on every machine. + +## The baseline + +`_axe-baseline.json` is the hand-written, committed ledger of accepted +findings. There is no capture flag — every entry exists because someone +wrote it and said why (`note` is required and non-empty). An entry is a +projection of a finding; the scanner reads three fields: + +- `signature` — the join key. +- `pages` — the scope. `[]` accepts site-wide (right for chrome). A listed + `pages` fails closed at finding level: the finding is known only while + *every* page it occurs on is listed, so the same signature on one unlisted + page re-alerts the whole finding. +- `impact` — the impact *at acceptance*. Escalation past it re-alerts + instead of hiding behind an old acceptance. + +Stale entries (not seen this scan) are reported, never auto-pruned: on a +subset scan an entry may live on an unscanned page. The ledger is validated +on read with a strict Zod schema — a typo'd field is a named error, and +scheme/version mismatches produce migration instructions rather than a pile +of unknown-key errors. Signature-scheme semantics and the residual warts are +recorded in the private investigation notes; the code and +`tests/unit/axe-signature.test.ts` are the authoritative record. + +## Artifacts and the anchor + +Artifacts anchor at the nearest project root at or above the site dir +(`resolveAnchor`: walk up for a `_quarto.yml`; fall back to the working +directory for loose HTML). They sit *beside* the output dir, never inside +it: a full website/book render deletes the output dir, and anything that +survived there would be published. Reading the project's own `output-dir` +would need `projectContext()` and its build-artifacts dependency, so the +prototype declines it; the cheap check agrees with `ProjectContext.dir` +wherever a project config exists. + +`_axe-checks/` writes its own `.gitignore` (`*`): a `--pages` subset scan +overwrites `findings.json` with a subset snapshot, so a committed copy would +diff as if findings were fixed. The committed contract is the baseline, +which lives beside the directory. Summary artifacts (`findings.json`, +`report.md`) are deleted up front so an aborted scan cannot leave stale ones +reading as current; per-cell payloads accumulate by name. + +Three human/agent surfaces, one data source: + +- `report.md` — GitHub-flavored markdown (decision 2026-08-25, superseding + the harness's HTML report): it renders on GitHub, drops into a site via + `--report`, and is accessible by construction. It is a dumb view — + grouping, labelling and reconciliation all live in the aggregate stage. + The rich sortable drill-down belongs to a future Quarto extension, not to + CLI code. +- `README.md` — generated beside the artifacts, and the *agent enabler* + (superseding the HTML report's per-finding AI-briefing buttons): nothing + else can reach a consumer working in a scanned site's repo, so the + baseline how-to lives there inline. +- `findings.json` — the machine contract. + +## Exit codes + +- `0` — scan complete; with `--fail-on`, no new finding reached the + threshold. +- `1` — a *complete* scan found NEW (non-baselined) findings at or above + `--fail-on `. Only possible when the flag is given: findings alone + never fail the command. +- `2` — scan incomplete (any not-ok cell, no browser, nothing to scan). + Takes precedence over 1: an incomplete scan never reads as a pass. + +The threshold logic is `failingFindings` in `aggregate.ts`; the precedence +lives in `axeScan`'s return order. End-to-end coverage runs in a subprocess +(`tests/smoke/axe/axe-exit-codes.test.ts`) because the action exits through +`exitWithCleanup`. + +## Conformance labels: mirrored, pinned + +`conformance.ts` mirrors `axeConformanceLevel`, `impactRank` and +`standardRank` from the render-time overlay's `axe-check.js`, so a finding +reads identically from a scan and from the in-page report. Importing the +browser module was tried and reverted: it inlines the whole overlay into the +CLI bundle and its page globals break `validate-bundle`. The duplication is +pinned by `tests/unit/axe-conformance-parity.test.ts`; extracting a shared +pure module is deferred until the command goes public, because it would +change the render path every `axe:` user hits. + +## Testing map + +- `tests/unit/axe-signature.test.ts` — normalization scheme 1, warts included. +- `tests/unit/axe-aggregate.test.ts` — grouping, matrix coverage, + anchor-relative paths, `failingFindings`, against captured real payloads. +- `tests/unit/axe-baseline-parse.test.ts` / `axe-baseline-reconcile.test.ts` + — ledger validation; known/new/stale and escalation semantics. +- `tests/unit/axe-mode-discovery.test.ts` — the mode sniff, stub sniff, + page selection. +- `tests/unit/axe-scan-cell.test.ts` — transport fail-closed with a stubbed + CDP client; slugs; URL encoding. +- `tests/unit/axe-config.test.ts` — flag parsing and its errors. +- `tests/unit/axe-report-readme.test.ts`, `axe-conformance-parity.test.ts` — + the views and the mirrored labellers. +- `tests/smoke/axe/*.test.ts` — full command over fixture sites + (`tests/docs/axe-scan/sites/`): findings/baseline behaviour, the + viewport×mode matrix, brand and darkly edge cases, exit codes. + +## Deliberately not here (yet) + +Deferred to the public command, in rough order of demand: `_axe.yml` config, +interaction states + state scripts, ruleset scoping (`standard` / +`best-practice` / raw `options`), source mapping (`.qmd` → output), the +report extension, output-dir defaulting, exclude-in-source tagging. Known +blind spots, documented rather than solved: a dark presentation Quarto does +not know about (hand-written `prefers-color-scheme` CSS, bslib shadow-root +rules) scans once; client-side render races (`--settle` mitigates; the +baseline absorbs stable false positives); reveal decks scan as resting DOM. diff --git a/llm-docs/html-dark-mode-architecture.md b/llm-docs/html-dark-mode-architecture.md new file mode 100644 index 00000000000..cf52c96b526 --- /dev/null +++ b/llm-docs/html-dark-mode-architecture.md @@ -0,0 +1,135 @@ +--- +main_commit: abc6a78ed +analyzed_date: 2026-08-20 +key_files: + - src/format/html/format-html-info.ts + - src/format/html/format-html-scss.ts + - src/format/html/format-html-bootstrap.ts + - src/command/render/pandoc-html.ts + - src/resources/formats/html/templates/quarto-html-before-body.ejs + - src/project/project-shared.ts + - src/core/brand/brand.ts +--- + +# HTML Dark Mode Architecture + +How an author configures dark/light mode for `format: html` (including websites), and how the rendered page exposes it. Written for tools that must detect and switch modes on rendered pages, such as the axe scanner. + +The core model: a page ships **one presentation or two**. When it ships two, the second is the **alternate**, and the alternate is always the dark slot. This is a per-page property, not a per-site property. + +## The one predicate + +`formatDarkMode(format)` in `src/format/html/format-html-info.ts:33-39` answers "does this page have a dark mode": + +- `undefined` — no dark mode. No toggle, no alternate-stylesheet JS, no before-body script. +- `false` — dark mode exists; the default is light. +- `true` — dark mode exists; the default is dark. + +It gates the toggle (`src/project/types/website/website-navigation.ts:356-362`), the before-body script (`src/format/html/format-html.ts:351`, template written at `:530`), and the body class (`format-html-bootstrap.ts:1073-1078`). Its inputs: `darkModeDefaultMetadata()` (`format-html-info.ts:46-70`) reads key order of `theme:` then `brand:`; `darkModeDefault()` (`:71-85`) adds a fallback on `format.render.brand?.dark`. + +## Configuration surface + +### `theme` + +Schema: `string | string[] | {light?, dark?}` (`src/resources/schema/document-options.yml:17-35`). Parsed by `resolveThemeLayer()` (`src/format/html/format-html-scss.ts:375-455`): + +| YAML | Normalized | Modes | +|---|---|---| +| `theme: cosmo` | `{light: [cosmo]}` | one (light) | +| `theme: darkly` | `{light: [darkly]}` | one — a dark-*colored* single mode; the machinery still calls it the light slot | +| `theme: {light: cosmo, dark: darkly}` | as written | two, default light | +| `theme: {dark: darkly, light: cosmo}` | as written | two, default dark (key order decides: `format-html-scss.ts:410`) | + +### `brand` + +Two routes, both feeding the same pipeline: + +1. **Explicit paths** — `brand: {light: path, dark: path}` at the config root or under `project:` (`src/project/project-shared.ts:599-607`). Dark mode exists iff a `dark:` path is present (`:656`). +2. **Unified `_brand.yml`** — candidate paths `_brand.yml`, `_brand.yaml`, `_brand/_brand.yml`, `_brand/_brand.yaml` (`project-shared.ts:620-629`). Dark is per-value: `color: {background: {light: ..., dark: ...}}`. `brandHasDarkMode()` (`src/core/brand/brand.ts:545-590`) scans colors, typography, and logos for any `dark` key. `splitUnifiedBrand()` (`:773-805`) splits the file into two `Brand` objects. + +Document front matter can override per page: `brand: false` removes branding (and its dark mode) for that page; a string or `{light:, dark:}` object replaces it (`project-shared.ts:668-719`). + +Brand SASS layers become one more bundle with a `dark` half (`src/core/sass/brand.ts:571-674`). From `src/command/render/pandoc-html.ts:140` on, brand dark and theme dark are indistinguishable — one `hasDark` flag drives all output. A detector does not need to know which route produced the dark mode. + +### Precedence between `theme.dark` and brand dark + +- **Default mode:** `theme:` key order wins over `brand:` key order (`format-html-info.ts:46-70`). A unified `_brand.yml` carries no preference and defaults to light (`:81-82`). +- **Variable values:** SASS `!default` order. Brand normally sits before the theme, so the theme overrides brand. An explicit `brand` marker in the theme list (`theme: {dark: [cyborg, brand]}`) reverses this (`pandoc-html.ts:91-137`, `format-html-scss.ts:216-219`). Pinned by `tests/integration/playwright/tests/html-dark-mode.spec.ts` and `html-dark-mode-defaultlight.spec.ts`. + +### `respect-user-color-scheme` + +`kRespectUserColorScheme` (`src/config/constants.ts:156`), default `false`. It changes **only the inline JS**: the initial mode comes from `matchMedia('(prefers-color-scheme: dark)')` instead of the author default, plus a `change` listener that defers to any stored user choice (`quarto-html-before-body.ejs:167-173`, `:192-201`). The markup is byte-identical to the default case. Quarto never emits `media=` attributes on links and never generates `prefers-color-scheme` CSS. Consequence: browser color-scheme emulation alone cannot select a default-config Quarto dark theme. + +### Per-page resolution + +Format metadata merges project `_quarto.yml` → directory `_metadata.yml` → front matter, last wins, per file (`src/command/render/render-contexts.ts:510-514`). Everything downstream is computed per file. One website legitimately mixes two-mode and one-mode pages. `theme` merges deeply (array union), and the **project's** key order decides the default even when a page adds the `dark:` slot. A detector must decide per page; `_quarto.yml` alone is not sufficient evidence. + +## Rendered-page markers + +Reference rendering (`theme: {light: cosmo, dark: darkly}` website): `tests/docs/axe-scan/sites/findings`. A two-mode page carries six stylesheet links: + +| Href | Class | Extra attributes | +|---|---|---| +| `quarto-syntax-highlighting-*.css` | `quarto-color-scheme` | `id="quarto-text-highlighting-styles"` | +| `quarto-syntax-highlighting-dark-*.css` | `quarto-color-scheme quarto-color-alternate` | same id | +| `quarto-syntax-highlighting-*.css` | `quarto-color-scheme-extra` | same id | +| `bootstrap-*.css` | `quarto-color-scheme` | `id="quarto-bootstrap" data-mode="light"` | +| `bootstrap-dark-*.css` | `quarto-color-scheme quarto-color-alternate` | `id="quarto-bootstrap" data-mode="dark"` | +| `bootstrap-*.css` | `quarto-color-scheme-extra` | `id="quarto-bootstrap" data-mode="light"` | + +Notes: + +- `rel="stylesheet"` enables a link; the JS disables with `rel="disabled-stylesheet"` (`quarto-html-before-body.ejs:40-71`). Dark is layered on light: in dark mode, the light sheets stay enabled and the alternates are enabled on top. +- The `-extra` duplicates exist only when the author default is light (`pandoc-html.ts:179-195`). They serve the no-JS cascade and are disabled at parse time (`.ejs:176-177`), never re-enabled. They do **not** carry the `quarto-color-scheme` class. +- Duplicate `id` attributes are by design (three links share `id="quarto-bootstrap"`). Expect axe to flag this. +- `data-mode` appears only on bootstrap links, and only measures color (next section). +- **Reliable static marker for "this page has two modes":** the inline ` +`; + +// theme: darkly — one bootstrap link, measured dark, no script, no toggle. +const kDarklyHtml = ` + +`; + +// A light-only _brand.yml: the full colour-scheme link set — one of them an +// alternate pointing at a dark Bootstrap build — but no before-body script. +// All the links measure light. +const kBrandLightOnlyHtml = ` + + + +`; + +const kStaticHtml = `legacy +

hand-written; no Quarto markers at all

`; + +unitTest( + "mode discovery - the before-body script marker means two modes", + // deno-lint-ignore require-await + async () => { + assertEquals(sniffModes(kTwoModeHtml), { + modes: ["light", "dark"], + darkColoured: false, + }); + }, +); + +unitTest( + "mode discovery - no marker means one mode, whatever the links say", + // deno-lint-ignore require-await + async () => { + // The light-only-brand trap: alternate links and a dark Bootstrap href + // with no dark mode behind them. An "alternate link exists" probe reads + // this as two modes; the sniffer must not. + assertEquals(sniffModes(kBrandLightOnlyHtml), { + modes: ["default"], + darkColoured: false, + }); + assertEquals(sniffModes(kStaticHtml), { + modes: ["default"], + darkColoured: false, + }); + }, +); + +// A one-mode page whose *content* talks about the toggle: the bare name in +// prose and a code sample, and the full definition split into highlight +// spans. None of these is the inline before-body script. +const kMentionsToggleHtml = ` + + +

Quarto defines quartoToggleColorScheme for the toggle.

+
window.quartoToggleColorScheme = () => {}
+`; + +unitTest( + "mode discovery - a page documenting the toggle is not two-mode", + // deno-lint-ignore require-await + async () => { + // The sniffer matches the marker's *definition*, not its name: a bare + // mention would seed a dark cell on a one-mode page, and the cell would + // fail closed with a misleading mode-mismatch error (or pass silently as + // a duplicate cell where no body class exists). + assertEquals(sniffModes(kMentionsToggleHtml), { + modes: ["default"], + darkColoured: false, + }); + // Whitespace around the assignment must not defeat the match. + assertEquals( + sniffModes(``) + .modes, + ["light", "dark"], + ); + }, +); + +unitTest( + "mode discovery - a sole dark-measured bootstrap link is annotated, not a mode", + // deno-lint-ignore require-await + async () => { + // theme: darkly — one presentation, dark-coloured. The annotation is the + // only rendered signal; the cell still scans once, as `default`. + assertEquals(sniffModes(kDarklyHtml), { + modes: ["default"], + darkColoured: true, + }); + }, +); + +// --------------------------------------------------------------------------- +// Page discovery: the walk, its skips, and the --pages / --max-pages narrowing +// --------------------------------------------------------------------------- + +/** A site dir on disk: every entry is a page written with static one-mode HTML. */ +function writeSite(dir: string, pages: string[]) { + for (const page of pages) { + ensureDirSync(join(dir, page, "..")); + Deno.writeTextFileSync(join(dir, page), kStaticHtml); + } +} + +unitTest( + "page discovery - the scanner's own output and vendored libs are never pages", + async () => { + await withTempDir((dir) => { + writeSite(dir, [ + "index.html", + "docs/guide.html", + // a previous run's report, when the anchor is the site dir + "_axe-checks/report.html", + // vendored: reveal ships this with every deck + "site_libs/revealjs/plugin/notes/speaker-view.html", + // nested site_libs (a deck rendered into a subdirectory) + "slides/site_libs/quarto-html/tippy.html", + ]); + const { pages } = discoverPages(axeScanConfig({}, dir)); + assertEquals( + pages.map((page) => page.path), + ["docs/guide.html", "index.html"], + ); + }); + }, +); + +// quarto's redirect-simple.ejs shape (also positron-website's _redirects stubs) +const kMetaRefreshStub = `Redirect + +

Redirecting…

`; + +// quarto's redirect-map.ejs shape, written for aliases: front matter +const kAliasStub = `Redirect +`; + +unitTest( + "page discovery - redirect stubs are set aside, recorded with a destination", + async () => { + await withTempDir((dir) => { + writeSite(dir, ["index.html"]); + Deno.writeTextFileSync(join(dir, "old.html"), kMetaRefreshStub); + Deno.writeTextFileSync(join(dir, "install.html"), kAliasStub); + const { pages, redirects } = discoverPages(axeScanConfig({}, dir)); + assertEquals(pages.map((page) => page.path), ["index.html"]); + assertEquals(redirects, [ + { path: "install.html", to: "download.html" }, + { path: "old.html", to: "https://example.com/moved/" }, + ]); + }); + }, +); + +unitTest( + "page discovery - a content page about redirects is not a stub", + // deno-lint-ignore require-await + async () => { + // The markers alone must not classify: a docs page quoting the redirect + // script in an unhighlighted code block carries them verbatim, but a real + // page has real body content where a stub's body is bytes. + const docsPage = `Using aliases +
var redirects = {"":"download.html"};
+window.location.replace(redirects[""]);
+${ + "

Documentation prose about how alias redirects work in Quarto.

" + .repeat(20) + } +`; + assertEquals(sniffRedirectStub(docsPage), undefined); + assertEquals(sniffRedirectStub(kMetaRefreshStub), { + to: "https://example.com/moved/", + }); + assertEquals(sniffRedirectStub(kAliasStub), { to: "download.html" }); + }, +); + +unitTest( + "page discovery - --pages narrows, --exclude prunes, --max-pages caps", + async () => { + await withTempDir((dir) => { + writeSite(dir, [ + "index.html", + "docs/a.html", + "docs/b.html", + "blog/post.html", + "slides/deck.html", + ]); + // globstar include + assertEquals( + discoverPages(axeScanConfig({ pages: "docs/**" }, dir)) + .pages.map((page) => page.path), + ["docs/a.html", "docs/b.html"], + ); + // exclude alone: everything but the decks (the resting-DOM opt-out) + assertEquals( + discoverPages(axeScanConfig({ exclude: "slides/**" }, dir)) + .pages.map((page) => page.path), + ["blog/post.html", "docs/a.html", "docs/b.html", "index.html"], + ); + // a bare directory name means everything beneath it, not nothing + assertEquals( + discoverPages(axeScanConfig({ exclude: "slides" }, dir)) + .pages.map((page) => page.path), + ["blog/post.html", "docs/a.html", "docs/b.html", "index.html"], + ); + // exclude applies after include, and before the cap — pruning frees cap + // room for the pages that remain + assertEquals( + discoverPages( + axeScanConfig( + { pages: "docs/**,slides/**", exclude: "slides/**", maxPages: 2 }, + dir, + ), + ).pages.map((page) => page.path), + ["docs/a.html", "docs/b.html"], + ); + // the cap is deterministic: sorted first, then sliced + assertEquals( + discoverPages(axeScanConfig({ maxPages: 2 }, dir)) + .pages.map((page) => page.path), + ["blog/post.html", "docs/a.html"], + ); + }); + }, +); + +// --------------------------------------------------------------------------- +// --themes as a filter over the discovered matrix +// --------------------------------------------------------------------------- + +function page(path: string, modes: AxePage["modes"]): AxePage { + return { path, modes, darkColoured: false }; +} + +unitTest( + "themes filter - prunes the pair, always keeps default cells", + // deno-lint-ignore require-await + async () => { + const pages = [ + page("a.html", ["light", "dark"]), + page("b.html", ["default"]), + ]; + assertEquals( + applyThemesFilter(pages, ["dark"]).map((p) => p.modes), + [["dark"], ["default"]], + ); + assertEquals( + applyThemesFilter(pages, ["light"]).map((p) => p.modes), + [["light"], ["default"]], + ); + }, +); + +unitTest( + "themes filter - the default filter is a no-op", + // deno-lint-ignore require-await + async () => { + // Both values requested means no narrowing — including on a site with no + // two-mode pages at all, which is just a plain site scanning normally. + const pages = [page("a.html", ["default"])]; + assertEquals(applyThemesFilter(pages, ["light", "dark"]), pages); + }, +); + +unitTest( + "themes filter - matching zero cells is an error, not an empty scan", + // deno-lint-ignore require-await + async () => { + // Asking for dark on a site with no light/dark pair must not quietly scan + // every page once and read as dark coverage. + const pages = [page("a.html", ["default"]), page("b.html", ["default"])]; + assertThrows( + () => applyThemesFilter(pages, ["dark"]), + Error, + "matched no cells", + ); + }, +); diff --git a/tests/unit/axe-report-readme.test.ts b/tests/unit/axe-report-readme.test.ts new file mode 100644 index 00000000000..ba95d2bd96e --- /dev/null +++ b/tests/unit/axe-report-readme.test.ts @@ -0,0 +1,280 @@ +/* + * axe-report-readme.test.ts + * + * The two rendered artifacts of `quarto call axe`: report.md (report.ts) + * and the generated _axe-checks/README.md (readme.ts). Both are dumb views + * over findings.json, so these tests aggregate the captured per-cell fixtures + * (tests/docs/axe-scan/cells) exactly as the aggregate tests do, then assert + * on projections of the rendered markdown — never a golden file. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../test.ts"; +import { assert, assertEquals } from "testing/asserts"; +import { join } from "../../src/deno_ral/path.ts"; +import { docs } from "../utils.ts"; +import { aggregate } from "../../src/command/call/axe/aggregate.ts"; +import { renderReport } from "../../src/command/call/axe/report.ts"; +import { + renderReadme, + scanCommand, +} from "../../src/command/call/axe/readme.ts"; +import { AxeScanConfig } from "../../src/command/call/axe/config.ts"; +import { AxeCell } from "../../src/command/call/axe/scan.ts"; +import { + AxeBaseline, + kFindingsVersion, + kSignatureScheme, +} from "../../src/command/call/axe/schemas.ts"; + +const kCells = [ + "about__1440x900__light", + "index__1440x900__light", + "index__1440x900__dark", +]; + +function capturedCells(): AxeCell[] { + return kCells.map((name) => + JSON.parse( + Deno.readTextFileSync( + join(docs("axe-scan/cells/findings"), `${name}.json`), + ), + ) as AxeCell + ); +} + +const kConfig: AxeScanConfig = { + siteDir: "_site", + viewports: [{ width: 1440, height: 900, label: "1440x900" }], + themes: ["light", "dark"], + timeout: 30000, + settle: 50, +}; + +function results(overrides: { + config?: Partial; + baseline?: AxeBaseline; + cells?: AxeCell[]; +}) { + const cells = overrides.cells ?? capturedCells(); + const pages = [...new Set(cells.map((cell) => cell.page))].sort(); + return aggregate({ + cells, + config: { ...kConfig, ...overrides.config }, + baseline: overrides.baseline ?? { findings: [] }, + baselineFile: "_axe-baseline.json", + anchor: Deno.cwd(), + pages: pages.map((path) => ({ + path, + modes: ["light", "dark"], + darkColoured: false, + })), + redirects: [{ path: "old.html", to: "new.html" }], + }); +} + +unitTest( + "report.md - findings are reachable by id, markdown structure intact", + // deno-lint-ignore require-await + async () => { + const findings = results({}); + const report = renderReport(findings); + for (const finding of findings.findings) { + // linked from the table, anchored by its occurrence heading + assert( + report.includes(`](#${finding.id})`), + `table row for ${finding.id} is not linked`, + ); + assert( + report.includes(`#### ${finding.id}`), + `no occurrence anchor for ${finding.id}`, + ); + // the finding-level detail is stated once, not repeated per occurrence + if (finding.detail) { + const uniform = finding.occurrences.every((occurrence) => + occurrence.detail === finding.detail + ); + if (uniform && finding.occurrences.length > 1) { + const mentions = report.split(`**Problem:** `).length - 1; + assert(mentions >= 1, "missing the Problem line"); + } + } + } + // every table row stays one line: pipes and newlines inside selectors and + // html excerpts must be neutralized, or GitHub renders garbage + for (const line of report.split("\n")) { + if (line.startsWith("|")) { + assert( + line.endsWith("|"), + `table row broken by unescaped content: ${line.slice(0, 80)}`, + ); + } + } + // redirect stubs are recorded, not red + assert(report.includes("Redirect stubs"), "missing redirects section"); + assert(report.includes("old.html"), "missing the recorded stub"); + // full scan: no partial banner + assert(!report.includes("Partial scan"), "full scan must not say partial"); + }, +); + +unitTest( + "report.md - a subset scan says so, loudly", + // deno-lint-ignore require-await + async () => { + const report = renderReport( + results({ config: { pages: ["docs/**"], maxPages: 5 } }), + ); + assert(report.includes("**Partial scan**"), "missing the partial banner"); + assert(report.includes("--pages docs/**"), "banner must name the filter"); + assert(report.includes("--max-pages 5"), "banner must name the cap"); + }, +); + +unitTest( + "report.md - baselined findings are listed with their why", + // deno-lint-ignore require-await + async () => { + const findings = results({ + baseline: { + findings: [{ + signature: "image-alt :: img", + pages: [], + impact: "critical", + note: "planted fixture defect, accepted for this test", + }], + }, + }); + const baselined = findings.findings.filter((f) => f.baselined); + assertEquals(baselined.length, 1); + const report = renderReport(findings); + assert( + report.includes("## Baselined (known, accepted)"), + "missing the baselined section", + ); + assert( + report.includes("planted fixture defect"), + "the why-accepted note must surface", + ); + }, +); + +unitTest( + "README - regenerate command reconstructs exactly the scan's flags", + // deno-lint-ignore require-await + async () => { + assertEquals( + scanCommand(results({})), + // non-default viewports and settle are echoed; defaults are not + "quarto call axe _site --viewports 1440x900", + ); + assertEquals( + scanCommand( + results({ + config: { pages: ["docs/**"], exclude: ["slides/**"], timeout: 5000 }, + }), + ), + 'quarto call axe _site --pages "docs/**" --exclude "slides/**" ' + + "--viewports 1440x900 --timeout 5000", + ); + }, +); + +unitTest( + "README - carries the baseline how-to, versions, and the partial warning", + // deno-lint-ignore require-await + async () => { + const readme = renderReadme(results({ config: { pages: ["docs/**"] } })); + assert( + readme.includes("regenerated on every scan"), + "must declare itself generated", + ); + assert( + readme.includes(`signature scheme ${kSignatureScheme}`) && + readme.includes(`version ${kFindingsVersion}`), + "missing the provenance versions", + ); + assert( + readme.includes("Accepting a finding"), + "missing the baseline how-to", + ); + assert( + readme.includes(`"note"`) && readme.includes(`"pages": []`), + "the how-to must show the entry shape inline", + ); + assert( + readme.includes("This was a partial scan"), + "a subset scan's README must say so", + ); + const full = renderReadme(results({})); + assert( + !full.includes("This was a partial scan"), + "a full scan's README must not claim partiality", + ); + }, +); + +unitTest( + "report.md - code content renders as backtick spans that survive rendering", + // deno-lint-ignore require-await + async () => { + // A page that *documents* fenced divs puts `:::` into its excerpts. + // Emitted as raw HTML, Pandoc parsed the inner text as markdown + // and Quarto's fenced-div check warned on every render of the report; + // a backtick span parses as a Code inline, which that check ignores. + const divsPage: AxeCell = { + page: "docs/divs.html", + viewport: "1440x900", + theme: "light", + url: "http://127.0.0.1/docs/divs.html", + status: "ok", + elapsed: 10, + result: { + violations: [{ + id: "heading-order", + impact: "moderate", + tags: ["best-practice"], + description: "Headings should not skip levels", + help: "Heading levels should only increase by one", + helpUrl: "https://dequeuniversity.com/rules/axe/4.10/heading-order", + nodes: [{ + html: + `
::: {.callout-note}\nlook\n:::
` + + "with a `tick` and a | pipe", + target: ['pre[class|="sourceCode"] > h6'], + failureSummary: "Fix any of the following:\n Heading order invalid", + }], + }], + testEngine: { name: "axe-core", version: "4.10.3" }, + }, + }; + const report = renderReport(results({ cells: [divsPage] })); + + // the selector renders as a backtick span, not raw HTML (the + // excerpt itself contains literal "
" text, so assert on the
+    // markup around the content rather than on the string "")
+    assert(
+      report.includes('`pre[class\\|="sourceCode"] > h6`'),
+      "the selector must render as a backtick span with an escaped pipe",
+    );
+    // the excerpt's ::: lives inside a backtick span (one line, one cell)
+    const row = report.split("\n").find((line) =>
+      line.includes("::: {.callout-note}")
+    );
+    assert(row, "the excerpt row went missing");
+    assert(
+      /`[^`]*::: \{\.callout-note\}/.test(row!),
+      `::: must sit inside a code span: ${row}`,
+    );
+    // pipes inside table code spans escape as \| so the row stays intact
+    assert(row!.includes("\\|"), `pipes must be escaped in table cells: ${row}`);
+    const columns = row!.split(/(?) => Promise,
+  loaded = false,
+): CdpClient {
+  return {
+    send,
+    once: () => ({
+      event: loaded ? Promise.resolve({}) : new Promise(() => {}),
+      cancel: () => {},
+    }),
+  } as unknown as CdpClient;
+}
+
+unitTest(
+  "scanCell - a rejected CDP send fails the cell closed, not the scan",
+  async () => {
+    const client = stubClient(() =>
+      Promise.reject(new Error("tab crashed (Inspector.targetCrashed)"))
+    );
+    // Must resolve to a cell — a rejection here is the whole-scan abort this
+    // test exists to prevent.
+    const cell = await scanCell(
+      client,
+      "/* axe source */",
+      axeScanConfig({}, "_site"),
+      kPage,
+      kViewport,
+      "default",
+      "http://127.0.0.1:9999/index.html",
+    );
+    assertEquals(cell.status, "error");
+    assert(
+      cell.message?.includes("CDP failure") &&
+        cell.message?.includes("tab crashed"),
+      `unexpected message: ${cell.message}`,
+    );
+  },
+);
+
+unitTest(
+  "scanCell - a hung command still times out, and the reset is attempted",
+  async () => {
+    const sent: string[] = [];
+    const client = stubClient((method) => {
+      sent.push(method);
+      // The reset navigation must complete so the timeout path can finish;
+      // everything else hangs, like a wedged renderer.
+      return method === "Page.navigate"
+        ? Promise.resolve({})
+        : new Promise(() => {});
+    });
+    const cell = await scanCell(
+      client,
+      "/* axe source */",
+      axeScanConfig({ timeout: 200 }, "_site"),
+      kPage,
+      kViewport,
+      "default",
+      "http://127.0.0.1:9999/index.html",
+    );
+    assertEquals(cell.status, "timeout");
+    assert(
+      sent.includes("Page.navigate"),
+      "the timeout path should navigate to about:blank to unwedge the tab",
+    );
+  },
+);
+
+// ---------------------------------------------------------------------------
+// The redirect guard: axe must run on the page we asked for
+// ---------------------------------------------------------------------------
+
+unitTest(
+  "redirectTarget - origin and path decide; search and hash do not",
+  // deno-lint-ignore require-await
+  async () => {
+    const requested = "http://127.0.0.1:4173/blog/index.html";
+    // stayed put, including reveal's history-API fragment rewrites
+    assertEquals(redirectTarget(requested, requested), undefined);
+    assertEquals(
+      redirectTarget(requested, `${requested}#/3`),
+      undefined,
+    );
+    assertEquals(
+      redirectTarget(requested, `${requested}?q=x`),
+      undefined,
+    );
+    // the positron-website case: a zero-delay meta refresh to an external host
+    assertEquals(
+      redirectTarget(requested, "https://opensource.posit.co/blog/q/positron/"),
+      "https://opensource.posit.co/blog/q/positron/",
+    );
+    // a local redirect is still a different page
+    assertEquals(
+      redirectTarget(requested, "http://127.0.0.1:4173/index.html"),
+      "http://127.0.0.1:4173/index.html",
+    );
+    // a relative document URL resolves against the request before comparing
+    assertEquals(redirectTarget(requested, "/blog/index.html"), undefined);
+    assertEquals(
+      redirectTarget(requested, "/index.html"),
+      "/index.html",
+    );
+    // wherever an unparseable location is, it is not the requested page
+    assertEquals(redirectTarget(requested, ""), "(unknown location)");
+    assertEquals(redirectTarget(requested, "about:blank"), "about:blank");
+  },
+);
+
+unitTest(
+  "scanCell - a page that redirects during settle fails closed, unscanned",
+  async () => {
+    let axeInjected = false;
+    const client = stubClient((method, params) => {
+      if (method === "Runtime.evaluate") {
+        const expression = String(params?.expression ?? "");
+        if (expression.includes("document.fonts")) {
+          // the readiness probe: the page reports ready
+          return Promise.resolve({ result: { value: true } });
+        }
+        if (expression.includes("location.href")) {
+          return Promise.resolve({
+            result: { value: "https://opensource.posit.co/blog/q/positron/" },
+          });
+        }
+        axeInjected = true;
+        return Promise.resolve({ result: {} });
+      }
+      return Promise.resolve({});
+    }, true);
+    const cell = await scanCell(
+      client,
+      "/* axe source */",
+      axeScanConfig({ settle: 1 }, "_site"),
+      kPage,
+      kViewport,
+      "default",
+      "http://127.0.0.1:9999/blog/index.html",
+    );
+    assertEquals(cell.status, "redirected");
+    assert(
+      cell.message?.includes("opensource.posit.co"),
+      `the destination should be named: ${cell.message}`,
+    );
+    assert(!axeInjected, "axe must not be injected into the destination");
+  },
+);
+
+// ---------------------------------------------------------------------------
+// Cell artifact naming and navigation URLs
+// ---------------------------------------------------------------------------
+
+unitTest(
+  "cell naming - slugs that collide get a hash suffix, others stay pretty",
+  // deno-lint-ignore require-await
+  async () => {
+    // `/` maps to `_`, so these two distinct pages share a raw slug — without
+    // disambiguation the second page's cells silently replace the first's.
+    const collided = pageSlugs(["docs/index.html", "docs_index.html"]);
+    const slugA = collided.get("docs/index.html")!;
+    const slugB = collided.get("docs_index.html")!;
+    assert(slugA !== slugB, `collision survived: ${slugA}`);
+    assert(slugA.startsWith("docs_index-"), slugA);
+    assert(slugB.startsWith("docs_index-"), slugB);
+
+    // No collision: the pretty, hash-free names are kept.
+    const plain = pageSlugs(["index.html", "docs/index.html"]);
+    assertEquals(plain.get("index.html"), "index");
+    assertEquals(plain.get("docs/index.html"), "docs_index");
+
+    assertEquals(cellName("docs_index", "1440x900", "dark"),
+      "docs_index__1440x900__dark");
+  },
+);
+
+unitTest(
+  "navigation - page paths are percent-encoded segment by segment",
+  // deno-lint-ignore require-await
+  async () => {
+    // `#` and `?` would truncate the URL at parse time: the cell would scan
+    // the wrong page and fail on the 404 with a misleading message.
+    assertEquals(encodePagePath("notes#1.html"), "notes%231.html");
+    assertEquals(encodePagePath("a b/q?.html"), "a%20b/q%3F.html");
+    // separators survive; plain paths are untouched
+    assertEquals(encodePagePath("docs/index.html"), "docs/index.html");
+  },
+);
diff --git a/tests/unit/axe-signature.test.ts b/tests/unit/axe-signature.test.ts
new file mode 100644
index 00000000000..b1160ab8880
--- /dev/null
+++ b/tests/unit/axe-signature.test.ts
@@ -0,0 +1,252 @@
+/*
+ * axe-signature.test.ts
+ *
+ * Tests the root-cause signature that `quarto call axe` groups and
+ * baselines on: `normalizeSelector` and `signatureOf` in
+ * src/command/call/axe/aggregate.ts.
+ *
+ * These are the most consequential lines in the scanner. A signature that is
+ * too broad makes one accepted defect suppress unrelated conformance failures
+ * across a whole site; one that is too narrow makes a baseline entry stop
+ * matching the moment a counter changes. So the tests are written as explicit
+ * should-collapse / must-stay-distinct pairs over selectors axe really emits on
+ * Quarto output, rather than as assertions about the regexes themselves.
+ *
+ * Copyright (C) 2026 Posit Software, PBC
+ */
+
+import { unitTest } from "../test.ts";
+import { assert, assertEquals, assertNotEquals } from "testing/asserts";
+import {
+  normalizeSelector,
+  signatureOf,
+} from "../../src/command/call/axe/aggregate.ts";
+import { AxeViolationNode } from "../../src/command/call/axe/scan.ts";
+
+function node(target: string[] | string): AxeViolationNode {
+  return {
+    html: "
", + target: Array.isArray(target) ? target : [target], + }; +} + +// --------------------------------------------------------------------------- +// Pairs that must collapse: the same defect, repeated by shared or generated +// code, has to be one finding with a count. +// --------------------------------------------------------------------------- + +const kMustCollapse: [string, string, string][] = [ + [ + "collapsible callouts differing only by index", + 'div[data-bs-target=".callout-4-contents"]', + 'div[data-bs-target=".callout-11-contents"]', + ], + [ + "sidebar sections differing only by index", + 'a[data-bs-target="#quarto-sidebar-section-1"]', + 'a[data-bs-target="#quarto-sidebar-section-2"]', + ], + [ + "code blocks: Pandoc's #cb- ids", + "#cb1-1 > code", + "#cb12-3 > code", + ], + [ + "footnote backrefs: #fn", + "#fn3 > p > a", + "#fn17 > p > a", + ], + [ + "nth-child position, which unrelated sibling edits change", + "p:nth-child(3) > a", + "p:nth-child(7) > a", + ], + [ + "href values, which differ per link but not per defect", + 'a[href="/docs/guide.html"]', + 'a[href="/docs/other.html"]', + ], + [ + "axe's bare-tag target versus the same tag with a position", + "h6", + "h6:nth-child(1)", + ], +]; + +for (const [label, a, b] of kMustCollapse) { + unitTest( + `axe signature - collapses: ${label}`, + // deno-lint-ignore require-await + async () => { + assertEquals( + normalizeSelector(a), + normalizeSelector(b), + `expected these to share a signature:\n ${a}\n ${b}`, + ); + }, + ); +} + +// --------------------------------------------------------------------------- +// Pairs that must stay distinct. Every one of these is a way for an accepted +// finding to silently suppress an unrelated one. +// --------------------------------------------------------------------------- + +const kMustStayDistinct: [string, string, string][] = [ + [ + "a callout and a modal both hang off data-bs-target", + 'div[data-bs-target=".callout-4-contents"]', + 'button[data-bs-target="#exampleModal"]', + ], + [ + "a callout and a carousel both hang off data-bs-target", + 'div[data-bs-target=".callout-4-contents"]', + 'button[data-bs-target="#carouselExampleControls"]', + ], + [ + "a sidebar link and a tabset link both hang off data-bs-target", + 'a[data-bs-target="#quarto-sidebar-section-1"]', + 'a[data-bs-target="#tabset-1-1"]', + ], + [ + "data-anchor-id values are content slugs, so headings are separate", + 'h4[data-anchor-id="markdown-syntax"]', + 'h4[data-anchor-id="latex-raw-blocks"]', + ], + [ + "the same slug at different heading levels is a different heading", + 'h4[data-anchor-id="markdown-syntax"]', + 'h6[data-anchor-id="markdown-syntax"]', + ], + [ + "an image in a layout cell versus a bare paragraph image", + ".quarto-layout-cell > p > .img-fluid", + "p > .img-fluid", + ], + [ + "different rules never share a signature even with one selector", + "h6", + "th", + ], + [ + "htmlwidget instances keep their hashes apart", + "#htmlwidget-9f0c1b2a3d4e", + "#htmlwidget-aa11bb22cc33", + ], +]; + +for (const [label, a, b] of kMustStayDistinct) { + unitTest( + `axe signature - keeps distinct: ${label}`, + // deno-lint-ignore require-await + async () => { + assertNotEquals( + normalizeSelector(a), + normalizeSelector(b), + `these must not share a signature:\n ${a}\n ${b}`, + ); + }, + ); +} + +// --------------------------------------------------------------------------- +// Exact output, so a scheme change is visible in a diff rather than inferred +// from a collapse count. Update these together with kSignatureScheme. +// --------------------------------------------------------------------------- + +const kExactOutput: [string, string][] = [ + [ + 'div[data-bs-target=".callout-4-contents"]', + 'div[data-bs-target=".callout-*-contents"]', + ], + ['a[data-bs-target="#tabset-1-1"]', 'a[data-bs-target="#tabset-*-*"]'], + [ + 'h4[data-anchor-id="markdown-syntax"]', + 'h4[data-anchor-id="markdown-syntax"]', + ], + ["#cb12-3 > code", "#cb > code"], + ["#fn17 > p > a", "#fn > p > a"], + ["p:nth-child(3) > a", "p > a"], + ['a[href="/docs/guide.html"]', "a"], + ['div[id="quarto-content"][data-index="3"]', "div"], + ['input[name="search-input"]', "input"], + ['img[src="elephant.png"][style="width:60px"]', 'img[src="elephant.png"]'], + ["#download-news > h6", "#download-news > h6"], +]; + +for (const [input, expected] of kExactOutput) { + unitTest( + `axe signature - normalizes ${input} -> ${expected}`, + // deno-lint-ignore require-await + async () => { + assertEquals(normalizeSelector(input), expected); + }, + ); +} + +unitTest( + "axe signature - axe's target array is joined into a descendant path", + // deno-lint-ignore require-await + async () => { + assertEquals( + normalizeSelector([".quarto-layout-cell", "p", ".img-fluid"]), + ".quarto-layout-cell > p > .img-fluid", + ); + }, +); + +// --------------------------------------------------------------------------- +// signatureOf: rule prefix, and the color-contrast special case +// --------------------------------------------------------------------------- + +unitTest( + "signatureOf - prefixes the rule id, so two rules never collide", + // deno-lint-ignore require-await + async () => { + assertEquals( + signatureOf("heading-order", node("h6")), + "heading-order :: h6", + ); + assertNotEquals( + signatureOf("heading-order", node("h6")), + signatureOf("empty-heading", node("h6")), + ); + }, +); + +unitTest( + "signatureOf - color-contrast keys on the colour pair, not the location", + // deno-lint-ignore require-await + async () => { + const contrastNode = (target: string): AxeViolationNode => ({ + html: "

", + target: [target], + any: [{ + id: "color-contrast", + data: { fgColor: "#767676", bgColor: "#ffffff" }, + }], + }); + // The root cause is one theme colour pair, so unrelated elements sharing it + // are one finding rather than one per element. + assertEquals( + signatureOf("color-contrast", contrastNode(".sidebar-link")), + "color-contrast :: #767676 on #ffffff", + ); + assertEquals( + signatureOf("color-contrast", contrastNode("p > code")), + signatureOf("color-contrast", contrastNode(".sidebar-link")), + ); + }, +); + +unitTest( + "signatureOf - color-contrast falls back to the selector without colour data", + // deno-lint-ignore require-await + async () => { + // A payload with no color-contrast check result must not produce + // "undefined on undefined" and collapse every contrast finding into one. + const signature = signatureOf("color-contrast", node(".sidebar-link")); + assertEquals(signature, "color-contrast :: .sidebar-link"); + assert(!signature.includes("undefined")); + }, +); diff --git a/tools/bundle-bug-finder/_prelude.js b/tools/bundle-bug-finder/_prelude.js index 6098fe81da5..a80207c85aa 100644 --- a/tools/bundle-bug-finder/_prelude.js +++ b/tools/bundle-bug-finder/_prelude.js @@ -1,5 +1,5 @@ /* definitions which exist in Deno's global scope and are ok for us to ignore */ -/*global Deno, globalThis, console, Uint8Array, URL, ArrayBuffer, setTimeout, clearTimeout, Promise, Symbol, TextEncoder, addEventListener, removeEventListener, Map, window, self, global, TransformStream, AggregateError, ReadableStream, WritableStream, TextDecoder, Set, Int32Array, performance, Response, WebAssembly, atob, EventTarget, DOMException, localStorage, fetch, FormData, btoa, Buffer, Headers, WebSocket, File, Blob, Request, CompressionStream, URLSearchParams, AbortController, Atomics, SharedArrayBuffer, setInterval, clearInterval, BigInt, crypto, Uint32Array, Uint16Array, Float32Array, Float64Array, Int16Array, Int8Array, DataView, Proxy, Reflect, Intl, FinalizationRegistry, WeakMap, WeakSet */ +/*global Deno, globalThis, console, Uint8Array, URL, ArrayBuffer, setTimeout, clearTimeout, Promise, Symbol, TextEncoder, addEventListener, removeEventListener, Map, window, self, global, TransformStream, AggregateError, ReadableStream, WritableStream, TextDecoder, TextDecoderStream, Set, Int32Array, performance, Response, WebAssembly, atob, EventTarget, DOMException, localStorage, fetch, FormData, btoa, Buffer, Headers, WebSocket, File, Blob, Request, CompressionStream, URLSearchParams, AbortController, Atomics, SharedArrayBuffer, setInterval, clearInterval, BigInt, crypto, Uint32Array, Uint16Array, Float32Array, Float64Array, Int16Array, Int8Array, DataView, Proxy, Reflect, Intl, FinalizationRegistry, WeakMap, WeakSet */ // globals that deno-dom is leaking currently and we need to ignore them // tracking on https://github.com/b-fuze/deno-dom/issues/151