Screenshots
tools/shoot/shoot.py is a standalone Python script (run with uv, via Playwright) that screenshots every lttr preview output at phone and desktop widths, in light and dark, and fails the run if any render scrolls horizontally. It is not a Go dependency — it never runs during go build, go test, go mod or make check, and it only touches preview/ (an lttr preview output directory), design/handoff-11/design/ (read-only) and its own tools/shoot/out/.
Run it
make shoot
which is lttr preview followed by:
uv run tools/shoot/shoot.py preview --out tools/shoot/out --design design/handoff-11/design
Or point it at any preview directory directly:
uv run tools/shoot/shoot.py PREVIEW_DIR --out OUT_DIR [--design DESIGN_DIR]
uv reads the inline script metadata at the top of shoot.py (requires-python, dependencies = ["playwright>=1.47"]) and installs Playwright into an ephemeral environment with no project virtualenv needed; on first run it also installs the Chromium build Playwright needs if it isn't cached (~/Library/Caches/ms-playwright).
Pass flags
| Flag | Required | Meaning |
|---|---|---|
preview_dir (positional) | yes | Directory of rendered preview HTML files (an lttr preview -out … output) |
--out DIR | yes | Directory to write screenshots and the contact sheet to |
--design DIR | no | Directory of design HTML files to screenshot for comparison (e.g. design/handoff-11/design) |
--nav-timeout-ms N | no | Per-page navigation timeout in milliseconds (default 15000, or $SHOOT_NAV_TIMEOUT_MS) |
Screenshot every render
For every {name}.html file directly in preview_dir (skipping index.html):
- opens it in headless Chromium at 600px and 375px;
- in both light and dark colour-scheme emulation (
browser.new_context(color_scheme=…)— a genuineprefers-color-schemeemulation, not a page toggle); - reads
document.documentElement.scrollWidthand fails that shot if it exceeds the viewport width — i.e. the render scrolls horizontally; - saves a full-page PNG as
OUT/{name}-{width}-{scheme}.png.
With --design DIR, it additionally opens every *.html file directly in DIR (no filenames are hardcoded) at a large viewport and screenshots every section[data-screen-label] element to OUT/design/{slug(label)}.png, so a render can be compared side by side with the original handoff frame it was built from. A label matching ^Q(\d+)\b (e.g. "Q1 Order shipped") is that template's desktop frame; ^Mobile Q(\d+)\b is a mobile frame — the digits, zero-padded to two, are matched against the preview file's leading {NN}- number. This mapping is derived at runtime from whatever data-screen-label text the design HTML contains, not a hardcoded list.
Each render or design frame is isolated — one failure (a navigation timeout, a section that won't screenshot) doesn't abort the run or the contact sheet; it's recorded and shown in the summary and the contact sheet's "Errors" panel instead.
Read the contact sheet
OUT/index.html is a static page, light mode by default (never following the OS prefers-color-scheme automatically — only its own "Toggle dark mode" button changes it, remembered in localStorage). It shows:
- a status line: how many renders scrolled horizontally or errored, or "all N render(s) fit their viewport";
- an "Errors" panel (only when something failed) listing each failure's file, width/scheme (or design label) and message;
- one row per template: "Our render" (600px and 375px, light and dark) and, when a matching design frame was captured, a "Design handoff" row underneath with the desktop and mobile frame(s);
- a shot outlined in red and labelled "FAIL (scrolls)" — its
scrollWidthexceeded its viewport, meaning the render itself needs a fix before merging; - a shot outlined in red with no image and an "ERROR: …" caption — the render failed outright (e.g. never finished loading) rather than scrolling;
- click any thumbnail to open the full-resolution PNG in a new tab.
Catch horizontal scroll
The check is exactly scroll_width <= width for each shot's viewport. Anything that pushes document.documentElement.scrollWidth past 600px (desktop) or 375px (mobile) — a fixed-width table too wide for the mobile column, an image without a max-width, an un-wrapped long word — fails that shot, marks the run as failed, and shows up outlined in red on the contact sheet. Since email HTML runs everywhere without a build step to catch this, tools/shoot is the only mechanical check that email markup actually fits its declared width; the structural lint catches other classes of mistakes but not layout overflow.
Read the exit status
shoot.py prints a summary table (file, width, scheme, scrollWidth, ok) and exits:
- 1 if any render scrolled horizontally or errored (a navigation timeout, a design frame that failed to capture, …), or if
preview_dir/--designdoesn't exist (exit 2 for that case specifically); - 0 otherwise.
make shoot's definition of M8 done (SPEC §16): lttr preview output has no horizontal scroll at 375px in either colour scheme, and the screenshots look right next to design/handoff-11/screenshots/ when reviewed by eye.
Next steps
- Live preview for
make preview, whichmake shootruns first. - Goldens and testing for the structural lint the Go test suite runs on every golden.