140 lines
5.5 KiB
Python
140 lines
5.5 KiB
Python
#!/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())
|