#!/usr/bin/env python3 """Render the running Thermograph frontend across the breakpoint matrix. Drives headless Chromium (Playwright) over the served app and writes one PNG per page x width x color-scheme into frontend/.screenshots/ (gitignored), so a design change can actually be seen. See DESIGN.md for the workflow. Prereqs: the app must be serving (`make lan-run`) and Playwright + Chromium must be installed (`make shots` handles both). Run directly for a narrow sweep: python tools/shoot.py # full matrix python tools/shoot.py index # one page, all widths/schemes python tools/shoot.py index --width 390 --scheme dark SHOTS_BASE=http://127.0.0.1:8137 python tools/shoot.py """ from __future__ import annotations import argparse import os import sys import urllib.request from pathlib import Path # Base URL of the served app (no trailing slash). Override with SHOTS_BASE. BASE = os.environ.get("SHOTS_BASE", "http://127.0.0.1:8137").rstrip("/") # Default output dir, relative to the repo root (this file lives in tools/). OUT_DIR = Path(__file__).resolve().parent.parent / "frontend" / ".screenshots" # The CLAUDE.md / DESIGN.md validation widths: phone, tablet, 1440p, 2K, 4K. WIDTHS = [390, 800, 1920, 2560, 3840] SCHEMES = ["dark", "light"] # A stable, well-populated location (Seattle) so data-driven pages aren't blank. # The frontend reads its spot from the URL hash (frontend/nav.js). _LOC = "lat=47.58&lon=-122.398" _DAY = f"{_LOC}&date=2026-07-10" # page name -> path under BASE. Hash-driven pages carry a default location. ROUTES = { "index": f"/#{_LOC}", "calendar": f"/calendar#{_LOC}", "compare": f"/compare#{_LOC}", "day": f"/day#{_DAY}", "score": f"/score#{_LOC}", "alerts": "/alerts", "legend": "/legend", "climate-hub": "/climate", "city": "/climate/seattle-washington-us", } def _server_up() -> bool: try: req = urllib.request.Request(BASE + "/", method="GET") with urllib.request.urlopen(req, timeout=4) as resp: return resp.status < 500 except Exception: return False def _parse_args() -> argparse.Namespace: ap = argparse.ArgumentParser(description="Screenshot the Thermograph frontend.") ap.add_argument("pages", nargs="*", choices=list(ROUTES), default=[], help="page names to shoot (default: all). One or more of: " + ", ".join(ROUTES)) ap.add_argument("--width", type=int, action="append", metavar="PX", help="viewport width; repeatable (default: all 5 breakpoints)") ap.add_argument("--scheme", choices=SCHEMES, action="append", help="color scheme; repeatable (default: dark and light)") ap.add_argument("--out", type=Path, default=OUT_DIR, help=f"output directory (default: {OUT_DIR})") return ap.parse_args() def main() -> int: args = _parse_args() pages = args.pages or list(ROUTES) widths = args.width or WIDTHS schemes = args.scheme or SCHEMES if not _server_up(): print(f"error: the app is not reachable at {BASE}\n" f"start it first with: make lan-run\n" f"(or point elsewhere with SHOTS_BASE=...)", file=sys.stderr) return 1 try: from playwright.sync_api import sync_playwright except ModuleNotFoundError: print("error: playwright is not installed.\n" "run `make shots` (installs it into .venv), or: " "pip install -r tools/requirements.txt && playwright install chromium", file=sys.stderr) return 1 args.out.mkdir(parents=True, exist_ok=True) shot_count = 0 with sync_playwright() as p: browser = p.chromium.launch() try: for scheme in schemes: for width in widths: # A fresh context per (scheme, width): color_scheme and viewport # are context-level, and reduced motion freezes animations so the # capture is the settled final frame. ctx = browser.new_context( viewport={"width": width, "height": 900}, color_scheme=scheme, reduced_motion="reduce", device_scale_factor=1, ) page = ctx.new_page() for name in pages: url = BASE + ROUTES[name] try: page.goto(url, wait_until="domcontentloaded", timeout=30000) try: page.wait_for_load_state("networkidle", timeout=15000) except Exception: pass # some pages keep a socket open; settle below page.wait_for_timeout(1200) # chart/layout settle dest = args.out / f"{name}@{width}-{scheme}.png" page.screenshot(path=str(dest), full_page=True) shot_count += 1 print(f" {dest.relative_to(args.out.parent)}") except Exception as e: print(f" ! {name}@{width}-{scheme}: {e}", file=sys.stderr) ctx.close() finally: browser.close() print(f"\n{shot_count} screenshot(s) -> {args.out}") return 0 if __name__ == "__main__": raise SystemExit(main())