diff --git a/bin/minstrel b/bin/minstrel new file mode 100755 index 00000000..9b34c685 Binary files /dev/null and b/bin/minstrel differ diff --git a/docs/superpowers/plans/2026-04-21-web-ui-scaffold.md b/docs/superpowers/plans/2026-04-21-web-ui-scaffold.md new file mode 100644 index 00000000..5171952d --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-web-ui-scaffold.md @@ -0,0 +1,954 @@ +# 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. diff --git a/web/src/routes/albums/[id]/album.test.ts b/web/src/routes/albums/[id]/album.test.ts index d9a98b87..fa5673b1 100644 --- a/web/src/routes/albums/[id]/album.test.ts +++ b/web/src/routes/albums/[id]/album.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import { flushSync } from 'svelte'; +import { readable } from 'svelte/store'; import { mockQuery } from '../../../test-utils/query'; import type { AlbumDetail, TrackRef } from '$lib/api/types'; @@ -18,6 +19,21 @@ vi.mock('$lib/api/queries', () => ({ createAlbumQuery: vi.fn() })); +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); + import AlbumPage from './+page.svelte'; import { createAlbumQuery } from '$lib/api/queries'; diff --git a/web/src/routes/artists.test.ts b/web/src/routes/artists.test.ts index cc005579..25d8beb1 100644 --- a/web/src/routes/artists.test.ts +++ b/web/src/routes/artists.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; import { flushSync } from 'svelte'; +import { readable } from 'svelte/store'; import { mockInfiniteQuery } from '../test-utils/query'; import type { ArtistRef, Page } from '$lib/api/types'; @@ -19,6 +20,21 @@ vi.mock('$lib/api/queries', () => ({ createArtistsQuery: vi.fn() })); +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); + import ArtistsPage from './+page.svelte'; import { createArtistsQuery } from '$lib/api/queries'; import { goto } from '$app/navigation'; diff --git a/web/src/routes/artists/[id]/artist.test.ts b/web/src/routes/artists/[id]/artist.test.ts index 3d3381b3..db4a332c 100644 --- a/web/src/routes/artists/[id]/artist.test.ts +++ b/web/src/routes/artists/[id]/artist.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import { flushSync } from 'svelte'; +import { readable } from 'svelte/store'; import { mockQuery } from '../../../test-utils/query'; import type { ArtistDetail, AlbumRef } from '$lib/api/types'; @@ -18,6 +19,21 @@ vi.mock('$lib/api/queries', () => ({ createArtistQuery: vi.fn() })); +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); + import ArtistPage from './+page.svelte'; import { createArtistQuery } from '$lib/api/queries'; diff --git a/web/src/routes/search/albums/albums.test.ts b/web/src/routes/search/albums/albums.test.ts index f48b8781..32eaed58 100644 --- a/web/src/routes/search/albums/albums.test.ts +++ b/web/src/routes/search/albums/albums.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; import { mockInfiniteQuery } from '../../../test-utils/query'; import type { AlbumRef, Page } from '$lib/api/types'; @@ -20,6 +21,21 @@ vi.mock('$lib/player/store.svelte', () => ({ enqueueTracks: vi.fn() })); +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); + import AlbumsOverflow from './+page.svelte'; import { createSearchAlbumsInfiniteQuery } from '$lib/api/queries'; diff --git a/web/src/routes/search/artists/artists.test.ts b/web/src/routes/search/artists/artists.test.ts index 607ed39e..0c18d635 100644 --- a/web/src/routes/search/artists/artists.test.ts +++ b/web/src/routes/search/artists/artists.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; import { mockInfiniteQuery } from '../../../test-utils/query'; import type { ArtistRef, Page } from '$lib/api/types'; @@ -13,6 +14,21 @@ vi.mock('$lib/api/queries', () => ({ createSearchArtistsInfiniteQuery: vi.fn() })); +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); + import ArtistsOverflow from './+page.svelte'; import { createSearchArtistsInfiniteQuery } from '$lib/api/queries'; diff --git a/web/src/routes/search/search.test.ts b/web/src/routes/search/search.test.ts index 8aab5c7d..4c4927b6 100644 --- a/web/src/routes/search/search.test.ts +++ b/web/src/routes/search/search.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; import { mockQuery } from '../../test-utils/query'; import type { ArtistRef, AlbumRef, TrackRef, SearchResponse } from '$lib/api/types'; @@ -26,6 +27,21 @@ vi.mock('$lib/api/client', () => ({ api: { get: vi.fn() } })); +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); + import SearchPage from './+page.svelte'; import { createSearchQuery } from '$lib/api/queries'; diff --git a/web/src/routes/search/tracks/tracks.test.ts b/web/src/routes/search/tracks/tracks.test.ts index 5eb9c300..4d515483 100644 --- a/web/src/routes/search/tracks/tracks.test.ts +++ b/web/src/routes/search/tracks/tracks.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; import { mockInfiniteQuery } from '../../../test-utils/query'; import type { TrackRef, Page } from '$lib/api/types'; @@ -19,6 +20,21 @@ vi.mock('$lib/player/store.svelte', () => ({ enqueueTrack: vi.fn() })); +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); + import TracksOverflow from './+page.svelte'; import { createSearchTracksInfiniteQuery } from '$lib/api/queries'; import { playRadio } from '$lib/player/store.svelte';