# 5. Frontend deep dive The **SSR + static-asset service**: server-rendered crawlable pages, the interactive tool's SPA shells, and every static asset. No climate data, no database, no compute — everything comes from the backend's content API over HTTP. > **The live service is Go.** `frontend/server/` builds, tests, ships and runs. > The Python files one directory up (`app.py`, `content.py`, `api_client.py`, > `format.py`, `content_loader.py`, `paths.py`, `templates/*.html.j2`) are the > superseded original implementation, kept in-tree as the reference the port was > made from. Nothing deploys them, and they hold duplicate copies of a couple of > contracts (`api_client.py`'s `API_VERSION` pin, `format.py`'s `F_COUNTRIES`) — > keep those in step if you run them locally. Whether to delete them is an open > question; raise it rather than doing it in passing. ## Shape of the Go service ``` main.go config → content client → glossary/pages → mux → listen → graceful shutdown internal/config/ every env var the service reads, with the same names, defaults and required/optional split as the Python internal/contentapi/ the backend /content/* client internal/content/ the SSR page handlers, template funcmap, SEO helpers internal/contentdata/ glossary.yaml / pages.yaml loader internal/format/ unit-aware °C/°F formatting + band names internal/handlers/ SPA shells + static serving internal/render/ html/template over an embed.FS + ETag helpers internal/render/templates/*.tmpl the page templates (embedded in the binary) ``` Dependencies: **stdlib plus `gopkg.in/yaml.v3`** only. The committed SSR copy in `frontend/content/*.yaml` is shared with the rest of the repo and uses block/folded scalars, so a real YAML parser is genuinely required. **Templates are embedded in the binary; `static/` and `content/` are read from disk relative to the working directory** (`frontend/` locally, `/app` in the image). That's why you build in `server/` and run from `frontend/`. ### Fail-loud boot `main.go` exits at startup on bad configuration *or* bad content: a missing backend URL, a garbage TTL, malformed glossary/pages YAML. The Python raised at import for the same cases, and the reasoning is identical — a bad content edit should break the boot, not silently 500 on the first request. The one deliberate exception is the IndexNow key: boot tries the key fetch once, catches failure, and falls back to a lazy per-request lookup. **Boot must survive an unreachable backend** — asynchronous FE/BE deploys depend on it. Don't make it fatal. ## Routes Everything below sits under `THERMOGRAPH_BASE` (default `/thermograph`; the image sets `/`). | Path | Kind | |---|---| | `/` | SSR homepage | | `/climate` | SSR climate hub | | `/climate/{slug}` | SSR per-city page | | `/climate/{slug}/records` | SSR all-time records | | `/climate/{slug}/{month}` | SSR per-month page | | `/glossary`, `/glossary/{term}` | SSR | | `/about`, `/privacy` | SSR | | `/robots.txt`, `/sitemap.xml` | generated | | `/{indexnow-key}.txt` | the IndexNow ownership file | | `/calendar`, `/day`, `/score`, `/compare`, `/legend`, `/alerts` | SPA shells | | `/healthz` | liveness, I/O-free (does **not** prove the backend is reachable) | | everything else | static assets | The static mount is registered last so the explicit routes win. **The backend owns the page metadata, not this service.** `page_title`, `canonical_path`, `breadcrumb` and `jsonld` come from `backend/api/content_payloads.py`. Don't recompute them here. ### SPA shells and `__ORIGIN__` The shell HTML files in `static/` carry an `__ORIGIN__` placeholder for the link-preview/Open Graph tags — preview crawlers need absolute URLs and the host differs between dev and prod. The substituted HTML and its ETag are **memoized per origin**, not recomputed per request. Origin resolution prefers `X-Forwarded-Host` over `Host`. That matters when the request arrives through the backend's catch-all proxy fallback: without it, the page would advertise the internal hop's own address as canonical. ## The content client `internal/contentapi` is a careful piece of code and worth reading in full before you change caching behaviour. It has four properties, all load-bearing: 1. **TTL cache** (`THERMOGRAPH_SSR_CACHE_TTL`, default 600s). Climate data changes at most hourly, so without this a page-view burst on one city costs one backend round trip per request. This is *in addition to* the backend's derived-store/ETag caching — this one saves the network hop, the backend's saves the recompute. 2. **Bounded LRU** (2048 entries). `city()` and `city_records()` fold the browser-facing `origin` into the cache key, and `origin` is client-controlled via `Host`/`X-Forwarded-Host`. An unbounded map there is a cheap memory-exhaustion vector. 3. **Per-key single-flight.** Without it, N concurrent requests missing the same key (cold start, or right after TTL expiry on a hot city) each fire their own backend call — a thundering herd. 4. **Origin forwarding.** The original browser-facing origin is passed through as `Host` / `X-Forwarded-Proto` so the backend builds JSON-LD `url` fields against the public origin rather than the internal `http://backend:8137` address. ## Static assets Hand-written, no build step, no bundler, no framework. | File | Role | |---|---| | `app.js` | the main tool: map, search, results, inline SVG chart | | `calendar.js` / `day.js` / `score.js` / `compare.js` | the SPA views | | `cache.js` | IndexedDB cache + the `/cell` bundle prefetch + `If-None-Match` revalidation | | `account.js` | auth; **pins `API_VERSION` and the `uv(path)` helper** | | `shared.js` | shared helpers; **`pctOrd()` mirrors the backend's `pct_ordinal()`** | | `units.js` | `F_REGIONS` — must match the backend's `F_COUNTRIES` | | `chart.js`, `mappicker.js`, `filtersheet.js`, `nav.js`, `push-client.js`, `ios-install.js`, `digest.js`, `climate.js`, `subscriptions.js` | the rest | | `style.css` | **the single hand-written stylesheet — every design token lives here** | | `sw.js`, `manifest.webmanifest`, icons | PWA | `cache.js` is where the ETag contract actually lands: it fetches `/api/v2/cell` once per view-set, slices it, and later revalidates each view with `If-None-Match`. The slice→view map must track the backend's payload shape. See [contracts](06-contracts.md). Static responses are served with `Cache-Control: public, max-age=300` — short on purpose, because asset filenames are **not** content-hashed. It's a revalidation window, not an immutable cache. ## The design system [`frontend/DESIGN.md`](../../frontend/DESIGN.md) is the source of truth and wins over any general design guidance. The essentials: - **Never hardcode a colour.** Every colour is a CSS custom property in `style.css`'s `:root`, with a `@media (prefers-color-scheme: light)` override right below. Always `var(--token)`. - **Grade palettes encode meaning, not decoration.** Temperature is a 9-step diverging, colourblind-safe scale (`--rec-cold` … `--normal` green midpoint … `--rec-hot`). Precipitation is `--dry` plus `--wet-1…9`. Keep the order and midpoints intact. - **Inter is not self-hosted.** It renders where the OS has it and falls back to `system-ui`. Adding a webfont link is a deliberate decision, not a tweak — it's a network dependency on every page. - **Inputs are `font-size: 16px` minimum.** Smaller text makes iOS zoom on focus. Hard rule. - **Mobile-first.** The base stylesheet is the phone layout; wider screens layer on via `min-width`. Primary breakpoint 640/641px. The content column widens in real steps at 1680 / 2400 / 3400px — it does *not* stay a centred 1200px strip. - **Metric order is always `Precip · High · Low`**, everywhere. - **Dark is the default**, light comes from `prefers-color-scheme`. Every change must look right in both. Honour `prefers-reduced-motion`. - Charts are bespoke inline SVG. No canvas, no charting library. ### Verifying a visual change **Validate at 390 / 800 / 1920 / 2560 / 3840px in both light and dark.** These straddle every breakpoint above. ```bash cd frontend .venv/bin/pip install -q -r tools/requirements.txt .venv/bin/python -m playwright install chromium .venv/bin/python tools/shoot.py # the full matrix .venv/bin/python tools/shoot.py index --width 390 --scheme dark # while iterating ``` PNGs land in `.screenshots/` (gitignored) as `{page}@{width}-{scheme}.png`. Point it elsewhere with `SHOTS_BASE`. Chrome DevTools MCP is the other good option here — a real browser on your own machine. ## Tests ```bash cd frontend/server && go build ./... && go vet ./... && go test ./... ``` The Go tests read two directories via a fixed relative path: the committed golden fixtures (`frontend/tests/fixtures/*.json` — the same set the Python golden-diff comparison used) and the SSR copy (`frontend/content/*.yaml`). `frontend/Dockerfile`'s builder stage copies them to container-root paths to preserve that relationship, and runs `gofmt -l` + `go vet` + `go test` **before** building. A failing Go test therefore fails the image build, which is how CI catches it — there is no separate frontend test step in the workflows. The Python tiers still exist and still run locally: ```bash cd frontend make test-unit # hermetic: SSR rendering fed committed fixtures make test-integration # pulls + runs the real backend image; local only make capture-fixtures # refresh tests/fixtures/*.json from a live backend ``` Treat `make capture-fixtures` as the shared operation: those fixtures feed the **Go** tests too. ## The image `python:3.12-slim` → gone. The frontend image is: - a `golang:1.26` builder that runs `gofmt -l` / `go vet` / `go test`, then builds a static `CGO_ENABLED=0 -trimpath -ldflags="-s -w"` binary; - an `alpine:3.22` final stage with **bash** (the Swarm stack bind-mounts a bash entrypoint shim over the image's entrypoint) and **curl** (the healthcheck); - **uid 10001**, matching the backend image — that's the uid infra provisions readable secrets for. Don't change it. - `static/` and `content/` copied to `/app`, read-only at runtime. There is no `WORKERS` knob any more: uvicorn needed a process count, the Go server handles concurrency in one process. Next: [Cross-service contracts](06-contracts.md).