Skip to content

Commit 9841fd2

Browse files
committed
Add dark mode screenshot support
capture.js now automatically generates -dark variants for screenshots with "dark": true in manifest. Dark mode is activated by clicking the Quarto color scheme toggle (.quarto-color-scheme-toggle). For clip screenshots, the clip region is computed as the union of light and dark bounding boxes so both variants have identical dimensions. - Add light/dark theme (cosmo/darkly) to example _quarto.yml files - Add defaults.dark config to manifest (toggle selector, ready check) - Update all docs: CLAUDE.md, SETUP.md, capture agent, screenshot command - Tighten about-jolla viewport to 650px to reduce bottom whitespace
1 parent 3b884f4 commit 9841fd2

8 files changed

Lines changed: 157 additions & 46 deletions

File tree

.claude/agents/screenshot-capture.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,24 @@ playwright-cli -s=screenshot run-code "async page => {
7474
playwright-cli -s=screenshot screenshot --filename=<output-path>
7575
```
7676

77-
### 6. Visual validation
77+
### 6. Dark mode variant
78+
79+
If the screenshot has `"dark": true` in manifest:
80+
1. Click the color scheme toggle: `playwright-cli -s=screenshot run-code "async page => await page.locator('.quarto-color-scheme-toggle').click()"`
81+
2. Wait for dark mode: `playwright-cli -s=screenshot run-code "async page => await page.locator('body.quarto-dark').waitFor()"`
82+
3. Re-run any interactions (e.g., dropdown may have closed during toggle)
83+
4. Take the screenshot again with `-dark` suffix on the filename
84+
5. Click toggle again to return to light mode
85+
86+
Note: `capture.js` handles this automatically. This step is only needed for manual/interactive captures.
87+
88+
### 7. Visual validation
7889

7990
After capturing, verify:
8091
- The screenshot file was created and is non-empty
8192
- Report what you captured (element, viewport size, interactions performed)
8293

83-
### 7. Close when done
94+
### 8. Close when done
8495

8596
```bash
8697
playwright-cli -s=screenshot close
@@ -89,7 +100,7 @@ playwright-cli -s=screenshot close
89100
## Rules
90101

91102
- ALWAYS use `-s=screenshot` session flag (avoids collisions with other sessions)
92-
- ALWAYS use light color scheme (default)
103+
- ALWAYS capture light mode first (default), then dark if needed
93104
- ALWAYS wait for fonts and icons to load before capturing
94105
- ALWAYS remove prerelease/preview banners
95106
- Use consistent viewport sizes from the manifest
1.9 KB
Loading

tools/screenshots/CLAUDE.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,20 @@ node tools/screenshots/capture.js --name "about-*" # glob pattern
1818

1919
1. Add entry to `tools/screenshots/manifest.json`
2020
2. If needed, create example project in `tools/screenshots/examples/`
21-
3. Run `/screenshot` or `capture.js`
21+
3. Set `"dark": true` if the screenshot needs a dark mode variant
22+
4. Ensure example `_quarto.yml` has `theme: { light: cosmo, dark: darkly }` for dark support
23+
5. Run `/screenshot` or `capture.js`
24+
25+
## Dark Mode
26+
27+
Screenshots with `"dark": true` in the manifest automatically get a `-dark` variant:
28+
- `about-jolla.png` → also generates `about-jolla-dark.png`
29+
- The dark variant is captured by clicking the Quarto color scheme toggle
30+
- Use `.include-dark` class on images in .qmd files so the filter generates both light/dark `<img>` tags
2231

2332
## Visual Rules
2433

25-
- Always use light color scheme
34+
- Default to light color scheme (dark captured automatically when `dark: true`)
2635
- Wait for full page load (fonts and icons must render)
2736
- Remove prerelease callouts before capture (automated in defaults)
2837
- Remove preview/prerelease banners
@@ -41,11 +50,12 @@ node tools/screenshots/capture.js --name "about-*" # glob pattern
4150
## Tools (preference order)
4251

