diff --git a/_tools/screenshots/CLAUDE.md b/_tools/screenshots/CLAUDE.md index 29bba9c313..5c16a0be5d 100644 --- a/_tools/screenshots/CLAUDE.md +++ b/_tools/screenshots/CLAUDE.md @@ -32,6 +32,23 @@ Use `/screenshot` to walk through the process: 5. Run `npm run capture` to produce the final image 6. Verify output — iterate on manifest/example if needed +## Manual Captures + +Some screenshots can't go through capture.js. They're registered in the +manifest's `manual` array — name, output, the script that produces them, and +why they're manual — so `npm run capture -- --list` and the listing tools show +them, and `npm run capture -- --name ` points at the right script instead +of failing. + +Current manual captures: + +- **axe-console** (`docs/output-formats/images/axe-console.png`) — the browser + DevTools console lives outside the page, so Playwright can't screenshot it. + `scripts/capture-axe-devtools.mjs` launches headed Chromium with DevTools + auto-opened (dock, panel, theme, and zoom preseeded via profile preferences) + and captures the OS window by window ID (macOS only). Prerequisites in the + script header. + ## Tools | Tool | Role | diff --git a/_tools/screenshots/capture.js b/_tools/screenshots/capture.js index 0a2d2fec83..77c1b6ae1a 100644 --- a/_tools/screenshots/capture.js +++ b/_tools/screenshots/capture.js @@ -83,8 +83,9 @@ function matchName(name, pattern) { } const screenshots = manifest.screenshots.filter(s => matchName(s.name, namePattern)); +const manualMatches = (manifest.manual ?? []).filter(s => matchName(s.name, namePattern)); -if (screenshots.length === 0) { +if (screenshots.length === 0 && manualMatches.length === 0) { console.error(`No screenshots match pattern: ${namePattern}`); process.exit(1); } @@ -98,9 +99,19 @@ if (listOnly) { console.log(`${s.name} (dark) → ${s.output.slice(0, ext)}-dark${s.output.slice(ext)}`); } } + for (const m of manualMatches) { + console.log(`${m.name} (manual: node ${m.script}) → ${m.output}`); + } process.exit(0); } +// Manual entries can't be captured by this script — point at theirs. +for (const m of manualMatches) { + console.log(`${m.name}: manual capture — ${m.reason}`); + console.log(` Run: node ${m.script} (see the script header for prerequisites)`); +} +if (screenshots.length === 0) process.exit(0); + // Group by source project (avoid re-rendering the same project) function groupBySource(shots) { const groups = new Map(); diff --git a/_tools/screenshots/examples/axe-violation/_quarto.yml b/_tools/screenshots/examples/axe-violation/_quarto.yml index 2c02f8c89f..e37ebdf0dc 100644 --- a/_tools/screenshots/examples/axe-violation/_quarto.yml +++ b/_tools/screenshots/examples/axe-violation/_quarto.yml @@ -2,6 +2,7 @@ project: type: website render: - index.qmd + - console.qmd website: title: "Axe Violation Example" diff --git a/_tools/screenshots/examples/axe-violation/console.qmd b/_tools/screenshots/examples/axe-violation/console.qmd new file mode 100644 index 0000000000..0914b6eb73 --- /dev/null +++ b/_tools/screenshots/examples/axe-violation/console.qmd @@ -0,0 +1,11 @@ +--- +title: Testing Quarto's accessibility checker +format: + html: + axe: true + include-in-header: + # keep the DevTools console free of a favicon 404 in captures + text: '' +--- + +This violates contrast rules: [insufficient contrast.]{style="color: #eee"}. diff --git a/_tools/screenshots/manifest-schema.json b/_tools/screenshots/manifest-schema.json index 8718328b9e..7cbd4e2cd1 100644 --- a/_tools/screenshots/manifest-schema.json +++ b/_tools/screenshots/manifest-schema.json @@ -16,6 +16,11 @@ "type": "array", "description": "List of screenshot entries to capture.", "items": { "$ref": "#/$defs/screenshotEntry" } + }, + "manual": { + "type": "array", + "description": "Screenshots capture.js cannot produce; each entry points to the script that does.", + "items": { "$ref": "#/$defs/manualEntry" } } }, "$defs": { @@ -225,6 +230,19 @@ "dark": { "$ref": "#/$defs/darkConfig" } } }, + "manualEntry": { + "type": "object", + "description": "A screenshot produced by a dedicated script instead of capture.js.", + "additionalProperties": false, + "required": ["name", "output", "script", "reason"], + "properties": { + "name": { "type": "string", "description": "Unique identifier, used with --name flag." }, + "output": { "type": "string", "description": "Output path relative to repo root." }, + "script": { "type": "string", "description": "Capture script relative to _tools/screenshots/ (see its header for prerequisites)." }, + "reason": { "type": "string", "description": "Why capture.js cannot produce this screenshot." }, + "doc": { "$ref": "#/$defs/docConfig" } + } + }, "screenshotEntry": { "type": "object", "description": "A single screenshot definition.", diff --git a/_tools/screenshots/manifest.json b/_tools/screenshots/manifest.json index d06320f17f..04cd0666f2 100644 --- a/_tools/screenshots/manifest.json +++ b/_tools/screenshots/manifest.json @@ -307,5 +307,17 @@ "alt": "Reader mode toggle appearing in the top navigation. The navbar shows dark mode, reader mode, and search icons. Below the navbar, an 'On this page' collapsed dropdown replaces the usual table of contents." } } + ], + "manual": [ + { + "name": "axe-console", + "output": "docs/output-formats/images/axe-console.png", + "script": "scripts/capture-axe-devtools.mjs", + "reason": "Shows the browser DevTools console, which lives outside the page — Playwright can only screenshot the page itself.", + "doc": { + "file": "docs/output-formats/html-accessibility.qmd", + "alt": "The rendered webpage with the browser DevTools console open, showing the axe-core violation description, selector, and offending element HTML" + } + } ] } diff --git a/_tools/screenshots/scripts/capture-axe-devtools.mjs b/_tools/screenshots/scripts/capture-axe-devtools.mjs new file mode 100644 index 0000000000..a9c7644fb6 --- /dev/null +++ b/_tools/screenshots/scripts/capture-axe-devtools.mjs @@ -0,0 +1,105 @@ +// Capture docs/output-formats/images/axe-console.png: a real Chromium window +// with the DevTools console open on the axe `output: console` example. +// +// This can't go through capture.js/manifest.json — Playwright screenshots only +// the page, and the DevTools panel lives outside it. Instead this launches +// headed Chromium with DevTools auto-opened and uses macOS `screencapture -l` +// on the window ID, which composites only that window (other windows can't +// leak in even if they're in front). macOS only. +// +// Usage (from _tools/screenshots/): +// node scripts/render.js examples/axe-violation +// (cd examples/axe-violation/_site && python3 -m http.server 8931) & +// node scripts/capture-axe-devtools.mjs +import { chromium } from "playwright"; +import sharp from "sharp"; +import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from "fs"; +import { execSync } from "child_process"; +import { tmpdir } from "os"; +import path from "path"; +import { fileURLToPath } from "url"; + +const URL = "http://localhost:8931/console.html"; +const W = 1100, H = 650; +const CHROME_PX = 174; // browser chrome height in device px (2x), cropped off +const BOTTOM_PX = 1150; // keep a little space below the console prompt +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const OUT = path.join(repoRoot, "docs/output-formats/images/axe-console.png"); + +const profile = mkdtempSync(path.join(tmpdir(), "axe-devtools-")); +mkdirSync(path.join(profile, "Default"), { recursive: true }); +// Preseed DevTools prefs: dock bottom, Console panel, light theme. +// Keys changed casing across Chrome versions — set both spellings. +writeFileSync( + path.join(profile, "Default", "Preferences"), + JSON.stringify({ + devtools: { + preferences: { + currentDockState: '"bottom"', + "current-dock-state": '"bottom"', + "panel-selectedTab": '"console"', + "panel-selected-tab": '"console"', + uiTheme: '"default"', + "ui-theme": '"default"', + "InspectorView.splitViewState": JSON.stringify({ horizontal: { size: 380 } }), + "inspector-view.split-view-state": JSON.stringify({ horizontal: { size: 380 } }), + }, + }, + // Zoom the DevTools UI so console text stays legible once the image is + // scaled down into the docs page. Zoom level 2 = factor 1.2^2 ≈ 1.44. + partition: { per_host_zoom_levels: { x: { devtools: 2.0 } } }, + }) +); + +const ctx = await chromium.launchPersistentContext(profile, { + headless: false, + viewport: null, + args: ["--auto-open-devtools-for-tabs"], +}); +const page = ctx.pages()[0] ?? (await ctx.newPage()); + +// Pin the OS window to exact bounds via CDP (--window-size is unreliable here). +const cdp = await ctx.newCDPSession(page); +const { windowId } = await cdp.send("Browser.getWindowForTarget"); +await cdp.send("Browser.setWindowBounds", { + windowId, + bounds: { left: 20, top: 40, width: W, height: H, windowState: "normal" }, +}); + +await page.goto(URL); +await page.waitForSelector("[data-quarto-axe-complete]", { timeout: 15000 }); +// The page scrollbar renders as a dark strip at the window edge — hide it. +await page.addStyleTag({ content: "::-webkit-scrollbar{display:none !important}" }); +await page.waitForTimeout(3000); // let DevTools finish painting + +// Find the OS window ID (owner is "Chromium" or "Google Chrome for Testing"). +const jxa = ` +ObjC.import('CoreGraphics'); +const list = ObjC.deepUnwrap(ObjC.castRefToObject( + $.CGWindowListCopyWindowInfo($.kCGWindowListOptionOnScreenOnly, $.kCGNullWindowID))); +const win = list.find(w => /Chrom/.test(w.kCGWindowOwnerName) + && w.kCGWindowBounds && Math.round(w.kCGWindowBounds.Width) === ${W}); +win ? String(win.kCGWindowNumber) : 'NOTFOUND'`; +const winId = execSync(`osascript -l JavaScript -e '${jxa.replace(/'/g, "'\\''")}'`) + .toString().trim(); +if (winId === "NOTFOUND") throw new Error("Chromium window not found"); + +const raw = path.join(profile, "devtools-raw.png"); +execSync(`screencapture -x -o -l ${winId} ${raw}`); +await ctx.close(); + +// Crop off the (dark) browser chrome and the empty console below the prompt; +// trim 8px sides where the window's rounded corners show the background. +const meta = await sharp(raw).metadata(); +await sharp(raw) + .extract({ + left: 8, + top: CHROME_PX, + width: meta.width - 16, + height: Math.min(BOTTOM_PX, meta.height) - CHROME_PX, + }) + .flatten({ background: "#ffffff" }) + .png() + .toFile(OUT); +rmSync(profile, { recursive: true, force: true }); +console.log("wrote", OUT); diff --git a/_tools/screenshots/scripts/list.js b/_tools/screenshots/scripts/list.js index dee66a55ee..41329ccee0 100644 --- a/_tools/screenshots/scripts/list.js +++ b/_tools/screenshots/scripts/list.js @@ -17,16 +17,18 @@ if (process.argv.includes('--name') && (!namePattern || namePattern.startsWith(' process.exit(1); } -const filtered = namePattern - ? manifest.screenshots.filter(s => { - if (namePattern.includes('*')) { - const escaped = namePattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); - const re = new RegExp('^' + escaped.replace(/\*/g, '.*') + '$'); - return re.test(s.name); - } - return s.name === namePattern; - }) - : manifest.screenshots; +function matchName(name) { + if (!namePattern) return true; + if (namePattern.includes('*')) { + const escaped = namePattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp('^' + escaped.replace(/\*/g, '.*') + '$'); + return re.test(name); + } + return name === namePattern; +} + +const filtered = manifest.screenshots.filter(s => matchName(s.name)); +const manual = (manifest.manual ?? []).filter(m => matchName(m.name)); console.log(`### ${filtered.length} screenshot(s) in manifest\n`); for (const s of filtered) { @@ -40,3 +42,13 @@ for (const s of filtered) { if (s.capture?.interaction?.length) console.log(` Interactions: ${s.capture.interaction.length} step(s)`); console.log(); } + +if (manual.length) { + console.log(`### ${manual.length} manual capture(s) — not produced by capture.js\n`); + for (const m of manual) { + console.log(`- **${m.name}** → \`${m.output}\``); + console.log(` Script: \`node ${m.script}\` (see its header for prerequisites)`); + console.log(` Why manual: ${m.reason}`); + console.log(); + } +} diff --git a/docs/output-formats/html-accessibility.qmd b/docs/output-formats/html-accessibility.qmd index ccf6d85fb1..69e3a33edc 100644 --- a/docs/output-formats/html-accessibility.qmd +++ b/docs/output-formats/html-accessibility.qmd @@ -4,15 +4,13 @@ css: - autodark.css --- -::: callout-note -This feature was introduced in Quarto 1.8. -::: +{{< prerelease-callout 1.8 >}} Quarto includes integrated support for [`axe-core`](https://github.com/dequelabs/axe-core), a broadly-supported, open source, industry standard tool for accessibility checks in HTML documents. -## Accessibility checks with `axe-core` +## Interactive accessibility checks -To enable the simplest form of accessibility checks in Quarto 1.8, add the `axe` YAML metadata configuration to HTML `format`s (`html`, `dashboard`, and `revealjs`): +To enable the simplest form of accessibility checks, add the `axe` YAML metadata configuration to HTML `format`s (`html`, `dashboard`, and `revealjs`): ``` yaml format: @@ -20,11 +18,53 @@ format: axe: true ``` -In this situation, if your webpage has accessibility violations that `axe-core` can catch, Quarto will produce console messages that are visible by opening your browser's development tools. +Render and open the document in a browser. Accessibility violations will be visible as console messages in your browser's development tools. + +For example, this document contains a color contrast violation: + +::: light-content + +``` markdown +--- +title: Testing Quarto's accessibility checker +format: + html: + axe: true +--- + +This violates contrast rules: [insufficient contrast.]{style="color: #eee"}. +``` + +::: + +::: dark-content + +``` markdown +--- +title: Testing Quarto's accessibility checker +format: + html: + axe: true +--- + +This violates contrast rules: [insufficient contrast.]{style="color: #111"}. +``` + +::: + +Previewing the rendered page and opening the browser's developer tools shows the violation `axe-core` found: + +::: {.light-content} +![The rendered webpage with the developer tools console open](images/axe-console.png){fig-alt="A rendered webpage with the browser developer tools open below it. The console shows messages from axe-core: a violation description reading 'Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds', followed by the selector and HTML of the offending element."} +::: + +::: {.dark-content} +![The rendered webpage with the developer tools console open](images/axe-console.png){.autodark fig-alt="A rendered webpage with the browser developer tools open below it. The console shows messages from axe-core: a violation description reading 'Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds', followed by the selector and HTML of the offending element."} +::: -### Customization +### Output location -Quarto supports two additional output formats for the accessibility checks, available through the `output` option. +Quarto supports three output formats for the accessibility checks, available through the `output` option. - `document`: embedded reports @@ -32,7 +72,7 @@ Quarto supports two additional output formats for the accessibility checks, avai format: html: axe: - output: document + output: document ``` With this option, Quarto will generate a visible report of `axe-core` violations on the webpage itself. Each violation displays its WCAG conformance level (e.g., `WCAG 2.0 AA`), so best-practice findings are distinguishable from genuine WCAG failures. This is useful for visual inspection of a page. Note that with this setting, Quarto will always produce a report. @@ -63,55 +103,47 @@ Quarto supports two additional output formats for the accessibility checks, avai This option is equivalent to `axe: true`. -## Example: insufficient contrast +For the example above, `output: document` places the report on the page itself: -As a minimal example of how this works in Quarto, consider this simple document: - -::: light-content +::: {.light-content} +![The rendered webpage with an accessibility violation warning](images/axe-violation.png){.border fig-alt="A rendered webpage with an overlay along the bottom reporting a color contrast violation found by axe-core, including its severity, WCAG conformance level, and the selector of the offending element."} +::: -``` markdown ---- -title: Testing Quarto's accessibility checker -format: - html: - axe: - output: document ---- +::: {.dark-content} +![The rendered webpage with an accessibility violation warning](images/axe-violation.png){.border .autodark fig-alt="A rendered webpage with an overlay along the bottom reporting a color contrast violation found by axe-core, including its severity, WCAG conformance level, and the selector of the offending element."} +::: -This violates contrast rules: [insufficient contrast.]{style="color: #eee"}. -``` +### Checking against a WCAG conformance level -::: +{{< prerelease-callout 1.10 >}} -::: dark-content +By default, `axe-core` runs its full default rule set, which combines rules for several WCAG versions and levels with axe's own "best practice" rules. If you are working toward a specific WCAG conformance level, use the `standard` option to check only the rules for that level: -``` markdown ---- -title: Testing Quarto's accessibility checker +``` yaml format: - html: + html: axe: - output: document ---- - -This violates contrast rules: [insufficient contrast.]{style="color: #111"}. + standard: wcag21aa ``` -::: +The available values name a WCAG version followed by a level: `wcag2a`, `wcag2aa`, `wcag2aaa`, `wcag21a`, `wcag21aa`, `wcag21aaa`, `wcag22a`, `wcag22aa`, and `wcag22aaa`. Each standard is cumulative — it includes the rules for the lower levels and earlier versions it builds on. For example, `standard: wcag21aa` checks the rules for WCAG 2.0 A, 2.0 AA, 2.1 A, and 2.1 AA. +For the full list of rules, grouped by these same WCAG versions and levels, see `axe-core`'s [rule descriptions](https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md). -This is the produced result visible on the page: +When you specify `standard` Axe's best-practice rules are excluded. To check them alongside a standard, add `best-practice: true`: -::: {.light-content} -![The rendered webpage with an accessibility violation warning](images/axe-violation.png) -::: +``` yaml +format: + html: + axe: + standard: wcag21aa + best-practice: true +``` -::: {.dark-content} -![The rendered webpage with an accessibility violation warning](images/axe-violation.png){.autodark} -::: +You can also exclude the best-practice rules without choosing a standard by setting `best-practice: false` on its own. -## Planned work: automated checks before publishing +## Site-wide accessibility checks -Currently, this feature requires users to open the webpage in a [local preview](/docs/websites/index.html#website-preview), and it uses a CDN to load the `axe-core` library itself. +The checks above are interactive: they require opening each page in a [local preview](/docs/websites/index.html#website-preview) and inspecting the results in the browser. -In the future, we envision a mode where every page of a website can be checked at the time of `quarto render` or `quarto publish` in order to reduce the amount of required manual intervention. \ No newline at end of file +In the future, we envision a mode where every page of a website can be checked at the time of `quarto render` or `quarto publish` in order to reduce the amount of required manual intervention. diff --git a/docs/output-formats/images/axe-console.png b/docs/output-formats/images/axe-console.png new file mode 100644 index 0000000000..07f365fc7e Binary files /dev/null and b/docs/output-formats/images/axe-console.png differ