# 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).