4352
1. `/screenshot` command — orchestrates render, serve, capture, compress
44-
2. `capture.js` — automated batch replay from manifest (no AI)
53+
2. `capture.js` — automated batch replay from manifest (no AI, uses Playwright API directly)
4554
3. `playwright-cli` — direct browser control for interactive/debugging use
4655

4756
## Environment
4857

4958
- `QUARTO_CMD` env var to override quarto command (default: `quarto`)
50-
- playwright-cli must use `-s=screenshot` session flag
59+
- `capture.js` uses Playwright as a Node.js library (npm dependency, no global install needed)
60+
- `playwright-cli` uses `-s=screenshot` session flag (for interactive/agent use)
5161
- oxipng compression is optional locally (CI handles it as safety net)

tools/screenshots/SETUP.md

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,18 @@ Tooling for capturing and maintaining documentation screenshots in quarto-web.
44

55
## Prerequisites
66

7-
- **Node.js 18+** — required for scripts and playwright-cli
7+
- **Node.js 18+** — required for scripts and Playwright
88
- **Quarto** — required for rendering example projects (set `QUARTO_CMD` env var if your binary has a different name)
99

1010
## Option A: Automated Replay (no AI)
1111

1212
```bash
13-
# One-time: install playwright-cli and browser
14-
npm install -g @playwright/cli@latest
15-
playwright-cli install-browser
13+
# One-time: install dependencies and browser
14+
cd tools/screenshots
15+
npm install
16+
npx playwright install chromium
1617

17-
# From tools/screenshots/:
18+
# Capture all screenshots (light + dark variants):
1819
npm run capture # all screenshots
1920
npm run capture -- --name navbar-tools # specific
2021
npm run capture -- --name "about-*" # glob pattern
@@ -73,27 +74,31 @@ A CI workflow compresses changed PNGs after merge to main. Local install is opti
7374
```
7475
tools/screenshots/
7576
├── manifest.json # screenshot definitions (single source of truth)
76-
├── capture.js # replay script (no AI)
77-
├── package.json # type:module, npm scripts
77+
├── capture.js # replay script (uses Playwright API directly)
78+
├── package.json # dependencies: playwright, open
7879
├── CLAUDE.md # visual rules for Claude
7980
├── SETUP.md # this file
8081
├── scripts/
8182
│ ├── list.js # read + format manifest
8283
│ ├── render.js # quarto render wrapper
8384
│ ├── serve.js # static file server
85+
│ ├── open.js # open file with OS default app
8486
│ └── compress.js # oxipng wrapper
8587
└── examples/
86-
├── about-pages/ # about page templates
87-
└── navbar-tools/ # navbar with dropdown
88+
├── about-pages/ # about page templates (light + dark theme)
89+
└── navbar-tools/ # navbar with dropdown (light + dark theme)
8890
```
8991

9092
## Manifest Format
9193

9294
Each screenshot in `manifest.json` specifies:
9395
- `name` — unique identifier
9496
- `output` — output PNG path (relative to repo root)
97+
- `dark` — if `true`, also captures a `-dark` variant (e.g. `about-jolla-dark.png`)
9598
- `source` — where to get the page (example project, URL, or local)
96-
- `capture` — viewport, interactions, element selector
99+
- `capture` — viewport, interactions, clip selectors, element selector
97100
- `doc` — which .qmd file references this image
98101

102+
Dark mode works by clicking the Quarto color scheme toggle (`.quarto-color-scheme-toggle`). Example projects must have `theme: { light: cosmo, dark: darkly }` in `_quarto.yml`.
103+
99104
See `manifest.json` for the full schema.

tools/screenshots/capture.js

Lines changed: 93 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ if (screenshots.length === 0) {
5454
if (listOnly) {
5555
for (const s of screenshots) {
5656
console.log(`${s.name}${s.output}`);
57+
if (s.dark) {
58+
const ext = s.output.lastIndexOf('.');
59+
console.log(`${s.name} (dark) → ${s.output.slice(0, ext)}-dark${s.output.slice(ext)}`);
60+
}
5761
}
5862
process.exit(0);
5963
}
@@ -143,32 +147,61 @@ async function runInteractions(page, shot) {
143147
}
144148
}
145149

146-
// Take a screenshot
147-
async function takeScreenshot(page, shot) {
148-
const outputPath = resolve(REPO_ROOT, shot.output);
150+
// Compute dark output path: about-jolla.png → about-jolla-dark.png
151+
function darkOutputPath(outputPath) {
152+
const ext = outputPath.lastIndexOf('.');
153+
return outputPath.slice(0, ext) + '-dark' + outputPath.slice(ext);
154+
}
149155

150-
if (shot.capture?.clip) {
151-
// Clip screenshot: union bounding box of multiple selectors
152-
const selectors = shot.capture.clip;
153-
const boxes = [];
154-
for (const sel of selectors) {
155-
const loc = page.locator(sel);
156-
if (await loc.count() > 0) {
157-
const box = await loc.first().boundingBox();
158-
if (box) boxes.push(box);
159-
}
156+
// Switch to dark mode by clicking the toggle
157+
async function switchToDark(page) {
158+
const darkConfig = manifest.defaults.dark;
159+
await page.locator(darkConfig.toggle).click();
160+
await page.locator(darkConfig.ready).waitFor({ timeout: 5000 });
161+
if (darkConfig.settle) {
162+
await page.waitForTimeout(darkConfig.settle);
163+
}
164+
}
165+
166+
// Switch back to light mode
167+
async function switchToLight(page) {
168+
const darkConfig = manifest.defaults.dark;
169+
await page.locator(darkConfig.toggle).click();
170+
// Wait for dark class to be removed
171+
const readySelector = darkConfig.ready.replace('.quarto-dark', '.quarto-light');
172+
await page.locator(readySelector).waitFor({ timeout: 5000 });
173+
if (darkConfig.settle) {
174+
await page.waitForTimeout(darkConfig.settle);
175+
}
176+
}
177+
178+
// Compute clip region from selectors
179+
async function computeClip(page, selectors) {
180+
const boxes = [];
181+
for (const sel of selectors) {
182+
const loc = page.locator(sel);
183+
if (await loc.count() > 0) {
184+
const box = await loc.first().boundingBox();
185+
if (box) boxes.push(box);
160186
}
161-
if (boxes.length === 0) throw new Error('No clip selectors matched');
162-
const vp = page.viewportSize();
163-
const pad = 10;
164-
const x = Math.max(0, Math.min(...boxes.map(b => b.x)) - pad);
165-
const y = Math.max(0, Math.min(...boxes.map(b => b.y)) - pad);
166-
const right = Math.min(Math.max(...boxes.map(b => b.x + b.width)) + pad, vp.width);
167-
const bottom = Math.min(Math.max(...boxes.map(b => b.y + b.height)) + pad, vp.height);
168-
await page.screenshot({
169-
path: outputPath,
170-
clip: { x, y, width: right - x, height: bottom - y }
171-
});
187+
}
188+
if (boxes.length === 0) throw new Error('No clip selectors matched');
189+
const vp = page.viewportSize();
190+
const pad = 10;
191+
const x = Math.max(0, Math.min(...boxes.map(b => b.x)) - pad);
192+
const y = Math.max(0, Math.min(...boxes.map(b => b.y)) - pad);
193+
const right = Math.min(Math.max(...boxes.map(b => b.x + b.width)) + pad, vp.width);
194+
const bottom = Math.min(Math.max(...boxes.map(b => b.y + b.height)) + pad, vp.height);
195+
return { x, y, width: right - x, height: bottom - y };
196+
}
197+
198+
// Take a screenshot (optionally override output path and/or clip)
199+
async function takeScreenshot(page, shot, overridePath, overrideClip) {
200+
const outputPath = overridePath || resolve(REPO_ROOT, shot.output);
201+
202+
if (shot.capture?.clip) {
203+
const clip = overrideClip || await computeClip(page, shot.capture.clip);
204+
await page.screenshot({ path: outputPath, clip });
172205
} else if (shot.capture?.element) {
173206
await page.locator(shot.capture.element).screenshot({ path: outputPath });
174207
} else {
@@ -261,12 +294,45 @@ async function main() {
261294
// Interactions
262295
await runInteractions(page, shot);
263296

264-
// Screenshot
265-
const outputPath = await takeScreenshot(page, shot);
297+
// For dark variants with clip: compute union clip across both modes
298+
// so light and dark screenshots have identical dimensions
299+
let sharedClip = null;
300+
if (shot.dark && shot.capture?.clip) {
301+
const lightClip = await computeClip(page, shot.capture.clip);
302+
await switchToDark(page);
303+
await runInteractions(page, shot);
304+
const darkClip = await computeClip(page, shot.capture.clip);
305+
// Union of both clip regions
306+
const x = Math.min(lightClip.x, darkClip.x);
307+
const y = Math.min(lightClip.y, darkClip.y);
308+
const right = Math.max(lightClip.x + lightClip.width, darkClip.x + darkClip.width);
309+
const bottom = Math.max(lightClip.y + lightClip.height, darkClip.y + darkClip.height);
310+
sharedClip = { x, y, width: right - x, height: bottom - y };
311+
// Take dark screenshot while we're here
312+
const darkPath = darkOutputPath(resolve(REPO_ROOT, shot.output));
313+
console.log(` ${shot.name} (dark)...`);
314+
await takeScreenshot(page, shot, darkPath, sharedClip);
315+
compressPng(darkPath);
316+
// Switch back to light for the light screenshot
317+
await switchToLight(page);
318+
await runInteractions(page, shot);
319+
}
266320

267-
// Compress
321+
// Light screenshot
322+
const outputPath = await takeScreenshot(page, shot, null, sharedClip);
268323
compressPng(outputPath);
269324

325+
// Dark variant (non-clip mode: element or viewport)
326+
if (shot.dark && !shot.capture?.clip) {
327+
console.log(` ${shot.name} (dark)...`);
328+
await switchToDark(page);
329+
const darkPath = darkOutputPath(outputPath);
330+
await runInteractions(page, shot);
331+
await takeScreenshot(page, shot, darkPath);
332+
compressPng(darkPath);
333+
await switchToLight(page);
334+
}
335+
270336
// Verify — open image for visual review
271337
if (verify) {
272338
console.log(` Opening for review: ${outputPath}`);

tools/screenshots/examples/about-pages/_quarto.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,9 @@ project:
66

77
website:
88
title: "About Pages"
9+
10+
format:
11+
html:
12+
theme:
13+
light: cosmo
14+
dark: darkly

tools/screenshots/examples/navbar-tools/_quarto.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ project:
33
render:
44
- index.qmd
55

6+
format:
7+
html:
8+
theme:
9+
light: cosmo
10+
dark: darkly
11+
612
website:
713
title: "ProjectX"
814
navbar:

tools/screenshots/manifest.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,21 @@
55
"cleanup": [
66
{ "action": "eval", "script": "document.querySelectorAll('.callout-note').forEach(el => { if (el.textContent.includes('Pre-release')) el.remove() })" },
77
{ "action": "eval", "script": "document.querySelectorAll('[class*=prerelease],[class*=preview]').forEach(el => el.remove())" }
8-
]
8+
],
9+
"dark": {
10+
"toggle": ".quarto-color-scheme-toggle",
11+
"ready": "body.quarto-dark",
12+
"settle": 500
13+
}
914
},
1015
"screenshots": [
1116
{
1217
"name": "about-jolla",
1318
"output": "docs/websites/images/about-jolla.png",
19+
"dark": true,
1420
"source": { "type": "example", "project": "examples/about-pages", "page": "jolla.html" },
1521
"capture": {
16-
"viewport": { "width": 1200, "height": 900 },
22+
"viewport": { "width": 1200, "height": 650 },
1723
"clip": [".quarto-about-jolla"]
1824
},
1925
"doc": {
@@ -24,6 +30,7 @@
2430
{
2531
"name": "navbar-tools",
2632
"output": "docs/websites/images/navbar-tools.png",
33+
"dark": true,
2734
"source": { "type": "example", "project": "examples/navbar-tools", "page": "index.html" },
2835
"capture": {
2936
"viewport": { "width": 1440, "height": 400 },

0 commit comments

Comments
 (0)