thermograph/frontend/tools/shoot.py

141 lines
5.5 KiB
Python
Raw Normal View History

#!/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())