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..9fbf1ab2d22 --- /dev/null +++ b/dev-docs/axe-scan.md @@ -0,0 +1,201 @@ +# 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 scannable pages — redirect stubs don't use up the cap) | +| `--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 — can't name the baseline, `findings.json`, `README.md`, `.gitignore`, or anything inside `cells/` | + +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 adds the scan's runtime to each full render. + +Only *full* renders scan. Quarto runs post-render scripts on incremental +renders and on preview reloads too, and the scan skips both with a note and +exit 0 — a partly-rebuilt site would mix this render's output with the last +one's. Run `quarto render` with no file argument to scan. + +## 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 scan no longer sees are reported as stale — prune them by hand, but only +after a scan of the full matrix (every page, viewport and mode) in which +every cell completed. Narrowing any axis with `--pages`, `--viewports` or +`--themes` means the scan can't tell "fixed" from "not scanned", and neither +can a full scan that lost a cell; the report says which applies to it. + +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 | +| `3` | usage error — a flag value the command can't accept (`--fail-on serius`), or a filter that matches nothing (`--themes dark` on a site with no dark mode). A typo is not a result, so it gets its own code | + +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 a workflow can post it straight +into the PR — no Quarto-side tooling needed. Use a sticky comment, which +updates in place on each push rather than adding one 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..9ea46f1417f --- /dev/null +++ b/llm-docs/axe-scan-architecture.md @@ -0,0 +1,321 @@ +--- +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 generic CDP client (`CdpClient` in `scan.ts`) +with three capabilities: send a command and await its result, wait +(cancellably) for one event, close. It is transport, not scan logic — the +scanning is six CDP *commands* that `scanCell` sends through it (navigate, +viewport override, media emulation, evaluate-with-`awaitPromise`, an +on-new-document script, the load event), and those stay the scanner's +responsibility under any refactor. Because the command surface is that +small, 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 — but be precise about which part: its +*facade* exposes mermaid's commands only (navigate/query/screenshot — no +emulation, no `awaitPromise`, no per-command timeout, so a hung `axe.run()` +would hang forever). Underneath that facade sits the vendored `deno-cri` +library, itself a generic send-any-command client; retargeting `CdpClient`'s +internals onto it (keeping the interface the tests stub) is the plausible +future unification of the *transport*. The more valuable near-term share is +the *launcher*: two exist in the tree (`launchScanBrowser` here, +`criClient`'s spawn half there), `cri.ts` cross-refers here, and extracting +one shared launcher is tracked follow-up work. Browser discovery is already +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, and +"resolved" is only claimed when the scan is entitled to claim it +(`staleIsConclusive`, config.ts). The matrix is pages × viewports × modes and +every axis is a way to not look: `--pages`, `--viewports` and `--themes` each +shrink it (`narrowedAxes`), so a light-only or wide-only scan can report a +finding unseen without anything having looked for it. A cell that failed +closed is the same hole inside an otherwise full run. `report.md` and the +console summary name which applies. 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. Cleanup only +covers the artifacts the scanner owns inside the output dir +(`staleArtifacts`, cmd.ts); a `--report` destination is never deleted — it +is a user-chosen path, overwritten at write time — and the destination is +echoed into `findings.json`, or the README's regenerate command would +silently revert it. `--report` is rejected outright when it names a path +the scan owns — the baseline itself, `findings.json`, `README.md`, +`.gitignore` (`reservedArtifacts`), or anything inside `cells/` — because +the report write at the end of a run overwrites whatever it lands on. The +collision +check folds case on Windows and macOS's default filesystem (case-insensitive +but case-preserving there), by platform rather than by asking +`Deno.realPathSync` to canonicalize: that API is documented to sometimes +return the queried case instead of correcting it, observed to disagree with +itself between two temp directories in the same run. + +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 - a short page quoting the redirect script is not a stub", + // deno-lint-ignore require-await + async () => { + // Body length can't carry this on its own: a terse page — a note, a + // snippet index — quoting the same script stays well under the near-empty + // threshold. What separates a stub from a page about stubs is that the + // stub *runs* the redirect, so the markers only count inside a