Add frontend-scoped CLAUDE.md + README + DESIGN.md, relocate tools/

Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
This commit is contained in:
Emi Griffith 2026-07-22 11:59:59 -07:00
parent 6ef7a37bf1
commit 290187db6b
5 changed files with 584 additions and 0 deletions

181
CLAUDE.md Normal file
View file

@ -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 `<meta>` 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 `/<key>.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.

134
DESIGN.md Normal file
View file

@ -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` ~8999). 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).

126
README.md Normal file
View file

@ -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.

3
tools/requirements.txt Normal file
View file

@ -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

140
tools/shoot.py Normal file
View file

@ -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())