diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..17ea9fa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,181 @@ +# Thermograph frontend — agent instructions + +This repo is the **SSR + static-asset service** split out of the `emi/thermograph` +monorepo (repo-split Stage 7). It has no climate data, no DB, and does no +polars/compute work — everything comes from the backend's content API over +HTTP. It is one of four sibling repos in `thermograph-repos/`: + +- **`thermograph-backend`** — FastAPI API + grading/scoring/grid compute + DB. + This repo's only dependency. +- **`thermograph-frontend`** (this repo) — SSR content pages + the interactive + tool's SPA shells + every static asset. +- **`thermograph-infra`** — Terraform, `docker-compose*.yml`, `deploy/` (incl. + `deploy.sh`, the SOPS secrets vault), Caddy config. No app code. +- **`thermograph-docs`** — architecture decision records + operator runbooks. + No code. + +See `MONOREPO-CUTOVER-PLAN.md` (one level up, in `thermograph-repos/`) for the +full split status and the remaining gap list before the monorepo can be +archived. + +## What this repo is + +- **`content.py`** — server-rendered, crawlable pages (climate hub, per-city, + month, records, glossary, about, privacy) + `robots.txt` + `sitemap.xml`. + Every route fetches its data from the backend's content API + (`api_client.py`) instead of computing in-process; `content_payloads.py` on + the backend owns `page_title`/`canonical_path`/`breadcrumb`/`jsonld`, not + this repo. +- **`static/*.js`** — the interactive tool: `app.js` (map/search/graded + results + inline SVG chart), `calendar.js`/`day.js`/`score.js`/`compare.js` + (SPA shells served by `app.py`'s `_page()`), `account.js` (auth), `cache.js` + (IndexedDB response cache + `/cell` bundle prefetch), `shared.js` (format + helpers shared across views). +- **`static/style.css`** — the single hand-written stylesheet; all design + tokens live here (see `DESIGN.md`). +- **`templates/*.html.j2`** — Jinja templates `content.py` renders. +- **`content/*.yaml`** — structured SSR copy (glossary, static-page SEO meta), + loaded by `content_loader.py`. Committed here as a starter copy extracted + alongside the split; real cross-repo copy vendoring (a pinned + `thermograph-copy` checkout at build time) is deferred, unbuilt follow-up — + don't assume it exists. + +## Branch/PR + deploy flow + +- Work happens on feature branches; PR into `main`. (The monorepo's + `dev`→`main`→`release` three-stage promotion and its LAN `deploy-dev.yml` + path do **not** exist in this repo yet — a documented gap, see the cutover + plan §4. Don't assume a `dev` branch here.) +- **`.forgejo/workflows/build-push.yml`** — on push to `dev`/`main`/`release` + or a `v*.*.*` tag: builds THIS repo's own `Dockerfile` and pushes + `git.thermograph.org/emi/thermograph-frontend/app`, tagged `sha-<12 hex>` + (every push) and the semver tag (tag pushes only). Backend publishes its own + separate image the same way — the two are no longer one shared + `emi/thermograph/app` image. +- **`.forgejo/workflows/build.yml`** — push/PR to `main`: proves the Dockerfile + builds. It is a **build check only**, not a boot/health check — booting the + real app crashes at import without a reachable backend (see API-version + section below), so a standalone boot check would need to check out + `thermograph-backend` too. Not yet built; flagged, not silently skipped. +- **`.forgejo/workflows/deploy.yml`** — push to `main`: SSH to beta, run + `SERVICE=frontend FRONTEND_IMAGE_TAG=sha-<12 hex> /opt/thermograph/deploy/deploy.sh` + (that script lives in `thermograph-infra`). Rolls **only** the frontend + container (`--no-deps`); backend is untouched and deployed independently by + its own repo's workflow. `deploy.sh` retries the image pull for ~5 min + (Forgejo has no cross-workflow `needs:`, so this deploy can race ahead of + `build-push.yml`) and waits for a healthy backend before declaring the roll + OK (frontend's boot fetches the IndexNow key from backend — see below). +- **`.forgejo/workflows/deploy-prod.yml`** — push to `release`: same shape, + targets prod via its own `PROD_SSH_*` secret set (fully separate from beta's, + so a beta credential leak can't touch prod). Nothing else deploys to prod; + there is no release-triggered Terraform apply from this repo. +- The `SERVICE=frontend` + `FRONTEND_IMAGE_TAG=sha-<12 hex>` pair is the entire + contract into `thermograph-infra/deploy/deploy.sh`: it persists each + service's live tag in `deploy/.image-tags.env` (host-side, untracked) so a + frontend-only roll never disturbs backend's currently-running tag, and + vice versa. + +## How to run / test + +There is no `run.sh`/`Makefile` in this repo yet (the monorepo's `make +lan-run`/`make run`/`make stop`/`venv` targets have no home here — see the +cutover plan's gap list). Run directly: + +```bash +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt +THERMOGRAPH_API_BASE_INTERNAL=http://127.0.0.1:8137 \ +THERMOGRAPH_BASE=/thermograph \ + .venv/bin/uvicorn app:app --host 0.0.0.0 --port 8080 +``` + +`THERMOGRAPH_API_BASE_INTERNAL` is **required** — `api_client.py` raises +`RuntimeError` at import if it's unset. Other env vars: `THERMOGRAPH_BASE` +(default `/thermograph`; the Dockerfile sets `/` for the deployed clean-root +topology), `THERMOGRAPH_API_VERSION` (default `v2`, see below), +`THERMOGRAPH_API_BASE_PUBLIC` (browser-facing backend origin for asset URLs +when frontend and backend are cross-origin; empty = same-origin, today's +default), `THERMOGRAPH_SSR_CACHE_TTL` (default 600s, the content-API response +cache in `api_client.py`), `THERMOGRAPH_GOOGLE_VERIFY`/`THERMOGRAPH_BING_VERIFY` +(search-console `` tags). + +**Known gap — `tests/conftest.py` does not run standalone in this repo.** It +still assumes the pre-split layout: a sibling `backend/` checkout with its own +`tests/conftest.py` (`make_history`/`make_recent`) and an `api.content_payloads` +module, neither of which exists here. It was extracted verbatim as reference +material for the real fix (a genuine cross-repo contract-test job, or a +self-contained set of fixtures owned in this repo) — not yet built. Do **not** +assume `pytest tests` passes here until that lands; `.forgejo/workflows/build.yml` +only proves the image builds, for the same reason. There is also no +`requirements-dev.txt` in this repo yet (pytest/httpx test deps aren't pinned). + +## API-version pinning contract + +Every backend call in this repo goes through a single pinned constant instead +of scattered `api/v2/...` literals: + +- **Python (SSR):** `api_client.py`'s module-level `API_VERSION` (default + `"v2"`, overridable via `THERMOGRAPH_API_VERSION`). Every path builder + (`hub()`, `sitemap()`, `indexnow_key()`, `home()`, `city()`, `city_month()`, + `city_records()`) reads it. +- **JS (interactive tool):** `static/account.js`'s exported `API_VERSION` + constant and the `uv(path)` helper — every fetch in `cache.js`, `account.js` + itself, etc. is built by wrapping the path in `uv(...)` rather than + hardcoding a version. + +**Bump only in lockstep with a verified backend `/api/version` check** — the +backend exposes `GET {BASE}/api/version` → `{backend_version, min_frontend, +payload_ver}` (`web/app.py`'s `API_CONTRACT_VERSION`/`MIN_SUPPORTED_FRONTEND`). +Before bumping this repo's `API_VERSION`, confirm the target backend's +`backend_version` actually supports it and its `min_frontend` doesn't already +exclude the version you're moving *away* from. + +**A v3 cutover would work like this:** backend mounts a new `v3 = APIRouter()` +alongside `v2`, re-registering only the changed handlers (v2 keeps serving +old clients); backend bumps `API_CONTRACT_VERSION` to `"3"` in that same PR. +Only once that's deployed and verified does this repo bump `API_VERSION` to +`"v3"` in both `api_client.py` and `account.js` — one PR, both pins together, +never one without the other. `v2` (and the `/api`, `/api/v1` aliases) stay +mounted and working until no client depends on them. + +The frontend's own boot is **resilient to the backend being down**: the +`indexnow_key()` fetch in `content.py`'s `register()` is tried once eagerly +(so the common case still serves `/.txt` as a plain static route), but a +failure is caught, logged, and falls back to a lazy per-request lookup +instead of crashing boot — asynchronous frontend/backend deploys depend on +this (a briefly-unreachable backend at frontend boot must be survivable, not +fatal). + +## Cross-repo contracts this repo depends on + +These are **live contracts with the backend** — a change on either side +without the other breaks something silently, not loudly: + +- **The `/cell` bundle + ETag/`If-None-Match`** — `static/cache.js` fetches + `/api/v2/cell` once per view-set and slices it for calendar/day/score/compare + instead of one request per view; conditional refetch is via `If-None-Match` + against the stored `ETag`, so an unchanged payload costs an empty 304. The + URL map (which slice serves which view) must stay in sync with + `thermograph-backend`'s route/payload shape. +- **`shared.js`'s `pctOrd()` must mirror `thermograph-backend`'s + `data/grading.py`'s `pct_ordinal()`** byte-for-byte in behavior: floor a + percentile into `1..99` (never round to 100/0 — "100th percentile" reads as + measurement error, not "as extreme as it has ever been"). Every percentile + shown anywhere (Day page, calendar tooltip, chart, city pages, homepage + strip) goes through one of these two functions; if they diverge, the same + reading says two different things on two different surfaces. +- **Unit/region logic** (`format.py`'s `F_COUNTRIES` / `static/units.js`'s + `F_REGIONS` / backend's `api/content_payloads.py`'s `F_COUNTRIES`) — the set + of Fahrenheit-using country codes must stay identical across all three; + backend has a test asserting this. + +## Design & visual verification + +Design tokens + conventions are documented in `DESIGN.md` (source of truth: +`static/style.css`). To see a change rendered, see `DESIGN.md`'s `make shots` +section (`tools/shoot.py`). + +## Commits & PRs + +Describe only the substance of the change; concise, technical. Never mention +AI/Claude/assistants or automated authorship anywhere — no trailers, +co-authors, emoji, or "as requested"/"per the agent" narration. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..067b262 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,134 @@ +# Thermograph — design system + +The project-specific visual spec. For general aesthetic guidance the global +`frontend-design` skill still applies; **this file is the source of truth for +Thermograph's own tokens and conventions.** When they disagree, this file wins. + +The authoritative values live in **`static/style.css`** (a single hand-written +stylesheet — no framework, no build step). This doc describes *how* to use them; +it deliberately avoids copying hex values that could drift. When in doubt, read +the `:root` block at the top of `style.css`. + +## Tokens (never hardcode) + +Every color is a CSS custom property in `static/style.css` `:root` (top of the +file), with a `@media (prefers-color-scheme: light)` override right below it. +**Always use `var(--token)`; never paste a raw hex into a rule or an inline +style.** New surfaces get their color from the existing tokens so light mode and +future retints keep working for free. + +Structural tokens: `--bg`, `--surface`, `--surface-2`, `--border`, `--text`, +`--muted`, and the warm orange brand `--accent` (`#f0803c`). The light-mode block +remaps the first six; the grade scales below are shared across both schemes. + +### Grade palettes + +These encode meaning, not decoration — keep their order and midpoints intact: + +- **Temperature** — a 9-step diverging scale, cold → green → hot, chosen to be + colorblind-safe: `--rec-cold` `--very-cold` `--cold` `--cool` **`--normal` + (green midpoint)** `--warm` `--hot` `--very-hot` `--rec-hot`. The two `--rec-*` + ends are the "Near Record" danger tiers — deliberately dark and saturated. +- **Precipitation** — `--dry` plus `--wet-1`…`--wet-9` (light green → teal → + deep navy), with `--wet-5` the scale midpoint. +- **Seasons** — `--season-winter/-spring/-summer/-fall` for month/season chrome. + +The inline-SVG charts (`static/chart.js`, and the SVG strings built in +`app.js`) pull from these same tokens, so a chart and its legend never diverge. + +## Typography + +`font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`. +**Inter is not self-hosted or imported** — it renders only where the OS has it, +otherwise the stack falls back to `system-ui`. Do not add a webfont link without +a deliberate decision (it's a network dependency on every page). + +- Headings are tight: `h1` is 22px with `letter-spacing: -0.02em`. +- Section/eyebrow labels are small, uppercase, muted, and letter-spaced + (~12px, `letter-spacing: .04–.05em`, `color: var(--muted)`). +- Body `line-height: 1.45`. + +## Components + +Match the existing recipes rather than inventing new ones: + +- **Panels** — `.panel`: `--surface` background, `1px solid var(--border)`, + `border-radius: 14px`, generous padding. The primary content container. +- **Grade cards** — `.normal-card`: `--surface-2`, ~11px radius, the big value + tinted by its grade color via `color-mix(in oklab, …)`; hover lifts the border + to `--accent`. +- **Buttons** — accent background, dark text, `border-radius: 10px`, + `font-weight: 600`. Toggle chips (e.g. `.today-chip`) go outlined → filled when + active. +- **Segmented toggles** — unit (°C/°F) and metric switches; the active segment is + tinted by that metric's grade color (tmax → warm/hot reds, tmin → cold blues, + precip → wet blues). +- **Inputs** — `--surface` background, `1px solid var(--border)`, and + **`font-size: 16px` minimum** — smaller text makes iOS zoom on focus. This is a + hard rule, not a preference. +- **Charts** — bespoke inline SVG (percentile fan + median + value trace + + pointer-driven `.chart-tip`). No canvas, no charting library. +- **Map** — Leaflet in the shared modal picker (`mappicker.js`). + +## Layout & responsive + +Mobile-first: the base stylesheet is the phone layout; wider screens layer on via +`min-width` queries. Design and test in this order. + +- **Phone** — single column. Primary phone breakpoint is **640/641px**; a few + tweaks at 560px. Keep ~44px touch targets and use pointer (not mouse-only) + events. The mobile header folds into a hamburger menu. +- **Large monitors** — the content column widens in real steps, it does **not** + stay a centered 1200px strip: `main` grows to 1440px at **1680px**, 1640px at + **2400px**, 1880px at **3400px** (`style.css` ~89–99). Per-page grids (normals, + calendar months, day cards) flow into the extra room; charts scale with it. +- **Both color schemes** — dark is the default; light comes from + `prefers-color-scheme`. Every change must look right in both. +- Honor `prefers-reduced-motion: reduce` — gate non-essential animation behind it. + +**Validate every visual change at 390 / 800 / 1920 / 2560 / 3840px in both light +and dark.** These viewport widths straddle the breakpoints above (phone, tablet, +1440p, 2K, 4K). Use `make shots` (below) to capture the full matrix. + +## Conventions + +- **Metric order is always `Precip · High · Low`** — chart legend, normal cards, + day rows, exported tables, everywhere. +- **Grades are relative, never absolute.** Use the percentile tier names + ("Above Normal", "High", "Near Record"), never absolute-temperature words + ("hot", "warm", "cold") — the same reading is Above Normal in a cool climate and + Below Normal in a hot one. This is a percentile against each place's own + climate history, not a thermometer reading (see `thermograph-backend`'s + `data/grading.py`'s `pct_ordinal()`, which `static/shared.js`'s `pctOrd()` + mirrors — see `CLAUDE.md`). +- The two chart temperature lines are labeled **"Daily high / Daily low"** so they + aren't confused with the **"High / Low"** percentile tiers. + +## Viewing & iterating — `make shots` + +There is no design without seeing it rendered. To view the running app across the +full breakpoint matrix, serve this repo's app (see `README.md`/`CLAUDE.md` for the +`uvicorn` invocation) and run the screenshot sweep: + +```sh +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt # if not already set up +.venv/bin/pip install -q -r tools/requirements.txt +.venv/bin/python -m playwright install chromium +.venv/bin/python tools/shoot.py +``` + +`tools/shoot.py` drives headless Chromium over the served app and writes PNGs to +`.screenshots/` (gitignored), named `{page}@{width}-{scheme}.png` (e.g. +`index@390-dark.png`, `city@3840-light.png`). Read those PNGs back to see the +result, adjust `static/style.css`, and re-shoot. + +Narrow the matrix while iterating on one thing: + +```sh +.venv/bin/python tools/shoot.py index --width 390 --scheme dark +``` + +Point it at a different server with `SHOTS_BASE` (default +`http://127.0.0.1:8137/thermograph` — override to match wherever this repo's +`app.py` is actually serving, e.g. `http://127.0.0.1:8080` for a local run +without `THERMOGRAPH_BASE` set). diff --git a/README.md b/README.md new file mode 100644 index 0000000..ccc40d5 --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# Thermograph frontend + +The server-rendered content pages, the interactive-tool SPA shells, and every +static asset for [Thermograph](https://thermograph.org) — grades recent local +weather against ~45 years of climate history. This repo holds **no climate +data and does no compute**; it renders pages and serves the JS/CSS/image +assets that call the backend's API from the browser. It was split out of the +`emi/thermograph` monorepo (`frontend_ssr/` + `frontend/` there) so it can +build its own image and deploy independently of the backend. See +[`CLAUDE.md`](CLAUDE.md) for the full agent-facing detail on the split +topology and deploy contract, and +[`thermograph-docs`](../thermograph-docs) (sibling repo) for the +architecture decision record behind the split. + +## Layout + +``` +api_client.py HTTP client for the backend's content API (api/content_routes.py + on the backend). Fails loud at import if + THERMOGRAPH_API_BASE_INTERNAL is unset. TTL-cached (10 min + default) in front of every call. +app.py FastAPI app: /healthz, the interactive tool's SPA-shell + routes (calendar/day/score/compare/legend/alerts), and the + StaticFiles mount for everything else. Calls + content.register(app) for the SSR routes below. +content.py Server-rendered pages: climate hub, per-city, month, + records, glossary, about, privacy, robots.txt, sitemap.xml. +content_loader.py Loads content/glossary.yaml + content/pages.yaml (SSR copy), + validated fail-loud at load time. +format.py Unit-aware (°C/°F) formatting for the SSR templates, + ContextVar-scoped active unit. +paths.py Canonical filesystem locations (STATIC_DIR, TEMPLATES_DIR, + CONTENT_DIR), resolved from the repo root. +templates/ Jinja templates content.py renders (base, home, city, month, + hub, records, glossary, glossary_term, about, privacy). +static/ Every static asset: the interactive tool's JS + (app.js, calendar.js, day.js, score.js, compare.js, + account.js, cache.js, shared.js, units.js, mappicker.js, …), + the SPA-shell HTML pages, style.css (all design tokens — + see DESIGN.md), icons/favicons, manifest.webmanifest, sw.js. +content/ Structured SSR copy: glossary.yaml, pages.yaml. +tests/ pytest suite — see the KNOWN GAP note below before relying on it. +tools/ shoot.py — the make-shots visual-verify screenshot tool + (see DESIGN.md). +Dockerfile Builds this repo's own image (python:3.12-slim, + uvicorn app:app), independent of the backend's. +``` + +## How it fits the split + +Four sibling repos replace the old monorepo: + +- **`thermograph-backend`** — the FastAPI API, grading/scoring/grid compute, + and DB. This repo's only runtime dependency. +- **`thermograph-frontend`** (this repo). +- **`thermograph-infra`** — Terraform, compose files, `deploy/deploy.sh` (the + script both repos' deploy workflows SSH into), the secrets vault. +- **`thermograph-docs`** — architecture/decision docs and runbooks. + +Each app repo now builds and publishes its **own** image +(`git.thermograph.org/emi/thermograph-frontend/app`, tagged by git SHA and +semver) instead of the old shared `emi/thermograph/app` image, and deploys via +its own `.forgejo/workflows/deploy.yml` (main → beta) and `deploy-prod.yml` +(release → prod), each rolling only the `frontend` compose service on the +target VPS. + +## Talking to the backend + +All data comes from the backend's content API over HTTP +(`api_client.py`) — this process holds nothing in-process. Two env vars +control the topology: + +- **`THERMOGRAPH_API_BASE_INTERNAL`** (required) — where this process reaches + the backend server-side, e.g. `http://backend:8137` in compose, or + `http://127.0.0.1:8137` for a local backend checkout. `api_client.py` raises + at import if this is unset — a missing backend URL should break boot, not + silently 500 the first request. +- **`THERMOGRAPH_API_BASE_PUBLIC`** (optional) — the backend's + browser-facing origin, used only when frontend and backend are served + cross-origin (e.g. a genuinely separate LAN-dev topology). Left empty + (the default), asset/API references stay relative and same-origin, which is + today's real deployed topology. + +**Same-origin cookie-auth caveat:** the interactive tool's auth +(`static/account.js`) is an HttpOnly session cookie. It uses +`credentials: "include"` (not the fetch default) specifically so the cookie +still rides along if this script is ever loaded cross-origin, but the backend +must actually be configured to accept credentialed cross-origin requests +(CORS + `SameSite`) for that case to work — in the default same-origin +deployment this is a non-issue. Don't assume a fully decoupled, independently- +hosted frontend "just works" for logged-in features without checking that +cookie/CORS configuration first. + +`THERMOGRAPH_BASE` (default `/thermograph`) must be set identically on both +frontend and backend in every real deployment — it's how both processes agree +on the shared URL prefix (or the deployed clean-root `/`, which the Dockerfile +sets by default). + +## Build & run + +```bash +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt +THERMOGRAPH_API_BASE_INTERNAL=http://127.0.0.1:8137 \ +THERMOGRAPH_BASE=/thermograph \ + .venv/bin/uvicorn app:app --host 0.0.0.0 --port 8080 +``` + +Or build the image directly: + +```bash +docker build -t thermograph-frontend . +docker run -p 8080:8080 -e THERMOGRAPH_API_BASE_INTERNAL=http://backend:8137 thermograph-frontend +``` + +The image healthchecks `GET /healthz` (liveness only — it does no I/O, so it +stays cheap; it does not prove the backend is reachable). + +## More detail + +- [`CLAUDE.md`](CLAUDE.md) — agent-facing instructions: deploy contract, + API-version pinning, the cross-repo `/cell`/ETag and `pctOrd()` contracts, + and the current known test-suite gap. +- [`DESIGN.md`](DESIGN.md) — the visual system (tokens, components, + breakpoints) and `make shots` visual verification. +- [`thermograph-docs`](../thermograph-docs) — the repo-topology decision + record this split implements. diff --git a/tools/requirements.txt b/tools/requirements.txt new file mode 100644 index 0000000..8842bc3 --- /dev/null +++ b/tools/requirements.txt @@ -0,0 +1,3 @@ +# Dev-only tooling deps (not needed by the backend or CI). Installed on demand by +# `make shots`. Chromium itself is fetched by `playwright install chromium`. +playwright diff --git a/tools/shoot.py b/tools/shoot.py new file mode 100644 index 0000000..e1b3988 --- /dev/null +++ b/tools/shoot.py @@ -0,0 +1,140 @@ +#!/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())