Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions _tools/screenshots/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 |
Expand Down
13 changes: 12 additions & 1 deletion _tools/screenshots/capture.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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();
Expand Down
1 change: 1 addition & 0 deletions _tools/screenshots/examples/axe-violation/_quarto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ project:
type: website
render:
- index.qmd
- console.qmd

website:
title: "Axe Violation Example"
Expand Down
11 changes: 11 additions & 0 deletions _tools/screenshots/examples/axe-violation/console.qmd
Original file line number Diff line number Diff line change
@@ -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: '<link rel="icon" href="data:,">'
---

This violates contrast rules: [insufficient contrast.]{style="color: #eee"}.
18 changes: 18 additions & 0 deletions _tools/screenshots/manifest-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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.",
Expand Down
12 changes: 12 additions & 0 deletions _tools/screenshots/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
]
}
105 changes: 105 additions & 0 deletions _tools/screenshots/scripts/capture-axe-devtools.mjs
Original file line number Diff line number Diff line change
@@ -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);
32 changes: 22 additions & 10 deletions _tools/screenshots/scripts/list.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
}
}
Loading