Skip to content

Commit 5a01795

Browse files
authored
ci(docs): capture panel screenshots in light + dark at 1920x1080 full-page (#1429)
Until now the docs site's auto-captured screenshots were single-theme (whatever default the inline FOUC bootloader resolved to — light) at a 1280x800 viewport with viewport-only crops. That under-represented the panel chrome on three axes: - Dark mode is roughly half the panel UX (the user-toggled `localStorage['sbpp-theme']` value is `'dark'` or `'system'` for a meaningful share of operators) and went entirely uncaptured. - 1280x800 cuts off tier-3 desktop-table columns (IP / Length / Banned / Started; see AGENTS.md "Responsive desktop-table chrome") that only appear at viewport >=1788px on the 1700px-capped bans / comms list pages. - Viewport-only crops truncated long pages (banlist, dashboard, admin sub-routes) at the fold. This change makes the trusted-from-main capture script emit: - 1920x1080 (Full HD) viewport with `fullPage: true` so the entire scrollable surface lands in the PNG. - Two captures per panel route — `panel-02-dashboard-light.png` and `panel-02-dashboard-dark.png`, etc. The dark pass spins up its own browser context with `colorScheme: 'dark'` and seeds `localStorage['sbpp-theme'] = 'dark'` via Playwright's `addInitScript` BEFORE any navigation, so the inline FOUC bootloader in `core/header.tpl` lands `<html class="dark">` on the very first paint (no white flash; matches the contract documented in AGENTS.md "Anti-FOUC theme bootloader"). - Install routes stay light-only and keep the bare `install-NN-*` filename. The wizard's `_chrome.tpl` doesn't load `theme.js` or the FOUC bootloader (no theme toggle by design — see AGENTS.md "Install wizard"), so a "dark install" capture would just be the same light render under a different filename. The output subdirectory is wiped at the start of each run (per-route-group, so `CAPTURE_ROUTES=panel` doesn't clobber a previous install pass) so the legacy single-theme PNGs from the pre-dual-theme runs (e.g. `panel-02-dashboard.png`) don't linger in the committed diff. The workflow's two-pass `CAPTURE_ROUTES=panel` / `=install` structure (with the `web/config.php` stash/restore around the install pass for the #1335 C2 guard) is unchanged — the dual-theme loop happens INSIDE the panel pass via `capture.mjs`, so no new env vars and no new step needed. The split-checkout security model (capture script from main, panel under test from PR head, auto-commit pushes from PR head) is untouched. Verification: - `node --check scripts/capture.mjs` (matches the `docs-screenshots-build.yml` per-PR gate). - `python3 yaml.safe_load` on the workflow YAML.
1 parent 25e6d9b commit 5a01795

3 files changed

Lines changed: 192 additions & 23 deletions

File tree

.github/workflows/docs-screenshots-capture.yml

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
11
# docs-screenshots-capture — boot the dev stack, capture installer +
22
# panel screenshots, and commit the diff back to the PR branch.
33
#
4+
# Capture geometry (driven by docs/scripts/capture.mjs):
5+
#
6+
# - Viewport: 1920×1080 (Full HD), with `fullPage: true` so the
7+
# entire scrollable surface lands in the PNG.
8+
# - Panel routes are captured TWICE — once in light mode and once
9+
# in dark — and emit per-theme PNGs (e.g.
10+
# `panel-02-dashboard-light.png`, `panel-02-dashboard-dark.png`).
11+
# The dark pass seeds `localStorage['sbpp-theme']` via Playwright's
12+
# `addInitScript` so the inline FOUC bootloader in
13+
# `core/header.tpl` lands `<html class="dark">` on the first
14+
# paint (no white flash). See AGENTS.md "Anti-FOUC theme
15+
# bootloader" for the contract.
16+
# - Install routes are captured ONCE in the :root light default.
17+
# The wizard's `_chrome.tpl` doesn't load `theme.js` or the FOUC
18+
# bootloader (the wizard has no theme toggle by design; see
19+
# AGENTS.md "Install wizard").
20+
#
421
# SECURITY MODEL — read this before changing the workflow.
522
#
623
# `pull_request_target` runs with the upstream repo's GITHUB_TOKEN
@@ -212,7 +229,13 @@ jobs:
212229
# below moves the file aside on the host (the bind-mounted
213230
# `./web` means the container sees the rename immediately) for
214231
# the duration of the install captures, then restores it.
215-
- name: Capture panel screenshots
232+
#
233+
# The panel pass loops both `light` and `dark` themes inside
234+
# `capture.mjs` (one browser context per theme; localStorage
235+
# seeded before navigation so the FOUC bootloader picks dark
236+
# up on the first paint). See the workflow header comment
237+
# for the per-theme output naming.
238+
- name: Capture panel screenshots (light + dark, 1920×1080 full-page)
216239
working-directory: trusted/docs
217240
env:
218241
STEAM_API_KEY: '00000000000000000000000000000000'
@@ -231,7 +254,11 @@ jobs:
231254
echo "web/config.php was already absent; nothing to stash"
232255
fi
233256
234-
- name: Capture install screenshots
257+
# Install pass is light-only — the wizard's `_chrome.tpl`
258+
# doesn't load `theme.js` or the FOUC bootloader, so a "dark
259+
# install" capture would just be the same light render with a
260+
# different filename (see the workflow header comment).
261+
- name: Capture install screenshots (light only, 1920×1080 full-page)
235262
working-directory: trusted/docs
236263
env:
237264
STEAM_API_KEY: '00000000000000000000000000000000'

docs/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,32 @@ The script writes PNGs into `src/assets/auto/install/` and
7272
what changed; commit the deltas alongside the UI change that produced
7373
them.
7474

75+
Capture geometry:
76+
77+
- **Viewport: 1920×1080 (Full HD), full-page screenshots.** The
78+
rendered PNG carries the full scrollable surface so high-DPI /
79+
zoomed-in inspection works without re-running the capture. The
80+
docs site renders the PNGs at responsive widths.
81+
- **Panel routes are captured TWICE — once light, once dark.**
82+
Each route emits a `<route>-light.png` and a `<route>-dark.png`
83+
(e.g. `panel-02-dashboard-light.png` /
84+
`panel-02-dashboard-dark.png`). The dark pass seeds
85+
`localStorage['sbpp-theme']` before navigation so the inline FOUC
86+
bootloader in `core/header.tpl` lands the `dark` class on
87+
`<html>` on the first paint (no white flash). See AGENTS.md
88+
"Anti-FOUC theme bootloader" for the contract.
89+
- **Install routes are captured ONCE in light.** The wizard's
90+
`_chrome.tpl` doesn't load `theme.js` or the FOUC bootloader (the
91+
wizard has no theme toggle by design — see AGENTS.md "Install
92+
wizard"); a "dark install" capture would just be the same light
93+
render with a different filename.
94+
- **The output subdirectory is wiped at the start of each run.**
95+
`CAPTURE_ROUTES=panel` clears `src/assets/auto/panel/` before
96+
capturing, `=install` clears `src/assets/auto/install/`, and the
97+
default `=all` clears both. Stale PNGs from earlier runs (route
98+
list changes, pre-dual-theme legacy filenames) don't linger in
99+
the committed diff.
100+
75101
The hardcoded `STEAM_API_KEY` is `00000000000000000000000000000000`
76102
(an all-zero dummy) — the dev seed never round-trips back to Steam,
77103
so the zero key is safe and avoids leaking real keys into screenshots.

docs/scripts/capture.mjs

Lines changed: 137 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,37 @@
5454
// Requires `web/config.php` to be absent so
5555
// the C2 guard doesn't intercept.
5656
//
57+
// Output shape:
58+
// - Panel routes are captured TWICE — once with the panel's light
59+
// theme active and once with dark — and emit per-theme PNGs
60+
// (`panel-02-dashboard-light.png`, `panel-02-dashboard-dark.png`,
61+
// etc.). The dark variant seeds `localStorage['sbpp-theme']`
62+
// before the page paints so the inline FOUC bootloader in
63+
// `core/header.tpl` lands the `dark` class on `<html>` on the
64+
// very first frame (no white flash). The browser context's
65+
// `colorScheme` is matched to the panel theme so native UA
66+
// surfaces (scrollbars, native pickers, autofill highlighting)
67+
// paint in the matching scheme too — see theme.css's
68+
// `color-scheme` declarations (#1309).
69+
// - Install routes are captured ONCE in the :root light default.
70+
// The wizard's `_chrome.tpl` doesn't load `theme.js` or the
71+
// FOUC bootloader (the wizard has no theme toggle by design;
72+
// see AGENTS.md "Install wizard"), so a "dark install" capture
73+
// would just be the same light render with a different filename.
74+
//
75+
// Default capture geometry is 1920×1080 viewport with `fullPage: true`
76+
// so the entire scrollable surface lands in the PNG (the docs site
77+
// renders these at responsive widths, but the source PNG carries the
78+
// full layout for high-DPI / zoomed-in inspection).
79+
//
5780
// The script is intentionally a runnable SKELETON for the first PR.
5881
// The route list below is the bones; flesh out per-route selectors
5982
// + click sequences as the install / panel chrome iterates. Routes
6083
// with `TODO:` notes are deferred to follow-up PRs that exercise
6184
// the actual flow end-to-end.
6285

6386
import { chromium } from '@playwright/test';
64-
import { mkdir } from 'node:fs/promises';
87+
import { mkdir, rm } from 'node:fs/promises';
6588
import { dirname, join } from 'node:path';
6689
import { fileURLToPath } from 'node:url';
6790

@@ -83,10 +106,26 @@ const STEAM_API_KEY =
83106
process.env.STEAM_API_KEY ?? '00000000000000000000000000000000';
84107
const ADMIN_USER = process.env.PANEL_ADMIN_USER ?? 'admin';
85108
const ADMIN_PASS = process.env.PANEL_ADMIN_PASS ?? 'admin';
86-
// Crop window chrome to a consistent viewport so screenshots line up
109+
// Pin the viewport to Full HD (1920×1080) so screenshots line up
87110
// across runs even when the runner's chromium gets a different
88-
// default size.
89-
const VIEWPORT = { width: 1280, height: 800 };
111+
// default size. Combined with `fullPage: true` on the screenshot
112+
// call, the rendered PNG carries the full scrollable surface at
113+
// the largest viewport the panel chrome's responsive breakpoints
114+
// target — wide enough to surface tier-3 desktop-table columns
115+
// (see AGENTS.md "Responsive desktop-table chrome") and the full
116+
// sidebar + content shell on the admin routes.
117+
const VIEWPORT = { width: 1920, height: 1080 };
118+
119+
// Panel themes to capture. Each panel route runs once per theme in
120+
// its own browser context; the dark pass seeds
121+
// `localStorage['sbpp-theme']` via `addInitScript` so the FOUC
122+
// bootloader in `core/header.tpl` resolves to dark BEFORE the
123+
// document body parses (the bootloader is the synchronous read; see
124+
// AGENTS.md "Anti-FOUC theme bootloader" for the contract). The
125+
// install wizard chrome doesn't carry the bootloader, so install
126+
// routes are captured light-only — see `main()` below.
127+
/** @type {readonly ('light' | 'dark')[]} */
128+
const PANEL_THEMES = ['light', 'dark'];
90129

91130
// CAPTURE_ROUTES picks which route groups run. CI invokes us twice
92131
// (once per group) with config.php stashed around the install pass
@@ -179,9 +218,23 @@ const INSTALL_ROUTES = [
179218
},
180219
];
181220

221+
// Reset the output directories for the route groups we're about to
222+
// capture so a previous run's stale PNGs (e.g. last-run filenames
223+
// that don't match the current route list, or pre-dual-theme legacy
224+
// `panel-02-dashboard.png` from before per-theme suffixes landed)
225+
// don't linger in the committed diff. We only nuke the subdir for
226+
// the group actually being captured so a `CAPTURE_ROUTES=panel`
227+
// invocation doesn't wipe a previous `install` capture (or vice
228+
// versa). `force: true` makes the missing-directory case idempotent.
182229
async function ensureOutDirs() {
183-
await mkdir(OUT_INSTALL, { recursive: true });
184-
await mkdir(OUT_PANEL, { recursive: true });
230+
if (CAPTURE_ROUTES === 'all' || CAPTURE_ROUTES === 'install') {
231+
await rm(OUT_INSTALL, { recursive: true, force: true });
232+
await mkdir(OUT_INSTALL, { recursive: true });
233+
}
234+
if (CAPTURE_ROUTES === 'all' || CAPTURE_ROUTES === 'panel') {
235+
await rm(OUT_PANEL, { recursive: true, force: true });
236+
await mkdir(OUT_PANEL, { recursive: true });
237+
}
185238
}
186239

187240
/**
@@ -215,10 +268,46 @@ async function loginAsAdmin(page) {
215268
* @param {import('@playwright/test').Browser} browser
216269
* @param {CaptureRoute[]} routes
217270
* @param {string} outDir
218-
* @param {{ login?: boolean }} [opts]
271+
* @param {{ login?: boolean; theme?: 'light' | 'dark' }} [opts]
219272
*/
220273
async function captureRoutes(browser, routes, outDir, opts = {}) {
221-
const ctx = await browser.newContext({ viewport: VIEWPORT });
274+
/** @type {import('@playwright/test').BrowserContextOptions} */
275+
const contextOptions = { viewport: VIEWPORT };
276+
if (opts.theme) {
277+
// Pair the OS-level scheme with the panel-side theme so native
278+
// UA surfaces (scrollbars, native pickers, autofill highlighting)
279+
// paint in the matching scheme too — mirrors the `color-scheme`
280+
// declarations on `:root` / `html.dark` in theme.css (#1309).
281+
// The bootloader is the load-bearing path for the panel chrome
282+
// itself; this is belt-and-suspenders for the surfaces the
283+
// browser draws on top of our DOM.
284+
contextOptions.colorScheme = opts.theme;
285+
}
286+
const ctx = await browser.newContext(contextOptions);
287+
288+
if (opts.theme) {
289+
// Seed `localStorage['sbpp-theme']` BEFORE any page navigation
290+
// so the inline FOUC bootloader in `core/header.tpl` (which runs
291+
// synchronously in `<head>` BEFORE `<body>` parses, ABOVE the
292+
// stylesheet link) reads the value and sets `<html class="dark">`
293+
// on the very first paint. Without the seed, the panel would
294+
// start in :root light defaults and theme.js (loaded from the
295+
// document tail via `core/footer.tpl`) would flip the class
296+
// mid-paint, producing the white-flash regression #1367 fixed.
297+
// Playwright's `addInitScript` runs after the document is
298+
// created but before any of its own scripts execute, which is
299+
// exactly the slot we need.
300+
await ctx.addInitScript((target) => {
301+
try {
302+
localStorage.setItem('sbpp-theme', target);
303+
} catch (_err) {
304+
// Private-mode SecurityError; the bootloader's own try/catch
305+
// falls through to the :root light default in that case,
306+
// matching theme.js's defensiveness.
307+
}
308+
}, opts.theme);
309+
}
310+
222311
const page = await ctx.newPage();
223312

224313
if (opts.login) {
@@ -235,7 +324,11 @@ async function captureRoutes(browser, routes, outDir, opts = {}) {
235324
const url = route.url.startsWith('http')
236325
? route.url
237326
: `${PANEL_URL}${route.url}`;
238-
const target = join(outDir, `${route.name}.png`);
327+
// Per-theme suffix on the filename so the light + dark variants
328+
// co-exist in the same output directory. Install routes pass no
329+
// theme and keep the bare slug.
330+
const slug = opts.theme ? `${route.name}-${opts.theme}` : route.name;
331+
const target = join(outDir, `${slug}.png`);
239332

240333
try {
241334
await page.goto(url, { waitUntil: 'networkidle', timeout: 15_000 });
@@ -244,7 +337,11 @@ async function captureRoutes(browser, routes, outDir, opts = {}) {
244337
}
245338
await page.screenshot({
246339
path: target,
247-
fullPage: route.fullPage ?? false,
340+
// Default to `fullPage: true` so the whole scrollable
341+
// surface lands in the PNG; routes can still opt out
342+
// explicitly via `fullPage: false` in their config (e.g.
343+
// a chrome-only shot of the login form).
344+
fullPage: route.fullPage ?? true,
248345
});
249346
const note = route.todo ? ` (TODO: ${route.todo})` : '';
250347
console.log(`[capture] wrote ${target}${note}`);
@@ -262,6 +359,8 @@ async function main() {
262359
console.log(`[capture] PANEL_URL=${PANEL_URL}`);
263360
console.log(`[capture] STEAM_API_KEY=${STEAM_API_KEY}`);
264361
console.log(`[capture] CAPTURE_ROUTES=${CAPTURE_ROUTES}`);
362+
console.log(`[capture] VIEWPORT=${VIEWPORT.width}x${VIEWPORT.height}`);
363+
console.log(`[capture] PANEL_THEMES=${PANEL_THEMES.join(',')}`);
265364
console.log(
266365
`[capture] writing → ${OUT_INSTALL} (install) and ${OUT_PANEL} (panel)`,
267366
);
@@ -273,20 +372,37 @@ async function main() {
273372
// `web/config.php` is absent — see the header comment for
274373
// the C2-guard rationale. CI stashes the file around this
275374
// call; local-dev defaults render the 409 page.
375+
//
376+
// The install chrome (`_chrome.tpl`) doesn't load `theme.js`
377+
// or the FOUC bootloader (the wizard has no theme toggle by
378+
// design — see AGENTS.md "Install wizard"). Capture once in
379+
// the :root light default with no per-theme suffix; trying
380+
// to flip the wizard to dark would just produce the same
381+
// light render under a different filename.
276382
await captureRoutes(browser, INSTALL_ROUTES, OUT_INSTALL);
277383
}
278384
if (CAPTURE_ROUTES === 'all' || CAPTURE_ROUTES === 'panel') {
279-
// Anonymous pass — captures the login screen before any
280-
// session cookie exists. Splitting this from the auth pass
281-
// avoids the `<script>window.location.href='index.php'</script>`
282-
// post-login redirect aborting the goto.
283-
await captureRoutes(browser, PANEL_PUBLIC_ROUTES, OUT_PANEL);
284-
// Authenticated pass — fresh context, drives the login form,
285-
// then visits the admin-only routes so they paint actual
286-
// content instead of the login wall.
287-
await captureRoutes(browser, PANEL_AUTH_ROUTES, OUT_PANEL, {
288-
login: true,
289-
});
385+
// Panel routes capture both light and dark variants. Each
386+
// theme runs in its own browser context so the localStorage
387+
// seeding is clean and the cookie/login state is rebuilt
388+
// per pass (logging in twice costs ~200ms total — cheap).
389+
for (const theme of PANEL_THEMES) {
390+
// Anonymous pass — captures the login screen before any
391+
// session cookie exists. Splitting this from the auth
392+
// pass avoids the
393+
// `<script>window.location.href='index.php'</script>`
394+
// post-login redirect aborting the goto.
395+
await captureRoutes(browser, PANEL_PUBLIC_ROUTES, OUT_PANEL, {
396+
theme,
397+
});
398+
// Authenticated pass — fresh context, drives the login
399+
// form, then visits the admin-only routes so they paint
400+
// actual content instead of the login wall.
401+
await captureRoutes(browser, PANEL_AUTH_ROUTES, OUT_PANEL, {
402+
login: true,
403+
theme,
404+
});
405+
}
290406
}
291407
} finally {
292408
await browser.close();

0 commit comments

Comments
 (0)