Add frontend-scoped CLAUDE.md + README + DESIGN.md, relocate tools/
Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
This commit is contained in:
parent
6ef7a37bf1
commit
290187db6b
5 changed files with 584 additions and 0 deletions
181
CLAUDE.md
Normal file
181
CLAUDE.md
Normal 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
134
DESIGN.md
Normal 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` ~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).
|
||||
126
README.md
Normal file
126
README.md
Normal 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
3
tools/requirements.txt
Normal 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
140
tools/shoot.py
Normal 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())
|
||||
Loading…
Reference in a new issue