# Web UI Scaffold Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Scaffold a SvelteKit SPA at `web/`, embed its build output into the Go binary, and update the Dockerfile + CI so a single container image serves the SPA at `/` alongside the existing `/api/*` and `/rest/*` surfaces. **Architecture:** SvelteKit with `@sveltejs/adapter-static` (SPA fallback to `index.html`) builds to `web/build/`. A thin `web` Go package `//go:embed`s that directory and returns an `http.Handler` that serves static assets and falls back to `index.html` for unknown paths. The handler is wired as the server's `NotFound` route so explicit routes (`/api`, `/rest`, `/healthz`) are unaffected; the `NotFound` wrapper also returns a JSON 404 for unmatched `/api/*` and `/rest/*` paths so the SPA fallback never produces HTML bodies for API consumers. A hand-crafted placeholder `web/build/index.html` is committed (via negated gitignore) so `go build` works in clean checkouts before anyone runs `npm run build`. **Tech Stack:** SvelteKit 2.x, Svelte 5, TypeScript 5, Vite 5, Tailwind CSS 3, Vitest, Go 1.23, chi/v5, Docker multi-stage builds, Forgejo Actions. **Reference spec:** `docs/superpowers/specs/2026-04-20-web-ui-scaffold-design.md`. This plan implements only the Stack / Packaging / Dev-workflow / Build sections. Feature UI (login, library views, player) is out of scope and lands in follow-up plans. --- ## File structure | File | Action | Purpose | |---|---|---| | `web/package.json` | Create | npm config + scripts | | `web/tsconfig.json` | Create | TypeScript config extending SvelteKit's generated one | | `web/svelte.config.js` | Create | `adapter-static` with `fallback: 'index.html'` | | `web/vite.config.ts` | Create | Vite config + dev proxy for `/api` and `/rest` | | `web/vitest.config.ts` | Create | Vitest + jsdom config | | `web/tailwind.config.js` | Create | Tailwind content globs + dark palette tokens | | `web/postcss.config.cjs` | Create | PostCSS pipeline (tailwindcss + autoprefixer) | | `web/src/app.html` | Create | HTML template | | `web/src/app.css` | Create | Tailwind directives + CSS custom properties | | `web/src/routes/+layout.svelte` | Create | Imports `app.css`, renders outlet | | `web/src/routes/+page.svelte` | Create | Placeholder landing page | | `web/src/lib/example.test.ts` | Create | Vitest smoke test | | `web/static/favicon.png` | Create | 1×1 transparent PNG placeholder | | `web/build/index.html` | Create | Hand-crafted placeholder committed so `go:embed` works pre-build | | `web/.gitignore` | Create | Ignore `node_modules/`, `.svelte-kit/`, `build/*` with `!build/index.html` | | `web/embed.go` | Create | `//go:embed` build tree + `Handler()` with SPA fallback (must live next to `web/build/` — `go:embed` cannot reach parent directories) | | `web/embed_test.go` | Create | Unit tests for the handler | | `internal/server/server.go` | Modify | Wire `web.Handler()` into `NotFound`; JSON-404 for `/api/*` and `/rest/*` | | `internal/server/server_test.go` | Modify | Assert `/` returns HTML; `/api/anything` does not return HTML | | `Dockerfile` | Rewrite | Three-stage build: `node` → `golang` → `debian:slim` | | `.forgejo/workflows/test.yml` | Modify | Install Node, build web, run `npm test` before Go steps | | `README.md` | Modify | Document two-process dev workflow | --- ## Task 1: SvelteKit project skeleton **Files:** - Create: `web/package.json`, `web/tsconfig.json`, `web/svelte.config.js`, `web/vite.config.ts`, `web/tailwind.config.js`, `web/postcss.config.cjs` - Create: `web/src/app.html`, `web/src/app.css`, `web/src/routes/+layout.svelte`, `web/src/routes/+page.svelte` - Create: `web/.gitignore`, `web/static/favicon.png`, `web/build/index.html` - [ ] **Step 1: Create `web/package.json`** ```json { "name": "minstrel-web", "version": "0.0.0", "private": true, "type": "module", "scripts": { "dev": "vite dev", "build": "vite build", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "test": "svelte-kit sync && vitest run" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.8.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", "autoprefixer": "^10.4.20", "jsdom": "^25.0.1", "postcss": "^8.4.49", "svelte": "^5.1.9", "svelte-check": "^4.0.5", "tailwindcss": "^3.4.14", "tslib": "^2.8.0", "typescript": "^5.6.3", "vite": "^5.4.10", "vitest": "^2.1.4" } } ``` - [ ] **Step 2: Create `web/tsconfig.json`** ```json { "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { "allowJs": true, "checkJs": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, "strict": true, "moduleResolution": "bundler" } } ``` - [ ] **Step 3: Create `web/svelte.config.js`** ```js import adapter from '@sveltejs/adapter-static'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; export default { preprocess: vitePreprocess(), kit: { adapter: adapter({ pages: 'build', assets: 'build', fallback: 'index.html', precompress: false, strict: false }), files: { assets: 'static' } } }; ``` - [ ] **Step 4: Create `web/vite.config.ts`** ```ts import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [sveltekit()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:4533', changeOrigin: true }, '/rest': { target: 'http://localhost:4533', changeOrigin: true } } } }); ``` - [ ] **Step 5: Create `web/tailwind.config.js`** ```js export default { content: ['./src/**/*.{html,js,ts,svelte}'], darkMode: 'class', theme: { extend: { colors: { surface: { 900: '#14161a', 800: '#1a1d22', 700: '#2a2f36' }, text: { primary: '#e8ecf2', secondary: '#cdd3db' } } } }, plugins: [] }; ``` - [ ] **Step 6: Create `web/postcss.config.cjs`** ```js module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } }; ``` - [ ] **Step 7: Create `web/src/app.html`** ```html Minstrel %sveltekit.head%
%sveltekit.body%
``` - [ ] **Step 8: Create `web/src/app.css`** ```css @tailwind base; @tailwind components; @tailwind utilities; :root { color-scheme: dark; } html, body { height: 100%; margin: 0; } ``` - [ ] **Step 9: Create `web/src/routes/+layout.svelte`** ```svelte ``` - [ ] **Step 10: Create `web/src/routes/+page.svelte`** ```svelte

