Files
minstrel/docs/superpowers/plans/2026-04-21-web-ui-scaffold.md
T
bvandeusen 1f08ee39bd fix(web): add likes/QueryClient mocks to route tests rendering LikeButton
Pages that render TrackRow/AlbumCard/ArtistRow/PlayerBar now indirectly
mount LikeButton, which calls useQueryClient(). Route tests need the
same mocks the component-level tests have.

- Added createLikedIdsQuery mock to 7 route test files
- Added useQueryClient mock to all affected route tests
- Added readable store import where needed
2026-04-26 17:18:39 -04:00

955 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Minstrel</title>
%sveltekit.head%
</head>
<body class="bg-surface-900 text-text-primary">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
```
- [ ] **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
<script lang="ts">
import '../app.css';
</script>
<slot />
```
- [ ] **Step 10: Create `web/src/routes/+page.svelte`**
```svelte
<main class="flex h-screen items-center justify-center">
<div class="text-center">
<h1 class="text-4xl font-semibold">Minstrel</h1>
<p class="mt-2 text-text-secondary">
Scaffold &mdash; UI features land in subsequent plans.
</p>
</div>
</main>
```
- [ ] **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
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Minstrel</title>
</head>
<body>
<p>
Minstrel SPA has not been built. Run
<code>cd web &amp;&amp; npm install &amp;&amp; npm run build</code> or
build the container image, which runs the web build during
<code>docker build</code>.
</p>
</body>
</html>
```
- [ ] **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)), "<html") {
t.Errorf("body missing <html tag; got: %q", string(body))
}
}
func TestHandler_UnknownPathFallsBackToIndex(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/artists/some-uuid", nil)
rec := httptest.NewRecorder()
Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (SPA fallback)", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html* for SPA fallback", ct)
}
}
func TestHandler_MissingAssetFallsBackToIndex(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/_app/immutable/chunks/does-not-exist.js", nil)
rec := httptest.NewRecorder()
Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (fallback to index.html)", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html* for missing asset", ct)
}
}
```
- [ ] **Step 2: Run to verify failure**
Run: `go test ./web/ -v`
Expected: FAIL — `Handler` undefined / package missing.
- [ ] **Step 3: Implement `web/embed.go`**
```go
// Package web embeds the SvelteKit SPA build output and serves it over HTTP.
// Requests for real files under build/ are served verbatim; anything else —
// deep links like /artists/abc, missing asset paths — returns build/index.html
// so the SPA can resolve the route client-side.
package web
import (
"embed"
"io/fs"
"net/http"
"path"
"strings"
)
//go:embed all:build
var buildFS embed.FS
// Handler returns an http.Handler that serves the embedded SPA.
//
// Behavior:
// - GET / -> build/index.html
// - GET /<path that exists in build/> -> that file, via http.FileServer
// - GET /<anything else> -> 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.