# Plan: Go rewrite — M6.1 Template skeleton ## Context M5 finished — Go and Python now produce byte-equal JSON across `/api/{adults,juniors,payments,version}` and `cmd/parity` enforces it. See [progress tracker §M5](2026-05-03-2349-go-backend-rewrite-progress.md#L96-L106). M6 begins the Go-native HTML frontend that lets us actually retire the Python web UI. **M6.1 is the foundation step**: stand up the base HTML layout, shared nav, terminal-green-on-black theme, and the `embed.FS` plumbing that every subsequent M6 page (adults, juniors, payments, sync, flush, qr) will compose into. After M6.1, opening :8080 shows chrome (header, nav, footer) on every route with placeholder bodies — no real data wiring; that lands in M6.2+. This is **not a Jinja port**. The Python templates are reference for nav structure and CSS values; the Go layout is designed natively (per the [master plan](2026-05-03-2349-go-backend-rewrite.md#L24-L27): "frontends are allowed to diverge"). ## Current Go web state (what we're building on) - [go/internal/web/server.go](../../go/internal/web/server.go) — `Run()` registers `GET /{$}` (text-plain hello) + four `/api/*` routes. `BuildInfo{Version,Commit,BuildDate}` already flows in. - [go/internal/web/api/handler.go](../../go/internal/web/api/handler.go) — central handler struct holding `Sources`, `Config`, `Logger`, build fields. - No `templates/`, no `static/`, no `embed` directives, no `html/template` import anywhere in `go/`. We're starting from a blank slate. ## Approach 1. **Create `templates/` and `static/` under `go/internal/web/`**, embedded via `//go:embed`. 2. **Single base template** (`base.tmpl`) using `html/template` `block`s for `title` and `content`. Each page template defines those blocks; the renderer executes via `ExecuteTemplate(w, "base", data)`. 3. **Nav partial** parameterized by a `pageData.Active` string so each page highlights its own link with `class="active"`. Three tiers preserved (Primary / Archived / Tools) — matches Python UX in [templates/adults.html:491-505](../../templates/adults.html#L491-L505). 4. **CSS** lifted verbatim from [templates/adults.html:8-487](../../templates/adults.html#L8-L487) into `static/css/app.css`. Page templates `` it from the base layout. M6.2+ reuses the same class names (`.cell-ok`, `.modal-content`, …) without re-extracting. 5. **HTML handlers** in a new `go/internal/web/html/` package (sibling of `api/`). One `Handler` struct holding `*Renderer` + `BuildInfo` + `Config`. Routes for `/`, `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache` registered in `server.go`. Each route renders a placeholder body ("Coming in M6.X") inside the base shell — the goal is to prove the chrome works, not to populate data. 6. **Replace the existing `helloHandler`**: `GET /{$}` → `http.Redirect` to `/adults` (mirrors Python's meta-refresh in [app.py:112-115](../../app.py#L112-L115)). 7. **Static file route**: `GET /static/` served from `staticFS` via `http.StripPrefix("/static/", http.FileServerFS(staticFS))`. 8. **Footer keeps it simple**: `{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}`. The Python per-request render-time breakdown is deferred — `middleware/timer.go` only logs to slog today; threading a timer into request context can come back in a later milestone if we miss it. ## Files to create / modify ``` go/internal/web/ ├── assets.go NEW — //go:embed templates/* static/* ├── render.go NEW — Renderer wraps parsed *template.Template ├── server.go MODIFY — drop helloHandler; mount HTML routes + /static/ ├── html/ │ ├── handler.go NEW — html.Handler + page methods │ └── handler_test.go NEW — httptest smoke per route ├── templates/ │ ├── base.tmpl NEW — //; nav + content + footer blocks │ ├── partials/ │ │ ├── nav.tmpl NEW — three-tier nav, active highlighting │ │ └── footer.tmpl NEW — build meta line │ ├── adults.tmpl NEW — placeholder "Coming in M6.2" │ ├── juniors.tmpl NEW — placeholder "Coming in M6.3" │ ├── payments.tmpl NEW — placeholder "Coming in M6.4" │ ├── sync.tmpl NEW — placeholder "Coming in M6.6" │ └── flush_cache.tmpl NEW — placeholder "Coming in M6.6" └── static/ └── css/ └── app.css NEW — verbatim from templates/adults.html:8-487 ``` ## Key design notes - **Parse once at startup**: `NewRenderer(fs embed.FS)` calls `template.ParseFS(fs, "templates/*.tmpl", "templates/partials/*.tmpl")`. Parse failure aborts boot — template syntax errors surface immediately, not at first request. - **Per-page cloned templates** to avoid `define "content"` collisions across pages: `Renderer` stores `map[string]*template.Template` keyed by page name; each is `base+partials` clone with that page's `content`/`title` overlaid. - **View model** — small `pageData` struct: `Active` (nav key), `Build` (BuildInfo), `Body` (per-page payload, `any` until M6.2 widens it). - **Active-link logic** in nav partial: `{{ if eq .Active "adults" }}class="active"{{ end }}` — markup-side, no template funcs needed. - **No JS in M6.1.** Member-detail and QR modals + `static/js/*.js` come in M6.5. M6.1 only sets up the embedded-asset machinery. - **Nav anchor labels are literal text** (`[Adults]`, `[Juniors]`, etc.) — keep the brackets in the template strings, they're part of the look. - **`/qr` is not in the nav** — it's an image endpoint, M6.6 territory. ## Critical files to read - [go/internal/web/server.go](../../go/internal/web/server.go) — mux registration; `Run()` signature stays unchanged. - [go/internal/web/api/handler.go](../../go/internal/web/api/handler.go) — pattern to mirror for `html.Handler` (same field layout, most fields unused in M6.1). - [templates/adults.html:8-487](../../templates/adults.html#L8-L487) — CSS source, copy verbatim. - [templates/adults.html:491-505](../../templates/adults.html#L491-L505) — nav markup source (three tiers). - [docs/plans/2026-05-03-2349-go-backend-rewrite.md:88-101](2026-05-03-2349-go-backend-rewrite.md#L88-L101) — original layout intent for `web/handlers/`, `web/templates/`, `web/static/`. ## Verification End-to-end smoke from a fresh `make go-build`: 1. `make web-go &` — server boots; no template-parse errors in slog output. 2. `curl -i localhost:8080/` → `302` to `/adults`. 3. `curl -s localhost:8080/adults | grep -F 'class="active"'` matches the Adults anchor only. 4. `curl -sI localhost:8080/static/css/app.css` → `200`, `Content-Type: text/css`. 5. Browser at `http://localhost:8080/adults`: terminal-green-on-black theme, three-tier nav at top (Adults active), "Coming in M6.2" placeholder, footer `{tag}@{commit} | built {date}`. 6. Click each nav link in turn (`/juniors`, `/payments`, `/sync-bank`, `/flush-cache`) — same chrome, the clicked link is highlighted, per-page placeholder body shows. 7. `make go-test` — `web/html/handler_test.go` passes: each route returns 200, `text/html`, `class="active"` only on the matching anchor. 8. `make go-lint` clean. 9. `make parity` still green (regression check — M6.1 doesn't touch `/api/*`). 10. CHANGELOG entry per CLAUDE.md; M6.1 ticked in [progress tracker §M6.1](2026-05-03-2349-go-backend-rewrite-progress.md#L113) with commit SHA. ## Branch & MR `feat/go-m6-1-template-skeleton` per CLAUDE.md branch-per-feature workflow. Open MR via `tea pr create --base main --head feat/go-m6-1-template-skeleton`. User merges in the Gitea browser UI.