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