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
27 KiB
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:embeds 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
{
"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
{
"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
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
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
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
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
- Step 7: Create
web/src/app.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
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
html,
body {
height: 100%;
margin: 0;
}
- Step 9: Create
web/src/routes/+layout.svelte
<script lang="ts">
import '../app.css';
</script>
<slot />
- Step 10: Create
web/src/routes/+page.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 — 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:
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.
<!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 && npm install && 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
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
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:
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
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:
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
// 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
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):
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()intoRouter()
Modify internal/server/server.go:
a) Add imports (preserving alphabetical order within the group):
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:
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
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
Dockerfilecontents
# 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
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.ymlcontents
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
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.
## 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
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:
docker compose up --build -d
Then in another terminal:
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) OR404 Not FoundwithContent-Type: application/json(nottext/html). Either is acceptable — the key requirement is notext/htmlbody.
Tear down:
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.