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

27 KiB
Raw Blame History

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: nodegolangdebian: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 &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:

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 &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
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() into Router()

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 Dockerfile contents

# 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.yml contents

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 /healthz200 OK, Content-Type: application/json.
  • GET /api/nonexistent401 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:

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.