Minstrel

Scaffold — UI features land in subsequent plans.

``` - [ ] **Step 11: Create `web/.gitignore`** ``` node_modules/ .svelte-kit/ build/* !build/index.html ``` - [ ] **Step 12: Create placeholder `web/static/favicon.png`** Run: ```bash mkdir -p web/static python3 -c "import base64,sys; sys.stdout.buffer.write(base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='))" > web/static/favicon.png ``` Expected: `web/static/favicon.png` is a valid 1×1 PNG (67 bytes). - [ ] **Step 13: Create placeholder `web/build/index.html`** This placeholder lets `//go:embed build` compile before anyone runs `npm run build`. Real `npm run build` overwrites it locally; only this hand-written version is committed. ```html Minstrel

Minstrel SPA has not been built. Run cd web && npm install && npm run build or build the container image, which runs the web build during docker build.

``` - [ ] **Step 14: Install dependencies** Run: `cd web && npm install` Expected: `node_modules/` populated, `package-lock.json` written, exit 0. - [ ] **Step 15: Verify SvelteKit sync + build** Run: `cd web && npm run build` Expected: exit 0; `web/build/` now contains `index.html`, `_app/`, `favicon.png`. (Local `index.html` is overwritten but not committed — `.gitignore` keeps only the committed placeholder under version control.) Undo the local overwrite so the committed placeholder remains: Run: `git checkout -- web/build/index.html` Expected: placeholder restored. - [ ] **Step 16: Commit** ```bash git add web/.gitignore web/package.json web/package-lock.json web/tsconfig.json \ web/svelte.config.js web/vite.config.ts web/tailwind.config.js web/postcss.config.cjs \ web/src/ web/static/ web/build/index.html git commit -m "feat(web): scaffold SvelteKit SPA with Tailwind + adapter-static - SvelteKit 2.x + Svelte 5 + TypeScript + Vite 5 - adapter-static with fallback:'index.html' for SPA deep-link routing - Tailwind configured with dark-only palette matching spec - Placeholder landing page at / - Committed web/build/index.html placeholder (via .gitignore negation) so go:embed works before the SvelteKit build has been run" ``` --- ## Task 2: Vitest smoke test **Files:** - Create: `web/vitest.config.ts` - Create: `web/src/lib/example.test.ts` - [ ] **Step 1: Create `web/vitest.config.ts`** ```ts import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [sveltekit()], test: { environment: 'jsdom', include: ['src/**/*.test.ts'] } }); ``` - [ ] **Step 2: Write failing smoke test** Create `web/src/lib/example.test.ts`: ```ts import { describe, expect, it } from 'vitest'; describe('scaffold smoke', () => { it('runs vitest', () => { expect(1 + 1).toBe(2); }); }); ``` - [ ] **Step 3: Run tests** Run: `cd web && npm test` Expected: 1 test passes. Exit 0. - [ ] **Step 4: Commit** ```bash git add web/vitest.config.ts web/src/lib/example.test.ts git commit -m "test(web): add Vitest smoke test and jsdom config" ``` --- ## Task 3: Go embed package + SPA fallback handler **Files:** - Create: `web/embed.go` - Create: `web/embed_test.go` - [ ] **Step 1: Write failing tests** Create `web/embed_test.go`: ```go package web import ( "io" "net/http" "net/http/httptest" "strings" "testing" ) func TestHandler_ServesIndexAtRoot(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") { t.Errorf("Content-Type = %q, want text/html*", ct) } body, _ := io.ReadAll(rec.Body) if !strings.Contains(strings.ToLower(string(body)), " build/index.html // - GET / -> that file, via http.FileServer // - GET / -> build/index.html (SPA fallback) // // Callers are expected to register this as the router's NotFound/catch-all so // explicitly-routed paths (like /api/* and /rest/*) take precedence. func Handler() http.Handler { sub, err := fs.Sub(buildFS, "build") if err != nil { // fs.Sub on a valid embed path can only fail if the embed directive // is malformed, which the compile would have caught. panic("web: fs.Sub(build) failed: " + err.Error()) } index, err := fs.ReadFile(sub, "index.html") if err != nil { panic("web: embedded build/index.html missing: " + err.Error()) } fileServer := http.FileServer(http.FS(sub)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { clean := path.Clean(r.URL.Path) if clean == "/" || clean == "." { serveIndex(w, index) return } name := strings.TrimPrefix(clean, "/") if info, err := fs.Stat(sub, name); err == nil && !info.IsDir() { fileServer.ServeHTTP(w, r) return } serveIndex(w, index) }) } func serveIndex(w http.ResponseWriter, index []byte) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") _, _ = w.Write(index) } ``` - [ ] **Step 4: Run tests — expect PASS** Run: `go test ./web/ -v` Expected: `TestHandler_ServesIndexAtRoot`, `TestHandler_UnknownPathFallsBackToIndex`, `TestHandler_MissingAssetFallsBackToIndex` all PASS. - [ ] **Step 5: Commit** ```bash git add web/ git commit -m "feat(web): embed SvelteKit build output with SPA fallback handler" ``` --- ## Task 4: Wire web handler into server router **Files:** - Modify: `internal/server/server.go` - Modify: `internal/server/server_test.go` - [ ] **Step 1: Write failing tests** Add to `internal/server/server_test.go` (also add `"strings"` to the imports): ```go func TestRouter_ServesSPAAtRoot(t *testing.T) { s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}) ts := httptest.NewServer(s.Router()) defer ts.Close() resp, err := http.Get(ts.URL + "/") if err != nil { t.Fatalf("GET /: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Errorf("status = %d, want 200", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") { t.Errorf("Content-Type = %q, want text/html*", ct) } } func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) { s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}) ts := httptest.NewServer(s.Router()) defer ts.Close() resp, err := http.Get(ts.URL + "/artists/00000000-0000-0000-0000-000000000001") if err != nil { t.Fatalf("GET deep link: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Errorf("status = %d, want 200", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") { t.Errorf("Content-Type = %q, want text/html*", ct) } } func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) { s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}) ts := httptest.NewServer(s.Router()) defer ts.Close() resp, err := http.Get(ts.URL + "/api/does-not-exist") if err != nil { t.Fatalf("GET /api/does-not-exist: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNotFound { t.Errorf("status = %d, want 404", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") { t.Errorf("Content-Type = %q; /api/* must never return text/html", ct) } } func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) { s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}) ts := httptest.NewServer(s.Router()) defer ts.Close() resp, err := http.Get(ts.URL + "/rest/does-not-exist") if err != nil { t.Fatalf("GET /rest/does-not-exist: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNotFound { t.Errorf("status = %d, want 404", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") { t.Errorf("Content-Type = %q; /rest/* must never return text/html", ct) } } ``` - [ ] **Step 2: Run to verify failure** Run: `go test ./internal/server/ -v` Expected: FAIL on all four new tests (chi returns its own 404 with empty/plain body for unmatched routes). - [ ] **Step 3: Wire `web.Handler()` into `Router()`** Modify `internal/server/server.go`: a) Add imports (preserving alphabetical order within the group): ```go import ( "context" "encoding/json" "log/slog" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/api" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/library" "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" "git.fabledsword.com/bvandeusen/minstrel/web" ) ``` b) Replace the `Router()` method with: ```go func (s *Server) Router() http.Handler { r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.Recoverer) r.Get("/healthz", s.handleHealthz) if s.Pool != nil { api.Mount(r, s.Pool, s.Logger) r.Route("/api/admin", func(admin chi.Router) { admin.Use(auth.RequireAdmin(s.Pool)) if s.Scanner != nil { admin.Post("/scan", s.handleAdminScan) } }) subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg) } spa := web.Handler() r.NotFound(func(w http.ResponseWriter, req *http.Request) { p := req.URL.Path if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/rest/") { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"not found"}}`)) return } spa.ServeHTTP(w, req) }) return r } ``` - [ ] **Step 4: Run tests — expect PASS** Run: `go test ./internal/server/ -v` Expected: all tests PASS, including the existing `TestHealthz` and the four new ones. - [ ] **Step 5: Run full test suite** Run: `go test -short -race ./...` Expected: PASS. - [ ] **Step 6: Run linter** Run: `golangci-lint run ./...` Expected: no issues. - [ ] **Step 7: Commit** ```bash git add internal/server/ git commit -m "feat(server): serve embedded SPA at root with /api,/rest carve-out - NotFound wrapper routes unknown /api/* and /rest/* to a JSON 404 - Everything else falls through to the embedded web handler, which resolves static assets or returns index.html for SPA deep links" ``` --- ## Task 5: Multi-stage Dockerfile **Files:** - Rewrite: `Dockerfile` - [ ] **Step 1: Replace `Dockerfile` contents** ```dockerfile # syntax=docker/dockerfile:1.6 FROM node:22-bookworm-slim AS web WORKDIR /web COPY web/package.json web/package-lock.json ./ RUN npm ci COPY web/ ./ RUN npm run build FROM golang:1.23-bookworm AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . # Overwrite the committed placeholder with the freshly-built SPA assets. COPY --from=web /web/build ./web/build ENV CGO_ENABLED=0 RUN go build -trimpath -ldflags="-s -w" -o /out/minstrel ./cmd/minstrel FROM debian:bookworm-slim RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates ffmpeg \ && rm -rf /var/lib/apt/lists/* RUN groupadd --system --gid 1000 minstrel \ && useradd --system --uid 1000 --gid minstrel --shell /usr/sbin/nologin minstrel COPY --from=builder /out/minstrel /usr/local/bin/minstrel COPY config.example.yaml /etc/smartmusic/config.yaml USER minstrel EXPOSE 4533 ENTRYPOINT ["/usr/local/bin/minstrel"] CMD ["--config", "/etc/smartmusic/config.yaml"] ``` - [ ] **Step 2: Build image locally** Run: `docker build -t minstrel:scaffold-smoke .` Expected: all three stages succeed. The final layer is `FROM debian:bookworm-slim`, the binary exists at `/usr/local/bin/minstrel`. - [ ] **Step 3: Inspect image for SPA assets** Run: `docker run --rm --entrypoint /bin/sh minstrel:scaffold-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` Expected: output `ok` (verifies the binary was copied in and is executable). - [ ] **Step 4: Commit** ```bash git add Dockerfile git commit -m "build: multi-stage Dockerfile with node->go->slim for embedded SPA Web assets are built in the node stage, copied into the go stage before go build so //go:embed picks them up, then the minimal slim runtime carries only the final binary and ffmpeg." ``` --- ## Task 6: CI — install Node and build web before Go **Files:** - Modify: `.forgejo/workflows/test.yml` - [ ] **Step 1: Replace `.forgejo/workflows/test.yml` contents** ```yaml name: test # Lint + short unit tests. Integration tests needing Postgres/ffmpeg run # locally via docker-compose; they should guard with testing.Short(). on: push: branches: [dev] pull_request: branches: [main] jobs: test: runs-on: go-ci steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: '22' cache: 'npm' cache-dependency-path: web/package-lock.json - name: Install web deps working-directory: web run: npm ci - name: Web build (populates web/build/ for go:embed) working-directory: web run: npm run build - name: Web test (vitest) working-directory: web run: npm test - name: Detect Go project id: gomod shell: bash run: | if [ -f go.mod ]; then echo "present=true" >> "$GITHUB_OUTPUT" else echo "present=false" >> "$GITHUB_OUTPUT" echo "::notice::No go.mod yet — Go steps will be skipped until the skeleton lands" fi - name: Toolchain versions if: steps.gomod.outputs.present == 'true' run: | go version golangci-lint --version - name: go vet if: steps.gomod.outputs.present == 'true' run: go vet ./... - name: golangci-lint if: steps.gomod.outputs.present == 'true' run: golangci-lint run ./... - name: go test (short, race) if: steps.gomod.outputs.present == 'true' run: go test -short -race ./... ``` - [ ] **Step 2: Commit** ```bash git add .forgejo/workflows/test.yml git commit -m "ci: install Node + build web before Go steps Go's //go:embed needs web/build/ to exist at compile time. CI now runs 'npm ci && npm run build && npm test' ahead of the Go steps so the embed directive sees real, freshly-built assets." ``` --- ## Task 7: README dev-workflow section **Files:** - Modify: `README.md` - [ ] **Step 1: Read current README** Run: `cat README.md` Note the existing structure (headings, sections). - [ ] **Step 2: Append or replace a "Development" section** Use the Edit tool to insert the following section. Place it after any existing "Getting started" or "Quickstart" section; if none exists, append to the end of the file. ```markdown ## Development Minstrel has two concurrent dev processes: 1. **Backend:** `docker compose up` — starts Postgres and Minstrel on `:4533`. 2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR. The Vite dev server proxies `/api/*` and `/rest/*` to the backend on `:4533`, so session cookies work correctly when the SPA is loaded from the Vite origin. ### Production build `docker build -t minstrel .` runs the SvelteKit build inside a `node` stage, copies the output into the `golang` stage, and `//go:embed`s it into the final binary. The resulting container serves the SPA from `/` alongside the API surfaces. No separate static-file server is required. ``` - [ ] **Step 3: Commit** ```bash git add README.md git commit -m "docs: document two-process dev workflow with Vite proxy" ``` --- ## Task 8: Final verification **Files:** none (verification only). - [ ] **Step 1: Full Go suite** Run: `go test -short -race ./...` Expected: PASS. - [ ] **Step 2: Go linter** Run: `golangci-lint run ./...` Expected: no issues. - [ ] **Step 3: Web tests + build** Run: `cd web && npm test && npm run build` Expected: PASS. Verify `web/build/index.html` was regenerated; then: Run: `git checkout -- web/build/index.html` Expected: committed placeholder restored (the fresh build output stays out of version control). - [ ] **Step 4: Docker build smoke** Run: `docker build -t minstrel:scaffold-final . && docker image inspect minstrel:scaffold-final --format '{{.Size}}'` Expected: image builds; size printed (bytes). - [ ] **Step 5: Local end-to-end** Bring up the stack: ```bash docker compose up --build -d ``` Then in another terminal: ```bash curl -sI http://localhost:4533/ curl -sI http://localhost:4533/artists/00000000-0000-0000-0000-000000000001 curl -sI http://localhost:4533/healthz curl -sI http://localhost:4533/api/nonexistent ``` Expected responses: - `GET /` → `200 OK`, `Content-Type: text/html; charset=utf-8`. - `GET /artists/…` → `200 OK`, `Content-Type: text/html; charset=utf-8` (SPA fallback). - `GET /healthz` → `200 OK`, `Content-Type: application/json`. - `GET /api/nonexistent` → `401 Unauthorized` (RequireUser kicks in before the NotFound wrapper for mounted `/api/*` routes) OR `404 Not Found` with `Content-Type: application/json` (not `text/html`). Either is acceptable — the key requirement is **no `text/html` body**. Tear down: ```bash docker compose down ``` - [ ] **Step 6: Finish the branch** Follow `superpowers:finishing-a-development-branch`: present the four-option menu and proceed per the user's choice. The expected path is Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green.